diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 60fd12e..6350baf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -44,72 +44,11 @@ jobs: key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - name: Run unit tests - run: cargo test --workspace --lib + run: cargo test -p replicant-client --lib --tests - name: Run doc tests run: cargo test --workspace --doc - integration-tests: - name: Integration Tests - runs-on: ubuntu-latest - env: - SQLX_OFFLINE: true - - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: replicant_server_test - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - uses: actions/checkout@v3 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo registry - uses: actions/cache@v3 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo build - uses: actions/cache@v3 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - - name: Set up Elixir - uses: erlef/setup-beam@v1 - with: - elixir-version: '1.17' - otp-version: '27' - - # The harness boots the stripped server (a library: no HTTP server of its - # own) behind a minimal endpoint that mounts ReplicantServer.Sync.Socket on - # :4000, seeds an enrolled user + credential (and one legacy nil-user - # credential for the negative test), and runs the gated integration suite. - # It clones the server itself at the pinned SERVER_REF, so no separate - # checkout/credential/health steps are needed here. - - name: Boot stripped server + run integration suite - env: - REPLICANT_SERVER_REF: '19ffa7a' # origin/main (PR #8 + #9 merged: canonical hash + sender-echo fix) - REPLICANT_SERVER_DIR: /tmp/replicant-server-interop - INTEROP_DB_HOST: localhost - INTEROP_DB_USER: postgres - INTEROP_DB_PASS: postgres - INTEROP_DB_NAME: replicant_server_test - run: ./test/run_phoenix_interop_local.sh - lint: name: Lint runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 78fb539..7ed01b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +### Breaking: protocol v2 and C API v2 (server 0.5.0 required) +- One engine per data dir per process, shared by every handle; `replicant_create` takes a `ReplicantConfig` and never waits on the network. +- New: `replicant_get_state`, `replicant_reconnect`, `replicant_abi_version` (C API v2 is ABI 1.0: `REPLICANT_ABI_VERSION_MAJOR`/`MINOR`), the C++ wrapper `replicant.hpp`, Kept copies (`replicant_list_recovered`, `replicant_dismiss_recovered`, `replicant_restore_document`, `replicant_restore_fields`), `replicant_list_parked`, `DatabaseChanged`, `IdentityAdopted`, `EventOrigin::OtherProcess`, results `ErrorNewerSchema`, `ErrorMigrationFailed`, `ErrorBusy`, `ErrorWrongThread`, `ErrorNoCallbacks`, `ErrorDocumentGone`, `ErrorBufferTooSmall`, `ErrorTokenRejected`, `ReplicantConfig.list_merge`, `struct_size` on `ReplicantConfig` and `ReplicantState`, error code `Diverged` (and `DeleteRefused` reserved), kept-copy reasons `delete_superseded`, `delete_refused`, `delete_publication` and `unmigratable`. +- Error codes: 1002 `InvalidSignature`, 2001 `ConnectionFailed`, 2002 `Timeout`, 2102 `InvalidTimestamp`, 3001 `MissingParams`, 3002 `TopicUserMismatch` and 5001 `UpdateConflict` are gone (a host that still matches them never sees them). Renamed, same number: 1001 `InvalidApiKey` → `AuthInvalid`, 1003 `CredentialNotEnrolled` → `NotEnrolled`, 2101 `TimestampExpired` → `ClockSkew`. +- Removed from callbacks and reads: `IdentityChanged`, `author_name`, `document_count`, `sync_revision`; `user_id` is now `owner_id` in callbacks and in the document JSON of `replicant_get_document`, `replicant_get_all_documents` and `replicant_search_documents`. C constants of the result, error-code, event and origin enums carry their type name as a prefix. +- Changed: `DocumentChanged` replaces Created/Updated; callbacks carry owner, author, visibility, read-only, document ids and kept copies on errors, and conflict reasons; deletes are conditional (a newer version from another device undoes a delete, reported as `ConflictDetected` `delete_superseded`); `replicant_enroll_claim` takes the data dir, stores the credentials itself and returns only the user id; one callback per kind (registering again replaces it), and callbacks run only on the thread that first registered; `create_document_with_id` refuses an existing or deleted id. +- An engine halted as signed out, or on refused credentials (`AuthInvalid`, `AccountDisabled`, `identity_drift`), re-reads the stored credentials every 3 s, so a sign-in in another process (the standalone app) reaches open plugins within about 3 s. Credentials the server refused are not tried again until they change. A sign-out while halted on a credential or account refusal reports `NotEnrolled`. Stored credentials that can never be read (key file missing, credentials not decryptable) count as signed out: `NotEnrolled`. +- Removed: the v1 client, `replicant-core`, `replicant-seed`, the JUCE wrapper; `replicant_store_credentials`, `replicant_load_credentials` and `REPLICANT_CREDENTIAL_MAX_LEN` (secrets never cross the C API: sign in with `replicant_enroll_claim`, sign out with `replicant_clear_credentials`, and read sign-in status from `replicant_get_state` and `replicant_get_user_id`). +- The first open migrates a v1 database, after copying it to `.v1-backup` (or `.v1-backup-` when that exists; a backup is never replaced, and a second is written only for a file restored from a backup); rows it cannot read are kept as `unmigratable` copies. After that, older builds cannot open it (`VersionMissing`): upgrade the app and every plugin format together and quit hosts before installing. +- Titles: the library knows no content keys. `ReplicantConfig.title_pointer` (a JSON Pointer, e.g. `"/title"`) names the string that is a document's `title` in reads, callbacks, kept copies and the `title:` search field; without it every title is null. The server's title is no longer used. Every process on a data dir must pass the same pointer: the shared-engine check (`ErrorConfigMismatch`) covers handles in one process only, so a different pointer in another process is not detected. Each launch with a different pointer recomputes every title and the search index, and titles end up mixed while both run (migration 016 records the pointer used). + ## 0.6.4 Patch release: no API change. Event timing changes for hosts that relied on the diff --git a/Cargo.lock b/Cargo.lock index f7c680d..2b798bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,124 +42,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "0.6.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" - -[[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.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.59.0", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "arc-swap" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" -dependencies = [ - "rustversion", -] - -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures 0.2.17", - "password-hash", -] - -[[package]] -name = "askama" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" -dependencies = [ - "askama_derive", - "askama_escape", -] - -[[package]] -name = "askama_derive" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" -dependencies = [ - "askama_parser", - "basic-toml", - "mime", - "mime_guess", - "proc-macro2", - "quote", - "serde", - "syn 2.0.101", -] - -[[package]] -name = "askama_escape" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" - -[[package]] -name = "askama_parser" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" -dependencies = [ - "nom", -] - [[package]] name = "assert-json-diff" version = "2.0.2" @@ -170,30 +52,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "async-compat" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" -dependencies = [ - "futures-core", - "futures-io", - "once_cell", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "async-trait" -version = "0.1.88" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "atoi" version = "2.0.0" @@ -203,12 +61,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "atomic-take" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8ab6b55fe97976e46f91ddbed8d147d966475dc29b2032757ba47e02376fbc3" - [[package]] name = "atomic-waker" version = "1.1.2" @@ -232,64 +84,6 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core", - "base64", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[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", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "base64" version = "0.22.1" @@ -302,24 +96,6 @@ version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" -[[package]] -name = "basic-toml" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" -dependencies = [ - "serde", -] - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -335,15 +111,6 @@ dependencies = [ "serde", ] -[[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" @@ -371,60 +138,13 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[package]] -name = "camino" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - -[[package]] -name = "castaway" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" -dependencies = [ - "rustversion", -] - [[package]] name = "cbindgen" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da6bc11b07529f16944307272d5bd9b22530bc7d05751717c9d416586cedab49" dependencies = [ - "clap 3.2.25", + "clap", "heck 0.4.1", "indexmap 1.9.3", "log", @@ -527,47 +247,13 @@ checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" dependencies = [ "atty", "bitflags 1.3.2", - "clap_lex 0.2.4", + "clap_lex", "indexmap 1.9.3", - "strsim 0.10.0", + "strsim", "termcolor", "textwrap", ] -[[package]] -name = "clap" -version = "4.5.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" -dependencies = [ - "anstream", - "anstyle", - "clap_lex 0.7.4", - "strsim 0.11.1", -] - -[[package]] -name = "clap_derive" -version = "4.5.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "clap_lex" version = "0.2.4" @@ -577,41 +263,6 @@ dependencies = [ "os_str_bytes", ] -[[package]] -name = "clap_lex" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" - -[[package]] -name = "colorchoice" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" - -[[package]] -name = "colored" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" -dependencies = [ - "lazy_static", - "windows-sys 0.59.0", -] - -[[package]] -name = "compact_str" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "ryu", - "static_assertions", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -621,19 +272,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width 0.2.0", - "windows-sys 0.59.0", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -704,31 +342,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" -dependencies = [ - "bitflags 2.9.1", - "crossterm_winapi", - "libc", - "mio 0.8.11", - "parking_lot", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - [[package]] name = "crypto-common" version = "0.1.6" @@ -775,19 +388,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "dialoguer" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" -dependencies = [ - "console", - "shell-words", - "tempfile", - "thiserror 1.0.69", - "zeroize", -] - [[package]] name = "digest" version = "0.10.7" @@ -826,35 +426,6 @@ dependencies = [ "serde", ] -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "env_filter" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -899,16 +470,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "flexstr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d50aef14619d336a54fca5a592d952eb39037b1a1e7e6afd9f91c892ac7ef65" -dependencies = [ - "serde", - "static_assertions", -] - [[package]] name = "flume" version = "0.11.1" @@ -956,15 +517,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs-err" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" -dependencies = [ - "autocfg", -] - [[package]] name = "futures" version = "0.3.31" @@ -1065,15 +617,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -1123,23 +666,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "goblin" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" -dependencies = [ - "log", - "plain", - "scroll", -] - [[package]] name = "h2" version = "0.4.19" @@ -1518,60 +1044,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[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.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.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" -[[package]] -name = "jiff" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a87d9b8105c23642f50cbbae03d1f75d8422c5cb98ce7ee9271f7ff7505be6b8" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-static" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "js-sys" version = "0.3.77" @@ -1653,36 +1131,12 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.3", -] - [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "md-5" version = "0.10.6" @@ -1699,40 +1153,6 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" -[[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 = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", -] - [[package]] name = "mio" version = "1.2.1" @@ -1761,25 +1181,6 @@ dependencies = [ "tempfile", ] -[[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 = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1843,12 +1244,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "once_cell_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" - [[package]] name = "opaque-debug" version = "0.3.1" @@ -1887,15 +1282,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-src" -version = "300.5.5+3.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" -dependencies = [ - "cc", -] - [[package]] name = "openssl-sys" version = "0.9.111" @@ -1904,7 +1290,6 @@ checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", - "openssl-src", "pkg-config", "vcpkg", ] @@ -1944,23 +1329,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1976,30 +1344,6 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" -[[package]] -name = "phoenix_channels_client" -version = "0.9.1" -source = "git+https://github.com/liveview-native/phoenix-channels-client#c54fb29b502b43edfc8568364aef4c0676b925da" -dependencies = [ - "arc-swap", - "atomic-take", - "bytes", - "flexstr", - "futures", - "fxhash", - "httparse", - "log", - "native-tls", - "serde", - "serde_json", - "thiserror 2.0.12", - "tokio", - "tokio-tungstenite", - "uniffi", - "url", - "uuid", -] - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2039,12 +1383,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "poly1305" version = "0.8.0" @@ -2056,21 +1394,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "portable-atomic" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" - -[[package]] -name = "portable-atomic-util" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" -dependencies = [ - "portable-atomic", -] - [[package]] name = "potential_utf" version = "0.1.2" @@ -2226,29 +1549,9 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" name = "rand_pcg" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - -[[package]] -name = "ratatui" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "bitflags 2.9.1", - "cassowary", - "compact_str", - "crossterm", - "itertools 0.12.1", - "lru", - "paste", - "stability", - "strum", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.1.14", + "rand_core 0.10.1", ] [[package]] @@ -2289,36 +1592,18 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" -[[package]] -name = "replicant" -version = "0.6.0" -dependencies = [ - "replicant-client", - "replicant-core", -] - [[package]] name = "replicant-client" version = "0.6.4" dependencies = [ - "anyhow", "cbindgen", "chacha20poly1305", "chrono", - "clap 4.5.38", - "colored", - "crossterm", - "dialoguer", - "env_logger", "futures-util", "hex", "hmac", "json-patch", - "log", - "phoenix_channels_client", "rand 0.8.5", - "ratatui", - "replicant-core", "reqwest", "serde", "serde_json", @@ -2331,46 +1616,11 @@ dependencies = [ "tokio-tungstenite", "tokio-util", "tracing", - "tracing-subscriber", "url", "uuid", "wiremock", ] -[[package]] -name = "replicant-core" -version = "0.6.0" -dependencies = [ - "argon2", - "axum", - "chrono", - "json-patch", - "serde", - "serde_json", - "sha2", - "sqlx", - "strum", - "thiserror 2.0.12", - "tokio", - "tracing", - "uuid", -] - -[[package]] -name = "replicant-seed" -version = "0.6.0" -dependencies = [ - "anyhow", - "clap 4.5.38", - "replicant-client", - "serde_json", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", - "walkdir", -] - [[package]] name = "reqwest" version = "0.12.28" @@ -2509,15 +1759,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[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 = "scc" version = "2.4.0" @@ -2542,26 +1783,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scroll" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" -dependencies = [ - "scroll_derive", -] - -[[package]] -name = "scroll_derive" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "sdd" version = "3.0.10" @@ -2591,16 +1812,6 @@ dependencies = [ "libc", ] -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "serde" version = "1.0.228" @@ -2643,16 +1854,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" -dependencies = [ - "itoa", - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2719,48 +1920,12 @@ dependencies = [ "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 = "shell-words" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" - [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" -dependencies = [ - "libc", - "mio 0.8.11", - "signal-hook", -] - [[package]] name = "signal-hook-registry" version = "1.4.5" @@ -2780,12 +1945,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - [[package]] name = "slab" version = "0.4.9" @@ -2804,12 +1963,6 @@ dependencies = [ "serde", ] -[[package]] -name = "smawk" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" - [[package]] name = "socket2" version = "0.5.9" @@ -3047,28 +2200,12 @@ dependencies = [ "uuid", ] -[[package]] -name = "stability" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" -dependencies = [ - "quote", - "syn 2.0.101", -] - [[package]] name = "stable_deref_trait" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "stringprep" version = "0.1.5" @@ -3086,34 +2223,6 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.101", -] - [[package]] name = "subtle" version = "2.6.1" @@ -3189,9 +2298,6 @@ name = "textwrap" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" -dependencies = [ - "smawk", -] [[package]] name = "thiserror" @@ -3233,16 +2339,6 @@ dependencies = [ "syn 2.0.101", ] -[[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 = "tinystr" version = "0.8.1" @@ -3276,7 +2372,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio 1.2.1", + "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -3377,7 +2473,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -3440,36 +2535,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" 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.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", ] [[package]] @@ -3503,12 +2568,6 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" -[[package]] -name = "unicase" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" - [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3536,152 +2595,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools 0.13.0", - "unicode-segmentation", - "unicode-width 0.1.14", -] - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" - -[[package]] -name = "uniffi" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cb08c58c7ed7033150132febe696bef553f891b1ede57424b40d87a89e3c170" -dependencies = [ - "anyhow", - "cargo_metadata", - "uniffi_bindgen", - "uniffi_core", - "uniffi_macros", -] - -[[package]] -name = "uniffi_bindgen" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cade167af943e189a55020eda2c314681e223f1e42aca7c4e52614c2b627698f" -dependencies = [ - "anyhow", - "askama", - "camino", - "cargo_metadata", - "fs-err", - "glob", - "goblin", - "heck 0.5.0", - "once_cell", - "paste", - "serde", - "textwrap", - "toml", - "uniffi_meta", - "uniffi_udl", -] - -[[package]] -name = "uniffi_checksum_derive" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802d2051a700e3ec894c79f80d2705b69d85844dafbbe5d1a92776f8f48b563a" -dependencies = [ - "quote", - "syn 2.0.101", -] - -[[package]] -name = "uniffi_core" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7687007d2546c454d8ae609b105daceb88175477dac280707ad6d95bcd6f1f" -dependencies = [ - "anyhow", - "async-compat", - "bytes", - "log", - "once_cell", - "paste", - "static_assertions", -] - -[[package]] -name = "uniffi_macros" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12c65a5b12ec544ef136693af8759fb9d11aefce740fb76916721e876639033b" -dependencies = [ - "bincode", - "camino", - "fs-err", - "once_cell", - "proc-macro2", - "quote", - "serde", - "syn 2.0.101", - "toml", - "uniffi_meta", -] - -[[package]] -name = "uniffi_meta" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a74ed96c26882dac1ca9b93ca23c827e284bacbd7ec23c6f0b0372f747d59e4" -dependencies = [ - "anyhow", - "bytes", - "siphasher", - "uniffi_checksum_derive", -] - -[[package]] -name = "uniffi_testing" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6f984f0781f892cc864a62c3a5c60361b1ccbd68e538e6c9fbced5d82268ac" -dependencies = [ - "anyhow", - "camino", - "cargo_metadata", - "fs-err", - "once_cell", -] - -[[package]] -name = "uniffi_udl" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037820a4cfc4422db1eaa82f291a3863c92c7d1789dc513489c36223f9b4cdfc" -dependencies = [ - "anyhow", - "textwrap", - "uniffi_meta", - "uniffi_testing", - "weedle2", -] - [[package]] name = "universal-hash" version = "0.5.1" @@ -3721,12 +2634,6 @@ 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 = "uuid" version = "1.17.0" @@ -3740,12 +2647,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "vcpkg" version = "0.2.15" @@ -3758,16 +2659,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[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" @@ -3907,15 +2798,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "weedle2" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" -dependencies = [ - "nom", -] - [[package]] name = "whoami" version = "1.6.0" diff --git a/Cargo.toml b/Cargo.toml index 66d584b..8e9a9ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["replicant", "replicant-core", "replicant-client", "replicant-seed"] +members = ["replicant-client"] resolver = "2" [profile.release] diff --git a/EVENT_CALLBACKS.md b/EVENT_CALLBACKS.md index e977529..677026a 100644 --- a/EVENT_CALLBACKS.md +++ b/EVENT_CALLBACKS.md @@ -1,741 +1,21 @@ -# Event Callback System - -The JSON Database Sync Client provides a comprehensive event callback system that allows applications to receive real-time notifications about document changes, sync operations, and connection status updates. The system is designed to be thread-safe, efficient, and easy to use across Rust, C, and C++ applications. - -## Overview - -### Key Features - -- ✅ **Thread-Safe Design**: No locks or mutexes needed in user code -- ✅ **Cross-Language Support**: Works with Rust, C, and C++ -- ✅ **Event Filtering**: Subscribe to specific event types or all events -- ✅ **Offline/Online Events**: Receive events for both local and synchronized operations -- ✅ **Simple Integration**: Easy-to-use callback registration and processing -- ✅ **High Performance**: Efficient event queuing with minimal overhead - -### How It Works - -The event system uses a **single-thread callback model** inspired by RealmCPP: - -1. **Events are generated** from any thread (main thread, sync thread, etc.) -2. **Events are queued** for processing without blocking the generating thread -3. **Callbacks are invoked** only on the thread that registered them -4. **User calls `process_events()`** in their main loop to handle queued events - -This design eliminates the need for complex thread synchronization in user code while ensuring all callbacks execute safely on a single, predictable thread. - -## Event Types - -The system supports the following event types: - -| Event Type | Description | When Triggered | -|------------|-------------|----------------| -| `DOCUMENT_CREATED` | A new document was created | Local creation or sync from server | -| `DOCUMENT_UPDATED` | An existing document was modified | Local update or sync from server | -| `DOCUMENT_DELETED` | A document was deleted | Local deletion or sync from server | -| `SYNC_STARTED` | Synchronization process began | When connecting to server | -| `SYNC_COMPLETED` | Synchronization finished successfully | After receiving all server changes | -| `SYNC_ERROR` | An error occurred during sync | Network issues, server errors, etc. | -| `CONFLICT_DETECTED` | A conflict was detected between versions | Concurrent modifications detected | -| `CONNECTION_STATE_CHANGED` | Connection status changed | Connected/disconnected from server | - -## Rust API - -### Basic Usage - -```rust -use sync_client::{SyncEngine, EventDispatcher, EventType}; -use std::sync::Arc; - -// Create sync engine with event support -let engine = SyncEngine::new( - "sqlite:client.db?mode=rwc", - "ws://localhost:8080/ws", - "demo-token" -).await?; - -// Get event dispatcher -let events = engine.event_dispatcher(); - -// Register callback for all events -events.register_callback( - |event_type, document_id, title, content, error, numeric_data, boolean_data, context| { - match event_type { - EventType::DocumentCreated => { - println!("📄 Document created: {} - {}", document_id.unwrap_or("unknown"), title.unwrap_or("untitled")); - }, - EventType::DocumentUpdated => { - println!("✏️ Document updated: {} - {}", document_id.unwrap_or("unknown"), title.unwrap_or("untitled")); - }, - EventType::SyncCompleted => { - println!("🔄 Sync completed: {} documents", numeric_data); - }, - EventType::ConnectionStateChanged => { - println!("🔗 Connection {}", if boolean_data { "established" } else { "lost" }); - }, - _ => { - println!("📨 Event: {:?}", event_type); - } - } - }, - std::ptr::null_mut(), // context (optional) - None // filter (None = all events) -)?; - -// In your main loop -loop { - // Process any pending events - let processed_count = events.process_events()?; - if processed_count > 0 { - println!("Processed {} events", processed_count); - } - - // Do other work... - std::thread::sleep(std::time::Duration::from_millis(16)); // ~60 FPS -} -``` - -### Event Filtering - -```rust -// Register callback only for document events -events.register_callback( - |event_type, document_id, title, content, error, numeric_data, boolean_data, context| { - println!("Document event: {:?} for {}", event_type, document_id.unwrap_or("unknown")); - }, - std::ptr::null_mut(), - Some(EventType::DocumentCreated) // Only document creation events -)?; - -// Register separate callbacks for different event types -events.register_callback(error_handler, std::ptr::null_mut(), Some(EventType::SyncError))?; -events.register_callback(sync_handler, std::ptr::null_mut(), Some(EventType::SyncCompleted))?; -``` - -## C API - -### Basic Usage - -```c -#include "sync_client_events.h" -#include - -// Event callback function -void my_event_callback(const SyncEventData* event, void* context) { - switch (event->event_type) { - case SYNC_EVENT_DOCUMENT_CREATED: - printf("📄 Document created: %s - %s\n", - event->document_id ? event->document_id : "unknown", - event->title ? event->title : "untitled"); - break; - - case SYNC_EVENT_DOCUMENT_UPDATED: - printf("✏️ Document updated: %s - %s\n", - event->document_id ? event->document_id : "unknown", - event->title ? event->title : "untitled"); - break; - - case SYNC_EVENT_SYNC_COMPLETED: - printf("🔄 Sync completed: %llu documents\n", - (unsigned long long)event->numeric_data); - break; - - case SYNC_EVENT_CONNECTION_STATE_CHANGED: - printf("🔗 Connection %s\n", - event->boolean_data ? "established" : "lost"); - break; - - case SYNC_EVENT_SYNC_ERROR: - printf("🚨 Sync error: %s\n", - event->error ? event->error : "unknown error"); - break; - - default: - printf("📨 Event type: %d\n", event->event_type); - break; - } -} - -int main() { - // Create sync engine - struct CSyncEngine* engine = sync_engine_create( - "sqlite:client.db?mode=rwc", - "ws://localhost:8080/ws", - "demo-token" - ); - - if (!engine) { - printf("Failed to create sync engine\n"); - return 1; - } - - // Register event callback for all events - SyncResult result = sync_engine_register_event_callback( - engine, - my_event_callback, - NULL, // context (optional) - -1 // event filter (-1 = all events) - ); - - if (result != SYNC_RESULT_SUCCESS) { - printf("Failed to register callback\n"); - sync_engine_destroy(engine); - return 1; - } - - printf("Event callback registered successfully!\n"); - - // Main loop - int running = 1; - while (running) { - // Process pending events - uint32_t processed_count; - result = sync_engine_process_events(engine, &processed_count); - - if (result == SYNC_RESULT_SUCCESS && processed_count > 0) { - printf("Processed %u events\n", processed_count); - } - - // Do other work... - usleep(16000); // ~60 FPS - - // Example: exit after some condition - // running = check_exit_condition(); - } - - // Clean up - sync_engine_destroy(engine); - return 0; -} -``` - -### Event Filtering - -```c -// Register callback only for document creation events -sync_engine_register_event_callback( - engine, - my_document_callback, - NULL, - SYNC_EVENT_DOCUMENT_CREATED -); - -// Register callback only for sync events -sync_engine_register_event_callback( - engine, - my_sync_callback, - NULL, - SYNC_EVENT_SYNC_COMPLETED -); - -// Register callback only for errors -sync_engine_register_event_callback( - engine, - my_error_callback, - NULL, - SYNC_EVENT_SYNC_ERROR -); -``` - -### Using Context Data - -```c -// Context structure to pass data to callbacks -struct MyAppContext { - int total_events; - int document_events; - char last_error[256]; -}; - -void context_callback(const SyncEventData* event, void* context) { - struct MyAppContext* app_context = (struct MyAppContext*)context; - app_context->total_events++; - - switch (event->event_type) { - case SYNC_EVENT_DOCUMENT_CREATED: - case SYNC_EVENT_DOCUMENT_UPDATED: - case SYNC_EVENT_DOCUMENT_DELETED: - app_context->document_events++; - break; - - case SYNC_EVENT_SYNC_ERROR: - if (event->error) { - strncpy(app_context->last_error, event->error, sizeof(app_context->last_error) - 1); - app_context->last_error[sizeof(app_context->last_error) - 1] = '\0'; - } - break; - } -} - -int main() { - struct MyAppContext app_context = {0}; - - // Register callback with context - sync_engine_register_event_callback( - engine, - context_callback, - &app_context, // Pass context to callback - -1 - ); - - // ... process events ... - - printf("Total events: %d, Document events: %d\n", - app_context.total_events, app_context.document_events); -} -``` - -## C++ API - -### Simple Usage - -```cpp -#include "sync_client_events.h" -#include -#include - -// Simple RAII wrapper for sync engine -class SyncEngine { -private: - CSyncEngine* engine_; - -public: - SyncEngine(const std::string& database_url, - const std::string& server_url, - const std::string& auth_token) - { - engine_ = sync_engine_create(database_url.c_str(), server_url.c_str(), auth_token.c_str()); - if (!engine_) { - throw std::runtime_error("Failed to create sync engine"); - } - } - - ~SyncEngine() { - if (engine_) { - sync_engine_destroy(engine_); - } - } - - CSyncEngine* get() const { return engine_; } - - SyncResult register_callback(SyncEventCallback callback, void* context = nullptr, int event_filter = -1) - { - return sync_engine_register_event_callback(engine_, callback, context, event_filter); - } - - SyncResult process_events(uint32_t* processed_count = nullptr) - { - return sync_engine_process_events(engine_, processed_count); - } -}; - -// Simple callback function -void simple_callback(const SyncEventData* event, void* context) -{ - switch (event->event_type) - { - case SYNC_EVENT_DOCUMENT_CREATED: - std::cout << "📄 Document created: " - << (event->document_id ? event->document_id : "unknown") - << " - " << (event->title ? event->title : "untitled") << "\n"; - break; - - case SYNC_EVENT_DOCUMENT_UPDATED: - std::cout << "✏️ Document updated: " - << (event->document_id ? event->document_id : "unknown") - << " - " << (event->title ? event->title : "untitled") << "\n"; - break; - - case SYNC_EVENT_SYNC_COMPLETED: - std::cout << "🔄 Sync completed: " << event->numeric_data << " documents\n"; - break; - - case SYNC_EVENT_CONNECTION_STATE_CHANGED: - std::cout << "🔗 Connection " << (event->boolean_data ? "established" : "lost") << "\n"; - break; - - case SYNC_EVENT_SYNC_ERROR: - std::cout << "🚨 Sync error: " << (event->error ? event->error : "unknown error") << "\n"; - break; - - default: - std::cout << "📨 Event type: " << event->event_type << "\n"; - break; - } -} - -int main() -{ - try - { - // Create sync engine - SyncEngine engine("sqlite:client.db?mode=rwc", "ws://localhost:8080/ws", "demo-token"); - - // Register callback - auto result = engine.register_callback(simple_callback); - if (result != SYNC_RESULT_SUCCESS) - { - std::cerr << "Failed to register callback\n"; - return 1; - } - - std::cout << "Event callback registered successfully!\n"; - - // Main loop - bool running = true; - while (running) - { - // Process pending events - uint32_t processed_count; - result = engine.process_events(&processed_count); - - if (result == SYNC_RESULT_SUCCESS && processed_count > 0) - { - std::cout << "Processed " << processed_count << " events\n"; - } - - // Do other work... - std::this_thread::sleep_for(std::chrono::milliseconds(16)); // ~60 FPS - - // Example: exit after some condition - // running = check_exit_condition(); - } - } - catch (const std::exception& e) - { - std::cerr << "Error: " << e.what() << "\n"; - return 1; - } - - return 0; -} -``` - -### Advanced C++ with Lambda Callbacks - -```cpp -#include "sync_client_events.h" -#include -#include -#include -#include - -// Advanced callback manager using std::function and lambdas -class CallbackManager -{ -private: - struct CallbackInfo - { - std::function handler; - SyncEventType filter; - bool filter_enabled; - - CallbackInfo(std::function h, SyncEventType f = SYNC_EVENT_DOCUMENT_CREATED, bool enabled = false) - : handler(std::move(h)), filter(f), filter_enabled(enabled) {} - }; - - std::vector> callbacks_; - - // Static C callback that forwards to C++ handlers - static void static_callback(const SyncEventData* event, void* context) - { - auto* manager = static_cast(context); - - for (auto& callback_info : manager->callbacks_) - { - if (!callback_info->filter_enabled || callback_info->filter == event->event_type) - { - callback_info->handler(event); - } - } - } - -public: - template - void add_callback(Callable&& callback, SyncEventType filter = SYNC_EVENT_DOCUMENT_CREATED, bool enable_filter = false) - { - callbacks_.emplace_back( - std::make_unique( - std::forward(callback), - filter, - enable_filter - ) - ); - } - - SyncResult register_with(SyncEngine& engine, int event_filter = -1) - { - return engine.register_callback(static_callback, this, event_filter); - } -}; - -// Event statistics collector -struct EventStats -{ - int total_events = 0; - int document_events = 0; - int sync_events = 0; - std::vector recent_events; - - void add_event(const std::string& event_name) - { - total_events++; - recent_events.push_back(event_name); - if (recent_events.size() > 5) - { - recent_events.erase(recent_events.begin()); - } - } - - void print_summary() const - { - std::cout << "\n=== Event Summary ===\n"; - std::cout << "Total events: " << total_events << "\n"; - std::cout << "Document events: " << document_events << "\n"; - std::cout << "Sync events: " << sync_events << "\n"; - - if (!recent_events.empty()) - { - std::cout << "Recent events: "; - for (const auto& name : recent_events) - { - std::cout << name << " "; - } - std::cout << "\n"; - } - std::cout << "====================\n"; - } -}; - -int main() -{ - try - { - SyncEngine engine("sqlite:client.db?mode=rwc", "ws://localhost:8080/ws", "demo-token"); - CallbackManager callbacks; - EventStats stats; - - // Lambda callback for statistics - callbacks.add_callback([&stats](const SyncEventData* event) - { - std::string event_name; - switch (event->event_type) - { - case SYNC_EVENT_DOCUMENT_CREATED: - event_name = "DocumentCreated"; - stats.document_events++; - break; - case SYNC_EVENT_DOCUMENT_UPDATED: - event_name = "DocumentUpdated"; - stats.document_events++; - break; - case SYNC_EVENT_SYNC_COMPLETED: - event_name = "SyncCompleted"; - stats.sync_events++; - break; - default: - event_name = "Other"; - break; - } - - stats.add_event(event_name); - std::cout << "📊 Event: " << event_name << "\n"; - }); - - // Lambda callback for error handling - callbacks.add_callback([](const SyncEventData* event) - { - if (event->event_type == SYNC_EVENT_SYNC_ERROR) - { - std::cerr << "🚨 Sync Error: " << (event->error ? event->error : "unknown") << "\n"; - } - else if (event->event_type == SYNC_EVENT_CONFLICT_DETECTED) - { - std::cout << "⚠️ Conflict detected for document: " - << (event->document_id ? event->document_id : "unknown") << "\n"; - } - }); - - // Lambda callback for connection monitoring - callbacks.add_callback([](const SyncEventData* event) - { - if (event->event_type == SYNC_EVENT_CONNECTION_STATE_CHANGED) - { - std::cout << "🔗 Connection " << (event->boolean_data ? "established" : "lost") << "\n"; - } - }); - - // Register all callbacks - auto result = callbacks.register_with(engine); - if (result != SYNC_RESULT_SUCCESS) - { - throw std::runtime_error("Failed to register callbacks"); - } - - std::cout << "Advanced lambda callbacks registered!\n"; - - // Main loop - bool running = true; - while (running) - { - uint32_t processed_count; - engine.process_events(&processed_count); - - if (processed_count > 0) - { - std::cout << "Processed " << processed_count << " events\n"; - } - - std::this_thread::sleep_for(std::chrono::milliseconds(16)); - - // Example: exit after some condition - // running = check_exit_condition(); - } - - // Print final statistics - stats.print_summary(); - } - catch (const std::exception& e) - { - std::cerr << "Error: " << e.what() << "\n"; - return 1; - } - - return 0; -} -``` - -## Compilation Examples - -### C Example - -```bash -# Build the Rust library first -cd sync-workspace -cargo build --release - -# Compile C example -gcc -I./sync-client/include \ - -L./target/release \ - -lsync_client \ - -framework CoreFoundation -framework Security \ - -ldl -lpthread -lm \ - your_c_code.c \ - -o your_program -``` - -### C++ Example - -```bash -# Build the Rust library first -cd sync-workspace -cargo build --release - -# Compile C++ example -g++ -std=c++14 \ - -I./sync-client/include \ - -L./target/release \ - -lsync_client \ - -framework CoreFoundation -framework Security \ - -ldl -lpthread -lm \ - your_cpp_code.cpp \ - -o your_program -``` - -## Best Practices - -### Thread Safety - -✅ **DO**: Call `process_events()` regularly in your main loop -✅ **DO**: Register all callbacks on the same thread -✅ **DO**: Use the callback context parameter to pass application state -❌ **DON'T**: Call callbacks directly from other threads -❌ **DON'T**: Access shared state from callbacks without proper synchronization - -### Performance - -✅ **DO**: Process events at regular intervals (e.g., 60 FPS) -✅ **DO**: Keep callback functions fast and non-blocking -✅ **DO**: Use event filtering to reduce unnecessary callback invocations -❌ **DON'T**: Perform long-running operations in callbacks -❌ **DON'T**: Block or sleep in callback functions - -### Error Handling - -✅ **DO**: Check return values from callback registration -✅ **DO**: Handle `SYNC_ERROR` events to detect sync issues -✅ **DO**: Log callback registration failures -❌ **DON'T**: Ignore callback registration errors -❌ **DON'T**: Throw exceptions from C callbacks (undefined behavior) - -### Memory Management - -✅ **DO**: Ensure context pointers remain valid for the engine's lifetime -✅ **DO**: Use RAII patterns in C++ for automatic cleanup -✅ **DO**: Copy event data if you need to store it beyond the callback -❌ **DON'T**: Store pointers to event data (they become invalid after the callback) -❌ **DON'T**: Free or modify event data passed to callbacks - -## Working Examples - -The repository includes complete working examples: - -- **`sync-client/examples/simple_callback_test.c`**: Basic C callback example -- **`sync-client/examples/simple_cpp_callbacks.cpp`**: Simple C++ callback example -- **`sync-client/examples/cpp_lambda_callbacks.cpp`**: Advanced C++ with lambdas -- **`sync-client/src/events.rs`**: Rust implementation with tests - -Build and run these examples to see the callback system in action: - -```bash -# Build and test C example -cargo build --release -gcc -I./sync-client/include -L./target/release -lsync_client \ - sync-client/examples/simple_callback_test.c -o c_test -./c_test - -# Build and test C++ examples -g++ -std=c++14 -I./sync-client/include -L./target/release -lsync_client \ - sync-client/examples/simple_cpp_callbacks.cpp -o cpp_simple_test -./cpp_simple_test - -g++ -std=c++14 -I./sync-client/include -L./target/release -lsync_client \ - sync-client/examples/cpp_lambda_callbacks.cpp -o cpp_lambda_test -./cpp_lambda_test -``` - -## Troubleshooting - -### Common Issues - -**Problem**: Callbacks not being invoked -**Solution**: Ensure you're calling `process_events()` regularly in your main loop - -**Problem**: Events missing or delayed -**Solution**: Check that `process_events()` is called on the same thread that registered callbacks - -**Problem**: Compilation errors with C++ -**Solution**: Use C++14 or later (`-std=c++14`) for modern language features - -**Problem**: Linking errors -**Solution**: Ensure all required libraries are linked (see compilation examples above) - -**Problem**: Segmentation fault in callbacks -**Solution**: Verify context pointers are valid and event data is not accessed after callback returns - -### Debug Build Features - -Build with debug mode to enable additional test functions: - -```bash -cargo build # Debug mode -g++ -DDEBUG -std=c++14 -I./sync-client/include -L./target/debug -lsync_client \ - your_code.cpp -o your_program -``` - -Debug builds include: -- `sync_engine_emit_test_event()`: Manually trigger test events -- `sync_engine_emit_test_event_burst()`: Generate multiple test events -- Additional logging and validation - -These functions are only available in debug builds and should not be used in production code. \ No newline at end of file +# Events + +Register callbacks on a handle, then call `replicant_process_events` from the same thread (usually the host's main or UI timer). The first registration fixes that thread; calls from any other thread are refused (`ErrorWrongThread`) and nothing is lost. Callbacks run only inside `replicant_process_events`; their strings are valid only during the call. Never call the library from an audio thread. + +| Callback | Events | +|---|---| +| document | `DocumentChanged`, `DocumentDeleted` (filter: -1 all, 1, 2) | +| sync | `SyncStarted`, `SyncCompleted`, `DatabaseChanged`, `IdentityAdopted` (re-read `replicant_get_user_id`, reload lists) | +| connection | `ConnectionAttempted`, `ConnectionSucceeded`, `ConnectionLost` | +| error | `SyncError` (`error_code`, protocol code, document id or null, `fatal`, `recovered_id` or -1) | +| conflict | `ConflictDetected` (`reason`, `recovered_id` or -1, `paths_json` for field conflicts) | + +- **Kept copies in events:** `ConflictDetected` reasons are `conflict`, `field_conflict`, `delete_wins` and `delete_superseded` (a delete undone because a newer version arrived; the document is back, and a copy is kept only if it was edited before the delete). `SyncError` carries a copy for `became_publication`, `create_rejected`, `delete_publication` (a delete undone because the document became a publication; the document is back, read-only, and the edits made before the delete are kept; the error code is still `became_publication`), and a delete the server refused (its protocol code is the server's, e.g. `forbidden`; the document is back, and edits made before the delete are kept as `delete_refused`). `diverged` (5007) means the document stopped uploading until its next edit; nothing is lost. + +- **Origins:** `Local` (a handle of this engine wrote it), `Remote` (sync wrote it, in any process), `OtherProcess` (another process or library copy on the same data dir). +- **One event per document per pass**, to every handle, the writer included. +- **Per connection:** `ConnectionSucceeded` → `SyncStarted` → `SyncCompleted` (at most once) → `ConnectionLost`. A handle created mid-connection first receives the events of that connection so far. Dials not yet delivered with no other event between them arrive as one `ConnectionAttempted` (the latest); all events keep their order. +- **`DatabaseChanged`:** changes were trimmed before this engine read them, or this handle fell 4096 events behind (its document events were collapsed); reload every list. +- **Conflicts** are raised only in the process that applied the rule; every process sees the kept copy in `replicant_list_recovered`, the source of truth. A `recovered_id` may already be dismissed or restored elsewhere (`ErrorNotFound`). +- **State** for a status indicator comes from `replicant_get_state`, not from counting events; call it right after `replicant_create`. Parked documents come from `replicant_list_parked` and are not in `replicant_count_pending_sync`. +- **Destroy** inside a callback is allowed: the handle is freed when `replicant_process_events` returns. Anywhere else a destroy must not overlap any other call on that handle, on any thread. diff --git a/EXAMPLES.md b/EXAMPLES.md deleted file mode 100644 index 541af5a..0000000 --- a/EXAMPLES.md +++ /dev/null @@ -1,435 +0,0 @@ -# Interactive Examples - -This project includes interactive examples that demonstrate the sync system in action. - -## Event Callback Examples - -The sync client provides real-time event callbacks for document changes, sync operations, and connection status. These examples show practical usage patterns. - -### Real-Time UI Updates - -Monitor document changes to update your user interface in real-time: - -```c -// C example for UI updates -struct AppContext { - int total_documents; - char status_message[256]; -}; - -void ui_update_callback(const SyncEventData* event, void* context) { - struct AppContext* app = (struct AppContext*)context; - - switch (event->event_type) { - case SYNC_EVENT_DOCUMENT_CREATED: - app->total_documents++; - snprintf(app->status_message, sizeof(app->status_message), - "Document created: %s", event->title); - update_ui_status(app->status_message); - refresh_document_list(); - break; - - case SYNC_EVENT_SYNC_COMPLETED: - snprintf(app->status_message, sizeof(app->status_message), - "Sync complete: %llu documents updated", event->numeric_data); - update_ui_status(app->status_message); - break; - - case SYNC_EVENT_CONNECTION_STATE_CHANGED: - if (event->boolean_data) { - strcpy(app->status_message, "✅ Connected to server"); - enable_sync_features(); - } else { - strcpy(app->status_message, "❌ Disconnected - working offline"); - disable_sync_features(); - } - update_ui_status(app->status_message); - break; - } -} - -// In your application initialization -struct AppContext app_context = {0}; -sync_engine_register_event_callback(engine, ui_update_callback, &app_context, -1); -``` - -### Sync Progress Monitoring - -Track synchronization progress and handle errors: - -```cpp -// C++ example for sync monitoring -class SyncMonitor { -private: - std::chrono::steady_clock::time_point sync_start_time; - int sync_progress = 0; - -public: - void handle_event(const SyncEventData* event) { - switch (event->event_type) { - case SYNC_EVENT_SYNC_STARTED: - sync_start_time = std::chrono::steady_clock::now(); - sync_progress = 0; - std::cout << "🔄 Starting sync..." << std::endl; - show_progress_bar(true); - break; - - case SYNC_EVENT_DOCUMENT_UPDATED: - sync_progress++; - update_progress_bar(sync_progress); - std::cout << "📄 Synced: " << event->title << std::endl; - break; - - case SYNC_EVENT_SYNC_COMPLETED: { - auto duration = std::chrono::steady_clock::now() - sync_start_time; - auto ms = std::chrono::duration_cast(duration).count(); - - std::cout << "✅ Sync completed in " << ms << "ms" << std::endl; - std::cout << "📊 Total documents: " << event->numeric_data << std::endl; - hide_progress_bar(); - break; - } - - case SYNC_EVENT_SYNC_ERROR: - std::cerr << "🚨 Sync error: " << event->error << std::endl; - hide_progress_bar(); - show_error_dialog(event->error); - break; - } - } -}; - -// Register with lambda -SyncMonitor monitor; -callbacks.add_callback([&monitor](const SyncEventData* event) { - monitor.handle_event(event); -}); -``` - -### Document Activity Logging - -Log all document operations for audit trails: - -```rust -// Rust example for activity logging -use std::fs::OpenOptions; -use std::io::Write; -use chrono::Utc; - -fn setup_activity_logging(events: &EventDispatcher) -> Result<(), Box> { - events.register_callback( - |event_type, document_id, title, content, error, numeric_data, boolean_data, context| { - let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S UTC"); - let log_entry = match event_type { - EventType::DocumentCreated => { - format!("[{}] CREATED: {} - {}", - timestamp, - document_id.unwrap_or("unknown"), - title.unwrap_or("untitled")) - }, - EventType::DocumentUpdated => { - format!("[{}] UPDATED: {} - {}", - timestamp, - document_id.unwrap_or("unknown"), - title.unwrap_or("untitled")) - }, - EventType::DocumentDeleted => { - format!("[{}] DELETED: {}", - timestamp, - document_id.unwrap_or("unknown")) - }, - EventType::SyncCompleted => { - format!("[{}] SYNC: Completed {} documents", timestamp, numeric_data) - }, - EventType::SyncError => { - format!("[{}] ERROR: {}", timestamp, error.unwrap_or("unknown error")) - }, - _ => return, // Skip other events - }; - - // Write to log file - if let Ok(mut file) = OpenOptions::new() - .create(true) - .append(true) - .open("sync_activity.log") - { - writeln!(file, "{}", log_entry).ok(); - } - - // Also print to console - println!("{}", log_entry); - }, - std::ptr::null_mut(), - None // Log all events - )?; - - Ok(()) -} -``` - -### Conflict Resolution UI - -Handle conflicts with user interaction: - -```c -// C example for conflict resolution -void conflict_handler(const SyncEventData* event, void* context) { - if (event->event_type == SYNC_EVENT_CONFLICT_DETECTED) { - printf("⚠️ Conflict detected for document: %s\n", event->document_id); - - // Show conflict resolution dialog - ConflictResolution resolution = show_conflict_dialog( - event->document_id, - "Local version conflicts with server version" - ); - - switch (resolution) { - case KEEP_LOCAL: - printf("User chose to keep local version\n"); - // Force push local version - break; - case ACCEPT_SERVER: - printf("User chose to accept server version\n"); - // Reload from server - break; - case MERGE_MANUALLY: - printf("User chose manual merge\n"); - // Open merge editor - open_merge_editor(event->document_id); - break; - } - } -} -``` - -### Performance Metrics - -Monitor sync performance and statistics: - -```cpp -// C++ example for performance monitoring -class PerformanceMonitor { -private: - struct { - int documents_created = 0; - int documents_updated = 0; - int documents_deleted = 0; - int sync_operations = 0; - int sync_errors = 0; - std::chrono::milliseconds total_sync_time{0}; - } stats; - - std::chrono::steady_clock::time_point sync_start; - -public: - void handle_event(const SyncEventData* event) { - switch (event->event_type) { - case SYNC_EVENT_DOCUMENT_CREATED: - stats.documents_created++; - break; - case SYNC_EVENT_DOCUMENT_UPDATED: - stats.documents_updated++; - break; - case SYNC_EVENT_DOCUMENT_DELETED: - stats.documents_deleted++; - break; - case SYNC_EVENT_SYNC_STARTED: - sync_start = std::chrono::steady_clock::now(); - break; - case SYNC_EVENT_SYNC_COMPLETED: - stats.sync_operations++; - stats.total_sync_time += std::chrono::duration_cast( - std::chrono::steady_clock::now() - sync_start - ); - break; - case SYNC_EVENT_SYNC_ERROR: - stats.sync_errors++; - break; - } - } - - void print_stats() const { - std::cout << "\n📊 Performance Statistics:" << std::endl; - std::cout << "Documents created: " << stats.documents_created << std::endl; - std::cout << "Documents updated: " << stats.documents_updated << std::endl; - std::cout << "Documents deleted: " << stats.documents_deleted << std::endl; - std::cout << "Sync operations: " << stats.sync_operations << std::endl; - std::cout << "Sync errors: " << stats.sync_errors << std::endl; - if (stats.sync_operations > 0) { - auto avg_time = stats.total_sync_time.count() / stats.sync_operations; - std::cout << "Average sync time: " << avg_time << "ms" << std::endl; - } - } -}; -``` - -For comprehensive documentation and more examples, see [EVENT_CALLBACKS.md](EVENT_CALLBACKS.md). - -## Prerequisites - -1. PostgreSQL running (use Docker Compose or local installation) -2. Database created and accessible - -## Running the Examples - -### 1. Start the Sync Server - -First, start the server: - -```bash -# Option 1: Using Docker (recommended) -docker-compose up -d - -# Option 2: Manual setup -export DATABASE_URL="postgres://postgres:postgres@localhost/sync_db" -cargo run --bin replicant-server - -# Option 3: With monitoring enabled (shows real-time activity) -export DATABASE_URL="postgres://postgres:postgres@localhost/sync_db" -MONITORING=true cargo run --bin replicant-server -``` - -With monitoring enabled, the server displays: -- Real-time connection events -- All messages sent/received -- JSON patch diffs when documents are updated -- Conflict detection alerts -- Colorized output for easy debugging - -### 2. Run the Interactive Task Manager Client - -In another terminal, run the interactive client: - -```bash -# Run with defaults (creates databases/alice.sqlite3) -cargo run --package replicant-client --example interactive_client - -# Or specify a different database name -cargo run --package replicant-client --example interactive_client -- --database bob - -# Or specify custom options -cargo run --package replicant-client --example interactive_client -- \ - --database my_tasks \ - --server ws://localhost:8080/ws \ - --token my-auth-token -``` - -The client provides a modern task management interface with: -- 📋 List tasks with status and priority indicators -- ➕ Create new tasks with form-based input -- ✏️ Edit tasks with guided field editing -- 🔍 View detailed task information -- ✅ Mark tasks as completed -- 🗑️ Delete tasks with confirmation -- 🔄 Check sync status -- 📱 Works offline with automatic sync when reconnected - -## Example Workflow - -1. **Start the server** - You'll see it's ready when it displays the listening address -2. **Start a client** - It will create a new user ID automatically -3. **Create a task** - Use the guided interface to create a task with title, description, priority, and tags -4. **Watch the server** - If monitoring is enabled, see real-time logs showing the task creation -5. **Edit the task** - Make changes through the form interface and see JSON patches in server logs -6. **Start another client** - Use the same database name to see tasks sync across clients -7. **Try different operations** - Mark tasks complete, change priorities, add tags - -## Server Log Example - -``` -🚀 Sync Server Monitor -===================== - -📊 Connecting to database: postgres://postgres:postgres@localhost/sync_db -✅ Created demo user: 123e4567-e89b-12d3-a456-426614174000 - -🌐 Server listening on: 0.0.0.0:8080 -🔌 WebSocket endpoint: ws://0.0.0.0:8080/ws - -📋 Activity Log: -──────────────────────────────────────────────────────────────────────────────── -14:23:15.123 → Client connected: a1b2c3d4-e5f6-7890-abcd-ef1234567890 -14:23:15.456 ↓ Authenticate from a1b2c3d4-e5f6-7890-abcd-ef1234567890 -14:23:15.457 ↑ AuthSuccess to a1b2c3d4-e5f6-7890-abcd-ef1234567890 -14:23:20.789 ↓ CreateDocument from a1b2c3d4-e5f6-7890-abcd-ef1234567890 -14:23:20.790 ↑ DocumentCreated to a1b2c3d4-e5f6-7890-abcd-ef1234567890 -14:23:25.123 ↓ UpdateDocument from a1b2c3d4-e5f6-7890-abcd-ef1234567890 -14:23:25.124 🔧 Patch applied to document 987fcdeb-51a2-43b7-8c9d-0e1f2a3b4c5d: - [ - { - "op": "replace", - "path": "/content", - "value": "Updated content" - }, - { - "op": "add", - "path": "/tags", - "value": ["example", "demo"] - } - ] -14:23:25.125 ↑ DocumentUpdated to a1b2c3d4-e5f6-7890-abcd-ef1234567890 -``` - -## Client Interface Example - -``` -🚀 JSON Database Sync Client -============================ -📁 Database: databases/alice.sqlite3 -👤 User ID: 123e4567-e89b-12d3-a456-426614174000 -🌐 Server: ws://localhost:8080/ws - -✅ Connected to sync server! - -What would you like to do? -> 📋 List tasks - ➕ Create new task - ✏️ Edit task - 🔍 View task details - ✅ Mark task completed - 🗑️ Delete task - 🔄 Sync status - ❌ Exit - -📋 Your Tasks: -──────────────────────────────────────────────────────────────────────────────────────────────── -⏳ 🔴 987fcdeb Fix critical bug - Investigate database connection issues 📤 -✅ 🟡 abc12345 Complete project documentation - Write API documentation -🔄 🟢 def67890 Code review - Review pull request #123 -──────────────────────────────────────────────────────────────────────────────────────────────── -Legend: ✅=done 🔄=progress ⏳=pending | 🔴=high 🟡=med 🟢=low | 📤=sync pending -``` - -## Authentication - -The system supports demo mode for easy testing and development: - -### Demo Mode (Default) - -```bash -# Uses demo-token by default - no setup required -cargo run --package replicant-client --example interactive_client - -# Server automatically creates users for demo-token -# Each client gets a unique user ID -``` - -### Custom Authentication - -```bash -# Use your own auth token -cargo run --package replicant-client --example interactive_client -- \ - --token my-custom-token \ - --user-id 550e8400-e29b-41d4-a716-446655440000 - -# Server will auto-register users with custom tokens in demo mode -``` - -## Tips - -- The client works offline - tasks show sync status indicators (📤 = pending sync) -- You can run multiple clients with the same database name to test real-time sync -- Use monitoring mode (`MONITORING=true`) to see JSON patch diffs and debug sync issues -- The task interface provides guided input - no need to write raw JSON -- Tasks support rich metadata: priorities, tags, descriptions, and completion status -- Use Ctrl+C to cleanly exit either application \ No newline at end of file diff --git a/FRAMEWORK_INTEGRATION.md b/FRAMEWORK_INTEGRATION.md deleted file mode 100644 index 7fa7858..0000000 --- a/FRAMEWORK_INTEGRATION.md +++ /dev/null @@ -1,291 +0,0 @@ -# Desktop Framework Integration - -## JUCE Integration - -The `replicant` JUCE module provides the simplest integration path. It handles timer-based event processing automatically and delivers callbacks on the message thread. - -### Prerequisites - -1. JUCE 6.0+ project -2. Replicant SDK (`dist/` folder from build) -3. C++14 or later - -### Project Setup (Projucer) - -1. **Add the module path** - - Open your `.jucer` file in Projucer - - Go to *File > Global Paths* - - Add the SDK path to *User Modules*: `/path/to/replicant-sdk/dist/juce` - -2. **Enable the module** - - In your project, go to *Modules* - - Click *+* and select `replicant` - -3. **Add library search path** - - Go to your exporter settings (e.g., *Xcode (macOS)*) - - Add to *Extra Library Search Paths*: `/path/to/replicant-sdk/dist/lib` - -4. **Save and export** - -### Project Setup (CMake) - -```cmake -cmake_minimum_required(VERSION 3.15) -project(MyApp VERSION 1.0.0) - -# Add JUCE -add_subdirectory(JUCE) - -# Add Replicant module path -list(APPEND JUCE_MODULE_PATH "/path/to/replicant-sdk/dist/juce") - -juce_add_gui_app(MyApp - PRODUCT_NAME "My App") - -target_sources(MyApp PRIVATE - Source/Main.cpp - Source/MainComponent.cpp) - -target_compile_features(MyApp PRIVATE cxx_std_14) - -target_link_libraries(MyApp PRIVATE - juce::juce_core - juce::juce_events - juce::juce_gui_basics - replicant) - -# Link the replicant_client library -target_link_directories(MyApp PRIVATE /path/to/replicant-sdk/dist/lib) -``` - -### Basic Usage - -```cpp -#include - -class MainComponent : public juce::Component -{ -public: - MainComponent() - : sync("sqlite:local.db?mode=rwc", - "wss://your-server.com/ws", - "user@example.com", - "rpa_your_api_key", - "rps_your_api_secret") - { - // Callbacks run on the message thread - safe to update UI directly - sync.onDocumentCreated = [this](const std::string& id, - const std::string& title, - const std::string& content) - { - addItemToList(id, title); - repaint(); - }; - - sync.onDocumentUpdated = [this](const std::string& id, - const std::string& title, - const std::string& content) - { - updateItemInList(id, title); - repaint(); - }; - - sync.onDocumentDeleted = [this](const std::string& id) - { - removeItemFromList(id); - repaint(); - }; - - sync.onConnectionChanged = [this](bool connected) - { - statusLabel.setText(connected ? "Online" : "Offline", - juce::dontSendNotification); - }; - - sync.onSyncError = [this](const std::string& message) - { - juce::AlertWindow::showMessageBoxAsync( - juce::MessageBoxIconType::WarningIcon, - "Sync Error", message); - }; - } - - void createNewDocument() - { - try - { - auto id = sync.createDocument(R"({"title":"New Note","content":""})"); - DBG("Created document: " + juce::String(id)); - } - catch (const replicant::SyncException& e) - { - DBG("Error: " + juce::String(e.what())); - } - } - -private: - replicant::Replicant sync; - juce::Label statusLabel; - - // ... UI implementation -}; -``` - -### API Reference - -**Constructor** -```cpp -Replicant(const std::string& databaseUrl, // e.g., "sqlite:data.db?mode=rwc" - const std::string& serverUrl, // e.g., "wss://server.com/ws" - const std::string& email, // User identifier - const std::string& apiKey, // rpa_ prefixed key - const std::string& apiSecret); // rps_ prefixed secret -``` - -**Document Operations** (all throw `replicant::SyncException` on failure) -```cpp -std::string createDocument(const std::string& contentJson); -void updateDocument(const std::string& documentId, const std::string& contentJson); -void deleteDocument(const std::string& documentId); -std::string getDocument(const std::string& documentId); -std::string getAllDocuments(); // Returns JSON array -``` - -**Status** -```cpp -bool isConnected(); -uint64_t countDocuments(); -uint64_t countPendingSync(); -``` - -**Callbacks** (all execute on message thread) -```cpp -std::function onDocumentCreated; -std::function onDocumentUpdated; -std::function onDocumentDeleted; -std::function onConnectionChanged; -std::function onSyncError; -``` - ---- - -## Qt Integration - -Use `QTimer` with Qt's signal/slot system: - -```cpp -#include -#include "replicant.hpp" - -class SyncWindow : public QMainWindow -{ - Q_OBJECT - -public: - SyncWindow() - : client("sqlite:client.db?mode=rwc", - "ws://localhost:8080/ws", - "user@example.com", - "rpa_key", "rps_secret") - { - client.register_document_callback(documentCallback, this); - client.register_connection_callback(connectionCallback, this); - - timer = new QTimer(this); - connect(timer, &QTimer::timeout, this, &SyncWindow::processEvents); - timer->start(100); // 10Hz - } - -private slots: - void processEvents() - { - client.process_events(); - } - -private: - replicant::Client client; - QTimer* timer; - - static void documentCallback(EventType type, const char* id, - const char* title, const char* content, void* ctx) - { - auto* self = static_cast(ctx); - // Handle on main thread - } - - static void connectionCallback(EventType type, bool connected, - uint32_t attempt, void* ctx) - { - auto* self = static_cast(ctx); - // Update connection status - } -}; -``` - ---- - -## GTK Integration - -Use `g_timeout_add()`: - -```c -#include -#include "replicant.h" - -typedef struct { - Replicant* client; - GtkLabel* status; -} AppData; - -gboolean process_events(gpointer data) -{ - AppData* app = (AppData*)data; - uint32_t count; - replicant_process_events(app->client, &count); - return G_SOURCE_CONTINUE; -} - -void on_document_created(EventType type, const char* id, - const char* title, const char* content, void* ctx) -{ - AppData* app = (AppData*)ctx; - gtk_label_set_text(app->status, "Document created"); -} - -int main(int argc, char* argv[]) -{ - gtk_init(&argc, &argv); - - AppData app = {0}; - app.client = replicant_create( - "sqlite:client.db?mode=rwc", - "ws://localhost:8080/ws", - "user@example.com", - "rpa_key", "rps_secret"); - - replicant_register_document_callback(app.client, on_document_created, &app, -1); - - g_timeout_add(100, process_events, &app); // 10Hz - - // Create UI... - gtk_main(); - - replicant_destroy(app.client); - return 0; -} -``` - ---- - -## Summary - -| Framework | Timer Mechanism | Module/Wrapper | -|-----------|-----------------|----------------| -| JUCE | Built into `replicant::Replicant` | `replicant` JUCE module | -| Qt | `QTimer` | `replicant::Client` (C++ wrapper) | -| GTK | `g_timeout_add()` | C API directly | - -All integrations follow the same principle: -- Call `process_events()` periodically (10Hz is sufficient) -- Callbacks execute on the main/UI thread -- No additional synchronization needed diff --git a/README.md b/README.md index 1c427b8..8930906 100644 --- a/README.md +++ b/README.md @@ -1,592 +1,39 @@ -# JSON Database Sync +# Replicant client -A client-server synchronization system built in Rust, featuring real-time WebSocket communication, bidirectional patch-based version control, and automatic conflict resolution. +An offline-first JSON document sync library in Rust, with a C API, for the Replicant sync server. -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +## Build -## Features + cargo build --release + python3 scripts/build_dist.py -### Real-Time Synchronization -- **WebSocket-based** bidirectional sync -- **Basic conflict resolution** with server-wins fallback -- **Offline-first** design with local storage -- **Concurrent update handling** with conflict detection +`build_dist.py` puts the headers (`replicant.h`, `replicant.hpp`) and the libraries in `dist/`. -### Version Control -- **JSON patches**: Forward patches for document changes -- **Document versioning**: Each document has version and revision tracking -- **Change events**: All modifications logged with sequence numbers -- **Audit trails**: Changes logged with user attribution +The static library contains the bundled SQLite amalgamation (SQLite 3.46.0 today, through `libsqlite3-sys` 0.30.1) and exports its `sqlite3_*` C API. Code linked into the same binary must use that SQLite and not link a second copy; entonal-common's `Migration.cpp` and IndieKey's SQLiteCpp already do. These symbols are not part of the replicant ABI, and the SQLite version may change in any release. The shared library exports only the `replicant_*` functions. -### Database Architecture -- **Client**: SQLite with offline queue and document caching -- **Server**: PostgreSQL with JSONB support and event logging -- **Change events**: Sequence-based sync with forward/reverse patch storage +## C API -## Architecture +`#include "replicant.h"` for C, or `replicant.hpp` for C++ (`replicant::Client`, a thin RAII wrapper that throws `SyncException`). The header's comments are the reference. The ABI is `REPLICANT_ABI_VERSION_MAJOR.MINOR`: a breaking change bumps the major, an addition the minor; `replicant_abi_version` returns the library's, packed `(major << 16) | minor`. -- **sync-core**: Shared library with data models, JSON patch operations, and sync protocols -- **sync-client**: Client library with SQLite storage, offline queue, and C FFI exports -- **sync-server**: Server binary with PostgreSQL storage, WebSocket support, and event logging +- `replicant_create` attaches to the engine for a data dir; every handle in the process on that data dir shares it. It never waits on the network. Set `struct_size` in `ReplicantConfig`. `ErrorNewerSchema` means a newer build migrated the database; `ErrorMigrationFailed` that a v1 library could not be upgraded (either the backup failed and nothing changed, or the migration failed after a `.v1-backup` copy was kept, `.v1-backup-` when that name was taken; an existing backup is never replaced); `ErrorBusy` that another process kept it locked; `ErrorConfigMismatch` that an open engine uses another config. +- `ReplicantConfig.list_merge` and `list_merge_rules_json` choose how a list changed on both sides is merged: `Append` (default) merges element by element while positions line up, `Atomic` keeps the server's list and sets the local one aside. `Full` is refused for now. Every handle on a data dir must pass the same config. +- `ReplicantConfig.title_pointer` is a JSON Pointer (e.g. `"/title"`) to the string in each document's content that is its title. The `title` in reads, callbacks, kept copies and the `title:` search field comes only from it; without it the library assigns no titles. The shared-engine check (`ErrorConfigMismatch`) covers handles in one process. Every process on a data dir must pass the same pointer: a different pointer in another process is not detected, each launch with one recomputes every title and the search index, and titles end up mixed while both run. +- Reads and writes are local SQLite; the engine uploads and downloads in the background. Never call from an audio thread. +- Sign-in: `replicant_enroll_request` emails a code, `replicant_enroll_claim` exchanges it and stores the credentials itself (it returns only the user id). `replicant_clear_credentials` signs out. Both apply at once to open engines on that data dir in this process; engines in other processes pick up a sign-in within about 3 s and a sign-out within about a second. The api key and secret never cross the C API: a host tells whether it is signed in from `replicant_get_state` (`halt_reason` is `NotEnrolled` when signed out or the stored credentials are damaged) and reads the user id with `replicant_get_user_id`. +- `replicant_get_state` gives connection, sync phase and halt reason; `replicant_reconnect` retries. +- Kept copies: `replicant_list_recovered`, `replicant_restore_document`, `replicant_restore_fields`, `replicant_dismiss_recovered`. Documents the server refused to change: `replicant_list_parked`. +- JSON results: every key, type and null case is documented on `replicant_get_document` (documents), `replicant_list_recovered` and `replicant_list_parked`. A document's own id is `id`; other records name a document `doc_id`; `created_at` and `updated_at` are RFC 3339 in UTC, `recovered_at` is Unix seconds. +- Events: see `EVENT_CALLBACKS.md`. +- Plugins must never unload the library while the process runs: `replicant_destroy` returns at once and the engine stops afterwards. -## Quick Start +## Release gate -### Prerequisites -- Rust 1.75+ -- PostgreSQL 16+ -- Docker (optional) +Every binary that links replicant-client carries `replicant-client-version=`. Before a release, check that all of them link the same version: -### Option 1: Docker (Recommended) + python3 scripts/check_replicant_versions.py -```bash -git clone https://github.com/yourusername/json-db-sync.git -cd json-db-sync/sync-workspace -docker-compose up -d -``` - -This starts PostgreSQL and the sync server on port 8080. - -### Option 2: Manual Setup - -1. **Install dependencies:** -```bash -cargo build --workspace -``` - -2. **Set up PostgreSQL:** -```bash -createdb sync_db -export DATABASE_URL="postgres://user:password@localhost/sync_db" -``` - -3. **Run migrations:** -```bash -cd sync-server -sqlx migrate run -``` - -4. **Generate API credentials:** -```bash -cargo run --bin sync-server generate-credentials --name "My Application" -``` - -Save the generated API key and secret securely. The secret will not be shown again. - -5. **Start the server:** -```bash -cargo run --bin sync-server serve -``` - -### Try the Interactive Client - -```bash -# Using your generated API credentials -cargo run --package sync-client --example interactive_client -- \ - --api-key "rpa_your_api_key_here" - -# Or specify a different database and user -cargo run --package sync-client --example interactive_client -- \ - --database bob \ - --api-key "rpa_your_api_key_here" \ - --user-id "user@example.com" -``` - -The interactive client provides a task management interface with: -- Create, edit, and complete tasks -- Priority levels and tags -- Rich task listing with status indicators -- Real-time sync across multiple clients -- Offline support with automatic sync when reconnected - -## API Usage - -### Rust Client Library - -```rust -use sync_client::SyncEngine; -use serde_json::json; - -// Connect to server with HMAC authentication -let engine = SyncEngine::new( - "sqlite:client.db?mode=rwc", - "ws://localhost:8080/ws", - "rpa_your_api_key_here", - "user@example.com" -).await?; - -// Create a document -let doc = engine.create_document( - json!({ - "title": "My Document", - "description": "Task description", - "status": "pending", - "priority": "medium", - "tags": ["work", "important"] - }) -).await?; - -// Update document -engine.update_document(doc.id, json!({ - "status": "completed" -})).await?; -``` - -#### Rust Event Callbacks - -```rust -use sync_client::events::{EventDispatcher, EventType}; - -// Get event dispatcher from engine -let events = engine.event_dispatcher(); - -// Register callback for document events -events.register_callback( - |event_type, document_id, title, content, error, numeric_data, boolean_data, context| { - match event_type { - EventType::DocumentCreated => { - println!("New document: {}", title.unwrap_or("untitled")); - }, - EventType::DocumentUpdated => { - println!("Updated: {}", title.unwrap_or("untitled")); - }, - EventType::SyncCompleted => { - println!("Synced {} documents", numeric_data); - }, - _ => {} - } - }, - std::ptr::null_mut(), // context - None // filter (None = all events) -)?; - -// In your main loop -loop { - let processed = events.process_events()?; - if processed > 0 { - println!("Processed {} events", processed); - } - - tokio::time::sleep(tokio::time::Duration::from_millis(16)).await; -} -``` - -### WebSocket API - -Connect to `ws://localhost:8080/ws` and authenticate with HMAC signature: - -```json -{ - "type": "authenticate", - "email": "user@example.com", - "client_id": "550e8400-e29b-41d4-a716-446655440000", - "api_key": "rpa_your_api_key_here", - "signature": "calculated_hmac_signature", - "timestamp": 1736525432 -} -``` - -The HMAC signature is calculated as: `HMAC-SHA256(secret, "timestamp.email.api_key.body")` - -Create documents: -```json -{ - "type": "create_document", - "document": { - "id": "550e8400-e29b-41d4-a716-446655440001", - "title": "My Document", - "content": {"text": "Hello, World!"} - } -} -``` - -Update with JSON patches: -```json -{ - "type": "update_document", - "patch": { - "document_id": "550e8400-e29b-41d4-a716-446655440001", - "patch": [ - {"op": "replace", "path": "/text", "value": "Updated text"} - ] - } -} -``` - -### C/C++ Integration - -The sync client provides a C API that can be used from C, C++, and other languages. Build the distribution SDK: - -```bash -# Build the complete SDK with headers and libraries -./scripts/build_dist.sh - -# This creates: -# - dist/include/sync_client.h (C header) -# - dist/include/sync_client.hpp (C++ wrapper) -# - dist/lib/ (compiled libraries) -# - dist/examples/ (working examples) -``` - -#### Event Callback System - -The sync client includes a comprehensive event callback system for real-time notifications about document changes, sync operations, and connection status. - -**Key Features:** -- Thread-safe design with no locks needed in user code -- Support for Rust, C, and C++ applications -- Event filtering and context passing -- Offline and online event notifications - -**Quick C Example:** -```c -#include "sync_client_events.h" - -void my_callback(const SyncEventData* event, void* context) { - switch (event->event_type) { - case SYNC_EVENT_DOCUMENT_CREATED: - printf("Document created: %s\n", event->title); - break; - case SYNC_EVENT_SYNC_COMPLETED: - printf("Sync completed: %llu documents\n", event->numeric_data); - break; - } -} - -int main() { - struct CSyncEngine* engine = sync_engine_create( - "sqlite:client.db", - "ws://localhost:8080/ws", - "user@example.com", - "rpa_your_api_key_here", - "rps_your_secret_here"); - - // Register callback for all events - sync_engine_register_event_callback(engine, my_callback, NULL, -1); - - // Main loop - process events regularly - while (running) { - uint32_t processed_count; - sync_engine_process_events(engine, &processed_count); - // ... do other work - usleep(16000); // ~60 FPS - } - - sync_engine_destroy(engine); - return 0; -} -``` - -**C++ Example with Lambdas:** -```cpp -#include "sync_client_events.h" - -int main() { - // Simple RAII wrapper - SyncEngine engine("sqlite:client.db", "ws://localhost:8080/ws", - "user@example.com", "rpa_your_api_key_here", - "rps_your_secret_here"); - - // Lambda callback with capture - int event_count = 0; - auto callback = [&event_count](const SyncEventData* event, void* context) { - event_count++; - std::cout << "Event #" << event_count << ": " - << get_event_name(event->event_type) << std::endl; - }; - - // Register callback - engine.register_callback(callback); - - // Main loop - while (running) { - engine.process_events(); - std::this_thread::sleep_for(std::chrono::milliseconds(16)); - } - - return 0; -} -``` - -For complete documentation and advanced examples, see [EVENT_CALLBACKS.md](EVENT_CALLBACKS.md). - -#### Framework Integration - -The event callback system integrates with desktop frameworks via timers: - -- **JUCE**: Use `juce::Timer` to call `process_events()` every 16ms -- **Qt**: Use `QTimer` with Qt's signal/slot system -- **GTK**: Use `g_timeout_add()` for periodic processing -- All callbacks execute on the framework's main thread - -See [FRAMEWORK_INTEGRATION.md](FRAMEWORK_INTEGRATION.md) for complete examples. - -**C API Example:** - -```c -#include "sync_client.h" -#include -#include - -int main() { - // Create sync engine with HMAC authentication - struct CSyncEngine* engine = sync_engine_create( - "sqlite:client.db?mode=rwc", - "ws://localhost:8080/ws", - "user@example.com", - "rpa_your_api_key_here", - "rps_your_secret_here" - ); - - if (!engine) { - printf("Failed to create sync engine\n"); - return 1; - } - - // Get version - char* version = sync_get_version(); - if (version) { - printf("Sync client version: %s\n", version); - sync_string_free(version); - } - - // Create a document - char doc_id[37] = {0}; // UUID string + null terminator - enum CSyncResult result = sync_engine_create_document( - engine, - "{\"title\":\"My Document\",\"content\":\"Hello World\",\"type\":\"note\",\"priority\":\"medium\"}", - doc_id - ); - - if (result == Success) { - printf("Created document: %s\n", doc_id); - - // Update the document - result = sync_engine_update_document( - engine, - doc_id, - "{\"content\":\"Hello Updated World\",\"type\":\"note\",\"priority\":\"high\"}" - ); - - if (result == Success) { - printf("Updated document successfully\n"); - } - } - - // Clean up - sync_engine_destroy(engine); - return 0; -} -``` - -**C++ Integration:** - -```cpp -#include "replicant.hpp" -#include - -int main() -{ - try - { - // Create client with HMAC authentication - replicant::Client client( - "sqlite:client.db?mode=rwc", - "ws://localhost:8080/ws", - "user@example.com", - "rpa_your_api_key_here", - "rps_your_secret_here" - ); - - std::cout << "Replicant version: " << replicant::Client::get_version() << std::endl; - - // Create a document - auto doc_id = client.create_document( - R"({"title":"My Document","content":"Hello World","type":"note","priority":"medium"})" - ); - - std::cout << "Created document: " << doc_id << std::endl; - - // Update the document - client.update_document( - doc_id, - R"({"content":"Hello Updated World","type":"note","priority":"high"})" - ); - - std::cout << "Updated document successfully" << std::endl; - } - catch (const replicant::SyncException& e) - { - std::cerr << "Replicant error: " << e.what() << std::endl; - return 1; - } - - return 0; -} -``` - -**Building and Linking:** - -Using the distribution SDK (recommended): - -```cmake -# CMakeLists.txt -cmake_minimum_required(VERSION 3.15) -project(MyProject) - -set(CMAKE_CXX_STANDARD 11) - -# Find the sync client SDK -find_package(sync_client REQUIRED PATHS /path/to/sync-workspace/dist) - -# Your C++ application -add_executable(my_app main.cpp) -target_link_libraries(my_app sync_client) -``` - -Or compile directly: - -```bash -# Build the distribution first -./scripts/build_dist.sh - -# Compile your C/C++ code (example for macOS/Linux) -gcc -I./dist/include your_code.c -L./dist/lib -lsync_client -framework Security -o your_program - -# For C++ -g++ -std=c++11 -I./dist/include your_code.cpp -L./dist/lib -lsync_client -framework Security -o your_program -``` - -## Bidirectional Patch System - -The system stores both forward and reverse patches for every document change: - -### How It Works - -**CREATE Event:** -- `forward_patch`: Contains the full document as initial state -- `reverse_patch`: `null` (creation cannot be undone) - -**UPDATE Event:** -- `forward_patch`: JSON patch to apply the change -- `reverse_patch`: JSON patch to undo the change - -**DELETE Event:** -- `forward_patch`: `null` (deletion is implicit) -- `reverse_patch`: Contains full document to restore if undeleted - -### Benefits - -- **Complete Audit Trail**: All changes logged with recovery capabilities -- **Efficient Storage**: JSON patches store only the differences between versions -- **Version History**: Forward and reverse patch support for future undo functionality - -## Conflict Resolution - -The system handles concurrent updates with basic conflict detection: - -1. **Multiple clients** can update the same document simultaneously -2. **Server processes updates** sequentially and detects conflicts using vector clocks -3. **Server-wins fallback** for conflict resolution (clients accept server state) -4. **Conflict detection** implemented with vector clock comparison - -## Testing - -### Unit Tests -```bash -cargo test --lib --bins -``` - -### Integration Tests -```bash -# Local setup with PostgreSQL (fast) -./test/run_integration_tests_local.sh - -# Docker-based setup (consistent environment) -./test/run_integration_tests_docker.sh - -# Manual setup -docker-compose -f docker-compose.test.yml up -d -export RUN_INTEGRATION_TESTS=1 -export TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5433/sync_test_db" -cargo test integration -- --test-threads=1 -``` - -### Test Coverage -- **sync-core**: 7 tests (vector clocks, document revisions, JSON patches) -- **sync-client**: 1 test (basic functionality) -- **sync-server**: 7 tests (authentication, WebSocket protocol, concurrent scenarios) - -## Authentication - -The system uses a two-level authentication model with HMAC-based request signing: - -### Authentication Levels - -1. **Application Level**: API credentials authenticate the application - - API Key format: `rpa_*` (Replicant API) - - Secret format: `rps_*` (Replicant Secret) - - Credentials are generated using the CLI tool - - One set of credentials per application/environment - -2. **User Level**: Email identifies which user's data to access - - Users are identified by email address - - No passwords required (security handled at application level) - - User records created automatically on first authentication - -### Generating Credentials - -Generate API credentials using the server CLI: - -```bash -cargo run --bin sync-server generate-credentials --name "Production" -``` - -This outputs: -``` -API Key: rpa_a8d73487645ef2b9c3d4e5f6a7b8c9d0 -Secret: rps_1f2e3d4c5b6a798897a6b5c4d3e2f1a0 -``` - -**Important**: Save the secret securely - it will not be shown again. - -### HMAC Signature - -All authenticated requests require an HMAC-SHA256 signature: - -1. **Message format**: `timestamp.email.api_key.body` -2. **Timestamp validation**: Requests expire after 5 minutes -3. **Signature verification**: Prevents tampering and replay attacks - -### Security Considerations - -- **Transport Security**: Use WSS/HTTPS in production -- **Credential Storage**: Store API secrets securely (environment variables, secrets manager) -- **Rate Limiting**: Implement rate limiting to prevent brute force attempts -- **Audit Logging**: Track authentication attempts and failures - -## Performance & Security - -### Optimizations -- **Connection pooling** for PostgreSQL and SQLite -- **JSONB indexing** for fast document searches -- **Patch-based storage** for efficient change tracking - -### Security Features -- HMAC-based authentication with timestamp validation -- API credentials stored in database (plaintext for MVP) -- Input validation for JSON patches -- Replay attack prevention via timestamp checks +It exits 1 when a binary has no marker, carries two versions, or the binaries disagree. ## License MIT - -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Add tests for new functionality -4. Run `cargo test` and `./test/run_integration_tests_docker.sh` -5. Submit a pull request - -See [TESTING.md](TESTING.md) for detailed testing guidelines and [EXAMPLES.md](EXAMPLES.md) for usage examples. diff --git a/TESTING.md b/TESTING.md index aa6ebe5..0b09fa4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,210 +1,3 @@ -# Testing Guide +# Testing -This document describes the testing strategy and how to run tests for the JSON-DB-Sync project. - -## Testing Philosophy - -We follow a pragmatic testing approach: -- **Minimal unit tests** for pure functions (auth token hashing, validators) -- **Comprehensive integration tests** for real-world scenarios -- **Property-based tests** for conflict resolution -- **Load tests** for concurrent connections - -## Test Structure - -``` -sync-server/tests_integration/ -├── integration_tests.rs # Main integration test entry point -├── integration/ -│ ├── mod.rs # Module declarations -│ ├── helpers.rs # Test utilities and helpers -│ ├── auth_integration.rs # Authentication flow tests -│ ├── sync_flow_integration.rs # Document sync scenarios -│ ├── conflict_resolution_integration.rs # Conflict handling -│ ├── websocket_integration.rs # WebSocket protocol tests -│ └── concurrent_clients_integration.rs # Load and concurrency tests -├── basic_test.rs # Core integration tests -└── full_sync_test.rs # End-to-end sync scenarios - -sync-server/tests/ -└── unit_tests.rs # Server-specific unit tests - -test/ -├── run_integration_tests.sh # Docker-based integration tests -├── run_integration_tests_fast.sh # Fast integration tests with caching -└── run_integration_tests_local.sh # Local integration tests -``` - -## Running Tests - -### Quick Reference - -```bash -# Unit tests -cargo test --lib --bins - -# Integration tests (local PostgreSQL - fast) -./test/run_integration_tests_local.sh - -# Integration tests (Docker - consistent environment) -./test/run_integration_tests_docker.sh - -# Manual integration test setup -docker-compose -f docker-compose.test.yml up -d -export RUN_INTEGRATION_TESTS=1 -export TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5433/sync_test_db" -cargo test integration -- --test-threads=1 -``` - -### Unit Tests -```bash -# Run all unit tests -cargo test --lib --bins - -# Run specific test -cargo test test_token_hashing -``` - -### Integration Tests - -#### Option 1: Local PostgreSQL (Fast) -```bash -# Run integration tests with automated setup/teardown (requires local PostgreSQL) -./test/run_integration_tests_local.sh -``` - -#### Option 2: Docker-based (Consistent) -```bash -# Run integration tests with Docker (consistent environment, no local PostgreSQL needed) -./test/run_integration_tests_docker.sh -``` - -#### Option 3: Manual setup -```bash -# Start PostgreSQL -docker-compose -f docker-compose.test.yml up -d - -# Set environment variables -export TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5433/sync_test_db" -export RUN_INTEGRATION_TESTS=1 - -# Run integration tests -cargo test integration -- --nocapture -``` - -### Running Specific Test Categories -```bash -# Auth tests only -cargo test integration::auth_integration - -# Conflict resolution tests -cargo test integration::conflict_resolution - -# WebSocket tests -cargo test integration::websocket -``` - -## Writing New Tests - -### Integration Test Template -```rust -use crate::integration::helpers::*; - -integration_test!(test_my_scenario, |ctx: TestContext| async move { - // Setup - let user_id = Uuid::new_v4(); - let client = ctx.create_test_client(user_id, "demo-token").await; - - // Test logic - let doc = TestContext::create_test_document(user_id, "Test Doc"); - client.create_document(doc).await.unwrap(); - - // Assertions - let docs = client.get_all_documents().await.unwrap(); - assert_eq!(docs.len(), 1); -}); -``` - -### Test Helpers - -- `TestContext`: Provides server URL, database URL, and utility methods -- `create_test_client()`: Creates authenticated sync client -- `create_test_document()`: Generates test documents -- `assert_eventually()`: Waits for async conditions -- `create_authenticated_websocket()`: Direct WebSocket connection - -## CI/CD Integration - -Tests run automatically on: -- Push to `main` or `develop` branches -- Pull requests to `main` - -GitHub Actions workflow includes: -1. Unit tests -2. Integration tests (with PostgreSQL service) -3. Docker integration tests -4. Linting (rustfmt, clippy) -5. Security audit - -## Performance Testing - -Load tests are included in `concurrent_clients_integration.rs`: -- Many concurrent clients test -- Rapid update scenarios -- Server under load conditions - -## Debugging Tests - -### Enable Debug Logging -```bash -export RUST_LOG=debug -export RUST_BACKTRACE=1 -cargo test integration::failing_test -- --nocapture -``` - -### View Server Logs -```bash -# During Docker tests -docker-compose -f docker-compose.integration.yml logs -f sync-server-test -``` - -### Database Inspection -```bash -# Connect to test database -docker exec -it sync-workspace_postgres-test_1 psql -U postgres -d sync_test_db - -# View test data -\dt # List tables -SELECT * FROM users; -SELECT * FROM documents; -``` - -## Common Issues - -### Tests Skipped -If you see "Skipping integration test", set: -```bash -export RUN_INTEGRATION_TESTS=1 -``` - -### Database Connection Failed -Ensure PostgreSQL is running: -```bash -docker-compose -f docker-compose.test.yml ps -``` - -### Port Conflicts -If ports 5433 or 8081 are in use, modify `docker-compose.integration.yml` - -## Test Coverage Goals - -- ✅ Authentication flows (demo token, custom tokens, auto-registration) -- ✅ Basic sync operations (create, update, delete) -- ✅ Conflict resolution scenarios -- ✅ WebSocket protocol handling -- ✅ Concurrent client scenarios -- ✅ Error handling and recovery -- ✅ Performance under load -- ⬜ Network failure simulation -- ⬜ Database failure recovery -- ⬜ Memory usage under stress \ No newline at end of file +The v2 interop suite against the Phoenix server arrives with 3d. Offline suites: `cargo test -p replicant-client` (unit, `ffi_tests`, `ffi_lifecycle_tests`, `wire_contract_tests`); `v2_smoke` needs a running v2 server (see the file header). diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index e6563d4..0000000 --- a/examples/README.md +++ /dev/null @@ -1,146 +0,0 @@ -# Sync Client SDK Examples - -This directory contains example projects demonstrating how to use the Sync Client SDK in C and C++ applications. - -## Directory Structure - -``` -examples/ -├── c/ # C examples -│ ├── simple_example.c -│ ├── callback_example.c -│ └── CMakeLists.txt -├── cpp/ # C++ examples -│ ├── simple_example.cpp -│ ├── callback_example.cpp -│ └── CMakeLists.txt -└── README.md # This file -``` - -## Prerequisites - -1. **SDK Distribution**: Ensure the SDK has been built by running: - ```bash - cd ../ - ./scripts/build_dist.sh - ``` - -2. **CMake**: Version 3.15 or higher -3. **C/C++ Compiler**: GCC, Clang, or MSVC - -## Building Examples - -### C Examples - -```bash -cd examples/c -mkdir build && cd build -cmake .. -cmake --build . - -# Run basic example -./simple_example - -# Run callback example -./callback_example -``` - -### C++ Examples - -```bash -cd examples/cpp -mkdir build && cd build -cmake .. -cmake --build . - -# Run basic example -./simple_example - -# Run callback example -./callback_example -``` - -## What the Examples Demonstrate - -### simple_example.c -- Creating a sync engine instance with HMAC authentication -- Basic document operations (create, update, delete) -- Library version retrieval -- Proper cleanup and error handling -- API key and secret authentication - -### callback_example.c -- Event callback registration and handling -- Real-time document change notifications -- Event filtering (all events vs specific types) -- Callback context management -- Event processing on the main thread - -### simple_example.cpp -- C++ wrapper usage with RAII -- Exception handling for sync operations -- Modern C++ features (C++14) -- Safe memory management -- HMAC authentication with API credentials - -### callback_example.cpp -- Thread-safe event handling without mutexes -- C++ callback registration and management -- Modern event processing patterns -- Cross-thread event queuing with single-thread execution -- Event statistics and monitoring - -## SDK Integration - -These examples show how external applications can integrate the Sync Client SDK: - -1. **Headers**: Include `sync_client.h` for C or `sync_client.hpp` for C++ -2. **Linking**: Link against `libsync_client` library -3. **Platform Libraries**: Additional system libraries required per platform -4. **CMake**: Use the provided CMakeLists.txt as templates - -## Error Handling - -Both examples demonstrate proper error handling: -- C: Check return codes (`SyncResult` enum) -- C++: Catch `sync_exception` for error conditions - -## Running Without Server - -These examples can run in offline mode without a running sync server. They will: -- Create local documents successfully -- Store data in local SQLite database -- Log connection errors (expected when no server is available) - -Note: Even in offline mode, the examples require API credentials to be provided. You can use placeholder values like "rpa_test" and "rps_test" for testing without a server. - -## Event System Usage - -The `callback_example.c` demonstrates the event-driven architecture: - -1. **Register Callbacks**: Use `sync_engine_register_event_callback()` to listen for events -2. **Event Types**: Available events include: - - `DocumentCreated` (0) - New document added - - `DocumentUpdated` (1) - Document content changed - - `DocumentDeleted` (2) - Document removed - - `SyncStarted` (3) - Synchronization began - - `SyncCompleted` (4) - Synchronization finished - - `SyncError` (5) - Synchronization failed - - `ConflictDetected` (6) - Document conflict found - - `ConnectionLost` (7) - Server connection lost - - `ConnectionAttempted` (8) - Attempting server connection - - `ConnectionSucceeded` (9) - Successfully connected to server - -3. **Process Events**: Call `sync_engine_process_events()` regularly (e.g., in a timer) -4. **Thread Safety**: Events are queued from any thread but processed on the callback thread - -## Extending the Examples - -To add more advanced features: -- Custom document schemas and validation -- Network connectivity handling and retry logic -- Multi-client synchronization scenarios -- Performance monitoring and metrics -- Framework integration (JUCE, Qt, GTK) - -See the SDK documentation and `FRAMEWORK_INTEGRATION.md` for detailed API reference. \ No newline at end of file diff --git a/examples/c/CMakeLists.txt b/examples/c/CMakeLists.txt deleted file mode 100644 index b5d1934..0000000 --- a/examples/c/CMakeLists.txt +++ /dev/null @@ -1,34 +0,0 @@ -cmake_minimum_required(VERSION 3.15) -project(ReplicantCExample LANGUAGES C) - -# Include the SDK headers -include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../../dist/include) - -# Find the replicant client library -find_library(REPLICANT_CLIENT_LIB - NAMES replicant_client - PATHS ${CMAKE_CURRENT_SOURCE_DIR}/../../dist/lib - NO_DEFAULT_PATH - REQUIRED -) - -# Platform-specific libraries -if(APPLE) - set(PLATFORM_LIBS "-framework Security" "-framework Foundation") -elseif(UNIX) - set(PLATFORM_LIBS pthread dl m) -elseif(WIN32) - set(PLATFORM_LIBS ws2_32 userenv bcrypt) -endif() - -# C examples -add_executable(simple_example simple_example.c) -target_link_libraries(simple_example ${REPLICANT_CLIENT_LIB} ${PLATFORM_LIBS}) - -add_executable(callback_example callback_example.c) -target_link_libraries(callback_example ${REPLICANT_CLIENT_LIB} ${PLATFORM_LIBS}) - -# Print build information -message(STATUS "Building C example against SDK:") -message(STATUS " Headers: ${CMAKE_CURRENT_SOURCE_DIR}/../../dist/include") -message(STATUS " Library: ${REPLICANT_CLIENT_LIB}") diff --git a/examples/c/callback_example.c b/examples/c/callback_example.c deleted file mode 100644 index 95f9526..0000000 --- a/examples/c/callback_example.c +++ /dev/null @@ -1,150 +0,0 @@ -/** - * @file callback_example.c - * @brief Simple test to verify C compilation and basic callback functionality - * - * This is a minimal example that can be compiled to test the C interface. - */ - -#include -#include -#include - -/* Include the main replicant header */ -#include "replicant.h" - -/* Document callback function */ -void document_callback( - EventType event_type, - const char* document_id, - const char* title, - const char* content, - void* context) -{ - int* call_count = (int*)context; - (*call_count)++; - - printf("Document event received: type=%d, call_count=%d\n", event_type, *call_count); - - if (document_id && strlen(document_id) > 0) - { - printf(" Document ID: %s\n", document_id); - } - - if (title && strlen(title) > 0) - { - printf(" Title: %s\n", title); - } - - if (content && strlen(content) > 0) - { - printf(" Content: %s\n", content); - } -} - -int main() -{ - printf("=== Simple C Callback Test ===\n"); - - /* Create Replicant client with HMAC authentication */ - struct Replicant* client = replicant_create( - "sqlite::memory:", - "ws://localhost:8080/ws", - "simple-test@example.com", - "rpa_test_api_key_example_12345", - "rps_test_api_secret_example_67890" - ); - - if (!client) - { - printf("Failed to create Replicant client\n"); - return 1; - } - - printf("Replicant client created\n"); - - /* Register document callback for all document events */ - int call_count = 0; - SyncResult result = replicant_register_document_callback( - client, - document_callback, - &call_count, - -1 /* All document events */ - ); - - if (result != Success) - { - printf("Failed to register callback: %d\n", result); - replicant_destroy(client); - return 1; - } - - printf("Callback registered\n"); - - /* Create a document to trigger an event */ - char doc_id[37] = {0}; - result = replicant_create_document( - client, - "{\"title\":\"Test Document\",\"message\":\"Hello from C!\"}", - doc_id - ); - - if (result == Success) - { - printf("Document created: %s\n", doc_id); - - /* Process events to trigger callbacks */ - uint32_t processed = 0; - replicant_process_events(client, &processed); - printf("Events processed: %u\n", processed); - - printf("Total callbacks received: %d\n", call_count); - - if (call_count > 0) - { - printf("Callback system working!\n"); - } - else - { - printf("Note: No callbacks received (may be expected in offline mode)\n"); - } - } - else - { - printf("Failed to create document: %d\n", result); - } - - /* Clean up */ - replicant_destroy(client); - printf("Cleanup complete\n"); - - printf("\n=== Test completed successfully! ===\n"); - return 0; -} - -/* - * To compile this test: - * - * 1. First build the Rust library: - * cd sync-workspace - * cargo build --release - * - * 2. Then compile this C test: - * gcc -I./replicant-client/include \ - * -L./target/release \ - * -lreplicant_client \ - * -ldl -lpthread -lm \ - * examples/c/callback_example.c \ - * -o callback_example - * - * 3. Run the test: - * ./callback_example - * - * Note: On macOS you might need additional flags: - * gcc -I./replicant-client/include \ - * -L./target/release \ - * -lreplicant_client \ - * -framework CoreFoundation -framework Security \ - * -ldl -lpthread -lm \ - * examples/c/callback_example.c \ - * -o callback_example - */ diff --git a/examples/c/simple_example.c b/examples/c/simple_example.c deleted file mode 100644 index c0d0e9c..0000000 --- a/examples/c/simple_example.c +++ /dev/null @@ -1,79 +0,0 @@ -#include "replicant.h" -#include -#include -#include - -int main() -{ - // Create Replicant client with HMAC authentication - struct Replicant* client = replicant_create( - "sqlite:client.db?mode=rwc", - "ws://localhost:8080/ws", - "test-user@example.com", - "rpa_test_api_key_example_12345", - "rps_test_api_secret_example_67890" - ); - - if (!client) - { - printf("Failed to create Replicant client\n"); - return 1; - } - - // Get version - char* version = replicant_get_version(); - if (version) - { - printf("Replicant client version: %s\n", version); - replicant_string_free(version); - } - - // Create a document - char doc_id[37] = {0}; - enum SyncResult result = replicant_create_document( - client, - "{\"title\":\"My Document\",\"content\":\"Hello World\",\"type\":\"note\",\"priority\":\"medium\"}", - doc_id - ); - - if (result == Success) - { - printf("Created document: %s\n", doc_id); - - // Update the document - result = replicant_update_document( - client, - doc_id, - "{\"content\":\"Hello Updated World\",\"type\":\"note\",\"priority\":\"high\"}" - ); - - if (result == Success) - { - printf("Updated document successfully\n"); - - // Delete the document - result = replicant_delete_document(client, doc_id); - - if (result == Success) - { - printf("Deleted document successfully\n"); - } - else - { - printf("Failed to delete document: %d\n", result); - } - } - else - { - printf("Failed to update document: %d\n", result); - } - } - else - { - printf("Failed to create document: %d\n", result); - } - - // Clean up - replicant_destroy(client); - return 0; -} diff --git a/examples/cpp/CMakeLists.txt b/examples/cpp/CMakeLists.txt deleted file mode 100644 index 6035107..0000000 --- a/examples/cpp/CMakeLists.txt +++ /dev/null @@ -1,39 +0,0 @@ -cmake_minimum_required(VERSION 3.15) -project(ReplicantCppExample LANGUAGES CXX) - -set(CMAKE_CXX_STANDARD 14) -set(CMAKE_CXX_STANDARD_REQUIRED ON) - -# Include the SDK headers -include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../../dist/include) - -# Find the replicant client library -find_library(REPLICANT_CLIENT_LIB - NAMES replicant_client - PATHS ${CMAKE_CURRENT_SOURCE_DIR}/../../dist/lib - NO_DEFAULT_PATH - REQUIRED -) - -# Platform-specific libraries -if(APPLE) - set(PLATFORM_LIBS "-framework Security" "-framework Foundation") -elseif(UNIX) - set(PLATFORM_LIBS pthread dl m) -elseif(WIN32) - set(PLATFORM_LIBS ws2_32 userenv bcrypt) -endif() - -# C++ examples -add_executable(simple_example simple_example.cpp) -target_link_libraries(simple_example ${REPLICANT_CLIENT_LIB} ${PLATFORM_LIBS}) - -add_executable(callback_example callback_example.cpp) -target_link_libraries(callback_example ${REPLICANT_CLIENT_LIB} ${PLATFORM_LIBS}) - - -# Print build information -message(STATUS "Building C++ example against SDK:") -message(STATUS " Headers: ${CMAKE_CURRENT_SOURCE_DIR}/../../dist/include") -message(STATUS " Library: ${REPLICANT_CLIENT_LIB}") -message(STATUS " C++ Standard: ${CMAKE_CXX_STANDARD}") diff --git a/examples/cpp/callback_example.cpp b/examples/cpp/callback_example.cpp deleted file mode 100644 index e9c53e3..0000000 --- a/examples/cpp/callback_example.cpp +++ /dev/null @@ -1,498 +0,0 @@ -/** - * @file callback_example.cpp - * @brief C++ example demonstrating type-safe event callbacks - * - * This example shows how to use the replicant event system with the new - * type-specific callback model that provides compile-time type safety and - * eliminates unused fields. - * - * Key features: - * - Separate callbacks for different event categories - * - No mutexes or locks required in user code - * - Events are automatically queued from any thread - * - Callbacks only execute on the thread that registered them - * - Simple main loop pattern with process_events() - * - snake_case naming conventions for consistency - */ - -#include -#include -#include -#include -#include - -// Include the replicant header -#include "replicant.h" - -// Simple event statistics - no thread synchronization needed! -struct event_stats -{ - int total_events = 0; - int document_events = 0; - int sync_events = 0; - int error_events = 0; - int connection_events = 0; - int conflict_events = 0; - std::vector recent_event_names; - - void add_event(const std::string& event_name) - { - total_events++; - recent_event_names.push_back(event_name); - - // Keep only the last 5 events - if (recent_event_names.size() > 5) - { - recent_event_names.erase(recent_event_names.begin()); - } - } - - void print_summary() const - { - std::cout << "\n=== Event Summary ===\n"; - std::cout << "Total events: " << total_events << "\n"; - std::cout << "Document events: " << document_events << "\n"; - std::cout << "Sync events: " << sync_events << "\n"; - std::cout << "Error events: " << error_events << "\n"; - std::cout << "Connection events: " << connection_events << "\n"; - std::cout << "Conflict events: " << conflict_events << "\n"; - - if (!recent_event_names.empty()) - { - std::cout << "Recent events: "; - for (const auto& name : recent_event_names) - { - std::cout << name << " "; - } - std::cout << "\n"; - } - std::cout << "=====================\n"; - } -}; - -// Global stats (safe because callbacks only run on main thread) -event_stats g_stats; - -// Helper function to get event type name -std::string get_event_type_name(EventType type) -{ - switch (type) - { - case DocumentCreated: return "DocumentCreated"; - case DocumentUpdated: return "DocumentUpdated"; - case DocumentDeleted: return "DocumentDeleted"; - case SyncStarted: return "SyncStarted"; - case SyncCompleted: return "SyncCompleted"; - case SyncError: return "SyncError"; - case ConflictDetected: return "ConflictDetected"; - case ConnectionLost: return "ConnectionLost"; - case ConnectionAttempted: return "ConnectionAttempted"; - case ConnectionSucceeded: return "ConnectionSucceeded"; - default: return "Unknown"; - } -} - -// ============================================================================= -// Type-specific callbacks - each receives only relevant parameters! -// ============================================================================= - -// Document callback - receives doc_id, title, content, user_id, author_name, -// visibility and origin. Fires for this process's own writes too: origin says -// which, so a consumer can ignore its own echoes. -void document_event_callback( - EventType event_type, - const char* document_id, - const char* title, - const char* content, - const char* user_id, - const char* author_name, - const char* visibility, - EventOrigin origin, - void* context) -{ - std::string event_name = get_event_type_name(event_type); - g_stats.add_event(event_name); - g_stats.document_events++; - - std::cout << "📄 " << event_name; - if (document_id) - { - std::cout << " - Doc ID: " << document_id; - } - if (title) - { - std::cout << " - Title: '" << title << "'"; - } - if (author_name) - { - std::cout << " - Author: " << author_name; - } - if (visibility) - { - std::cout << " - Visibility: " << visibility; - } - std::cout << " - Origin: " << (origin == Local ? "local" : "remote"); - std::cout << "\n"; -} - -// Sync callback - receives document count -void sync_event_callback( - EventType event_type, - uint64_t document_count, - void* context) -{ - std::string event_name = get_event_type_name(event_type); - g_stats.add_event(event_name); - g_stats.sync_events++; - - std::cout << "🔄 " << event_name; - if (document_count > 0) - { - std::cout << " - Documents: " << document_count; - } - std::cout << "\n"; -} - -// Error callback - receives error message -void error_event_callback( - EventType event_type, - int32_t error_code, - const char* error, - void* context) -{ - std::string event_name = get_event_type_name(event_type); - g_stats.add_event(event_name); - g_stats.error_events++; - - std::cout << "🚨 " << event_name; - if (error) - { - std::cout << " - Error: " << error; - } - std::cout << "\n"; -} - -// Connection callback - receives connected state and attempt number -void connection_event_callback( - EventType event_type, - bool connected, - uint32_t attempt_number, - void* context) -{ - std::string event_name = get_event_type_name(event_type); - g_stats.add_event(event_name); - g_stats.connection_events++; - - std::cout << "🔗 " << event_name; - std::cout << " - Connected: " << (connected ? "Yes" : "No"); - if (event_type == ConnectionAttempted) - { - std::cout << " - Attempt: " << attempt_number; - } - std::cout << "\n"; -} - -// Conflict callback - receives doc_id, winning content, losing content -void conflict_event_callback( - EventType event_type, - const char* document_id, - const char* winning_content, - const char* losing_content, - void* context) -{ - std::string event_name = get_event_type_name(event_type); - g_stats.add_event(event_name); - g_stats.conflict_events++; - - std::cout << "⚠️ " << event_name; - if (document_id) - { - std::cout << " - Doc ID: " << document_id; - } - std::cout << "\n"; -} - -// Simple RAII wrapper for sync engine -class simple_sync_engine -{ -private: - Replicant* engine_; - -public: - simple_sync_engine(const std::string& database_url, - const std::string& server_url, - const std::string& email, - const std::string& api_key, - const std::string& api_secret) - { - engine_ = replicant_create(database_url.c_str(), server_url.c_str(), email.c_str(), api_key.c_str(), api_secret.c_str()); - if (!engine_) - { - throw std::runtime_error("Failed to create sync engine"); - } - } - - ~simple_sync_engine() - { - if (engine_) - { - replicant_destroy(engine_); - } - } - - // Non-copyable - simple_sync_engine(const simple_sync_engine&) = delete; - simple_sync_engine& operator=(const simple_sync_engine&) = delete; - - Replicant* get() const { return engine_; } - - // Register type-specific callbacks - SyncResult register_document_callback(DocumentEventCallback callback, void* context = nullptr, int event_filter = -1) - { - return replicant_register_document_callback(engine_, callback, context, event_filter); - } - - SyncResult register_sync_callback(SyncEventCallback callback, void* context = nullptr) - { - return replicant_register_sync_callback(engine_, callback, context); - } - - SyncResult register_error_callback(ErrorEventCallback callback, void* context = nullptr) - { - return replicant_register_error_callback(engine_, callback, context); - } - - SyncResult register_connection_callback(ConnectionEventCallback callback, void* context = nullptr) - { - return replicant_register_connection_callback(engine_, callback, context); - } - - SyncResult register_conflict_callback(ConflictEventCallback callback, void* context = nullptr) - { - return replicant_register_conflict_callback(engine_, callback, context); - } - - SyncResult process_events(uint32_t* processed_count = nullptr) - { - return replicant_process_events(engine_, processed_count); - } - - SyncResult create_document(const std::string& content_json, std::string& out_doc_id) - { - char doc_id[37] = {0}; - auto result = replicant_create_document(engine_, content_json.c_str(), doc_id); - if (result == Success) - { - out_doc_id = std::string(doc_id); - } - return result; - } - - SyncResult update_document(const std::string& document_id, const std::string& content_json) - { - return replicant_update_document(engine_, document_id.c_str(), content_json.c_str()); - } - - SyncResult delete_document(const std::string& document_id) - { - return replicant_delete_document(engine_, document_id.c_str()); - } - - #ifdef DEBUG - SyncResult emit_test_event(int event_type) - { - return replicant_emit_test_event(engine_, event_type); - } - - SyncResult emit_test_event_burst(int count) - { - return replicant_emit_test_event_burst(engine_, count); - } - #endif -}; - -int main() -{ - std::cout << "=== Type-Safe Callbacks Demo ===\n"; - std::cout << "This demo shows separate callbacks for different event types!\n\n"; - - try - { - // Create sync engine with HMAC authentication - simple_sync_engine engine("sqlite::memory:", "ws://localhost:8080/ws", "callback-test@example.com", "rpa_test_api_key_example_12345", "rps_test_api_secret_example_67890"); - std::cout << "✓ Sync engine created\n"; - - // Register type-specific callbacks - auto result = engine.register_document_callback(document_event_callback); - if (result != Success) - { - std::cout << "❌ Failed to register document callback: " << result << "\n"; - return 1; - } - std::cout << "✓ Document callback registered\n"; - - result = engine.register_sync_callback(sync_event_callback); - if (result != Success) - { - std::cout << "❌ Failed to register sync callback: " << result << "\n"; - return 1; - } - std::cout << "✓ Sync callback registered\n"; - - result = engine.register_error_callback(error_event_callback); - if (result != Success) - { - std::cout << "❌ Failed to register error callback: " << result << "\n"; - return 1; - } - std::cout << "✓ Error callback registered\n"; - - result = engine.register_connection_callback(connection_event_callback); - if (result != Success) - { - std::cout << "❌ Failed to register connection callback: " << result << "\n"; - return 1; - } - std::cout << "✓ Connection callback registered\n"; - - result = engine.register_conflict_callback(conflict_event_callback); - if (result != Success) - { - std::cout << "❌ Failed to register conflict callback: " << result << "\n"; - return 1; - } - std::cout << "✓ Conflict callback registered\n"; - - // Test document operations - std::cout << "\n--- Testing Document Operations ---\n"; - - std::string doc_id; - auto create_result = engine.create_document( - R"({"title": "Type-Safe Document", "language": "C++", "type_safe": true})", - doc_id - ); - - if (create_result == Success) - { - std::cout << "✓ Document created: " << doc_id << "\n"; - - // Process events - this is where callbacks are invoked! - uint32_t processed; - engine.process_events(&processed); - std::cout << "✓ Processed " << processed << " events\n"; - - // Update the document - auto update_result = engine.update_document(doc_id, - R"({"title": "Type-Safe Document", "language": "C++", "type_safe": true, "updated": true})"); - - if (update_result == Success) - { - std::cout << "✓ Document updated\n"; - engine.process_events(&processed); - std::cout << "✓ Processed " << processed << " events\n"; - - // Delete the document - auto delete_result = engine.delete_document(doc_id); - if (delete_result == Success) - { - std::cout << "✓ Document deleted\n"; - engine.process_events(&processed); - std::cout << "✓ Processed " << processed << " events\n"; - } - } - } - else - { - std::cout << "ℹ️ Document creation failed (expected in offline mode): " << create_result << "\n"; - } - - #ifdef DEBUG - // Test with debug events - std::cout << "\n--- Testing Debug Events ---\n"; - - engine.emit_test_event(SyncStarted); - engine.emit_test_event(SyncCompleted); - engine.emit_test_event(SyncError); - engine.emit_test_event(ConnectionLost); - engine.emit_test_event(ConnectionAttempted); - engine.emit_test_event(ConnectionSucceeded); - engine.emit_test_event(ConflictDetected); - - // Process all queued events - uint32_t total_processed = 0; - uint32_t batch_processed; - do - { - engine.process_events(&batch_processed); - total_processed += batch_processed; - } while (batch_processed > 0); - - std::cout << "✓ Processed " << total_processed << " debug events\n"; - - // Test event burst - std::cout << "\nTesting event burst...\n"; - engine.emit_test_event_burst(5); - - // Simple main loop simulation - std::cout << "Simulating main loop...\n"; - for (int i = 0; i < 10; ++i) - { - engine.process_events(&batch_processed); - if (batch_processed > 0) - { - std::cout << " Loop " << i << ": processed " << batch_processed << " events\n"; - } - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - } - #endif - - // Print final statistics - g_stats.print_summary(); - - std::cout << "\n✅ SUCCESS: Type-safe callbacks executed without unused fields!\n"; - std::cout << "📝 Key benefits:\n"; - std::cout << " - DocumentEventCallback only receives doc-related fields\n"; - std::cout << " - SyncEventCallback only receives document count\n"; - std::cout << " - ErrorEventCallback only receives error message\n"; - std::cout << " - ConnectionEventCallback only receives connection state\n"; - std::cout << " - ConflictEventCallback only receives conflict data\n"; - - } - catch (const std::exception& e) - { - std::cout << "❌ Error: " << e.what() << "\n"; - return 1; - } - - std::cout << "\n=== Demo completed successfully! ===\n"; - return 0; -} - -/* - * Compilation instructions: - * - * 1. Build the Rust library first: - * cd sync-workspace - * cargo build --release - * - * 2. Compile this C++ example: - * g++ -std=c++14 \ - * -I./sync-client/target/include \ - * -L./target/release \ - * -lsync_client \ - * -framework CoreFoundation -framework Security \ - * -ldl -lpthread -lm \ - * examples/cpp/callback_example.cpp \ - * -o callback_example - * - * 3. Run the example: - * ./callback_example - * - * Key benefits of type-specific callbacks: - * - * ✅ COMPILE-TIME TYPE SAFETY - each callback receives only relevant parameters - * ✅ NO WASTED FIELDS - old EventData had 5-6 unused fields for most events - * ✅ CLEANER API - document callbacks get doc info, sync callbacks get counts - * ✅ EASIER MAINTENANCE - can't accidentally access wrong fields - * ✅ BETTER DOCUMENTATION - function signature shows exactly what you receive - */ diff --git a/examples/cpp/simple_example.cpp b/examples/cpp/simple_example.cpp deleted file mode 100644 index 5093722..0000000 --- a/examples/cpp/simple_example.cpp +++ /dev/null @@ -1,74 +0,0 @@ -#include "replicant.hpp" -#include -#include - -int main() -{ - try - { - // Create Replicant client with HMAC authentication - replicant::Client client( - "sqlite:client.db?mode=rwc", - "ws://localhost:8080/ws", - "test-user@example.com", - "rpa_test_api_key_example_12345", - "rps_test_api_secret_example_67890" - ); - - std::cout << "Replicant client version: " << replicant::Client::get_version() << std::endl; - std::cout << "Connected to server: " << (client.is_connected() ? "yes" : "no") << std::endl; - - // Check initial document count - std::cout << "Initial document count: " << client.count_documents() << std::endl; - std::cout << "Pending sync count: " << client.count_pending_sync() << std::endl; - - // Create a document - std::string doc_id = client.create_document( - R"({"title":"My Document","content":"Hello World","type":"note","priority":"medium"})" - ); - std::cout << "Created document: " << doc_id << std::endl; - - // Read the document back - std::string doc_json = client.get_document(doc_id); - std::cout << "Read document: " << doc_json << std::endl; - - // Check counts after create - std::cout << "Document count after create: " << client.count_documents() << std::endl; - std::cout << "Pending sync count: " << client.count_pending_sync() << std::endl; - - // Update the document - client.update_document( - doc_id, - R"({"title":"My Document","content":"Hello Updated World","type":"note","priority":"high"})" - ); - std::cout << "Updated document successfully" << std::endl; - - // Read updated document - std::string updated_doc = client.get_document(doc_id); - std::cout << "Updated document: " << updated_doc << std::endl; - - // Get all documents - std::string all_docs = client.get_all_documents(); - std::cout << "All documents: " << all_docs << std::endl; - - // Delete the document - client.delete_document(doc_id); - std::cout << "Deleted document successfully" << std::endl; - - // Check final count - std::cout << "Final document count: " << client.count_documents() << std::endl; - - } - catch (const replicant::SyncException& e) - { - std::cerr << "Sync error: " << e.what() << std::endl; - return 1; - } - catch (const std::exception& e) - { - std::cerr << "Error: " << e.what() << std::endl; - return 1; - } - - return 0; -} diff --git a/replicant-client/Cargo.toml b/replicant-client/Cargo.toml index 0ffff14..edb9a4f 100644 --- a/replicant-client/Cargo.toml +++ b/replicant-client/Cargo.toml @@ -4,12 +4,10 @@ version = "0.6.4" edition = "2021" [dependencies] -replicant-core = { path = "../replicant-core" } tokio = { workspace = true } tokio-tungstenite = { version = "0.24", features = ["native-tls"] } futures-util = { version = "0.3", features = ["sink"] } tokio-util = { version = "0.7", features = ["time"] } -phoenix_channels_client = { git = "https://github.com/liveview-native/phoenix-channels-client", features = ["native-tls"] } sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "json", "uuid", "chrono", "tls-rustls"] } serde = { workspace = true } serde_json = { workspace = true } @@ -22,7 +20,6 @@ hmac = "0.12" sha2 = "0.10" hex = "0.4" url = "2.5" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } chacha20poly1305 = "0.10" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } @@ -31,20 +28,8 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus tokio = { workspace = true, features = ["test-util"] } tempfile = "3" wiremock = "0.6" -clap = { version = "4.4", features = ["derive", "env"] } -dialoguer = "0.11" -colored = "2.1" -ratatui = "0.26" -crossterm = "0.27" -anyhow = "1.0" -env_logger = "0.11" -log = "0.4" serial_test = "3.0" -[[example]] -name = "phoenix_spike" -path = "examples/phoenix_spike.rs" - [build-dependencies] # Exact pin: the tracked replicant-client/include/replicant.h must be regenerated # with this exact cbindgen version to avoid banner/whitespace churn on commit. diff --git a/replicant-client/build.rs b/replicant-client/build.rs index dc05504..330aabc 100644 --- a/replicant-client/build.rs +++ b/replicant-client/build.rs @@ -17,6 +17,10 @@ fn main() { .expect("Unable to generate bindings") .write_to_file(&output_file); + println!( + "cargo:rustc-env=REPLICANT_GENERATED_HEADER={}", + output_file.display() + ); println!("cargo:rerun-if-changed=src/"); println!("cargo:rerun-if-changed=migrations"); println!("cargo:rerun-if-changed=cbindgen.toml"); diff --git a/replicant-client/cbindgen.toml b/replicant-client/cbindgen.toml index ff9ca43..5323e7c 100644 --- a/replicant-client/cbindgen.toml +++ b/replicant-client/cbindgen.toml @@ -8,21 +8,34 @@ autogen_warning = "/* Warning, this file is autogenerated by cbindgen. Don't mod include_version = true namespace = "replicant" cpp_compat = true +usize_is_size_t = true documentation = true [export] include = [ "Replicant", + "ReplicantConfig", + "ReplicantListMerge", + "ReplicantState", + "ReplicantConnection", + "ReplicantSync", + "ReplicantHaltReason", "SyncResult", - "Document", "EventType", "EventOrigin", "ReplicantErrorCode", - "DocumentEventCallback", - "SyncEventCallback", - "ErrorEventCallback", - "ConnectionEventCallback", - "ConflictEventCallback", + "ReplicantDocumentEventCallback", + "ReplicantSyncEventCallback", + "ReplicantErrorEventCallback", + "ReplicantConnectionEventCallback", + "ReplicantConflictEventCallback", +] +# Engine tuning constants are not API. +exclude = [ + "MAX_MISMATCH_ATTEMPTS", + "MAX_DIVERGENT_REPLIES", + "MAX_IN_FLIGHT", + "PAGE_LIMIT", ] # Prefix types to avoid polluting global namespace in C++ @@ -31,9 +44,6 @@ include = [ "EventOrigin" = "ReplicantEventOrigin" "SyncResult" = "ReplicantSyncResult" -[enum] -rename_variants = "PascalCase" - [fn] rename_args = "snake_case" diff --git a/replicant-client/examples/c_event_callbacks.c b/replicant-client/examples/c_event_callbacks.c deleted file mode 100644 index a9da589..0000000 --- a/replicant-client/examples/c_event_callbacks.c +++ /dev/null @@ -1,340 +0,0 @@ -/** - * @file c_event_callbacks.c - * @brief Example demonstrating event callbacks in the sync client - * - * This example shows how to: - * 1. Register event callbacks for different event types - * 2. Handle various sync events (document operations, sync progress, errors) - * 3. Use context pointers to maintain state - * 4. Filter events by type - */ - -#include -#include -#include -#include - -/* Include the main sync client header */ -/* Note: In a real build system, you'd link against the compiled library */ -/* This is just for demonstration - the actual header paths may differ */ -#include "../include/sync_client_events.h" - -/* Forward declaration of the main sync client functions */ -/* These would normally be in sync_client.h */ -struct CSyncEngine; -typedef enum { - Success = 0, - ErrorInvalidInput = -1, - ErrorConnection = -2, - ErrorDatabase = -3, - ErrorSerialization = -4, - ErrorUnknown = -99, -} CSyncResult; - -extern struct CSyncEngine* sync_engine_create(const char* database_url, const char* server_url, const char* auth_token, const char* user_identifier); -extern void sync_engine_destroy(struct CSyncEngine* engine); -extern CSyncResult sync_engine_create_document(struct CSyncEngine* engine, const char* content_json, char* out_document_id); -extern CSyncResult sync_engine_update_document(struct CSyncEngine* engine, const char* document_id, const char* content_json); -extern CSyncResult sync_engine_delete_document(struct CSyncEngine* engine, const char* document_id); - -/* Context structure to track callback statistics */ -typedef struct { - int document_created_count; - int document_updated_count; - int document_deleted_count; - int sync_started_count; - int sync_completed_count; - int sync_error_count; - int conflict_count; - int connection_state_changes; - char last_document_id[64]; - char last_error_message[256]; -} CallbackStats; - -/* Callback function for all events */ -void all_events_callback(const SyncEventData* event, void* context) { - CallbackStats* stats = (CallbackStats*)context; - - printf("[ALL_EVENTS] Event type: %d", event->event_type); - - switch (event->event_type) { - case SYNC_EVENT_DOCUMENT_CREATED: - stats->document_created_count++; - if (event->document_id) { - strncpy(stats->last_document_id, event->document_id, sizeof(stats->last_document_id) - 1); - printf(" - Document created: %s", event->document_id); - if (event->title) { - printf(" (title: %s)", event->title); - } - } - break; - - case SYNC_EVENT_DOCUMENT_UPDATED: - stats->document_updated_count++; - if (event->document_id) { - printf(" - Document updated: %s", event->document_id); - if (event->title) { - printf(" (title: %s)", event->title); - } - } - break; - - case SYNC_EVENT_DOCUMENT_DELETED: - stats->document_deleted_count++; - if (event->document_id) { - printf(" - Document deleted: %s", event->document_id); - } - break; - - case SYNC_EVENT_SYNC_STARTED: - stats->sync_started_count++; - printf(" - Sync started"); - break; - - case SYNC_EVENT_SYNC_COMPLETED: - stats->sync_completed_count++; - printf(" - Sync completed (%llu documents)", (unsigned long long)event->numeric_data); - break; - - case SYNC_EVENT_SYNC_ERROR: - stats->sync_error_count++; - if (event->error) { - strncpy(stats->last_error_message, event->error, sizeof(stats->last_error_message) - 1); - printf(" - Sync error: %s", event->error); - } - break; - - case SYNC_EVENT_CONFLICT_DETECTED: - stats->conflict_count++; - if (event->document_id) { - printf(" - Conflict detected for document: %s", event->document_id); - } - break; - - case SYNC_EVENT_CONNECTION_STATE_CHANGED: - stats->connection_state_changes++; - printf(" - Connection state changed: %s", event->boolean_data ? "connected" : "disconnected"); - break; - - default: - printf(" - Unknown event type"); - break; - } - - printf("\n"); -} - -/* Callback function for document events only */ -void document_events_callback(const SyncEventData* event, void* context) { - const char* prefix = (const char*)context; - - printf("[%s] Document event: ", prefix); - - switch (event->event_type) { - case SYNC_EVENT_DOCUMENT_CREATED: - printf("CREATED"); - break; - case SYNC_EVENT_DOCUMENT_UPDATED: - printf("UPDATED"); - break; - case SYNC_EVENT_DOCUMENT_DELETED: - printf("DELETED"); - break; - default: - printf("UNKNOWN"); - break; - } - - if (event->document_id) { - printf(" - ID: %s", event->document_id); - } - if (event->title) { - printf(" - Title: %s", event->title); - } - - printf("\n"); -} - -/* Print callback statistics */ -void print_stats(const CallbackStats* stats) { - printf("\n=== Callback Statistics ===\n"); - printf("Documents created: %d\n", stats->document_created_count); - printf("Documents updated: %d\n", stats->document_updated_count); - printf("Documents deleted: %d\n", stats->document_deleted_count); - printf("Sync operations started: %d\n", stats->sync_started_count); - printf("Sync operations completed: %d\n", stats->sync_completed_count); - printf("Sync errors: %d\n", stats->sync_error_count); - printf("Conflicts detected: %d\n", stats->conflict_count); - printf("Connection state changes: %d\n", stats->connection_state_changes); - if (strlen(stats->last_document_id) > 0) { - printf("Last document ID: %s\n", stats->last_document_id); - } - if (strlen(stats->last_error_message) > 0) { - printf("Last error: %s\n", stats->last_error_message); - } - printf("===========================\n\n"); -} - -int main() { - printf("=== Sync Client Event Callbacks Example ===\n\n"); - - /* Initialize callback statistics */ - CallbackStats stats = {0}; - - /* Create sync engine (works offline if server is not available) */ - struct CSyncEngine* engine = sync_engine_create( - "sqlite:client_events_example.db?mode=rwc", - "ws://localhost:8080/ws", - "demo-token", - "callback-test@example.com" - ); - - if (!engine) { - printf("Failed to create sync engine\n"); - return 1; - } - - printf("Sync engine created successfully\n"); - - /* Register callback for all events */ - SyncResult result = sync_engine_register_event_callback( - engine, - all_events_callback, - &stats, - -1 /* -1 means all event types */ - ); - - if (result != SYNC_RESULT_SUCCESS) { - printf("Failed to register all-events callback: %d\n", result); - sync_engine_destroy(engine); - return 1; - } - - printf("Registered callback for all events\n"); - - /* Register additional callback for document events only */ - const char* doc_prefix = "DOC_ONLY"; - result = sync_engine_register_event_callback( - engine, - document_events_callback, - (void*)doc_prefix, - SYNC_EVENT_DOCUMENT_CREATED /* Only document created events */ - ); - - if (result != SYNC_RESULT_SUCCESS) { - printf("Failed to register document-only callback: %d\n", result); - sync_engine_destroy(engine); - return 1; - } - - printf("Registered callback for document creation events\n\n"); - - /* Test document operations to trigger events */ - printf("=== Testing Document Operations ===\n"); - - /* Create a document */ - printf("Creating document...\n"); - char doc_id[37] = {0}; - result = sync_engine_create_document( - engine, - "{\"title\":\"Test Document\",\"content\":\"Hello from event callbacks!\",\"type\":\"note\",\"priority\":\"high\"}", - doc_id - ); - - if (result == Success) { - printf("Document created with ID: %s\n", doc_id); - - /* Small delay to see events */ - usleep(100000); /* 100ms */ - - /* Update the document */ - printf("Updating document...\n"); - result = sync_engine_update_document( - engine, - doc_id, - "{\"content\":\"Updated content!\",\"type\":\"note\",\"priority\":\"medium\",\"updated\":true}" - ); - - if (result == Success) { - printf("Document updated successfully\n"); - usleep(100000); /* 100ms */ - - /* Delete the document */ - printf("Deleting document...\n"); - result = sync_engine_delete_document(engine, doc_id); - - if (result == Success) { - printf("Document deleted successfully\n"); - usleep(100000); /* 100ms */ - } else { - printf("Failed to delete document: %d\n", result); - } - } else { - printf("Failed to update document: %d\n", result); - } - } else { - printf("Failed to create document: %d\n", result); - } - - /* Test events with debug functions (if available in debug builds) */ - #ifdef DEBUG - printf("\n=== Testing Debug Events ===\n"); - - printf("Triggering test sync started event...\n"); - sync_engine_emit_test_event(engine, SYNC_EVENT_SYNC_STARTED); - usleep(50000); - - printf("Triggering test sync completed event...\n"); - sync_engine_emit_test_event(engine, SYNC_EVENT_SYNC_COMPLETED); - usleep(50000); - - printf("Triggering test error event...\n"); - sync_engine_emit_test_event(engine, SYNC_EVENT_SYNC_ERROR); - usleep(50000); - - printf("Triggering burst of events...\n"); - sync_engine_emit_test_event_burst(engine, 3); - usleep(100000); - - #else - printf("\n=== Debug Events Not Available ===\n"); - printf("Compile with DEBUG flag to enable test event functions\n"); - #endif - - /* Print final statistics */ - print_stats(&stats); - - /* Clean up */ - printf("Cleaning up...\n"); - sync_engine_destroy(engine); - - printf("Example completed successfully!\n"); - return 0; -} - -/* - * Compilation instructions: - * - * This example demonstrates the API but won't compile without the actual - * sync client library. To compile and run: - * - * 1. Build the sync client library: - * cd sync-workspace - * cargo build --release - * - * 2. Compile this example (adjust paths as needed): - * gcc -I./sync-client/include \ - * -L./target/release \ - * -lsync_client \ - * sync-client/examples/c_event_callbacks.c \ - * -o event_callbacks_example - * - * 3. Run the example: - * ./event_callbacks_example - * - * Expected output: - * - Callback registrations - * - Document creation, update, delete events - * - Statistics showing event counts - * - All events logged with context information - */ \ No newline at end of file diff --git a/replicant-client/examples/cpp_lambda_callbacks.cpp b/replicant-client/examples/cpp_lambda_callbacks.cpp deleted file mode 100644 index 59e4ef6..0000000 --- a/replicant-client/examples/cpp_lambda_callbacks.cpp +++ /dev/null @@ -1,237 +0,0 @@ -/** - * @file cpp_lambda_callbacks.cpp - * @brief Modern C++ example demonstrating event callbacks with lambda functions - * - * This example shows how to use the replicant event system with: - * - Lambda functions for callbacks - * - The replicant::Client C++ wrapper - * - RAII patterns for cleanup - * - Modern C++ features (C++14) - * - snake_case naming conventions for consistency with Rust - */ - -#include -#include -#include -#include -#include -#include -#include - -#include "../include/replicant.hpp" - -/** - * @brief Event statistics collector - */ -struct EventStats -{ - std::atomic documents_created{0}; - std::atomic documents_updated{0}; - std::atomic documents_deleted{0}; - std::atomic sync_events{0}; - std::atomic errors{0}; - std::atomic connection_events{0}; - - void print_summary() const - { - std::cout << "\n=== Event Statistics ===\n"; - std::cout << "Documents created: " << documents_created << "\n"; - std::cout << "Documents updated: " << documents_updated << "\n"; - std::cout << "Documents deleted: " << documents_deleted << "\n"; - std::cout << "Sync events: " << sync_events << "\n"; - std::cout << "Connection events: " << connection_events << "\n"; - std::cout << "Errors: " << errors << "\n"; - std::cout << "========================\n"; - } -}; - -/** - * @brief Document event callback using C-style function - */ -void on_document_event(EventType event_type, const char* doc_id, - const char* title, const char* content, void* context) -{ - auto* stats = static_cast(context); - const std::string id = doc_id ? doc_id : ""; - const std::string doc_title = title ? title : ""; - - switch (event_type) - { - case DocumentCreated: - stats->documents_created++; - std::cout << "Document created: " << id << " - " << doc_title << "\n"; - break; - - case DocumentUpdated: - stats->documents_updated++; - std::cout << "Document updated: " << id << " - " << doc_title << "\n"; - break; - - case DocumentDeleted: - stats->documents_deleted++; - std::cout << "Document deleted: " << id << "\n"; - break; - - default: - break; - } -} - -/** - * @brief Connection event callback - */ -void on_connection_event(EventType event_type, bool is_connected, - uint32_t attempt_number, void* context) -{ - auto* stats = static_cast(context); - stats->connection_events++; - - switch (event_type) - { - case ConnectionLost: - std::cout << "Connection lost\n"; - break; - - case ConnectionAttempted: - std::cout << "Connection attempt #" << attempt_number << "\n"; - break; - - case ConnectionSucceeded: - std::cout << "Connected to server\n"; - break; - - default: - break; - } -} - -/** - * @brief Error event callback - */ -void on_error_event(EventType event_type, int32_t error_code, const char* error_message, void* context) -{ - auto* stats = static_cast(context); - stats->errors++; - std::cerr << "Sync error: " << (error_message ? error_message : "unknown") << "\n"; -} - -/** - * @brief Sync event callback - */ -void on_sync_event(EventType event_type, uint64_t documents_synced, void* context) -{ - auto* stats = static_cast(context); - stats->sync_events++; - - if (event_type == SyncStarted) - { - std::cout << "Sync started\n"; - } - else if (event_type == SyncCompleted) - { - std::cout << "Sync completed: " << documents_synced << " documents\n"; - } -} - -/** - * @brief Test function demonstrating callback registration and document operations - */ -void test_replicant_callbacks() -{ - std::cout << "=== Replicant C++ Callbacks Demo ===\n"; - std::cout << "Version: " << replicant::Client::get_version() << "\n\n"; - - try - { - // Create client with RAII - replicant::Client client( - "sqlite::memory:", - "ws://localhost:8080/ws", - "demo@example.com", - "rpa_demo_key", - "rps_demo_secret" - ); - std::cout << "Client created\n"; - - // Create statistics collector - EventStats stats; - - // Register callbacks - client.register_document_callback(on_document_event, &stats); - client.register_connection_callback(on_connection_event, &stats); - client.register_error_callback(on_error_event, &stats); - client.register_sync_callback(on_sync_event, &stats); - std::cout << "Callbacks registered\n"; - - // Test document operations - std::cout << "\n--- Testing Document Operations ---\n"; - - auto doc_id = client.create_document( - R"({"title": "Test Document", "content": "Hello from C++"})" - ); - std::cout << "Created document: " << doc_id << "\n"; - - // Process events to trigger callbacks - client.process_events(); - - // Update the document - client.update_document(doc_id, - R"({"title": "Updated Document", "content": "Modified content"})" - ); - std::cout << "Updated document\n"; - - client.process_events(); - - // Get document count - std::cout << "Document count: " << client.count_documents() << "\n"; - std::cout << "Pending sync: " << client.count_pending_sync() << "\n"; - std::cout << "Connected: " << (client.is_connected() ? "yes" : "no") << "\n"; - - // Delete the document - client.delete_document(doc_id); - std::cout << "Deleted document\n"; - - client.process_events(); - - // Print statistics - stats.print_summary(); - - std::cout << "\nDemo completed successfully!\n"; - } - catch (const replicant::SyncException& e) - { - std::cerr << "Replicant error: " << e.what() << "\n"; - } - catch (const std::exception& e) - { - std::cerr << "Error: " << e.what() << "\n"; - } -} - -int main() -{ - test_replicant_callbacks(); - return 0; -} - -/* - * Compilation instructions: - * - * 1. Build the Rust library first: - * cd sync-workspace - * cargo build --release - * - * 2. Compile the C++ example: - * g++ -std=c++14 \ - * -I./replicant-client/include \ - * -I./target/include \ - * -L./target/release \ - * -lreplicant_client \ - * -framework CoreFoundation -framework Security \ - * -ldl -lpthread -lm \ - * replicant-client/examples/cpp_lambda_callbacks.cpp \ - * -o cpp_callback_demo - * - * 3. Run the example: - * ./cpp_callback_demo - */ diff --git a/replicant-client/examples/interactive_client.rs b/replicant-client/examples/interactive_client.rs deleted file mode 100644 index 73bb0ab..0000000 --- a/replicant-client/examples/interactive_client.rs +++ /dev/null @@ -1,897 +0,0 @@ -use chrono::Utc; -use clap::Parser; -use colored::*; -use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; -use replicant_client::{Client, ClientDatabase}; -use replicant_core::models::Document; -use serde_json::{json, Value}; -use sqlx::Row; -use uuid::Uuid; - -#[derive(Parser)] -#[command(name = "sync-client")] -#[command(about = "Interactive JSON database sync client", long_about = None)] -struct Cli { - /// Database file name (will auto-create in databases/ directory) - #[arg(short, long, default_value = "alice")] - database: String, - - /// Server WebSocket URL - #[arg(short, long, default_value = "ws://localhost:8080/ws")] - server: String, - - /// API key for authentication - #[arg( - short = 'k', - long, - default_value = "rpa_demo123456789012345678901234567890" - )] - api_key: String, - - /// API secret for authentication - #[arg( - short = 'k', - long, - default_value = "rpa_demo123456789012345678901234567890" - )] - api_secret: String, - - /// User ID (will be generated if not provided) - #[arg(short, long)] - user_id: Option, - - /// Email - #[arg(short, long, default_value = "example@gmail.com")] - email: String, -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Initialize logging (only show warnings and errors) - tracing_subscriber::fmt().with_env_filter("warn").init(); - - let cli = Cli::parse(); - - // Auto-create database file path with .sqlite3 extension in databases/ folder - std::fs::create_dir_all("databases")?; - let db_file = format!("databases/{}.sqlite3", cli.database); - let db_url = format!("sqlite:{}?mode=rwc", db_file); - - println!("{}", "🚀 JSON Database Sync Client".bold().cyan()); - println!("{}", "============================".cyan()); - println!("📁 Database: {}", db_file.green()); - - // Initialize database (will auto-create the file) - let db = ClientDatabase::new(&db_url).await?; - - // Run migrations - db.run_migrations().await?; - - // Get or create user - let user_id = match cli.user_id { - Some(id) => Uuid::parse_str(&id)?, - None => match db.get_user_id().await { - Ok(id) => id, - Err(_) => { - let id = Uuid::new_v4(); - println!("🆕 Creating new user: {}", id.to_string().yellow()); - let client_id = Uuid::new_v4(); - setup_user(&db, id, client_id, &cli.server, &cli.api_key).await?; - id - } - }, - }; - - println!("👤 User ID: {}", user_id.to_string().green()); - println!("🌐 Server: {}", cli.server.blue()); - println!(); - - // Create sync engine (auto-starts with built-in reconnection) - let sync_engine = match Client::new( - &db_url, - &cli.server, - &cli.email, - &cli.api_key, - &cli.api_secret, - None, - ) - .await - { - Ok(engine) => { - println!("✅ Sync engine initialized (auto-connecting)!"); - Some(engine) - } - Err(e) => { - println!( - "⚠️ Failed to initialize sync engine: {}", - e.to_string().yellow() - ); - None - } - }; - - // Main interactive loop - loop { - let choices = vec![ - "📋 List tasks", - "➕ Create new task", - "✏️ Edit task", - "🔍 View task details", - "✅ Mark task completed", - "🗑️ Delete task", - "🔄 Sync status", - "❌ Exit", - ]; - - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("What would you like to do?") - .items(&choices) - .default(0) - .interact()?; - - match selection { - 0 => list_tasks(&db, user_id).await?, - 1 => create_task(&db, &sync_engine, user_id).await?, - 2 => edit_task(&db, &sync_engine, user_id).await?, - 3 => view_task(&db, user_id).await?, - 4 => complete_task(&db, &sync_engine, user_id).await?, - 5 => delete_task(&db, &sync_engine, user_id).await?, - 6 => show_sync_status(&db).await?, - 7 => { - if Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt("Are you sure you want to exit?") - .default(false) - .interact()? - { - println!("👋 Goodbye!"); - break; - } - } - _ => unreachable!(), - } - println!(); - } - - Ok(()) -} - -async fn setup_user( - db: &ClientDatabase, - user_id: Uuid, - client_id: Uuid, - server_url: &str, - _api_key: &str, -) -> Result<(), Box> { - sqlx::query("INSERT INTO user_config (user_id, client_id, server_url) VALUES (?1, ?2, ?3)") - .bind(user_id.to_string()) - .bind(client_id.to_string()) - .bind(server_url) - .execute(&db.pool) - .await?; - Ok(()) -} - -async fn list_tasks(db: &ClientDatabase, user_id: Uuid) -> Result<(), Box> { - let rows = sqlx::query( - r#" - SELECT id, title, content, sync_status, updated_at - FROM documents - WHERE user_id = ?1 AND deleted_at IS NULL - ORDER BY updated_at DESC - "#, - ) - .bind(user_id.to_string()) - .fetch_all(&db.pool) - .await?; - - if rows.is_empty() { - println!("📭 No tasks found."); - } else { - println!("{}", "📋 Your Tasks:".bold()); - println!("{}", "─".repeat(100).dimmed()); - - for row in rows { - let id = row.try_get::("id")?; - let title = row.try_get::("title")?; - let content_str = row.try_get::("content")?; - let sync_status = row.try_get::, _>("sync_status")?; - let _updated_at = row.try_get::, _>("updated_at")?; - - let content: Value = serde_json::from_str(&content_str).unwrap_or_default(); - let task_status = content - .get("status") - .and_then(|s| s.as_str()) - .unwrap_or("unknown"); - let priority = content - .get("priority") - .and_then(|s| s.as_str()) - .unwrap_or("medium"); - let description = content - .get("description") - .and_then(|s| s.as_str()) - .unwrap_or(""); - - let status_icon = match task_status { - "completed" => "✅", - "in_progress" => "🔄", - "pending" => "⏳", - _ => "❓", - }; - - let priority_icon = match priority { - "high" => "🔴", - "medium" => "🟡", - "low" => "🟢", - _ => "⚪", - }; - - let sync_icon = match sync_status.as_deref() { - Some("synced") => "", - Some("pending") => "📤", - Some("conflict") => "⚠️", - _ => "📱", - }; - - println!( - "{} {} {} {} {} {}", - status_icon, - priority_icon, - id[..8].blue(), - title.white().bold(), - if !description.is_empty() { - format!("- {}", description.dimmed()) - } else { - "".to_string() - }, - sync_icon - ); - } - println!("{}", "─".repeat(100).dimmed()); - println!( - "{}", - "Legend: ✅=done 🔄=progress ⏳=pending | 🔴=high 🟡=med 🟢=low | 📤=sync pending" - .dimmed() - ); - } - - Ok(()) -} - -async fn create_task( - db: &ClientDatabase, - sync_engine: &Option, - user_id: Uuid, -) -> Result<(), Box> { - println!("{}", "📝 Create New Task".bold().cyan()); - println!("{}", "─".repeat(40).dimmed()); - - let title: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Task title") - .interact_text()?; - - let description: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Description") - .default("".to_string()) - .interact_text()?; - - let priority_choices = vec!["low", "medium", "high"]; - let priority_selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Priority") - .items(&priority_choices) - .default(1) - .interact()?; - let priority = priority_choices[priority_selection]; - - let tags_input: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Tags (comma-separated, optional)") - .default("".to_string()) - .interact_text()?; - - let tags: Vec = if tags_input.trim().is_empty() { - vec![] - } else { - tags_input - .split(',') - .map(|s| s.trim().to_string()) - .collect() - }; - - let content = json!({ - "title": title.clone(), - "description": description, - "status": "pending", - "priority": priority, - "tags": tags, - "created_at": Utc::now().to_rfc3339(), - "due_date": null - }); - - if let Some(engine) = sync_engine { - // Use sync engine if connected - let mut full_content = content.clone(); - full_content["title"] = serde_json::json!(title); - let doc = engine.create_document(full_content).await?; - println!("✅ Task created: {}", doc.id.to_string().green()); - } else { - // Offline mode - create locally - let mut full_content = content.clone(); - full_content["title"] = serde_json::json!(title); - let doc = Document { - id: Uuid::new_v4(), - user_id: Some(user_id), - content: full_content.clone(), - sync_revision: 1, - content_hash: None, - title: full_content - .get("title") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }; - - db.save_document(&doc).await?; - println!("✅ Task created (offline): {}", doc.id.to_string().yellow()); - } - - Ok(()) -} - -async fn complete_task( - db: &ClientDatabase, - sync_engine: &Option, - user_id: Uuid, -) -> Result<(), Box> { - // List pending tasks for selection - let rows = sqlx::query( - r#" - SELECT id, title, content - FROM documents - WHERE user_id = ?1 AND deleted_at IS NULL - ORDER BY updated_at DESC - "#, - ) - .bind(user_id.to_string()) - .fetch_all(&db.pool) - .await?; - - let pending_tasks: Vec<_> = rows - .into_iter() - .filter_map(|row| { - let id = row.try_get::("id").ok()?; - let title = row.try_get::("title").ok()?; - let content_str = row.try_get::("content").ok()?; - let content: Value = serde_json::from_str(&content_str).ok()?; - let status = content.get("status")?.as_str()?; - - if status != "completed" { - Some((id, title, content)) - } else { - None - } - }) - .collect(); - - if pending_tasks.is_empty() { - println!("✅ All tasks are already completed!"); - return Ok(()); - } - - let choices: Vec = pending_tasks - .iter() - .map(|(id, title, content)| { - let _status = content - .get("status") - .and_then(|s| s.as_str()) - .unwrap_or("pending"); - let priority = content - .get("priority") - .and_then(|s| s.as_str()) - .unwrap_or("medium"); - let priority_icon = match priority { - "high" => "🔴", - "medium" => "🟡", - "low" => "🟢", - _ => "⚪", - }; - format!("{} {} - {}", priority_icon, &id[..8], title) - }) - .collect(); - - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Select task to mark as completed") - .items(&choices) - .interact()?; - - let (task_id, task_title, task_content) = &pending_tasks[selection]; - let mut task_content = task_content.clone(); - let doc_id = Uuid::parse_str(task_id)?; - - // Update the task content to mark as completed - if let Some(obj) = task_content.as_object_mut() { - obj.insert("status".to_string(), json!("completed")); - obj.insert("completed_at".to_string(), json!(Utc::now().to_rfc3339())); - } - - if let Some(engine) = sync_engine { - engine.update_document(doc_id, task_content.clone()).await?; - println!( - "✅ Task '{}' marked as completed and synced!", - task_title.green() - ); - } else { - // Offline update - let doc = db.get_document(&doc_id).await?; - let mut updated_doc = doc; - updated_doc.content = task_content.clone(); - updated_doc.sync_revision += 1; - updated_doc.updated_at = chrono::Utc::now(); - - db.save_document(&updated_doc).await?; - - // Mark as pending sync - sqlx::query("UPDATE documents SET sync_status = 'pending' WHERE id = ?1") - .bind(doc_id.to_string()) - .execute(&db.pool) - .await?; - - println!( - "✅ Task '{}' marked as completed (offline)", - task_title.yellow() - ); - } - - Ok(()) -} - -async fn edit_task( - db: &ClientDatabase, - sync_engine: &Option, - user_id: Uuid, -) -> Result<(), Box> { - // List tasks for selection - let rows = sqlx::query( - r#" - SELECT id, title, content - FROM documents - WHERE user_id = ?1 AND deleted_at IS NULL - ORDER BY updated_at DESC - "#, - ) - .bind(user_id.to_string()) - .fetch_all(&db.pool) - .await?; - - if rows.is_empty() { - println!("📭 No tasks to edit."); - return Ok(()); - } - - let mut task_info = Vec::new(); - let mut choices = Vec::new(); - - for row in rows { - let id = row.try_get::("id")?; - let title = row.try_get::("title")?; - let content_str = row.try_get::("content")?; - let content: Value = serde_json::from_str(&content_str).unwrap_or_default(); - let status = content - .get("status") - .and_then(|s| s.as_str()) - .unwrap_or("pending"); - let priority = content - .get("priority") - .and_then(|s| s.as_str()) - .unwrap_or("medium"); - - let status_icon = match status { - "completed" => "✅", - "in_progress" => "🔄", - "pending" => "⏳", - _ => "❓", - }; - let priority_icon = match priority { - "high" => "🔴", - "medium" => "🟡", - "low" => "🟢", - _ => "⚪", - }; - - task_info.push((id.clone(), content)); - choices.push(format!( - "{} {} {} - {}", - status_icon, - priority_icon, - &id[..8], - title - )); - } - - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Select task to edit") - .items(&choices) - .interact()?; - - let doc_id = Uuid::parse_str(&task_info[selection].0)?; - let current_content = task_info[selection].1.clone(); - - println!("{}", "✏️ Edit Task".bold().cyan()); - println!("{}", "─".repeat(40).dimmed()); - - // Edit title - let current_title = current_content - .get("title") - .and_then(|s| s.as_str()) - .unwrap_or(""); - let new_title: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Title") - .default(current_title.to_string()) - .interact_text()?; - - // Edit description - let current_description = current_content - .get("description") - .and_then(|s| s.as_str()) - .unwrap_or(""); - let new_description: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Description") - .default(current_description.to_string()) - .interact_text()?; - - // Edit status - let status_choices = vec!["pending", "in_progress", "completed"]; - let current_status = current_content - .get("status") - .and_then(|s| s.as_str()) - .unwrap_or("pending"); - let current_status_index = status_choices - .iter() - .position(|&s| s == current_status) - .unwrap_or(0); - let status_selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Status") - .items(&status_choices) - .default(current_status_index) - .interact()?; - - // Edit priority - let priority_choices = vec!["low", "medium", "high"]; - let current_priority = current_content - .get("priority") - .and_then(|s| s.as_str()) - .unwrap_or("medium"); - let current_priority_index = priority_choices - .iter() - .position(|&s| s == current_priority) - .unwrap_or(1); - let priority_selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Priority") - .items(&priority_choices) - .default(current_priority_index) - .interact()?; - - // Edit tags - let current_tags = current_content - .get("tags") - .and_then(|t| t.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .collect::>() - .join(", ") - }) - .unwrap_or_default(); - let tags_input: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Tags (comma-separated)") - .default(current_tags) - .interact_text()?; - - let tags: Vec = if tags_input.trim().is_empty() { - vec![] - } else { - tags_input - .split(',') - .map(|s| s.trim().to_string()) - .collect() - }; - - // Create updated content - let mut new_content = current_content.clone(); - if let Some(obj) = new_content.as_object_mut() { - obj.insert("title".to_string(), json!(new_title.clone())); - obj.insert("description".to_string(), json!(new_description)); - obj.insert( - "status".to_string(), - json!(status_choices[status_selection]), - ); - obj.insert( - "priority".to_string(), - json!(priority_choices[priority_selection]), - ); - obj.insert("tags".to_string(), json!(tags)); - - if status_choices[status_selection] == "completed" && current_status != "completed" { - obj.insert("completed_at".to_string(), json!(Utc::now().to_rfc3339())); - } - } - - if let Some(engine) = sync_engine { - engine.update_document(doc_id, new_content).await?; - println!("✅ Task updated and synced!"); - } else { - // Offline update - let doc = db.get_document(&doc_id).await?; - let mut updated_doc = doc; - let mut full_content = new_content.clone(); - full_content["title"] = serde_json::json!(new_title); - updated_doc.content = full_content; - updated_doc.sync_revision += 1; - updated_doc.updated_at = chrono::Utc::now(); - - db.save_document(&updated_doc).await?; - - // Mark as pending sync - sqlx::query("UPDATE documents SET sync_status = 'pending' WHERE id = ?1") - .bind(doc_id.to_string()) - .execute(&db.pool) - .await?; - - println!("✅ Task updated (offline)"); - } - - Ok(()) -} - -async fn view_task(db: &ClientDatabase, user_id: Uuid) -> Result<(), Box> { - let rows = sqlx::query( - r#" - SELECT id, title, content, created_at, updated_at, version - FROM documents - WHERE user_id = ?1 AND deleted_at IS NULL - ORDER BY updated_at DESC - "#, - ) - .bind(user_id.to_string()) - .fetch_all(&db.pool) - .await?; - - if rows.is_empty() { - println!("📭 No tasks to view."); - return Ok(()); - } - - let mut task_info = Vec::new(); - let mut choices = Vec::new(); - - for row in rows { - let id = row.try_get::("id")?; - let title = row.try_get::("title")?; - let content_str = row.try_get::("content")?; - let content: Value = serde_json::from_str(&content_str).unwrap_or_default(); - let status = content - .get("status") - .and_then(|s| s.as_str()) - .unwrap_or("pending"); - let priority = content - .get("priority") - .and_then(|s| s.as_str()) - .unwrap_or("medium"); - - let status_icon = match status { - "completed" => "✅", - "in_progress" => "🔄", - "pending" => "⏳", - _ => "❓", - }; - let priority_icon = match priority { - "high" => "🔴", - "medium" => "🟡", - "low" => "🟢", - _ => "⚪", - }; - - task_info.push(row); - choices.push(format!( - "{} {} {} - {}", - status_icon, - priority_icon, - &id[..8], - title - )); - } - - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Select task to view") - .items(&choices) - .interact()?; - - let row = &task_info[selection]; - let id = row.try_get::("id")?; - let title = row.try_get::("title")?; - let content_str = row.try_get::("content")?; - let created_at = row.try_get::, _>("created_at")?; - let updated_at = row.try_get::, _>("updated_at")?; - let version = row.try_get::("version")?; - - let content: Value = serde_json::from_str(&content_str)?; - - println!("{}", "📋 Task Details".bold()); - println!("{}", "═".repeat(60).dimmed()); - println!("📌 ID: {}", id.blue()); - println!("📝 Title: {}", title.white().bold()); - - if let Some(description) = content.get("description").and_then(|s| s.as_str()) { - if !description.is_empty() { - println!("📄 Description: {}", description); - } - } - - if let Some(status) = content.get("status").and_then(|s| s.as_str()) { - let status_display = match status { - "completed" => "✅ Completed".green(), - "in_progress" => "🔄 In Progress".yellow(), - "pending" => "⏳ Pending".blue(), - _ => status.dimmed(), - }; - println!("📊 Status: {}", status_display); - } - - if let Some(priority) = content.get("priority").and_then(|s| s.as_str()) { - let priority_display = match priority { - "high" => "🔴 High".red(), - "medium" => "🟡 Medium".yellow(), - "low" => "🟢 Low".green(), - _ => priority.dimmed(), - }; - println!("⚡ Priority: {}", priority_display); - } - - if let Some(tags) = content.get("tags").and_then(|t| t.as_array()) { - let tag_strings: Vec = tags - .iter() - .filter_map(|v| v.as_str()) - .map(|s| format!("#{}", s)) - .collect(); - if !tag_strings.is_empty() { - println!("🏷️ Tags: {}", tag_strings.join(" ").cyan()); - } - } - - if let Some(created_str) = content.get("created_at").and_then(|s| s.as_str()) { - println!("🕐 Created: {}", created_str.dimmed()); - } else { - println!("🕐 Created: {}", created_at.to_string().dimmed()); - } - - if let Some(completed_str) = content.get("completed_at").and_then(|s| s.as_str()) { - println!("✅ Completed: {}", completed_str.green()); - } - - println!("🔄 Updated: {}", updated_at.to_string().dimmed()); - println!("📊 Version: {}", version.to_string().yellow()); - println!("{}", "═".repeat(60).dimmed()); - - Ok(()) -} - -async fn delete_task( - db: &ClientDatabase, - sync_engine: &Option, - user_id: Uuid, -) -> Result<(), Box> { - // List tasks for selection - let rows = sqlx::query( - r#" - SELECT id, title, content - FROM documents - WHERE user_id = ?1 AND deleted_at IS NULL - ORDER BY updated_at DESC - "#, - ) - .bind(user_id.to_string()) - .fetch_all(&db.pool) - .await?; - - if rows.is_empty() { - println!("📭 No tasks to delete."); - return Ok(()); - } - - let mut task_info = Vec::new(); - let mut choices = Vec::new(); - - for row in rows { - let id = row.try_get::("id")?; - let title = row.try_get::("title")?; - let content_str = row.try_get::("content")?; - let content: Value = serde_json::from_str(&content_str).unwrap_or_default(); - let status = content - .get("status") - .and_then(|s| s.as_str()) - .unwrap_or("pending"); - let priority = content - .get("priority") - .and_then(|s| s.as_str()) - .unwrap_or("medium"); - - let status_icon = match status { - "completed" => "✅", - "in_progress" => "🔄", - "pending" => "⏳", - _ => "❓", - }; - let priority_icon = match priority { - "high" => "🔴", - "medium" => "🟡", - "low" => "🟢", - _ => "⚪", - }; - - task_info.push((id.clone(), title.clone())); - choices.push(format!( - "{} {} {} - {}", - status_icon, - priority_icon, - &id[..8], - title - )); - } - - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Select task to delete") - .items(&choices) - .interact()?; - - let task_id = Uuid::parse_str(&task_info[selection].0)?; - let task_title = &task_info[selection].1; - - // Confirm deletion - if !Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(&format!( - "⚠️ Are you sure you want to delete task '{}'? This action cannot be undone.", - task_title - )) - .default(false) - .interact()? - { - println!("❌ Deletion cancelled."); - return Ok(()); - } - - if let Some(engine) = sync_engine { - engine.delete_document(task_id).await?; - println!("✅ Task '{}' deleted and synced!", task_title.green()); - } else { - // Offline delete - just mark as deleted locally - db.delete_document(&task_id).await?; - println!("✅ Task '{}' deleted (offline)", task_title.yellow()); - } - - Ok(()) -} - -async fn show_sync_status(db: &ClientDatabase) -> Result<(), Box> { - let pending_row = - sqlx::query("SELECT COUNT(*) as count FROM documents WHERE sync_status = 'pending'") - .fetch_one(&db.pool) - .await?; - let pending_count = pending_row.try_get::("count")?; - - let conflict_row = - sqlx::query("SELECT COUNT(*) as count FROM documents WHERE sync_status = 'conflict'") - .fetch_one(&db.pool) - .await?; - let conflict_count = conflict_row.try_get::("count")?; - - println!("{}", "🔄 Sync Status".bold()); - println!("{}", "─".repeat(40).dimmed()); - println!("⏳ Pending sync: {}", pending_count.to_string().yellow()); - println!("⚠️ Conflicts: {}", conflict_count.to_string().red()); - println!("{}", "─".repeat(40).dimmed()); - - Ok(()) -} diff --git a/replicant-client/examples/phoenix_spike.rs b/replicant-client/examples/phoenix_spike.rs deleted file mode 100644 index 2d41ab2..0000000 --- a/replicant-client/examples/phoenix_spike.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! Spike test for phoenix_channels_client against Elixir/Phoenix server -//! -//! Run with: -//! 1. Start Phoenix server: cd replicant_server && mix phx.server -//! 2. Generate credentials: mix replicant.gen.credentials --name "spike" -//! 3. Run spike: -//! REPLICANT_API_KEY="rpa_..." REPLICANT_API_SECRET="rps_..." \ -//! cargo run --example phoenix_spike -//! -//! Pass criteria: -//! - Clean connect/join/leave lifecycle -//! - Request-reply works -//! - Error handling works - -use phoenix_channels_client::{Event, Payload, Socket, Topic}; -use serde_json::json; -use std::time::Duration; -use url::Url; - -// Test credentials - set via environment variables -// Generate with: mix replicant.gen.credentials --name "Spike Test" -fn get_api_key() -> String { - std::env::var("REPLICANT_API_KEY").expect("REPLICANT_API_KEY env var required") -} -fn get_secret() -> String { - std::env::var("REPLICANT_API_SECRET").expect("REPLICANT_API_SECRET env var required") -} -fn get_email() -> String { - std::env::var("REPLICANT_EMAIL").unwrap_or_else(|_| "spike@test.com".to_string()) -} -fn get_server_url() -> String { - std::env::var("SYNC_SERVER_URL") - .unwrap_or_else(|_| "ws://127.0.0.1:4000/socket/websocket".to_string()) -} - -fn create_signature(secret: &str, timestamp: i64, email: &str, api_key: &str) -> String { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - - let message = format!("{}.{}.{}.", timestamp, email, api_key); - let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); - mac.update(message.as_bytes()); - hex::encode(mac.finalize().into_bytes()) -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let _ = env_logger::builder() - .filter_level(log::LevelFilter::Debug) - .is_test(true) - .try_init(); - - println!("=== Phoenix Channels Client Spike Test ===\n"); - - let server_url = get_server_url(); - let api_key = get_api_key(); - let secret = get_secret(); - let email = get_email(); - - // Test 1: Connect to server - println!("1. Connecting to Phoenix server at {}...", server_url); - let url = Url::parse(&server_url)?; - let socket = Socket::spawn(url, None, None).await?; - socket.connect(Duration::from_secs(10)).await?; - println!(" ✓ Connected\n"); - - // Test 2: Join channel with authentication - println!("2. Joining sync channel with HMAC auth..."); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs() as i64; - let signature = create_signature(&secret, timestamp, &email, &api_key); - - let join_payload = json!({ - "email": email, - "api_key": api_key, - "signature": signature, - "timestamp": timestamp - }); - - let payload = Payload::json_from_serialized(join_payload.to_string())?; - let channel = socket - .channel(Topic::from_string("sync:main".to_string()), Some(payload)) - .await?; - - channel.join(Duration::from_secs(10)).await?; - println!(" ✓ Joined channel\n"); - - // Test 3: Create a document - println!("3. Creating a document..."); - let doc_id = uuid::Uuid::new_v4().to_string(); - let create_payload = json!({ - "id": doc_id, - "content": {"title": "Spike Test Document", "body": "Hello from Rust!"} - }); - - let response = channel - .call( - Event::from_string("create_document".to_string()), - Payload::json_from_serialized(create_payload.to_string())?, - Duration::from_secs(5), - ) - .await; - match response { - Ok(reply) => println!(" Response: {:?}", reply), - Err(e) => println!(" Error: {:?}", e), - } - println!(" ✓ Create document sent\n"); - - // Test 4: Request full sync - println!("4. Requesting full sync..."); - let response = channel - .call( - Event::from_string("request_full_sync".to_string()), - Payload::json_from_serialized(json!({}).to_string())?, - Duration::from_secs(5), - ) - .await; - match response { - Ok(reply) => println!(" Response: {:?}", reply), - Err(e) => println!(" Error: {:?}", e), - } - println!(" ✓ Full sync completed\n"); - - // Test 5: Get changes since - println!("5. Getting changes since sequence 0..."); - let response = channel - .call( - Event::from_string("get_changes_since".to_string()), - Payload::json_from_serialized(json!({"last_sequence": 0}).to_string())?, - Duration::from_secs(5), - ) - .await; - match response { - Ok(reply) => println!(" Response: {:?}", reply), - Err(e) => println!(" Error: {:?}", e), - } - println!(" ✓ Changes retrieved\n"); - - // Test 6: Leave channel - println!("6. Leaving channel..."); - channel.leave().await?; - println!(" ✓ Left channel\n"); - - // Test 7: Disconnect - println!("7. Disconnecting..."); - socket.disconnect().await?; - println!(" ✓ Disconnected\n"); - - println!("=== Spike test completed ==="); - println!("\nIf all tests showed responses, phoenix_channels_client works correctly with our Phoenix server."); - println!("Proceed with Phase 6b: Rust client websocket rewrite using phoenix_channels_client."); - - Ok(()) -} diff --git a/replicant-client/examples/simple_callback_test.c b/replicant-client/examples/simple_callback_test.c deleted file mode 100644 index 818574c..0000000 --- a/replicant-client/examples/simple_callback_test.c +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @file simple_callback_test.c - * @brief Simple test to verify C compilation and basic callback functionality - * - * This is a minimal example that can be compiled to test the C interface. - */ - -#include -#include -#include - -/* Include the main sync client header */ -#include "../../dist/include/sync_client.h" - -/* Simple callback function */ -void simple_callback(const EventData* event, void* context) { - int* call_count = (int*)context; - (*call_count)++; - - printf("Event received: type=%d, call_count=%d\n", event->event_type, *call_count); - - if (event->document_id && strlen(event->document_id) > 0) { - printf(" Document ID: %s\n", event->document_id); - } - - if (event->title && strlen(event->title) > 0) { - printf(" Title: %s\n", event->title); - } - - if (event->error && strlen(event->error) > 0) { - printf(" Error: %s\n", event->error); - } - - if (event->numeric_data > 0) { - printf(" Numeric data: %llu\n", (unsigned long long)event->numeric_data); - } - - if (event->boolean_data) { - printf(" Boolean data: true\n"); - } -} - -int main() { - printf("=== Simple C Callback Test ===\n"); - - /* Create sync engine */ - struct SyncEngine* engine = sync_engine_create( - "sqlite::memory:", - "ws://localhost:8080/ws", - "test-token", - "simple-test@example.com" - ); - - if (!engine) { - printf("Failed to create sync engine\n"); - return 1; - } - - printf("✓ Sync engine created\n"); - - /* Register callback */ - int call_count = 0; - SyncResult result = sync_engine_register_event_callback( - engine, - simple_callback, - &call_count, - -1 /* All events */ - ); - - if (result != Success) { - printf("Failed to register callback: %d\n", result); - sync_engine_destroy(engine); - return 1; - } - - printf("✓ Callback registered\n"); - - /* Create a document to trigger an event */ - char doc_id[37] = {0}; - result = sync_engine_create_document( - engine, - "Test Document", - "{\"message\":\"Hello from C!\"}", - doc_id - ); - - if (result == Success) { - printf("✓ Document created: %s\n", doc_id); - printf("✓ Total callbacks received: %d\n", call_count); - - if (call_count > 0) { - printf("✓ Callback system working!\n"); - } else { - printf("⚠ No callbacks received (may be expected in offline mode)\n"); - } - } else { - printf("Failed to create document: %d\n", result); - } - - /* Clean up */ - sync_engine_destroy(engine); - printf("✓ Cleanup complete\n"); - - printf("\n=== Test completed successfully! ===\n"); - return 0; -} - -/* - * To compile this test: - * - * 1. First build the Rust library: - * cd sync-workspace - * cargo build --release - * - * 2. Then compile this C test: - * gcc -I./sync-client/include \ - * -L./target/release \ - * -lsync_client \ - * -ldl -lpthread -lm \ - * sync-client/examples/simple_callback_test.c \ - * -o simple_test - * - * 3. Run the test: - * ./simple_test - * - * Note: On macOS you might need additional flags: - * gcc -I./sync-client/include \ - * -L./target/release \ - * -lsync_client \ - * -framework CoreFoundation -framework Security \ - * -ldl -lpthread -lm \ - * sync-client/examples/simple_callback_test.c \ - * -o simple_test - */ \ No newline at end of file diff --git a/replicant-client/examples/simple_cpp_callbacks.cpp b/replicant-client/examples/simple_cpp_callbacks.cpp deleted file mode 100644 index 04ab3a9..0000000 --- a/replicant-client/examples/simple_cpp_callbacks.cpp +++ /dev/null @@ -1,401 +0,0 @@ -/** - * @file simple_cpp_callbacks.cpp - * @brief Simple C++ example demonstrating thread-safe event callbacks without locks - * - * This example shows how to use the sync client event system with the new - * single-thread callback model that eliminates the need for mutexes and locks. - * - * Key features: - * - No mutexes or locks required in user code - * - Events are automatically queued from any thread - * - Callbacks only execute on the thread that registered them - * - Simple main loop pattern with process_events() - * - snake_case naming conventions for consistency - */ - -#include -#include -#include -#include -#include - -// Include the event header -#include "../include/sync_client_events.h" - -// Forward declarations for the main sync client functions -extern "C" { - struct CSyncEngine; - extern struct CSyncEngine* sync_engine_create(const char* database_url, const char* server_url, const char* auth_token, const char* user_identifier); - extern void sync_engine_destroy(struct CSyncEngine* engine); - extern SyncResult sync_engine_create_document(struct CSyncEngine* engine, const char* content_json, char* out_document_id); - extern SyncResult sync_engine_update_document(struct CSyncEngine* engine, const char* document_id, const char* content_json); - extern SyncResult sync_engine_delete_document(struct CSyncEngine* engine, const char* document_id); - - // Debug functions (only available in debug builds) - #ifdef DEBUG - extern SyncResult sync_engine_emit_test_event(struct CSyncEngine* engine, int event_type); - extern SyncResult sync_engine_emit_test_event_burst(struct CSyncEngine* engine, int count); - #endif -} - -// Simple event statistics - no thread synchronization needed! -struct event_stats -{ - int total_events = 0; - int document_events = 0; - int sync_events = 0; - int error_events = 0; - std::vector recent_event_names; - - void add_event(const std::string& event_name) - { - total_events++; - recent_event_names.push_back(event_name); - - // Keep only the last 5 events - if (recent_event_names.size() > 5) - { - recent_event_names.erase(recent_event_names.begin()); - } - } - - void print_summary() const - { - std::cout << "\n=== Event Summary ===\n"; - std::cout << "Total events: " << total_events << "\n"; - std::cout << "Document events: " << document_events << "\n"; - std::cout << "Sync events: " << sync_events << "\n"; - std::cout << "Error events: " << error_events << "\n"; - - if (!recent_event_names.empty()) - { - std::cout << "Recent events: "; - for (const auto& name : recent_event_names) - { - std::cout << name << " "; - } - std::cout << "\n"; - } - std::cout << "=====================\n"; - } -}; - -// Global stats (safe because callbacks only run on main thread) -event_stats g_stats; - -// Helper function to get event type name -std::string get_event_type_name(SyncEventType type) -{ - switch (type) - { - case SYNC_EVENT_DOCUMENT_CREATED: return "DocumentCreated"; - case SYNC_EVENT_DOCUMENT_UPDATED: return "DocumentUpdated"; - case SYNC_EVENT_DOCUMENT_DELETED: return "DocumentDeleted"; - case SYNC_EVENT_SYNC_STARTED: return "SyncStarted"; - case SYNC_EVENT_SYNC_COMPLETED: return "SyncCompleted"; - case SYNC_EVENT_SYNC_ERROR: return "SyncError"; - case SYNC_EVENT_CONFLICT_DETECTED: return "ConflictDetected"; - case SYNC_EVENT_CONNECTION_STATE_CHANGED: return "ConnectionStateChanged"; - default: return "Unknown"; - } -} - -// Simple callback function - no locks needed! -void simple_event_callback(const SyncEventData* event, void* context) -{ - std::string event_name = get_event_type_name(event->event_type); - - // Update stats (safe - only called on main thread) - g_stats.add_event(event_name); - - // Handle different event types - switch (event->event_type) - { - case SYNC_EVENT_DOCUMENT_CREATED: - case SYNC_EVENT_DOCUMENT_UPDATED: - case SYNC_EVENT_DOCUMENT_DELETED: - g_stats.document_events++; - std::cout << "📄 " << event_name; - if (event->document_id) - { - std::cout << " - Doc ID: " << event->document_id; - } - if (event->title) - { - std::cout << " - Title: '" << event->title << "'"; - } - std::cout << "\n"; - break; - - case SYNC_EVENT_SYNC_STARTED: - case SYNC_EVENT_SYNC_COMPLETED: - g_stats.sync_events++; - std::cout << "🔄 " << event_name; - if (event->numeric_data > 0) - { - std::cout << " - Count: " << event->numeric_data; - } - std::cout << "\n"; - break; - - case SYNC_EVENT_SYNC_ERROR: - g_stats.error_events++; - std::cout << "🚨 " << event_name; - if (event->error) - { - std::cout << " - Error: " << event->error; - } - std::cout << "\n"; - break; - - case SYNC_EVENT_CONNECTION_STATE_CHANGED: - std::cout << "🔗 " << event_name << " - Connected: " - << (event->boolean_data ? "Yes" : "No") << "\n"; - break; - - case SYNC_EVENT_CONFLICT_DETECTED: - std::cout << "⚠️ " << event_name; - if (event->document_id) - { - std::cout << " - Doc ID: " << event->document_id; - } - std::cout << "\n"; - break; - - default: - std::cout << "❓ " << event_name << "\n"; - break; - } -} - -// Simple RAII wrapper for sync engine -class simple_sync_engine -{ -private: - CSyncEngine* engine_; - -public: - simple_sync_engine(const std::string& database_url, - const std::string& server_url, - const std::string& auth_token, - const std::string& user_identifier) - { - engine_ = sync_engine_create(database_url.c_str(), server_url.c_str(), auth_token.c_str(), user_identifier.c_str()); - if (!engine_) - { - throw std::runtime_error("Failed to create sync engine"); - } - } - - ~simple_sync_engine() - { - if (engine_) - { - sync_engine_destroy(engine_); - } - } - - // Non-copyable - simple_sync_engine(const simple_sync_engine&) = delete; - simple_sync_engine& operator=(const simple_sync_engine&) = delete; - - CSyncEngine* get() const { return engine_; } - - SyncResult register_callback(SyncEventCallback callback, void* context = nullptr, int event_filter = -1) - { - return sync_engine_register_event_callback(engine_, callback, context, event_filter); - } - - SyncResult process_events(uint32_t* processed_count = nullptr) - { - return sync_engine_process_events(engine_, processed_count); - } - - SyncResult create_document(const std::string& content_json, std::string& out_doc_id) - { - char doc_id[37] = {0}; - auto result = sync_engine_create_document(engine_, content_json.c_str(), doc_id); - if (result == SYNC_RESULT_SUCCESS) - { - out_doc_id = std::string(doc_id); - } - return result; - } - - SyncResult update_document(const std::string& document_id, const std::string& content_json) - { - return sync_engine_update_document(engine_, document_id.c_str(), content_json.c_str()); - } - - SyncResult delete_document(const std::string& document_id) - { - return sync_engine_delete_document(engine_, document_id.c_str()); - } - - #ifdef DEBUG - SyncResult emit_test_event(int event_type) - { - return sync_engine_emit_test_event(engine_, event_type); - } - - SyncResult emit_test_event_burst(int count) - { - return sync_engine_emit_test_event_burst(engine_, count); - } - #endif -}; - -int main() -{ - std::cout << "=== Simple C++ Callbacks Demo ===\n"; - std::cout << "This demo shows thread-safe callbacks WITHOUT locks or mutexes!\n\n"; - - try - { - // Create sync engine - simple_sync_engine engine("sqlite::memory:", "ws://localhost:8080/ws", "simple-test-token", "simple-cpp-test@example.com"); - std::cout << "✓ Sync engine created\n"; - - // Register callback - this sets the callback thread to the current thread - auto result = engine.register_callback(simple_event_callback); - if (result != SYNC_RESULT_SUCCESS) - { - std::cout << "❌ Failed to register callback: " << result << "\n"; - return 1; - } - std::cout << "✓ Event callback registered\n"; - - // Test document operations - std::cout << "\n--- Testing Document Operations ---\n"; - - std::string doc_id; - auto create_result = engine.create_document( - R"({"title": "Simple C++ Document", "language": "C++", "complexity": "simple", "thread_safe": true})", - doc_id - ); - - if (create_result == SYNC_RESULT_SUCCESS) - { - std::cout << "✓ Document created: " << doc_id << "\n"; - - // Process events - this is where callbacks are invoked! - uint32_t processed; - engine.process_events(&processed); - std::cout << "✓ Processed " << processed << " events\n"; - - // Update the document - auto update_result = engine.update_document(doc_id, - R"({"language": "C++", "complexity": "simple", "thread_safe": true, "updated": true})"); - - if (update_result == SYNC_RESULT_SUCCESS) - { - std::cout << "✓ Document updated\n"; - engine.process_events(&processed); - std::cout << "✓ Processed " << processed << " events\n"; - - // Delete the document - auto delete_result = engine.delete_document(doc_id); - if (delete_result == SYNC_RESULT_SUCCESS) - { - std::cout << "✓ Document deleted\n"; - engine.process_events(&processed); - std::cout << "✓ Processed " << processed << " events\n"; - } - } - } - else - { - std::cout << "ℹ️ Document creation failed (expected in offline mode): " << create_result << "\n"; - } - - #ifdef DEBUG - // Test with debug events - std::cout << "\n--- Testing Debug Events ---\n"; - - engine.emit_test_event(SYNC_EVENT_SYNC_STARTED); - engine.emit_test_event(SYNC_EVENT_SYNC_COMPLETED); - engine.emit_test_event(SYNC_EVENT_CONNECTION_STATE_CHANGED); - - // Process all queued events - uint32_t total_processed = 0; - uint32_t batch_processed; - do - { - engine.process_events(&batch_processed); - total_processed += batch_processed; - } while (batch_processed > 0); - - std::cout << "✓ Processed " << total_processed << " debug events\n"; - - // Test event burst - std::cout << "\nTesting event burst...\n"; - engine.emit_test_event_burst(5); - - // Simple main loop simulation - std::cout << "Simulating main loop...\n"; - for (int i = 0; i < 10; ++i) - { - engine.process_events(&batch_processed); - if (batch_processed > 0) - { - std::cout << " Loop " << i << ": processed " << batch_processed << " events\n"; - } - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - } - #endif - - // Print final statistics - g_stats.print_summary(); - - std::cout << "\n✅ SUCCESS: All callbacks executed on main thread without locks!\n"; - std::cout << "📝 Key insight: Events can be generated on any thread, but callbacks\n"; - std::cout << " are only executed when you call process_events() on the main thread.\n"; - std::cout << " This eliminates the need for thread synchronization in your code!\n"; - - } - catch (const std::exception& e) - { - std::cout << "❌ Error: " << e.what() << "\n"; - return 1; - } - - std::cout << "\n=== Demo completed successfully! ===\n"; - return 0; -} - -/* - * Compilation instructions: - * - * 1. Build the Rust library first: - * cd sync-workspace - * cargo build --release - * - * 2. Compile this C++ example: - * g++ -std=c++14 \ - * -I./sync-client/include \ - * -L./target/release \ - * -lsync_client \ - * -framework CoreFoundation -framework Security \ - * -ldl -lpthread -lm \ - * sync-client/examples/simple_cpp_callbacks.cpp \ - * -o simple_cpp_test - * - * 3. Run the test: - * ./simple_cpp_test - * - * Key benefits of this approach: - * - * ✅ NO MUTEXES OR LOCKS in user code - * ✅ Thread-safe by design (single callback thread) - * ✅ Simple main loop pattern - * ✅ Events can be generated from any thread - * ✅ Callbacks only execute on the thread that registered them - * ✅ Easy to understand and maintain - * ✅ No risk of deadlocks or race conditions in user code - * ✅ Perfect for game engines, UI frameworks, and event loops - * - * Usage pattern: - * 1. Register callbacks (once, on main thread) - * 2. In your main loop, regularly call process_events() - * 3. That's it! No need to worry about thread safety. - */ \ No newline at end of file diff --git a/replicant-client/examples/simple_sync_test.rs b/replicant-client/examples/simple_sync_test.rs deleted file mode 100644 index beca0f9..0000000 --- a/replicant-client/examples/simple_sync_test.rs +++ /dev/null @@ -1,386 +0,0 @@ -use replicant_client::Client; -use serde_json::json; -use std::env; -use std::sync::Arc; -use tracing::{debug, info}; -use uuid::Uuid; - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Initialize logging - tracing_subscriber::fmt() - .with_env_filter( - std::env::var("RUST_LOG") - .unwrap_or_else(|_| "info,sync_client=debug,replicant_core=debug".to_string()), - ) - .init(); - - let args: Vec = env::args().collect(); - - if args.len() < 2 { - eprintln!("Usage: {} [args...]", args[0]); - eprintln!("Actions:"); - eprintln!(" create --database --user --title --desc <description>"); - eprintln!(" update --database <path> --user <email> --id <doc_id> --title <title>"); - eprintln!(" delete --database <path> --user <email> --id <doc_id>"); - eprintln!(" list --database <path> --user <email>"); - eprintln!(" sync --database <path> --user <email>"); - eprintln!(" status --database <path> --user <email>"); - eprintln!(" daemon --database <path> --user <email>"); - std::process::exit(1); - } - - let action = &args[1]; - let mut database_path = String::new(); - let mut user_email = String::new(); - let mut doc_id = None; - let mut title = String::new(); - let mut description = String::new(); - - // Parse arguments - let mut i = 2; - while i < args.len() { - match args[i].as_str() { - "--database" => { - if i + 1 < args.len() { - database_path = format!("sqlite:databases/{}.sqlite3?mode=rwc", args[i + 1]); - i += 2; - } else { - eprintln!("--database requires a value"); - std::process::exit(1); - } - } - "--user" => { - if i + 1 < args.len() { - user_email = args[i + 1].clone(); - i += 2; - } else { - eprintln!("--user requires a value"); - std::process::exit(1); - } - } - "--id" => { - if i + 1 < args.len() { - doc_id = Some(Uuid::parse_str(&args[i + 1])?); - i += 2; - } else { - eprintln!("--id requires a value"); - std::process::exit(1); - } - } - "--title" => { - if i + 1 < args.len() { - title = args[i + 1].clone(); - i += 2; - } else { - eprintln!("--title requires a value"); - std::process::exit(1); - } - } - "--desc" => { - if i + 1 < args.len() { - description = args[i + 1].clone(); - i += 2; - } else { - eprintln!("--desc requires a value"); - std::process::exit(1); - } - } - _ => { - eprintln!("Unknown argument: {}", args[i]); - std::process::exit(1); - } - } - } - - if database_path.is_empty() || user_email.is_empty() { - eprintln!("--database and --user are required"); - std::process::exit(1); - } - - info!( - "Starting simple_sync_test: action={}, database={}, user={}", - action, database_path, user_email - ); - - // Create sync engine (auto-starts with built-in reconnection) - debug!("Creating sync engine for database: {}", database_path); - let engine = Client::new( - &database_path, - "ws://localhost:8080/ws", - &user_email, - "test-key", - "test-secret", - None, - ) - .await?; - - match action.as_str() { - "create" => { - if title.is_empty() { - eprintln!("--title is required for create"); - std::process::exit(1); - } - - let content = json!({ - "title": title, - "description": description - }); - - debug!("Creating document with content: {:?}", content); - let document = engine.create_document(content).await?; - info!("Successfully created document: {}", document.id); - println!("Created document: {}", document.id); - - // Allow time for immediate sync to complete before disconnecting - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - } - - "update" => { - let id = doc_id.ok_or("--id is required for update")?; - if title.is_empty() { - eprintln!("--title is required for update"); - std::process::exit(1); - } - - let content = json!({ - "title": title, - "description": description - }); - - debug!("Updating document {} with content: {:?}", id, content); - engine.update_document(id, content).await?; - - // Allow time for immediate sync to complete before disconnecting - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - info!("Successfully updated document: {}", id); - println!("Updated document: {}", id); - } - - "delete" => { - let id = doc_id.ok_or("--id is required for delete")?; - engine.delete_document(id).await?; - - // Allow time for immediate sync to complete before disconnecting - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - println!("Deleted document: {}", id); - } - - "list" => { - let documents = engine.get_all_documents().await?; - if documents.is_empty() { - println!("No documents found"); - } else { - for doc in documents { - let title = doc - .content - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("No title"); - let desc = doc - .content - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - println!( - "Document: {} | Title: {} | Description: {}", - doc.id, title, desc - ); - } - } - } - - "sync" => { - info!("Starting sync_all operation"); - let start = std::time::Instant::now(); - engine.sync_all().await?; - let elapsed = start.elapsed(); - info!("Sync completed in {:?}", elapsed); - - // Allow time for sync messages to complete before disconnecting - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - println!("Sync completed"); - } - - "status" => { - let documents = engine.get_all_documents().await?; - println!("Database: {}", database_path); - println!("User: {}", user_email); - println!("Documents: {}", documents.len()); - - for doc in documents { - let title = doc - .content - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("No title"); - - println!(" {} | {}", doc.id, title); - } - - // Allow extra time for potential incoming sync messages (like auto-sync after reconnection) - info!("Waiting for potential incoming sync updates..."); - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - } - - "daemon" => { - // Daemon mode - keep sync engine alive and process commands from stdin - info!("Starting daemon mode - sync engine will stay alive"); - - // Create a shared engine reference that can be used across async tasks - let engine = Arc::new(engine); - let engine_for_stdin = engine.clone(); - - println!("DAEMON_READY"); - - // Process stdin commands in a separate async task - let stdin_task = tokio::spawn(async move { - use tokio::io::{AsyncBufReadExt, BufReader}; - - let stdin = tokio::io::stdin(); - let reader = BufReader::new(stdin); - let mut lines = reader.lines(); - - while let Ok(Some(line)) = lines.next_line().await { - let line = line.trim().to_string(); - - if line.is_empty() { - continue; - } - - if line == "QUIT" { - info!("Received QUIT command, exiting daemon"); - break; - } - - let parts: Vec<&str> = line.split(':').collect(); - match parts.get(0) { - Some(&"CREATE") => { - if parts.len() >= 3 { - let title = parts[1]; - let desc = parts.get(2).unwrap_or(&""); - let content = json!({ - "title": title, - "description": desc, - "priority": "medium", - "tags": ["daemon"] - }); - - let create_future = engine_for_stdin.create_document(content); - match tokio::time::timeout( - std::time::Duration::from_secs(5), - create_future, - ) - .await - { - Ok(Ok(doc)) => println!("RESPONSE:CREATED:{}", doc.id), - Ok(Err(e)) => println!("RESPONSE:ERROR:Create failed: {}", e), - Err(_) => println!("RESPONSE:ERROR:Create timeout"), - } - } else { - println!("RESPONSE:ERROR:CREATE requires title:description"); - } - } - Some(&"UPDATE") => { - if parts.len() >= 4 { - let doc_id_str = parts[1]; - let title = parts[2]; - let desc = parts.get(3).unwrap_or(&""); - - match Uuid::parse_str(doc_id_str) { - Ok(doc_id) => { - let content = json!({ - "title": title, - "description": desc, - "updated": true - }); - - let update_future = - engine_for_stdin.update_document(doc_id, content); - match tokio::time::timeout( - std::time::Duration::from_secs(5), - update_future, - ) - .await - { - Ok(Ok(_)) => println!("RESPONSE:UPDATED:{}", doc_id), - Ok(Err(e)) => { - println!("RESPONSE:ERROR:Update failed: {}", e) - } - Err(_) => println!("RESPONSE:ERROR:Update timeout"), - } - } - Err(_) => println!("RESPONSE:ERROR:Invalid document ID"), - } - } else { - println!("RESPONSE:ERROR:UPDATE requires doc_id:title:description"); - } - } - Some(&"STATUS") => { - // Use timeouts to prevent blocking during reconnection - let doc_count_future = engine_for_stdin.count_documents(); - let pending_count_future = engine_for_stdin.count_pending_sync(); - - let doc_count = match tokio::time::timeout( - std::time::Duration::from_millis(500), - doc_count_future, - ) - .await - { - Ok(Ok(count)) => count, - _ => 0, // Return 0 on timeout or error - }; - - let pending_count = match tokio::time::timeout( - std::time::Duration::from_millis(500), - pending_count_future, - ) - .await - { - Ok(Ok(count)) => count, - _ => 0, // Return 0 on timeout or error - }; - - let connected = engine_for_stdin.is_connected(); - println!( - "RESPONSE:STATUS:{}:{}:{}", - doc_count, pending_count, connected - ); - } - Some(&"LIST") => match engine_for_stdin.get_all_documents().await { - Ok(docs) => { - for doc in docs { - let title = doc - .content - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("No title"); - println!("RESPONSE:DOC:{}:{}", doc.id, title); - } - println!("RESPONSE:LIST_END"); - } - Err(e) => println!("RESPONSE:ERROR:List failed: {}", e), - }, - _ => { - println!("RESPONSE:ERROR:Unknown command: {}", line); - } - } - } - - info!("Stdin processing task exiting"); - }); - - // Wait for the stdin task to complete - let _ = stdin_task.await; - info!("Daemon mode exiting"); - } - - _ => { - eprintln!("Unknown action: {}", action); - std::process::exit(1); - } - } - - Ok(()) -} diff --git a/replicant-client/examples/task_list_example.md b/replicant-client/examples/task_list_example.md deleted file mode 100644 index 0d81ebe..0000000 --- a/replicant-client/examples/task_list_example.md +++ /dev/null @@ -1,86 +0,0 @@ -# Task List Edit Functionality - -The task list example supports editing with cursor inversion, field cycling, and priority selection. - -## Key Bindings - -### Normal Mode: -- `j/k` or arrows: Navigate between tasks -- `space`: Toggle task completion status -- `e`: Edit task title -- `E`: Edit task description -- `p`: Edit task priority -- `n`: Create new task -- `d`: Delete selected task -- `q`: Quit - -### Edit Mode: -- `Enter`: Save the edit -- `Esc`: Cancel edit without saving -- `Tab` or `↓`: Cycle to next field (Title → Description → Priority → Title) -- `↑`: Cycle to previous field (Priority → Description → Title → Priority) - -#### For Title & Description: -- `←/→`: Move cursor left/right -- `Backspace`: Delete character before cursor -- Type any character to insert at cursor position - -#### For Priority: -- `←/→`: Cycle through priority levels (Low ⟷ Medium ⟷ High) -- Cannot type text (controlled selection only) - -## Priority System -Priority editing uses **controlled selection** with three levels: -- 🟢 **Low**: Low priority tasks -- 🟡 **Medium**: Medium priority tasks (default) -- 🔴 **High**: High priority tasks - -Use `←/→` arrows to cycle through these levels when editing priority. No text input required! - -## How to Test - -1. Run the task list example: - ```bash - cargo run --example task_list_example -- --auto --user test-enhanced-edit - ``` - -2. Create some tasks with `n` - -3. Select a task and try the enhanced editing: - - Press `e` to edit the title - see the **proper cursor inversion** - - Press `E` to edit the description - notice the **underlined field** - - Press `p` to edit the priority - use `←/→` to **cycle priorities** - -4. Try field cycling: - - Press `Tab` or `↓` to cycle forward through fields - - Press `↑` to cycle backward through fields - - Watch the help text change based on which field you're editing - -5. Notice the enhanced visuals: - - **Inverted cursor** that highlights the actual character - - **Underlined fields** instead of background highlighting - - **Dynamic help text** that adapts to the current field - - **Priority hints** showing `[←/→ to change]` - -## Enhanced Visual Features - -- ✅ **Enhanced cursor display**: Bright red `│` cursor with yellow background highlighting -- ✅ **Field highlighting**: Active field shows with `▶` arrow and yellow label -- ✅ **Dimmed inactive fields**: Non-active fields are grayed out during editing -- ✅ **Field cycling**: Tab/Up/Down arrows to seamlessly switch between fields -- ✅ **Current field indicator**: Help text shows which field you're editing -- ✅ **Empty field display**: Shows "(empty)" for empty descriptions when editing -- ✅ **Proper validation**: Priority values with smart defaults -- ✅ **Real-time sync**: Changes sync across clients when online, queue when offline -- ✅ **Activity notifications**: Updates logged in activity panel - -## Visual Editing Experience - -When you enter edit mode: -1. **Active field** gets a yellow `▶` arrow and bright yellow background -2. **Cursor** appears as a red `│` line with precise positioning -3. **Other fields** are dimmed to show they're not active -4. **Help text** updates to show current field and available commands -5. **Tab/arrows** let you smoothly cycle between Title → Description → Priority - -The editing experience is now much more intuitive with clear visual feedback about which field is active and where your cursor is positioned! \ No newline at end of file diff --git a/replicant-client/examples/task_list_example.rs b/replicant-client/examples/task_list_example.rs deleted file mode 100644 index 9185e83..0000000 --- a/replicant-client/examples/task_list_example.rs +++ /dev/null @@ -1,2084 +0,0 @@ -use clap::Parser; -use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use ratatui::{ - backend::{Backend, CrosstermBackend}, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, - Frame, Terminal, -}; -use replicant_client::events::SyncEvent; -use replicant_client::{Client, ClientDatabase}; -use replicant_core::models::Document; -use serde_json::{json, Value}; -use sqlx::Row; -use std::{ - error::Error, - io::{self, Write}, - sync::{Arc, Mutex}, - time::{Duration, Instant}, -}; -use uuid::Uuid; - -fn debug_log(msg: &str) { - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open("/tmp/task_list_debug.log") - .unwrap(); - let timestamp = chrono::Utc::now().format("%H:%M:%S%.3f"); - writeln!(file, "[{}] {}", timestamp, msg).unwrap(); -} - -#[derive(Parser)] -#[command(name = "task-list")] -#[command(about = "Task list manager with real-time sync", long_about = None)] -struct Cli { - /// Database file name (will auto-create in databases/ directory) - #[arg(short, long, default_value = "tasks")] - database: String, - - /// Auto-generate unique database name for concurrent testing - #[arg(short, long)] - auto: bool, - - /// User identifier (email/username) for shared identity across clients - #[arg(short, long)] - user: Option<String>, - - /// Server WebSocket URL (Phoenix server) - #[arg(short, long, default_value = "ws://localhost:4000/socket/websocket")] - server: String, - - /// API key for authentication (from: mix replicant.gen.credentials) - #[arg(short = 'k', long, env = "REPLICANT_API_KEY")] - api_key: String, - - /// API secret for authentication (from: mix replicant.gen.credentials) - #[arg(long, env = "REPLICANT_API_SECRET")] - api_secret: String, -} - -#[derive(Clone)] -struct Task { - id: Uuid, - title: String, - description: String, - status: String, - priority: String, - tags: Vec<String>, - created_at: chrono::DateTime<chrono::Utc>, - updated_at: chrono::DateTime<chrono::Utc>, - sync_revision: i64, - sync_status: Option<String>, -} - -struct ActivityEntry { - timestamp: chrono::DateTime<chrono::Utc>, - message: String, - event_type: ActivityType, -} - -#[derive(Clone, Copy)] -enum ActivityType { - Created, - Updated, - Deleted, - SyncStarted, - SyncCompleted, - Connected, - Disconnected, - Error, -} - -struct AppState { - tasks: Vec<Task>, - selected_task: usize, - activity_log: Vec<ActivityEntry>, - sync_status: SyncStatus, - last_sync: Option<Instant>, - should_quit: bool, - needs_refresh: bool, - last_refresh: Option<Instant>, - database_name: String, - edit_mode: Option<EditMode>, -} - -#[derive(Clone)] -struct EditMode { - task_id: Uuid, - field: EditField, - input: String, - cursor_pos: usize, - priority: Priority, -} - -#[derive(Clone, PartialEq)] -enum EditField { - Title, - Description, - Priority, -} - -impl EditField { - fn next(&self) -> Self { - match self { - EditField::Title => EditField::Description, - EditField::Description => EditField::Priority, - EditField::Priority => EditField::Title, - } - } - - fn previous(&self) -> Self { - match self { - EditField::Title => EditField::Priority, - EditField::Description => EditField::Title, - EditField::Priority => EditField::Description, - } - } -} - -#[derive(Clone, PartialEq)] -enum Priority { - Low, - Medium, - High, -} - -impl Priority { - fn from_str(s: &str) -> Self { - match s.to_lowercase().as_str() { - "low" | "l" => Priority::Low, - "high" | "h" => Priority::High, - _ => Priority::Medium, - } - } - - fn to_string(&self) -> String { - match self { - Priority::Low => "low".to_string(), - Priority::Medium => "medium".to_string(), - Priority::High => "high".to_string(), - } - } - - fn to_display(&self) -> &'static str { - match self { - Priority::Low => "🟢 Low", - Priority::Medium => "🟡 Medium", - Priority::High => "🔴 High", - } - } - - fn next(&self) -> Self { - match self { - Priority::Low => Priority::Medium, - Priority::Medium => Priority::High, - Priority::High => Priority::Low, - } - } - - fn previous(&self) -> Self { - match self { - Priority::Low => Priority::High, - Priority::Medium => Priority::Low, - Priority::High => Priority::Medium, - } - } -} - -#[derive(Clone)] -struct SyncStatus { - connected: bool, - pending_count: usize, - conflict_count: usize, - connection_state: String, - last_attempt: Option<Instant>, - next_retry: Option<Instant>, -} - -impl AppState { - fn new(database_name: String) -> Self { - Self { - tasks: Vec::new(), - selected_task: 0, - activity_log: Vec::new(), - sync_status: SyncStatus { - connected: false, - pending_count: 0, - conflict_count: 0, - connection_state: "Starting...".to_string(), - last_attempt: None, - next_retry: None, - }, - last_sync: None, - should_quit: false, - needs_refresh: true, - last_refresh: None, - database_name, - edit_mode: None, - } - } - - fn add_activity(&mut self, message: String, event_type: ActivityType) { - self.activity_log.push(ActivityEntry { - timestamp: chrono::Utc::now(), - message, - event_type, - }); - // Keep only last 20 entries - if self.activity_log.len() > 20 { - self.activity_log.remove(0); - } - } - - fn get_selected_task(&self) -> Option<&Task> { - self.tasks.get(self.selected_task) - } - - fn move_selection_up(&mut self) { - if self.selected_task > 0 { - self.selected_task -= 1; - } - } - - fn move_selection_down(&mut self) { - if self.selected_task < self.tasks.len().saturating_sub(1) { - self.selected_task += 1; - } - } - - fn start_edit(&mut self, field: EditField) { - if let Some(task) = self.get_selected_task() { - let initial_value = match field { - EditField::Title => task.title.clone(), - EditField::Description => task.description.clone(), - EditField::Priority => task.priority.clone(), - }; - - self.edit_mode = Some(EditMode { - task_id: task.id, - field, - input: initial_value.clone(), - cursor_pos: initial_value.len(), - priority: Priority::from_str(&task.priority), - }); - } - } - - fn handle_edit_input(&mut self, ch: char) { - if let Some(ref mut edit) = self.edit_mode { - edit.input.insert(edit.cursor_pos, ch); - edit.cursor_pos += 1; - } - } - - fn handle_edit_backspace(&mut self) { - if let Some(ref mut edit) = self.edit_mode { - if edit.cursor_pos > 0 { - edit.cursor_pos -= 1; - edit.input.remove(edit.cursor_pos); - } - } - } - - fn handle_edit_cursor_left(&mut self) { - if let Some(ref mut edit) = self.edit_mode { - if edit.cursor_pos > 0 { - edit.cursor_pos -= 1; - } - } - } - - fn handle_edit_cursor_right(&mut self) { - if let Some(ref mut edit) = self.edit_mode { - if edit.cursor_pos < edit.input.len() { - edit.cursor_pos += 1; - } - } - } - - fn cancel_edit(&mut self) { - self.edit_mode = None; - } - - fn is_editing(&self) -> bool { - self.edit_mode.is_some() - } - - fn cycle_edit_field_next(&mut self) { - if let Some(edit) = &self.edit_mode { - // Get task data first - let task_data = if let Some(task) = self.get_selected_task() { - if edit.task_id == task.id { - Some(( - task.title.clone(), - task.description.clone(), - task.priority.clone(), - )) - } else { - None - } - } else { - None - }; - - // Now modify edit mode - if let (Some((title, description, priority)), Some(ref mut edit)) = - (task_data, &mut self.edit_mode) - { - let new_field = edit.field.next(); - let initial_value = match new_field { - EditField::Title => title, - EditField::Description => description, - EditField::Priority => priority.clone(), - }; - edit.field = new_field; - edit.input = initial_value.clone(); - edit.cursor_pos = initial_value.len(); - edit.priority = Priority::from_str(&priority); - } - } - } - - fn cycle_edit_field_previous(&mut self) { - if let Some(edit) = &self.edit_mode { - // Get task data first - let task_data = if let Some(task) = self.get_selected_task() { - if edit.task_id == task.id { - Some(( - task.title.clone(), - task.description.clone(), - task.priority.clone(), - )) - } else { - None - } - } else { - None - }; - - // Now modify edit mode - if let (Some((title, description, priority)), Some(ref mut edit)) = - (task_data, &mut self.edit_mode) - { - let new_field = edit.field.previous(); - let initial_value = match new_field { - EditField::Title => title, - EditField::Description => description, - EditField::Priority => priority.clone(), - }; - edit.field = new_field; - edit.input = initial_value.clone(); - edit.cursor_pos = initial_value.len(); - edit.priority = Priority::from_str(&priority); - } - } - } - - fn cycle_priority_next(&mut self) { - if let Some(ref mut edit) = self.edit_mode { - if matches!(edit.field, EditField::Priority) { - edit.priority = edit.priority.next(); - edit.input = edit.priority.to_string(); - } - } - } - - fn cycle_priority_previous(&mut self) { - if let Some(ref mut edit) = self.edit_mode { - if matches!(edit.field, EditField::Priority) { - edit.priority = edit.priority.previous(); - edit.input = edit.priority.to_string(); - } - } - } -} - -// Thread-safe wrapper for app state -type SharedState = Arc<Mutex<AppState>>; - -#[tokio::main] -async fn main() -> Result<(), Box<dyn Error>> { - // Clear the debug log file and start logging - std::fs::write("/tmp/task_list_debug.log", "").unwrap(); - debug_log("=== Task List Example Starting ==="); - - // Initialize comprehensive logging to file for debugging - tracing_subscriber::fmt() - .with_env_filter("sync_client=info,replicant_core=info,task_list_example=info") - .with_writer( - std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open("/tmp/sync_debug.log") - .unwrap(), - ) - .init(); - - let cli = Cli::parse(); - debug_log(&format!( - "CLI args - database: {}, auto: {}, user: {:?}", - cli.database, cli.auto, cli.user - )); - - // Setup database - std::fs::create_dir_all("databases")?; - - // Generate database name - let db_name = if cli.auto { - // Auto-generate unique name with timestamp and random suffix - let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); - let random_suffix = Uuid::new_v4().to_string()[..8].to_string(); - format!("client_{}_{}", timestamp, random_suffix) - } else { - cli.database.clone() - }; - - let db_file = format!("databases/{}.sqlite3", db_name); - let db_url = format!("sqlite:{}?mode=rwc", db_file); - - let db = Arc::new(ClientDatabase::new(&db_url).await?); - db.run_migrations().await?; - - // Get or create user - let user_id = match db.get_user_id().await { - Ok(id) => id, - Err(_) => { - // Provisional random id; the server assigns a canonical id on first contact. - let id = Uuid::new_v4(); - - // Client ID should always be unique per client instance - let client_id = Uuid::new_v4(); - setup_user(&db, id, client_id, &cli.server, &cli.api_key).await?; - id - } - }; - - // Create shared state - let state = Arc::new(Mutex::new(AppState::new(db_name.clone()))); - - // Load initial tasks - { - let mut app_state = state.lock().unwrap(); - app_state.add_activity( - format!("Loading tasks for user: {}", user_id), - ActivityType::SyncStarted, - ); - } - - match load_tasks(&db, user_id, state.clone()).await { - Ok(_) => { - let task_count = state.lock().unwrap().tasks.len(); - let mut app_state = state.lock().unwrap(); - app_state.add_activity( - format!("Loaded {} tasks", task_count), - ActivityType::SyncCompleted, - ); - } - Err(e) => { - let mut app_state = state.lock().unwrap(); - app_state.add_activity( - format!("Failed to load initial tasks: {}", e), - ActivityType::Error, - ); - // Don't propagate the error - let the UI start anyway - } - } - - // Setup terminal first - we want to show the UI immediately - debug_log("Enabling raw mode..."); - enable_raw_mode()?; - let mut stdout = io::stdout(); - debug_log("Setting up terminal..."); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - debug_log(&format!( - "Terminal initialized, size: {:?}", - terminal.size()? - )); - - // Initialize UI with startup message - { - let mut app_state = state.lock().unwrap(); - app_state.add_activity( - "Task Manager starting...".to_string(), - ActivityType::SyncStarted, - ); - if cli.auto { - app_state.add_activity( - format!("Created new database: {}", db_name), - ActivityType::SyncStarted, - ); - } else { - app_state.add_activity( - format!("Using database: {}", db_name), - ActivityType::SyncStarted, - ); - } - if let Some(user_identifier) = &cli.user { - app_state.add_activity( - format!("Using shared identity: {}", user_identifier), - ActivityType::SyncStarted, - ); - } - app_state.sync_status.connection_state = "Starting...".to_string(); - } - - // Create sync engine - automatic reconnection is now built-in - let user_email = cli.user.clone().unwrap_or_else(|| "anonymous".to_string()); - - let sync_engine = match Client::new( - &db_url, - &cli.server, - &user_email, - &cli.api_key, - &cli.api_secret, - None, - ) - .await - { - Ok(engine) => { - // Register Rust-native callback for event handling - let state_for_callback = state.clone(); - if let Err(e) = engine - .event_dispatcher() - .register_rust_callback(move |event| { - let mut app_state = state_for_callback.lock().unwrap(); - match event { - SyncEvent::DocumentCreated { title, .. } => { - app_state.add_activity( - format!("Task created: {}", title), - ActivityType::Created, - ); - app_state.needs_refresh = true; - } - SyncEvent::DocumentUpdated { title, .. } => { - app_state.add_activity( - format!("Task updated: {}", title), - ActivityType::Updated, - ); - app_state.needs_refresh = true; - } - SyncEvent::DocumentDeleted { id, .. } => { - app_state.add_activity( - format!("Task deleted: {}...", &id[..8.min(id.len())]), - ActivityType::Deleted, - ); - app_state.needs_refresh = true; - } - SyncEvent::SyncStarted => { - app_state.add_activity( - "Sync started".to_string(), - ActivityType::SyncStarted, - ); - } - SyncEvent::SyncCompleted { document_count } => { - app_state.add_activity( - format!("Sync completed ({} docs)", document_count), - ActivityType::SyncCompleted, - ); - app_state.last_sync = Some(Instant::now()); - app_state.needs_refresh = true; - } - SyncEvent::SyncError { message, .. } => { - app_state.add_activity( - format!("Sync error: {}", message), - ActivityType::Error, - ); - } - SyncEvent::ConnectionLost { server_url } => { - app_state.add_activity( - format!("Lost connection to {}", server_url), - ActivityType::Disconnected, - ); - app_state.sync_status.connected = false; - app_state.sync_status.connection_state = "Disconnected".to_string(); - app_state.needs_refresh = true; - } - SyncEvent::ConnectionAttempted { server_url } => { - app_state.add_activity( - format!("Connecting to {}", server_url), - ActivityType::SyncStarted, - ); - app_state.sync_status.connection_state = "Connecting...".to_string(); - } - SyncEvent::ConnectionSucceeded { server_url } => { - app_state.add_activity( - format!("Connected to {}", server_url), - ActivityType::Connected, - ); - app_state.sync_status.connected = true; - app_state.sync_status.connection_state = "Connected".to_string(); - } - SyncEvent::ConflictDetected { document_id, .. } => { - app_state.add_activity( - format!( - "Conflict detected: {}...", - &document_id[..8.min(document_id.len())] - ), - ActivityType::Error, - ); - } - SyncEvent::IdentityChanged { new_user_id, .. } => { - app_state.add_activity( - format!( - "Identity adopted: {}...", - &new_user_id[..8.min(new_user_id.len())] - ), - ActivityType::Connected, - ); - } - } - }) - { - debug_log(&format!("Failed to register callback: {:?}", e)); - } - - { - let mut app_state = state.lock().unwrap(); - app_state.add_activity( - "Sync engine initialized".to_string(), - ActivityType::SyncStarted, - ); - app_state.sync_status.connection_state = "Auto-connecting...".to_string(); - } - Arc::new(Mutex::new(Some(Arc::new(engine)))) - } - Err(e) => { - { - let mut app_state = state.lock().unwrap(); - app_state.add_activity( - format!("Engine initialization failed: {}", e), - ActivityType::Error, - ); - app_state.sync_status.connection_state = "Initialization failed".to_string(); - } - Arc::new(Mutex::new(None)) - } - }; - - // Run app - debug_log("Starting run_app..."); - let res = run_app( - &mut terminal, - state.clone(), - db.clone(), - sync_engine, - user_id, - ) - .await; - debug_log(&format!("run_app finished with result: {:?}", res.is_ok())); - - // Restore terminal - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; - - if let Err(err) = res { - println!("{err:?}"); - } - - println!("\n📁 Database file: {}", db_file); - println!( - " Full path: {}", - std::path::Path::new(&db_file) - .canonicalize() - .unwrap_or_default() - .display() - ); - - Ok(()) -} - -async fn run_app<B: Backend>( - terminal: &mut Terminal<B>, - state: SharedState, - db: Arc<ClientDatabase>, - sync_engine: Arc<Mutex<Option<Arc<Client>>>>, - user_id: Uuid, -) -> io::Result<()> { - debug_log(&format!("run_app started for user: {}", user_id)); - - let mut last_tick = Instant::now(); - let tick_rate = Duration::from_millis(250); - - // Track the last known connection state - let mut last_connection_state: Option<bool> = None; - - debug_log("Starting main loop..."); - loop { - // Process sync events FIRST - if let Some(engine) = sync_engine.lock().unwrap().as_ref() { - let dispatcher = engine.event_dispatcher(); - match dispatcher.process_events() { - Ok(count) if count > 0 => { - // Events were processed - this might set needs_refresh - } - Err(e) => eprintln!("Error processing events: {:?}", e), - _ => {} - } - - // Check connection state and emit event if it changed - let current_connected = engine.is_connected(); - match last_connection_state { - Some(last_connected) if last_connected != current_connected => { - // Connection state changed - let mut app_state = state.lock().unwrap(); - if current_connected { - app_state.add_activity( - "Connected to server".to_string(), - ActivityType::Connected, - ); - app_state.sync_status.connected = true; - app_state.sync_status.connection_state = "Connected".to_string(); - } else { - app_state.add_activity( - "Disconnected from server".to_string(), - ActivityType::Disconnected, - ); - app_state.sync_status.connected = false; - app_state.sync_status.connection_state = "Disconnected".to_string(); - } - } - None => { - // First time checking - set initial state - let mut app_state = state.lock().unwrap(); - app_state.sync_status.connected = current_connected; - app_state.sync_status.connection_state = if current_connected { - "Connected".to_string() - } else { - "Disconnected".to_string() - }; - } - _ => {} // No change - } - last_connection_state = Some(current_connected); - } - - // Check if we should refresh tasks - { - let should_refresh = { - let mut app_state = state.lock().unwrap(); - if app_state.needs_refresh { - app_state.needs_refresh = false; - true - } else { - false - } - }; - - if should_refresh { - // Load tasks with error handling - if let Err(e) = load_tasks(&db, user_id, state.clone()).await { - let mut app_state = state.lock().unwrap(); - app_state - .add_activity(format!("Failed to load tasks: {}", e), ActivityType::Error); - } else { - // Mark refresh time for visual feedback only on success - let mut app_state = state.lock().unwrap(); - app_state.last_refresh = Some(Instant::now()); - } - - update_sync_status(&db, state.clone()).await; - } - } - - // Draw UI once per loop iteration - terminal.draw(|f| ui(f, &state))?; - - // Handle input with reasonable timeout - let timeout = Duration::from_millis(100); - if crossterm::event::poll(timeout)? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - handle_key_event( - key.code, - state.clone(), - db.clone(), - sync_engine.clone(), - user_id, - ) - .await; - } - } - } - - // Check if we should quit - if state.lock().unwrap().should_quit { - return Ok(()); - } - - // Update tick for time tracking - if last_tick.elapsed() >= tick_rate { - last_tick = Instant::now(); - } - } -} - -fn ui(f: &mut Frame, state: &SharedState) { - let app_state = state.lock().unwrap(); - - // Main layout - title bar and content - let main_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Title bar - Constraint::Min(0), // Content - ]) - .split(f.size()); - - // Title bar with refresh indicator - let title = Block::default() - .borders(Borders::ALL) - .style(Style::default().fg(Color::Cyan)); - - let refresh_indicator = if let Some(last_refresh) = app_state.last_refresh { - let elapsed = last_refresh.elapsed(); - if elapsed.as_secs() < 2 { - " ●" // Show indicator for 2 seconds after refresh - } else { - "" - } - } else { - "" - }; - - let title_text = format!( - "Task Manager [{}]{}", - app_state.database_name, refresh_indicator - ); - - let title_paragraph = Paragraph::new(title_text) - .block(title) - .alignment(Alignment::Center); - f.render_widget(title_paragraph, main_chunks[0]); - - // Content area - 2x2 grid - let content_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(main_chunks[1]); - - let left_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(67), Constraint::Percentage(33)]) - .split(content_chunks[0]); - - let right_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(67), Constraint::Percentage(33)]) - .split(content_chunks[1]); - - // Top-left: Task list - render_task_list(f, left_chunks[0], &app_state); - - // Top-right: Task details - render_task_details(f, right_chunks[0], &app_state); - - // Bottom-left: Sync status - render_sync_status(f, left_chunks[1], &app_state); - - // Bottom-right: Activity log - render_activity_log(f, right_chunks[1], &app_state); -} - -fn render_task_list(f: &mut Frame, area: Rect, app_state: &AppState) { - let todo_count = app_state - .tasks - .iter() - .filter(|t| t.status != "completed") - .count(); - let block = Block::default().borders(Borders::ALL).title(format!( - "Tasks ({} total, {} todo)", - app_state.tasks.len(), - todo_count - )); - - let items: Vec<ListItem> = app_state - .tasks - .iter() - .enumerate() - .map(|(idx, task)| { - let status_icon = match task.status.as_str() { - "completed" => "✅", - "in_progress" => "🔄", - "pending" => "⏳", - _ => "❓", - }; - - let priority_icon = match task.priority.as_str() { - "high" => "🔴", - "medium" => "🟡", - "low" => "🟢", - _ => "⚪", - }; - - let sync_icon = match task.sync_status.as_deref() { - Some("pending") => " 📤", - Some("conflict") => " ⚠️", - _ => "", - }; - - let content = format!( - "{} {} {}{}", - status_icon, priority_icon, task.title, sync_icon - ); - let style = if idx == app_state.selected_task { - Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - }; - - ListItem::new(content).style(style) - }) - .collect(); - - let list = List::new(items) - .block(block) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - - let mut list_state = ListState::default(); - list_state.select(Some(app_state.selected_task)); - - f.render_stateful_widget(list, area, &mut list_state); - - // Render help text at bottom - let help_text = if app_state.is_editing() { - let current_field = app_state - .edit_mode - .as_ref() - .map(|e| match e.field { - EditField::Title => "Title", - EditField::Description => "Description", - EditField::Priority => "Priority", - }) - .unwrap_or("Unknown"); - - vec![ - Line::from(vec![ - Span::styled( - "EDIT MODE", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), - Span::raw(format!(" - Editing {}", current_field)), - ]), - Line::from(vec![Span::raw( - "[Enter: save] [Esc: cancel] [Tab/↓: next field] [↑: prev field]", - )]), - Line::from(vec![if current_field == "Priority" { - Span::raw("[←/→: change priority] [Backspace: delete]") - } else { - Span::raw("[←/→: move cursor] [Backspace: delete]") - }]), - ] - } else { - vec![ - Line::from(vec![Span::raw("[j/k: navigate] [space: toggle]")]), - Line::from(vec![Span::raw( - "[e: edit title] [E: edit desc] [p: priority]", - )]), - Line::from(vec![Span::raw("[n: new] [d: delete] [q: quit]")]), - ] - }; - - let help_area = Rect { - x: area.x + 1, - y: area.y + area.height - 4, - width: area.width - 2, - height: 3, - }; - - let help = Paragraph::new(help_text).style(Style::default().fg(Color::DarkGray)); - f.render_widget(help, help_area); -} - -fn render_text_with_cursor(text: &str, cursor_pos: usize) -> Line { - let chars: Vec<char> = text.chars().collect(); - let mut spans = Vec::new(); - - // Add text before cursor - if cursor_pos > 0 { - let before: String = chars[..cursor_pos.min(chars.len())].iter().collect(); - spans.push(Span::styled(before, Style::default())); - } - - // Add cursor character (invert the character at cursor position) - if cursor_pos < chars.len() { - let cursor_char = chars[cursor_pos].to_string(); - spans.push(Span::styled( - cursor_char, - Style::default().add_modifier(Modifier::REVERSED), - )); - - // Add text after cursor - if cursor_pos + 1 < chars.len() { - let after: String = chars[cursor_pos + 1..].iter().collect(); - spans.push(Span::styled(after, Style::default())); - } - } else { - // Cursor at end - show space as cursor (always visible even when text is empty) - spans.push(Span::styled( - " ", - Style::default().add_modifier(Modifier::REVERSED), - )); - } - - Line::from(spans) -} - -fn render_task_details(f: &mut Frame, area: Rect, app_state: &AppState) { - let outer_block = Block::default() - .borders(Borders::ALL) - .title("Task Details") - .border_style(Style::default().fg(Color::White)); - - // Create inner area within the outer block - let inner_area = outer_block.inner(area); - f.render_widget(outer_block, area); - - if let Some(task) = app_state.get_selected_task() { - let status_display = match task.status.as_str() { - "completed" => "✅ Completed", - "in_progress" => "🔄 In Progress", - "todo" => "📋 Todo", - _ => &task.status, - }; - - let priority_display = match task.priority.as_str() { - "high" => "🔴 High", - "medium" => "🟡 Medium", - "low" => "🟢 Low", - _ => &task.priority, - }; - - // Create layout for editing mode vs normal mode - if let Some(ref edit) = app_state.edit_mode { - if edit.task_id == task.id { - // Edit mode - create separate blocks for each field - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Title block - Constraint::Length(3), // Priority block - Constraint::Length(4), // Description block - Constraint::Min(0), // Status and metadata - ]) - .split(inner_area); - - // Title block - let title_block = if matches!(edit.field, EditField::Title) { - Block::default() - .borders(Borders::ALL) - .title("Title") - .border_style(Style::default().fg(Color::Yellow)) - .title_style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - } else { - Block::default() - .borders(Borders::ALL) - .title("Title") - .border_style(Style::default().fg(Color::Cyan)) - }; - - let title_content = if matches!(edit.field, EditField::Title) { - render_text_with_cursor(&edit.input, edit.cursor_pos) - } else { - Line::from(vec![Span::styled( - &task.title, - Style::default().fg(Color::DarkGray), - )]) - }; - - let title_paragraph = Paragraph::new(vec![title_content]).block(title_block); - f.render_widget(title_paragraph, chunks[0]); - - // Priority block - let priority_block = if matches!(edit.field, EditField::Priority) { - Block::default() - .borders(Borders::ALL) - .title("Priority") - .border_style(Style::default().fg(Color::Yellow)) - .title_style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - } else { - Block::default() - .borders(Borders::ALL) - .title("Priority") - .border_style(Style::default().fg(Color::Cyan)) - }; - - let priority_content = if matches!(edit.field, EditField::Priority) { - Line::from(vec![ - Span::styled( - edit.priority.to_display(), - Style::default().add_modifier(Modifier::BOLD), - ), - Span::styled(" [←/→ to change]", Style::default().fg(Color::DarkGray)), - ]) - } else { - Line::from(vec![Span::styled( - priority_display, - Style::default().fg(Color::DarkGray), - )]) - }; - - let priority_paragraph = - Paragraph::new(vec![priority_content]).block(priority_block); - f.render_widget(priority_paragraph, chunks[1]); - - // Description block - let description_block = if matches!(edit.field, EditField::Description) { - Block::default() - .borders(Borders::ALL) - .title("Description") - .border_style(Style::default().fg(Color::Yellow)) - .title_style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - } else { - Block::default() - .borders(Borders::ALL) - .title("Description") - .border_style(Style::default().fg(Color::Cyan)) - }; - - let description_content = if matches!(edit.field, EditField::Description) { - vec![render_text_with_cursor(&edit.input, edit.cursor_pos)] - } else { - if !task.description.is_empty() { - vec![Line::from(vec![Span::styled( - &task.description, - Style::default().fg(Color::DarkGray), - )])] - } else { - vec![Line::from(vec![Span::styled( - "(empty)", - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - )])] - } - }; - - let description_paragraph = Paragraph::new(description_content) - .block(description_block) - .wrap(Wrap { trim: true }); - f.render_widget(description_paragraph, chunks[2]); - - // Status and metadata in remaining space - let mut metadata_lines = vec![]; - metadata_lines.push(Line::from(vec![ - Span::styled("Status: ", Style::default().fg(Color::Gray)), - Span::raw(status_display), - ])); - - if !task.tags.is_empty() { - let tags_str = task - .tags - .iter() - .map(|t| format!("#{}", t)) - .collect::<Vec<_>>() - .join(" "); - metadata_lines.push(Line::from(vec![ - Span::styled("Tags: ", Style::default().fg(Color::Gray)), - Span::styled(tags_str, Style::default().fg(Color::Cyan)), - ])); - } - - metadata_lines.push(Line::from("")); - metadata_lines.push(Line::from(vec![ - Span::styled("Created: ", Style::default().fg(Color::Gray)), - Span::raw(task.created_at.format("%Y-%m-%d %H:%M").to_string()), - ])); - metadata_lines.push(Line::from(vec![ - Span::styled("Updated: ", Style::default().fg(Color::Gray)), - Span::raw(task.updated_at.format("%Y-%m-%d %H:%M").to_string()), - ])); - metadata_lines.push(Line::from(vec![ - Span::styled("Version: ", Style::default().fg(Color::Gray)), - Span::styled( - task.sync_revision.to_string(), - Style::default().fg(Color::Yellow), - ), - ])); - - let metadata_paragraph = Paragraph::new(metadata_lines).wrap(Wrap { trim: true }); - f.render_widget(metadata_paragraph, chunks[3]); - - return; - } - } - - // Normal mode - use same block layout for consistency - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Title block - Constraint::Length(3), // Priority block - Constraint::Length(4), // Description block - Constraint::Min(0), // Status and metadata - ]) - .split(inner_area); - - // Title block (normal mode) - let title_block = Block::default() - .borders(Borders::ALL) - .title("Title") - .border_style(Style::default().fg(Color::Cyan)); - - let title_content = Line::from(vec![Span::styled( - &task.title, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - )]); - - let title_paragraph = Paragraph::new(vec![title_content]).block(title_block); - f.render_widget(title_paragraph, chunks[0]); - - // Priority block (normal mode) - let priority_block = Block::default() - .borders(Borders::ALL) - .title("Priority") - .border_style(Style::default().fg(Color::Cyan)); - - let priority_content = Line::from(vec![Span::raw(priority_display)]); - - let priority_paragraph = Paragraph::new(vec![priority_content]).block(priority_block); - f.render_widget(priority_paragraph, chunks[1]); - - // Description block (normal mode) - let description_block = Block::default() - .borders(Borders::ALL) - .title("Description") - .border_style(Style::default().fg(Color::Cyan)); - - let description_content = if !task.description.is_empty() { - vec![Line::from(task.description.clone())] - } else { - vec![Line::from(vec![Span::styled( - "(empty)", - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - )])] - }; - - let description_paragraph = Paragraph::new(description_content) - .block(description_block) - .wrap(Wrap { trim: true }); - f.render_widget(description_paragraph, chunks[2]); - - // Status and metadata in remaining space - let mut metadata_lines = vec![]; - metadata_lines.push(Line::from(vec![ - Span::styled("Status: ", Style::default().fg(Color::Gray)), - Span::raw(status_display), - ])); - - if !task.tags.is_empty() { - let tags_str = task - .tags - .iter() - .map(|t| format!("#{}", t)) - .collect::<Vec<_>>() - .join(" "); - metadata_lines.push(Line::from(vec![ - Span::styled("Tags: ", Style::default().fg(Color::Gray)), - Span::styled(tags_str, Style::default().fg(Color::Cyan)), - ])); - } - - metadata_lines.push(Line::from("")); - metadata_lines.push(Line::from(vec![ - Span::styled("Created: ", Style::default().fg(Color::Gray)), - Span::raw(task.created_at.format("%Y-%m-%d %H:%M").to_string()), - ])); - metadata_lines.push(Line::from(vec![ - Span::styled("Updated: ", Style::default().fg(Color::Gray)), - Span::raw(task.updated_at.format("%Y-%m-%d %H:%M").to_string()), - ])); - metadata_lines.push(Line::from(vec![ - Span::styled("Version: ", Style::default().fg(Color::Gray)), - Span::styled( - task.sync_revision.to_string(), - Style::default().fg(Color::Yellow), - ), - ])); - - let metadata_paragraph = Paragraph::new(metadata_lines).wrap(Wrap { trim: true }); - f.render_widget(metadata_paragraph, chunks[3]); - } else { - let paragraph = - Paragraph::new("No task selected").style(Style::default().fg(Color::DarkGray)); - f.render_widget(paragraph, inner_area); - } -} - -fn render_sync_status(f: &mut Frame, area: Rect, app_state: &AppState) { - let block = Block::default().borders(Borders::ALL).title("Sync Status"); - - let connection_status = { - let mut status_text = app_state.sync_status.connection_state.clone(); - - // Show live countdown if we're waiting to retry - if let Some(next_retry) = app_state.sync_status.next_retry { - if !app_state.sync_status.connected { - let now = Instant::now(); - if next_retry > now { - let seconds_left = (next_retry - now).as_secs(); - if seconds_left > 0 { - status_text = format!("Offline (retry in {}s)", seconds_left); - } - } - } - } - - Line::from(vec![ - Span::raw(if app_state.sync_status.connected { - "🔗 " - } else { - "📡 " - }), - Span::styled( - status_text, - Style::default().fg(if app_state.sync_status.connected { - Color::Green - } else { - Color::Yellow - }), - ), - ]) - }; - - let sync_info = - if app_state.sync_status.pending_count > 0 || app_state.sync_status.conflict_count > 0 { - if app_state.sync_status.conflict_count > 0 { - Line::from(vec![ - Span::raw("⚠️ "), - Span::styled( - format!("{} conflicts", app_state.sync_status.conflict_count), - Style::default().fg(Color::Red), - ), - ]) - } else { - Line::from(vec![ - Span::raw("📤 "), - Span::styled( - format!("{} pending sync", app_state.sync_status.pending_count), - Style::default().fg(Color::Yellow), - ), - ]) - } - } else { - Line::from(vec![Span::raw("✅ "), Span::raw("All changes synced")]) - }; - - let last_sync_line = if let Some(last_sync) = app_state.last_sync { - let elapsed = last_sync.elapsed(); - let time_str = if elapsed.as_secs() < 60 { - format!("{} seconds ago", elapsed.as_secs()) - } else if elapsed.as_secs() < 3600 { - format!("{} minutes ago", elapsed.as_secs() / 60) - } else { - format!("{} hours ago", elapsed.as_secs() / 3600) - }; - Line::from(vec![Span::raw("📊 Last sync: "), Span::raw(time_str)]) - } else { - Line::from(vec![ - Span::raw("📊 Last sync: "), - Span::styled("Never", Style::default().fg(Color::DarkGray)), - ]) - }; - - let mut lines = vec![connection_status, sync_info, last_sync_line]; - - // Add database filename info - lines.push(Line::from(vec![ - Span::raw("💾 Database: "), - Span::styled(&app_state.database_name, Style::default().fg(Color::Cyan)), - ])); - - // Add retry info if we're offline and have attempted connections - if !app_state.sync_status.connected && app_state.sync_status.last_attempt.is_some() { - lines.push(Line::from(vec![Span::styled( - "Auto-retrying in background", - Style::default().fg(Color::DarkGray), - )])); - } - - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "[n: new task] [q: quit]", - Style::default().fg(Color::DarkGray), - )])); - - let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: true }); - f.render_widget(paragraph, area); -} - -fn render_activity_log(f: &mut Frame, area: Rect, app_state: &AppState) { - let block = Block::default().borders(Borders::ALL).title("Activity Log"); - - let items: Vec<Line> = app_state - .activity_log - .iter() - .rev() - .map(|entry| { - let icon = match entry.event_type { - ActivityType::Created => "➕", - ActivityType::Updated => "📝", - ActivityType::Deleted => "🗑️", - ActivityType::SyncStarted => "🔄", - ActivityType::SyncCompleted => "✅", - ActivityType::Connected => "🔗", - ActivityType::Disconnected => "❌", - ActivityType::Error => "🚨", - }; - - let time_str = entry.timestamp.format("%H:%M").to_string(); - - Line::from(vec![ - Span::styled(time_str, Style::default().fg(Color::DarkGray)), - Span::raw(" "), - Span::raw(icon), - Span::raw(" "), - Span::raw(&entry.message), - ]) - }) - .collect(); - - let paragraph = Paragraph::new(items).block(block).wrap(Wrap { trim: true }); - f.render_widget(paragraph, area); -} - -async fn handle_key_event( - key: KeyCode, - state: SharedState, - db: Arc<ClientDatabase>, - sync_engine: Arc<Mutex<Option<Arc<Client>>>>, - user_id: Uuid, -) { - // Check if we're in edit mode - let is_editing = state.lock().unwrap().is_editing(); - - if is_editing { - // Handle edit mode keys - match key { - KeyCode::Enter => { - // Save the edit - let edit_data = state.lock().unwrap().edit_mode.clone(); - if let Some(edit) = edit_data { - save_task_edit(&db, &sync_engine, edit, state.clone()).await; - } - state.lock().unwrap().cancel_edit(); - } - KeyCode::Esc => { - // Cancel edit - state.lock().unwrap().cancel_edit(); - } - KeyCode::Backspace => { - state.lock().unwrap().handle_edit_backspace(); - } - KeyCode::Left => { - // Check if editing priority - if so, cycle priority, otherwise move cursor - let is_editing_priority = state - .lock() - .unwrap() - .edit_mode - .as_ref() - .map(|e| matches!(e.field, EditField::Priority)) - .unwrap_or(false); - - if is_editing_priority { - state.lock().unwrap().cycle_priority_previous(); - } else { - state.lock().unwrap().handle_edit_cursor_left(); - } - } - KeyCode::Right => { - // Check if editing priority - if so, cycle priority, otherwise move cursor - let is_editing_priority = state - .lock() - .unwrap() - .edit_mode - .as_ref() - .map(|e| matches!(e.field, EditField::Priority)) - .unwrap_or(false); - - if is_editing_priority { - state.lock().unwrap().cycle_priority_next(); - } else { - state.lock().unwrap().handle_edit_cursor_right(); - } - } - KeyCode::Tab | KeyCode::Down => { - // Cycle to previous field - state.lock().unwrap().cycle_edit_field_previous(); - } - KeyCode::BackTab | KeyCode::Up => { - // Cycle to next field - state.lock().unwrap().cycle_edit_field_next(); - } - KeyCode::Char(ch) => { - // Only allow text input for title and description, not priority - let is_editing_priority = state - .lock() - .unwrap() - .edit_mode - .as_ref() - .map(|e| matches!(e.field, EditField::Priority)) - .unwrap_or(false); - - if !is_editing_priority { - state.lock().unwrap().handle_edit_input(ch); - } - } - _ => {} - } - } else { - // Handle normal mode keys - match key { - KeyCode::Char('q') => { - state.lock().unwrap().should_quit = true; - } - KeyCode::Char('j') | KeyCode::Down => { - state.lock().unwrap().move_selection_down(); - } - KeyCode::Char('k') | KeyCode::Up => { - state.lock().unwrap().move_selection_up(); - } - KeyCode::Char(' ') => { - // Toggle task completion - let task_id = { - let app_state = state.lock().unwrap(); - app_state.get_selected_task().map(|t| t.id) - }; - - if let Some(id) = task_id { - toggle_task_completion(&db, &sync_engine, id, state.clone()).await; - } - } - KeyCode::Char('n') => { - // Create new task - create_sample_task(&db, &sync_engine, user_id, state.clone()).await; - } - KeyCode::Char('d') => { - // Delete selected task - let task_id = { - let app_state = state.lock().unwrap(); - app_state.get_selected_task().map(|t| t.id) - }; - - if let Some(id) = task_id { - delete_task(&db, &sync_engine, id, state.clone()).await; - } - } - KeyCode::Char('e') => { - // Edit task title - state.lock().unwrap().start_edit(EditField::Title); - } - KeyCode::Char('E') => { - // Edit task description - state.lock().unwrap().start_edit(EditField::Description); - } - KeyCode::Char('p') => { - // Edit task priority - state.lock().unwrap().start_edit(EditField::Priority); - } - KeyCode::Char('c') => { - // Copy database path to clipboard - let (_db_name, db_path) = { - let app_state = state.lock().unwrap(); - let name = app_state.database_name.clone(); - let path = format!("databases/{}.sqlite3", name); - (name, path) - }; - - // Try to copy to clipboard based on OS - let copy_result = copy_to_clipboard(&db_path); - - // Add activity log entry - { - let mut app_state = state.lock().unwrap(); - if copy_result { - app_state.add_activity( - format!("Database path copied: {}", db_path), - ActivityType::SyncCompleted, - ); - } else { - app_state.add_activity( - format!("Copy failed - path: {}", db_path), - ActivityType::Error, - ); - } - } - } - KeyCode::Enter => { - // Enter edit mode for task title - state.lock().unwrap().start_edit(EditField::Title); - } - _ => {} - } - } -} - -async fn load_tasks( - db: &ClientDatabase, - user_id: Uuid, - state: SharedState, -) -> Result<(), Box<dyn Error>> { - // Log the query we're about to execute - debug_log(&format!("Loading tasks for user_id: {}", user_id)); - - let rows = sqlx::query( - r#" - SELECT id, content, sync_status, created_at, updated_at, sync_revision - FROM documents - WHERE user_id = ?1 AND deleted_at IS NULL - ORDER BY created_at DESC - "#, - ) - .bind(user_id.to_string()) - .fetch_all(&db.pool) - .await?; - - debug_log(&format!("Found {} documents", rows.len())); - - let mut tasks = Vec::new(); - for row in rows { - let id = Uuid::parse_str(&row.try_get::<String, _>("id")?)?; - let content_str = row.try_get::<String, _>("content")?; - let sync_status = row.try_get::<Option<String>, _>("sync_status")?; - let created_at = row.try_get::<chrono::DateTime<chrono::Utc>, _>("created_at")?; - let updated_at = row.try_get::<chrono::DateTime<chrono::Utc>, _>("updated_at")?; - let sync_revision = row.try_get::<i64, _>("sync_revision")?; - - let content: Value = serde_json::from_str(&content_str).unwrap_or_default(); - - let task = Task { - id, - title: content - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("Untitled") - .to_string(), - description: content - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - status: content - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("pending") - .to_string(), - priority: content - .get("priority") - .and_then(|v| v.as_str()) - .unwrap_or("medium") - .to_string(), - tags: content - .get("tags") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(), - created_at, - updated_at, - sync_revision, - sync_status, - }; - - tasks.push(task); - } - - { - let mut app_state = state.lock().unwrap(); - app_state.tasks = tasks; - } - - // Update sync status - update_sync_status(db, state).await; - - Ok(()) -} - -async fn update_sync_status(db: &ClientDatabase, state: SharedState) { - let pending = - sqlx::query("SELECT COUNT(*) as count FROM documents WHERE sync_status = 'pending'") - .fetch_one(&db.pool) - .await - .ok() - .and_then(|row| row.try_get::<i64, _>("count").ok()) - .unwrap_or(0) as usize; - - let conflicts = - sqlx::query("SELECT COUNT(*) as count FROM documents WHERE sync_status = 'conflict'") - .fetch_one(&db.pool) - .await - .ok() - .and_then(|row| row.try_get::<i64, _>("count").ok()) - .unwrap_or(0) as usize; - - let mut app_state = state.lock().unwrap(); - app_state.sync_status.pending_count = pending; - app_state.sync_status.conflict_count = conflicts; -} - -async fn toggle_task_completion( - db: &ClientDatabase, - sync_engine: &Arc<Mutex<Option<Arc<Client>>>>, - task_id: Uuid, - state: SharedState, -) { - let doc = match db.get_document(&task_id).await { - Ok(doc) => doc, - Err(_) => return, - }; - - let mut content = doc.content.clone(); - if let Some(obj) = content.as_object_mut() { - let current_status = obj.get("status").and_then(|v| v.as_str()).unwrap_or("todo"); - let new_status = if current_status == "completed" { - "todo" - } else { - "completed" - }; - obj.insert("status".to_string(), json!(new_status)); - - if new_status == "completed" { - obj.insert( - "completed_at".to_string(), - json!(chrono::Utc::now().to_rfc3339()), - ); - } else { - obj.remove("completed_at"); - } - } - - let engine = sync_engine.lock().unwrap().clone(); - if let Some(engine) = engine { - let _ = engine.update_document(task_id, content).await; - } else { - // Offline update - let mut updated_doc = doc; - updated_doc.content = content; - updated_doc.sync_revision += 1; - updated_doc.updated_at = chrono::Utc::now(); - - let _ = db.save_document(&updated_doc).await; - - // Mark as pending sync - let _ = sqlx::query("UPDATE documents SET sync_status = 'pending' WHERE id = ?1") - .bind(task_id.to_string()) - .execute(&db.pool) - .await; - } - - // Trigger UI refresh - { - let mut app_state = state.lock().unwrap(); - app_state.needs_refresh = true; - } - - // Also reload tasks immediately for responsive UI - if let Ok(user_id) = db.get_user_id().await { - if let Err(e) = load_tasks(db, user_id, state.clone()).await { - let mut app_state = state.lock().unwrap(); - app_state.add_activity( - format!("Failed to reload tasks: {}", e), - ActivityType::Error, - ); - } - } -} - -async fn create_sample_task( - db: &ClientDatabase, - sync_engine: &Arc<Mutex<Option<Arc<Client>>>>, - user_id: Uuid, - state: SharedState, -) { - let title = format!("New Task {}", chrono::Utc::now().format("%H:%M:%S")); - let content = json!({ - "title": title.clone(), - "description": "Created from task list UI", - "status": "todo", - "priority": "medium", - "tags": vec!["ui", "demo"], - "created_at": chrono::Utc::now().to_rfc3339(), - }); - - let engine = sync_engine.lock().unwrap().clone(); - if let Some(engine) = engine { - let _ = engine.create_document(content).await; - } else { - // Offline create - let doc = Document { - id: Uuid::new_v4(), - user_id: Some(user_id), - content: content.clone(), - sync_revision: 1, - content_hash: None, - title: content - .get("title") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }; - - let _ = db.save_document(&doc).await; - } - - // Activity will be logged via event callback if online, or we can add it manually if offline - if sync_engine.lock().unwrap().is_none() { - let mut app_state = state.lock().unwrap(); - app_state.add_activity("Task created (offline)".to_string(), ActivityType::Created); - } - - // Trigger UI refresh for immediate feedback - { - let mut app_state = state.lock().unwrap(); - app_state.needs_refresh = true; - } -} - -async fn delete_task( - db: &ClientDatabase, - sync_engine: &Arc<Mutex<Option<Arc<Client>>>>, - task_id: Uuid, - state: SharedState, -) { - let engine = sync_engine.lock().unwrap().clone(); - if let Some(engine) = engine { - // Use sync engine if available - let _ = engine.delete_document(task_id).await; - - let mut app_state = state.lock().unwrap(); - app_state.add_activity("Task deleted".to_string(), ActivityType::Deleted); - } else { - // Offline delete - let _ = db.delete_document(&task_id).await; - - let mut app_state = state.lock().unwrap(); - app_state.add_activity("Task deleted (offline)".to_string(), ActivityType::Deleted); - } - - // Trigger UI refresh for immediate feedback - { - let mut app_state = state.lock().unwrap(); - app_state.needs_refresh = true; - } -} - -async fn save_task_edit( - db: &ClientDatabase, - sync_engine: &Arc<Mutex<Option<Arc<Client>>>>, - edit: EditMode, - state: SharedState, -) { - // Get the current document - let doc = match db.get_document(&edit.task_id).await { - Ok(doc) => doc, - Err(_) => return, - }; - - // Create updated content - let mut content = doc.content.clone(); - if let Some(obj) = content.as_object_mut() { - match edit.field { - EditField::Title => { - obj.insert("title".to_string(), json!(edit.input)); - } - EditField::Description => { - obj.insert("description".to_string(), json!(edit.input)); - } - EditField::Priority => { - // Use the priority enum value - obj.insert("priority".to_string(), json!(edit.priority.to_string())); - } - } - } - - // Update the document - let engine = sync_engine.lock().unwrap().clone(); - if let Some(engine) = engine { - let _ = engine.update_document(edit.task_id, content).await; - } else { - // Offline update - let mut updated_doc = doc; - updated_doc.content = content; - updated_doc.sync_revision += 1; - updated_doc.updated_at = chrono::Utc::now(); - - let _ = db.save_document(&updated_doc).await; - - // Mark as pending sync - let _ = sqlx::query("UPDATE documents SET sync_status = 'pending' WHERE id = ?1") - .bind(edit.task_id.to_string()) - .execute(&db.pool) - .await; - } - - // Add activity log - { - let mut app_state = state.lock().unwrap(); - let field_name = match edit.field { - EditField::Title => "title", - EditField::Description => "description", - EditField::Priority => "priority", - }; - app_state.add_activity( - format!("Updated task {}", field_name), - ActivityType::Updated, - ); - app_state.needs_refresh = true; - } - - // Also reload tasks immediately for responsive UI - if let Ok(user_id) = db.get_user_id().await { - if let Err(e) = load_tasks(db, user_id, state.clone()).await { - let mut app_state = state.lock().unwrap(); - app_state.add_activity( - format!("Failed to reload tasks: {}", e), - ActivityType::Error, - ); - } - } -} - -async fn setup_user( - db: &ClientDatabase, - user_id: Uuid, - client_id: Uuid, - server_url: &str, - _api_key: &str, -) -> Result<(), Box<dyn Error>> { - sqlx::query("INSERT INTO user_config (user_id, client_id, server_url) VALUES (?1, ?2, ?3)") - .bind(user_id.to_string()) - .bind(client_id.to_string()) - .bind(server_url) - .execute(&db.pool) - .await?; - Ok(()) -} - -fn copy_to_clipboard(text: &str) -> bool { - #[cfg(target_os = "macos")] - { - use std::io::Write; - use std::process::{Command, Stdio}; - - match Command::new("pbcopy").stdin(Stdio::piped()).spawn() { - Ok(mut child) => { - if let Some(mut stdin) = child.stdin.take() { - if stdin.write_all(text.as_bytes()).is_ok() { - drop(stdin); - return child.wait().map(|s| s.success()).unwrap_or(false); - } - } - false - } - Err(_) => false, - } - } - - #[cfg(target_os = "linux")] - { - use std::io::Write; - use std::process::{Command, Stdio}; - - // Try xclip first - if let Ok(mut child) = Command::new("xclip") - .arg("-selection") - .arg("clipboard") - .stdin(Stdio::piped()) - .spawn() - { - if let Some(mut stdin) = child.stdin.take() { - if stdin.write_all(text.as_bytes()).is_ok() { - drop(stdin); - return child.wait().map(|s| s.success()).unwrap_or(false); - } - } - } - - // Try xsel as fallback - if let Ok(mut child) = Command::new("xsel") - .arg("--clipboard") - .arg("--input") - .stdin(Stdio::piped()) - .spawn() - { - if let Some(mut stdin) = child.stdin.take() { - if stdin.write_all(text.as_bytes()).is_ok() { - drop(stdin); - return child.wait().map(|s| s.success()).unwrap_or(false); - } - } - } - - false - } - - #[cfg(target_os = "windows")] - { - use std::io::Write; - use std::process::{Command, Stdio}; - - match Command::new("cmd") - .args(&["/C", "clip"]) - .stdin(Stdio::piped()) - .spawn() - { - Ok(mut child) => { - if let Some(mut stdin) = child.stdin.take() { - if stdin.write_all(text.as_bytes()).is_ok() { - drop(stdin); - return child.wait().map(|s| s.success()).unwrap_or(false); - } - } - false - } - Err(_) => false, - } - } - - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - { - false - } -} diff --git a/replicant-client/examples/test_rust_callbacks.rs b/replicant-client/examples/test_rust_callbacks.rs deleted file mode 100644 index 8b6059a..0000000 --- a/replicant-client/examples/test_rust_callbacks.rs +++ /dev/null @@ -1,234 +0,0 @@ -use replicant_client::events::SyncEvent; -use replicant_client::{Client, ClientDatabase}; -use serde_json::json; -use std::sync::{Arc, Mutex}; -use std::time::Duration; -use tokio::time::sleep; -use uuid::Uuid; - -/// Shared state for tracking received events -struct EventTracker { - events: Vec<String>, -} - -impl EventTracker { - fn new() -> Self { - Self { events: Vec::new() } - } - - fn add(&mut self, event: String) { - println!(" 📥 Callback received: {}", event); - self.events.push(event); - } -} - -#[tokio::main] -async fn main() -> Result<(), Box<dyn std::error::Error>> { - // Initialize logging - tracing_subscriber::fmt().with_env_filter("warn").init(); - - println!("🧪 Testing Rust event callbacks..."); - - // Setup database - std::fs::create_dir_all("databases")?; - let db_file = "databases/callback_test.sqlite3"; - let db_url = format!("sqlite:{}?mode=rwc", db_file); - - let db = Arc::new(ClientDatabase::new(&db_url).await?); - db.run_migrations().await?; - - // Get or create user - let user_id = match db.get_user_id().await { - Ok(id) => id, - Err(_) => { - let id = Uuid::new_v4(); - let client_id = Uuid::new_v4(); - setup_user(&db, id, client_id, "ws://nonexistent:8080/ws", "test-token").await?; - id - } - }; - - println!("👤 User ID: {}", user_id); - - // Shared state for event tracking (thread-safe) - let tracker = Arc::new(Mutex::new(EventTracker::new())); - - // Try to connect to server (will fail, but we want to test offline mode) - let sync_engine = match Client::new( - &db_url, - "ws://nonexistent:8080/ws", - "test-user@example.com", - "test-key", - "test-secret", - None, - ) - .await - { - Ok(engine) => { - println!("📡 Sync engine created successfully"); - - // Register Rust-native callback using the new SyncEvent enum - let tracker_clone = tracker.clone(); - engine - .event_dispatcher() - .register_rust_callback(move |event| { - let event_desc = match &event { - SyncEvent::DocumentCreated { id, title, .. } => { - format!("📄 Document created: {} ({})", title, &id[..8]) - } - SyncEvent::DocumentUpdated { id, title, .. } => { - format!("✏️ Document updated: {} ({})", title, &id[..8]) - } - SyncEvent::DocumentDeleted { id, .. } => { - format!("🗑️ Document deleted: {}", &id[..8]) - } - SyncEvent::SyncStarted => "🔄 Sync started".to_string(), - SyncEvent::SyncCompleted { document_count } => { - format!("✅ Sync completed: {} docs", document_count) - } - SyncEvent::SyncError { message, .. } => { - format!("🚨 Sync error: {}", message) - } - SyncEvent::ConnectionLost { server_url } => { - format!("❌ Disconnected from {}", server_url) - } - SyncEvent::ConnectionAttempted { server_url } => { - format!("🔄 Connecting to {}...", server_url) - } - SyncEvent::ConnectionSucceeded { server_url } => { - format!("🔗 Connected to {}", server_url) - } - SyncEvent::ConflictDetected { document_id, .. } => { - format!("⚠️ Conflict detected: {}", &document_id[..8]) - } - SyncEvent::IdentityChanged { new_user_id, .. } => { - format!("🪪 Identity changed: {}", new_user_id) - } - }; - - if let Ok(mut t) = tracker_clone.lock() { - t.add(event_desc); - } - })?; - - println!("✓ Rust callback registered"); - Some(Arc::new(engine)) - } - Err(e) => { - println!("⚠️ Offline mode: {}", e); - None - } - }; - - // Test local document operations - println!("\n🔧 Testing document operations..."); - - // Create some test documents - for i in 1..=3 { - let title = format!("Test Task {}", i); - let content = json!({ - "title": title.clone(), - "description": format!("This is test task number {}", i), - "status": "pending", - "priority": if i == 1 { "high" } else { "medium" }, - "tags": ["test", "demo"] - }); - - if let Some(engine) = &sync_engine { - println!(" ➕ Creating document: {}", title); - let mut full_content = content.clone(); - full_content["title"] = serde_json::json!(title); - let doc = engine.create_document(full_content).await?; - println!(" Created: {}", doc.id); - } else { - // Offline mode - create directly in database - println!(" ➕ Creating document offline: {}", title); - let mut full_content = content.clone(); - full_content["title"] = serde_json::json!(title); - let doc = replicant_core::models::Document { - id: Uuid::new_v4(), - user_id: Some(user_id), - content: full_content.clone(), - sync_revision: 1, - content_hash: None, - title: full_content - .get("title") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }; - - db.save_document(&doc).await?; - println!(" Created: {}", doc.id); - } - - // Give time for async operations - sleep(Duration::from_millis(10)).await; - } - - // Test event emission with callback processing - println!("\n🧪 Testing event emission..."); - if let Some(engine) = &sync_engine { - let events = engine.event_dispatcher(); - - // Emit some test events - let test_doc_id = Uuid::new_v4(); - let test_content = json!({"title": "Test Document", "test": "data"}); - - println!(" 📤 Emitting test events..."); - events.emit_document_created(&test_doc_id, &test_content); - events.emit_sync_started(); - events.emit_sync_completed(42); - events.emit_connection_succeeded("ws://test-server"); - - // Process queued events - this invokes our Rust callback - let processed = events.process_events()?; - println!(" 🔄 Processed {} events", processed); - - // Check connection status - println!( - " 🔗 Connection status: {}", - if engine.is_connected() { - "Connected" - } else { - "Disconnected" - } - ); - } - - // Wait a bit more - sleep(Duration::from_millis(100)).await; - - // Show summary of received events - println!("\n📊 Event Summary:"); - if let Ok(t) = tracker.lock() { - println!(" Total events received: {}", t.events.len()); - for (i, event) in t.events.iter().enumerate() { - println!(" {}. {}", i + 1, event); - } - } - - println!("\n✅ Rust callback test completed!"); - Ok(()) -} - -async fn setup_user( - db: &ClientDatabase, - user_id: Uuid, - client_id: Uuid, - server_url: &str, - _token: &str, -) -> Result<(), Box<dyn std::error::Error>> { - sqlx::query("INSERT INTO user_config (user_id, client_id, server_url) VALUES (?1, ?2, ?3)") - .bind(user_id.to_string()) - .bind(client_id.to_string()) - .bind(server_url) - .execute(&db.pool) - .await?; - Ok(()) -} diff --git a/replicant-client/include/replicant.h b/replicant-client/include/replicant.h index 5797f8b..4247084 100644 --- a/replicant-client/include/replicant.h +++ b/replicant-client/include/replicant.h @@ -11,6 +11,7 @@ #include <stdarg.h> #include <stdbool.h> +#include <stddef.h> #include <stdint.h> #include <stdlib.h> @@ -19,957 +20,918 @@ namespace replicant { #endif // __cplusplus /** - * Where a document event came from. - * - * Document events are emitted for this client's own writes as well as for - * changes applied from the server, and the rest of the payload cannot tell the - * two apart: `user_id` is the document owner, not the writer, and delivery is - * asynchronous. Consumers that only care about changes made elsewhere should - * ignore `Local` events. + * The C ABI version is MAJOR.MINOR. A breaking change bumps the major; an addition (a function, + * a struct field at the end, an enum value) bumps the minor. A host needs the library's major + * equal to the header's, and its minor at least the minor that added what the host uses. + * Hosts treat unknown enum values as unknown: an unknown `ReplicantEventType` is ignored, an + * unknown `ReplicantHaltReason` is `Other`, an unknown error code is 0 (`Unknown`). + */ +#define REPLICANT_ABI_VERSION_MAJOR 1 + +/** + * See `REPLICANT_ABI_VERSION_MAJOR`. */ -typedef enum ReplicantEventOrigin { +#define REPLICANT_ABI_VERSION_MINOR 0 + +/** + * Longest email, in bytes. + */ +#define REPLICANT_EMAIL_MAX_LEN 254 + +/** + * A user id's length; its buffer needs one more for the NUL. + */ +#define REPLICANT_USER_ID_LEN 36 + +/** + * A document id's length; its buffer needs one more for the NUL. + */ +#define REPLICANT_DOCUMENT_ID_LEN 36 + +enum ReplicantEventOrigin +#ifdef __cplusplus + : int32_t +#endif // __cplusplus + { + /** + * A handle of this engine wrote it (this process, this copy of the library): another + * handle's write on the same engine is `Local` too. + */ + ReplicantEventOrigin_Local = 0, /** - * This client wrote the document itself. + * Sync wrote it: a download, a settle, a conflict revert or a sweep, in any process. */ - Local = 0, + ReplicantEventOrigin_Remote = 1, /** - * The change was applied from the server: a broadcast from another client - * or instance, or a sync pass. + * Another process, or another copy of the library in this process, on the same data dir. */ - Remote = 1, -} ReplicantEventOrigin; + ReplicantEventOrigin_OtherProcess = 2, +}; +#ifndef __cplusplus +typedef int32_t ReplicantEventOrigin; +#endif // __cplusplus -/** - * Event types that can be emitted by the sync client - */ -typedef enum ReplicantEventType { +enum ReplicantEventType +#ifdef __cplusplus + : int32_t +#endif // __cplusplus + { /** - * A new document was created. - * - * Fires for THIS client's own `create_document` call as well as for a - * document that arrived from the server. Check the event's origin before - * treating it as a change made elsewhere. + * A document was created or changed; `EventOrigin` says by whom. */ - DocumentCreated = 0, + ReplicantEventType_DocumentChanged = 1, + ReplicantEventType_DocumentDeleted = 2, + ReplicantEventType_SyncStarted = 3, /** - * An existing document was updated. - * - * Fires for THIS client's own `update_document` call as well as for a - * patch applied from the server. Check the event's origin before treating - * it as a change made elsewhere. + * Every subscribed scope caught up, once per connection. Not a promise that uploads are done. */ - DocumentUpdated = 1, + ReplicantEventType_SyncCompleted = 4, + ReplicantEventType_SyncError = 5, /** - * A document was deleted. - * - * Fires for THIS client's own `delete_document` call as well as for a - * deletion applied from the server. Check the event's origin before - * treating it as a change made elsewhere. + * Local content was set aside in Kept copies. */ - DocumentDeleted = 2, + ReplicantEventType_ConflictDetected = 6, + ReplicantEventType_ConnectionLost = 7, + ReplicantEventType_ConnectionAttempted = 8, + ReplicantEventType_ConnectionSucceeded = 9, /** - * Synchronization process started + * Changes were trimmed before this engine read them, or this handle fell more than 4096 + * events behind: reload every list. */ - SyncStarted = 3, + ReplicantEventType_DatabaseChanged = 11, /** - * Synchronization completed successfully + * The data dir adopted the signed-in account's user id and restamped its documents: + * re-read `replicant_get_user_id` and reload lists. On the sync callback. */ - SyncCompleted = 4, + ReplicantEventType_IdentityAdopted = 12, +}; +#ifndef __cplusplus +typedef int32_t ReplicantEventType; +#endif // __cplusplus + +enum ReplicantConnection +#ifdef __cplusplus + : int32_t +#endif // __cplusplus + { /** - * An error occurred during synchronization + * Never reported: `replicant_create` returns after the engine left it. */ - SyncError = 5, + ReplicantConnection_Idle = 0, + ReplicantConnection_Disconnected = 1, + ReplicantConnection_Connecting = 2, + ReplicantConnection_Connected = 3, /** - * A conflict was detected between document versions + * Not retrying on its own; see `halt_reason`. */ - ConflictDetected = 6, + ReplicantConnection_Halted = 4, + ReplicantConnection_Stopped = 5, +}; +#ifndef __cplusplus +typedef int32_t ReplicantConnection; +#endif // __cplusplus + +enum ReplicantErrorCode +#ifdef __cplusplus + : int32_t +#endif // __cplusplus + { + ReplicantErrorCode_Unknown = 0, + /** + * The server refused the credentials; the engine is halted until they change. + */ + ReplicantErrorCode_AuthInvalid = 1001, + /** + * No stored credentials; the engine is halted until someone signs in. + */ + ReplicantErrorCode_NotEnrolled = 1003, + /** + * The server needs a newer client; halted. + */ + ReplicantErrorCode_UpdateRequired = 2003, /** - * Connection to server was lost + * This computer's clock is off; not fatal, the engine keeps retrying. */ - ConnectionLost = 7, + ReplicantErrorCode_ClockSkew = 2101, + ReplicantErrorCode_ProtocolError = 3003, /** - * A connection attempt was made to the server + * A subscribed scope was refused and dropped. */ - ConnectionAttempted = 8, + ReplicantErrorCode_SubscriptionForbidden = 3004, /** - * Successfully connected to the server + * The data dir belongs to another account; halted. */ - ConnectionSucceeded = 9, + ReplicantErrorCode_IdentityDrift = 4001, + ReplicantErrorCode_AccountDisabled = 4002, /** - * The server-authoritative user id was adopted, replacing the local one + * The server refused this document's changes; its next local edit retries them. If the + * refused change was a delete, the server's version is back instead, and edits made before + * the delete are in Kept copies (`recovered_id`). Also for `Forbidden` and `TooLarge`. */ - IdentityChanged = 10, -} ReplicantEventType; + ReplicantErrorCode_Validation = 5002, + ReplicantErrorCode_Forbidden = 5003, + ReplicantErrorCode_TooLarge = 5004, + /** + * The document became a read-only publication; unsent edits are in Kept copies. + */ + ReplicantErrorCode_BecamePublication = 5005, + /** + * Its id belongs to another account; the content is in Kept copies. + */ + ReplicantErrorCode_CreateRejected = 5006, + /** + * The server kept storing something other than what was uploaded; the document stops + * uploading until its next local edit. Local content is kept. + */ + ReplicantErrorCode_Diverged = 5007, + /** + * Reserved: a delete the server refused (the document is back). Not emitted before 0.8; + * until then a refused delete arrives as `Validation`, `Forbidden` or `TooLarge`. + */ + ReplicantErrorCode_DeleteRefused = 5008, + /** + * The local database failed while checking a join; not fatal, the engine retries. + */ + ReplicantErrorCode_LocalDatabase = 6001, +}; +#ifndef __cplusplus +typedef int32_t ReplicantErrorCode; +#endif // __cplusplus + +enum ReplicantHaltReason +#ifdef __cplusplus + : int32_t +#endif // __cplusplus + { + ReplicantHaltReason_None = 0, + /** + * No stored credentials, or stored ones that can never be read (key missing, not + * decryptable): sign in. + */ + ReplicantHaltReason_NotEnrolled = 1, + /** + * The server refused the credentials: sign in again. + */ + ReplicantHaltReason_AuthInvalid = 2, + /** + * The server needs a newer client. + */ + ReplicantHaltReason_UpdateRequired = 3, + /** + * The server disabled the account. A different account signed in on this data dir, in any + * process, is picked up within about 3 s; the same account stays halted. + */ + ReplicantHaltReason_AccountDisabled = 4, + /** + * This data dir belongs to another account. + */ + ReplicantHaltReason_IdentityDrift = 5, + ReplicantHaltReason_Other = 6, +}; +#ifndef __cplusplus +typedef int32_t ReplicantHaltReason; +#endif // __cplusplus /** - * Structured error code carried by every `SyncError` event. - * - * The numeric values are STABLE and exported to C via cbindgen. They are - * banded by the action a consumer should take: - * - * - `0` — unknown / uncategorized. - * - `1xxx` — **credential rejected**: the stored credential is bad. The - * consumer should clear it and re-enroll. See [`is_credential_rejection`]. - * - `2xxx` — **transient**: retry later; NEVER clear credentials. This band - * includes the timestamp reasons (`2101`, `2102`), which are client/server - * clock skew — not a bad credential — and so must never trigger a clear. - * - `3xxx` — **protocol**: the exchange was malformed or violated the contract. - * - `4xxx` — **identity drift**: the local identity diverged from the account; - * refuse to sync, but do NOT clear credentials. - * - `5xxx` — **unresolved divergence**: a local edit could not be reconciled - * with the server's copy. Retrying cannot help; surface it to the user. + * Merge policy for a list changed on both sides. `ReplicantListMerge_Full` is refused for now. */ -enum ReplicantErrorCode +enum ReplicantListMerge #ifdef __cplusplus : int32_t #endif // __cplusplus { /** - * Unknown or uncategorized error. + * Element by element while positions line up on both sides; otherwise the server's list is + * kept and the local one set aside. + */ + ReplicantListMerge_Append = 0, + /** + * Any change on both sides keeps the server's list and sets the local one aside. */ - Unknown = 0, + ReplicantListMerge_Atomic = 1, + ReplicantListMerge_Full = 2, +}; +#ifndef __cplusplus +typedef int32_t ReplicantListMerge; +#endif // __cplusplus + +enum ReplicantSync +#ifdef __cplusplus + : int32_t +#endif // __cplusplus + { + ReplicantSync_Idle = 0, + ReplicantSync_CatchingUp = 1, + ReplicantSync_Live = 2, +}; +#ifndef __cplusplus +typedef int32_t ReplicantSync; +#endif // __cplusplus + +enum ReplicantSyncResult +#ifdef __cplusplus + : int32_t +#endif // __cplusplus + { + ReplicantSyncResult_Success = 0, + ReplicantSyncResult_ErrorInvalidInput = -1, /** - * The API key is not recognized by the server. + * Enrollment only: the server could not be reached, or answered with an unexpected status. */ - InvalidApiKey = 1001, + ReplicantSyncResult_ErrorConnection = -2, + ReplicantSyncResult_ErrorDatabase = -3, + ReplicantSyncResult_ErrorSerialization = -4, /** - * The HMAC signature did not verify. + * A newer build migrated the database; this build cannot open it. */ - InvalidSignature = 1002, + ReplicantSyncResult_ErrorNewerSchema = -5, /** - * The credential authenticated but is not bound to an enrolled user. + * An engine is open on this data dir with a different server, host, list merge config or + * title pointer. + * A programming error: every binary on a data dir must pass the same config. Never fall + * back to a temporary library. */ - CredentialNotEnrolled = 1003, + ReplicantSyncResult_ErrorConfigMismatch = -6, + ReplicantSyncResult_ErrorNotFound = -7, /** - * The socket/transport failed to connect. + * Another account's document, or a read-only publication. */ - ConnectionFailed = 2001, + ReplicantSyncResult_ErrorNotWritable = -8, /** - * A join or call timed out. + * The id exists here or was deleted. An import should skip the id, not count a failure. */ - Timeout = 2002, + ReplicantSyncResult_ErrorAlreadyExists = -9, /** - * The signed timestamp was outside the server's acceptance window - * (client/server clock skew, not a bad credential). + * Migrating a v1 library failed. Show "Your library needs attention"; never fall back to a + * temporary library. Either the backup itself failed: no backup was written and the + * database is unchanged. Or the migration failed after the backup: the documents are + * unchanged but 0.6 builds cannot open the database; restoring the backup + * (`<database>.v1-backup`, or `<database>.v1-backup-<unix seconds>` when that name was + * taken) is the way back. */ - TimestampExpired = 2101, + ReplicantSyncResult_ErrorMigrationFailed = -10, /** - * The timestamp field was malformed or unparseable (treated as clock skew). + * Another process kept the database locked; try `replicant_create` again shortly. */ - InvalidTimestamp = 2102, + ReplicantSyncResult_ErrorBusy = -11, /** - * A required join parameter was missing. + * `replicant_process_events` or a `replicant_register_*_callback` call came from another + * thread than the one the handle's first registration bound. */ - MissingParams = 3001, + ReplicantSyncResult_ErrorWrongThread = -12, /** - * The join topic's user id did not match the credential's user. + * `replicant_process_events` was called before any callback was registered. */ - TopicUserMismatch = 3002, + ReplicantSyncResult_ErrorNoCallbacks = -13, /** - * Generic malformed or unexpected server reply. + * The kept copy's document was deleted: `replicant_restore_document` re-creates the copy. */ - ProtocolError = 3003, + ReplicantSyncResult_ErrorDocumentGone = -14, /** - * The server-reported user id diverged from the local identity. + * An output buffer is smaller than the value plus its NUL; see the `REPLICANT_*_LEN` limits. */ - IdentityDrift = 4001, + ReplicantSyncResult_ErrorBufferTooSmall = -15, /** - * A local edit could not be rebased onto the server's current content. - * The server's copy is now local truth and the edit was discarded. + * The server refused the enrollment code (wrong or expired): ask for a new one. */ - UpdateConflict = 5001, + ReplicantSyncResult_ErrorTokenRejected = -16, + ReplicantSyncResult_ErrorUnknown = -99, }; #ifndef __cplusplus -typedef int32_t ReplicantErrorCode; +typedef int32_t ReplicantSyncResult; #endif // __cplusplus /** - * Result codes for C API functions - */ -typedef enum ReplicantSyncResult { - Success = 0, - ErrorInvalidInput = -1, - ErrorConnection = -2, - ErrorDatabase = -3, - ErrorSerialization = -4, - ErrorUnknown = -99, -} ReplicantSyncResult; - -/** - * Opaque handle to a Replicant client instance + * Opaque handle. Every call except `replicant_process_events`, the + * `replicant_register_*_callback` calls and the destroy calls is thread-safe; the first two run + * only on the thread the first registration bound, and a destroy must not overlap any other + * call on the handle (see `replicant_destroy`). Never call from an audio thread: every read and + * write is a SQLite transaction. */ typedef struct Replicant Replicant; /** - * Document event callback for DocumentCreated, DocumentUpdated, DocumentDeleted - * - * Fires for this client's OWN writes as well as for changes applied from the - * server. Check `origin` before treating an event as a change made elsewhere. - * - * # Parameters - * * `event_type` - The specific document event type - * * `document_id` - UUID of the document (always non-null) - * * `title` - Document title (null for Deleted events) - * * `content` - Full document JSON (null for Deleted events) - * * `user_id` - Owner UUID (null if unknown) - * * `author_name` - Author display name (null if unknown) - * * `visibility` - "private"/"public" (null if unknown) - * * `origin` - `Local` if this client wrote the document, `Remote` if the - * change was applied from the server - * * `context` - User-defined context pointer - */ -typedef void (*DocumentEventCallback)(enum ReplicantEventType event_type, - const char *document_id, - const char *title, - const char *content, - const char *user_id, - const char *author_name, - const char *visibility, - enum ReplicantEventOrigin origin, - void *context); - -/** - * Sync event callback for SyncStarted, SyncCompleted - * - * # Parameters - * * `event_type` - SyncStarted or SyncCompleted - * * `document_count` - Number of documents synced (0 for SyncStarted) - * * `context` - User-defined context pointer + * Every string is UTF-8 and copied by `replicant_create`. */ -typedef void (*SyncEventCallback)(enum ReplicantEventType event_type, - uint64_t document_count, - void *context); +typedef struct ReplicantConfig { + /** + * `sizeof(ReplicantConfig)`. Smaller than the ABI 1.0 struct is refused + * (`ErrorInvalidInput`); the library reads only the fields it knows. + */ + uint32_t struct_size; + /** + * Holds the database file and the stored credentials. + */ + const char *data_dir; + /** + * File name inside `data_dir`, e.g. "tonaldb.sqlite3"; a path separator or `..` is refused. + */ + const char *database_file; + const char *server_url; + /** + * May be null (`""` counts as null). Signs joins when the stored credentials carry no email. + * Credentials stored by 0.6 carry none: with a null email here the engine reports + * `NotEnrolled`, so a host upgrading from 0.6 must pass the user's email. Not part of the + * shared-engine check: a later handle on an open data dir never gets `ErrorConfigMismatch` + * for a different email, and the email of the handle that started the engine is the one used. + */ + const char *email; + /** + * Named in the User-Agent, e.g. "Entonal Studio" and "2.0.1 CLAP". + */ + const char *host_app; + const char *host_version; + /** + * A `ReplicantListMerge`: how a list both sides changed is merged when no rule matches. + * An `int32_t` so that a value from C outside the enum is refused, not undefined. + */ + int32_t list_merge; + /** + * May be null. A JSON array of `{"path": "/pitches", "policy": "atomic"}`; + * policies `append`, `atomic`, `full`. `*` matches one key or index; the rule with the most + * literal segments wins, a tie goes to the first listed. + */ + const char *list_merge_rules_json; + /** + * May be null (`""` counts as null). An RFC 6901 JSON Pointer, e.g. "/title", to the string + * in each document's content that is its `title`: in reads, callbacks, kept copies and the + * `title:` search field. Null: the library assigns no titles. Anything else that is not a + * JSON Pointer is refused (`ErrorInvalidInput`). The shared-engine check + * (`ErrorConfigMismatch`) covers handles in one process. Every process on a data dir must + * pass the same pointer: a different pointer in another process is not detected, each launch + * with one recomputes every title and the search index, and titles end up mixed while both + * run. + */ + const char *title_pointer; +} ReplicantConfig; -/** - * Error event callback for SyncError - * - * # Parameters - * * `event_type` - Always SyncError - * * `error_code` - Stable `ReplicantErrorCode` value; use - * `replicant_error_is_credential_rejection` to decide whether to clear the - * stored credential - * * `error` - Error message (always non-null) - * * `context` - User-defined context pointer - */ -typedef void (*ErrorEventCallback)(enum ReplicantEventType event_type, - int32_t error_code, - const char *error, - void *context); +typedef struct ReplicantState { + /** + * Set to `sizeof(ReplicantState)` before `replicant_get_state`; smaller than the ABI 1.0 + * struct is refused. The library writes only the fields it knows and sets this to their + * size, so a host from a later version can tell which of its fields were filled. + */ + uint32_t struct_size; + ReplicantConnection connection; + ReplicantSync sync; + ReplicantHaltReason halt_reason; +} ReplicantState; + +/** + * `DocumentChanged` / `DocumentDeleted`. For a deletion only `document_id` is set; for a change + * `title`, `owner_id` and `author_id` may be null. `title` comes from + * `ReplicantConfig.title_pointer`: always null without one. + * `visibility` is `public` (curated or read-only) or `private`. `content` is the document's + * content, the host's own JSON. Every id is a lowercase hyphenated UUID. + * Strings are valid only during the call; copy what you keep. + */ +typedef void (*ReplicantDocumentEventCallback)(ReplicantEventType event_type, + const char *document_id, + const char *title, + const char *content, + const char *owner_id, + const char *author_id, + const char *visibility, + bool read_only, + ReplicantEventOrigin origin, + void *context); + +/** + * `SyncStarted`, `SyncCompleted`, `DatabaseChanged`, `IdentityAdopted`. + * Strings are valid only during the call; copy what you keep. + */ +typedef void (*ReplicantSyncEventCallback)(ReplicantEventType event_type, void *context); + +/** + * `error_code` is a `ReplicantErrorCode`; `error` is the protocol code, e.g. "clock_skew"; + * `document_id` is null unless the error is about one document; `scope` is null unless it is + * about one subscribed scope (e.g. `subscription_forbidden`); `fatal` means halted; + * `recovered_id` names local content kept aside with the error (a Kept copies id, reasons as + * in `ReplicantConflictEventCallback`), -1 if none. + * Per engine: only the handles of the engine that applied the rule get this event; another + * process, or another copy of the library, sees just `DocumentChanged`. + * `replicant_list_recovered` is the durable record. `recovered_id` may already be dismissed or + * restored by another process: `restore_*` then returns `ErrorNotFound`. + * Strings are valid only during the call; copy what you keep. + */ +typedef void (*ReplicantErrorEventCallback)(ReplicantEventType event_type, + int32_t error_code, + const char *error, + const char *document_id, + const char *scope, + bool fatal, + int64_t recovered_id, + void *context); + +/** + * `attempt_number` counts dials since the last successful connection. + * Strings are valid only during the call; copy what you keep. + */ +typedef void (*ReplicantConnectionEventCallback)(ReplicantEventType event_type, + bool connected, + uint32_t attempt_number, + void *context); + +/** + * `reason`: `conflict`, `field_conflict`, `delete_wins` or `delete_superseded` (a delete undone + * because a newer version arrived). `recovered_id` is the Kept copies id, or -1 when nothing + * was kept. + * A kept copy's reason is one of `conflict`, `field_conflict`, `delete_wins`, + * `delete_superseded`, `delete_refused`, `delete_publication`, `became_publication`, + * `create_rejected` or `unmigratable` (set aside while upgrading the database, with no event). + * `paths_json` is a JSON array of strings, each a JSON Pointer, for `field_conflict`; else null. + * Per engine: only the handles of the engine that applied the rule get this event; another + * process, or another copy of the library, sees just `DocumentChanged`. + * `replicant_list_recovered` is the durable record. `recovered_id` may already be dismissed or + * restored by another process: `restore_*` then returns `ErrorNotFound`. + * Strings are valid only during the call; copy what you keep. + */ +typedef void (*ReplicantConflictEventCallback)(ReplicantEventType event_type, + const char *document_id, + const char *reason, + int64_t recovered_id, + const char *paths_json, + void *context); -/** - * Connection event callback for ConnectionLost, ConnectionAttempted, ConnectionSucceeded - * - * # Parameters - * * `event_type` - The connection event type - * * `connected` - true if connected (valid for Lost/Succeeded), false otherwise - * * `attempt_number` - Reconnection attempt number (valid for ConnectionAttempted) - * * `context` - User-defined context pointer - */ -typedef void (*ConnectionEventCallback)(enum ReplicantEventType event_type, - bool connected, - uint32_t attempt_number, - void *context); +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus /** - * Conflict event callback for ConflictDetected - * - * # Parameters - * * `event_type` - Always ConflictDetected - * * `document_id` - UUID of the conflicted document (always non-null) - * * `winning_content` - Content of the winning version (always non-null) - * * `losing_content` - Content of the losing version (may be null) - * * `context` - User-defined context pointer + * Whether `code` (a `ReplicantErrorCode`) means the credentials were refused or are missing. + * Never clear the stored credentials because of this: a sign-out is always explicit. */ -typedef void (*ConflictEventCallback)(enum ReplicantEventType event_type, - const char *document_id, - const char *winning_content, - const char *losing_content, - void *context); +bool replicant_error_is_credential_rejection(int32_t code); /** - * Identity event callback for IdentityChanged - * - * # Parameters - * * `event_type` - Always IdentityChanged - * * `old_user_id` - The provisional/previous user id (always non-null) - * * `new_user_id` - The adopted canonical user id (always non-null) - * * `email` - The email the server resolved the id from (may be empty) - * * `context` - User-defined context pointer + * The library's ABI version, packed: `(major << 16) | minor`. */ -typedef void (*IdentityEventCallback)(enum ReplicantEventType event_type, - const char *old_user_id, - const char *new_user_id, - const char *email, - void *context); - -/** - * Document structure for C API - */ -typedef struct Document { - char *id; - char *title; - char *content; - int64_t sync_revision; -} Document; - -#ifdef __cplusplus -extern "C" { -#endif // __cplusplus +uint32_t replicant_abi_version(void); /** - * Band check exposed over FFI: `true` iff `code` is a credential rejection. + * Attaches to the engine for `config`'s data dir, starting it if this process has none yet. + * Opens (and after an upgrade migrates) the database on this thread; never waits on the + * network. On success `*out_handle` is set; on any failure, a null `config` included, it is null. * - * Bindings should treat a `true` result as "clear the stored credential and - * re-enroll". Implemented once here so consumers do not re-implement the band - * logic against the raw numeric values. + * The stored credentials are read before this returns, so `replicant_get_state` is already + * true: `Halted`/`NotEnrolled` without usable credentials (none stored, or ones that can never + * be read), else dialling (or, for a later handle, the shared engine's current state). An + * engine that starts without usable credentials also sends one fatal `not_enrolled` error, to + * the handle that started it; a later handle on that engine is not sent it again and reads + * `replicant_get_state` instead. * * # Safety - * This function is pure and takes the code by value; it is always safe to call. + * `config` and `out_handle` must be valid; the config's strings valid C strings (`email` may be null). */ -bool replicant_error_is_credential_rejection(int32_t code); +ReplicantSyncResult replicant_create(const struct ReplicantConfig *config, + struct Replicant **out_handle); /** - * Create a new sync engine instance + * Detaches and frees the handle. Returns at once: when this was the engine's last handle, the + * engine stops on a Replicant thread afterwards (never waiting on the network). * - * # Arguments - * * `database_url` - SQLite database URL (e.g., "sqlite:client.db?mode=rwc") - * * `server_url` - WebSocket server URL (e.g., "ws://localhost:8080/ws") - * * `email` - User email address - * * `api_key` - Application API key (rpa_ prefix) - * * `api_secret` - Application API secret (rps_ prefix) + * A destroy must not overlap any other call on this handle, on any thread: the host ends every + * other use of the handle first. The one exception is a destroy from inside one of this + * handle's callbacks, which is deferred: the free happens when `replicant_process_events` + * returns. Other handles, including those on the same data dir, are unaffected. * - * # Returns - * * Pointer to SyncEngine on success, null on failure - * - * # Events - * Connection and initial sync run in the background. ConnectionSucceeded is - * emitted once per successful connect (at start-up or on a later reconnect), - * after the engine uses that connection. SyncStarted and SyncCompleted then - * bracket the full sync that follows; SyncCompleted means the server answered. - * An offline start emits neither until the server is reached. An engine with - * no API key, or whose database has never adopted an identity (no `user_id` - * on this or an earlier run), never connects, so it emits neither. + * Unloading: library code can still run after this returns, and even after + * `replicant_destroy_and_wait` returns true, because a DNS lookup the engine started finishes + * on its own thread, bounded only by the OS resolver. Never unload this library while the + * process runs: a plugin pins its module (`RTLD_NODELETE`, or + * `GET_MODULE_HANDLE_EX_FLAG_PIN` on Windows). * * # Safety - * Caller must ensure all pointers are valid, non-null C strings + * `handle` must come from `replicant_create` and not be used again. */ -struct Replicant *replicant_create(const char *database_url, - const char *server_url, - const char *email, - const char *api_key, - const char *api_secret, - const char *user_id); +void replicant_destroy(struct Replicant *handle); /** - * Destroy a sync engine instance and free memory - * - * NON-BLOCKING, fire-and-forget: it returns immediately and the teardown — - * closing both sqlite pools and joining this instance's threads — finishes on - * a Replicant-owned thread afterwards. The handle is invalid the moment this - * returns; no further call may use it. - * - * This is safe to call on a UI or audio-host thread (plugin scans, project - * close) precisely because it does not wait. The consequence is that threads - * Replicant started can still be running Replicant code for a short time after - * it returns — normally milliseconds, longer if sqlite is contended. - * - * THEREFORE: any caller that can be UNLOADED FROM MEMORY — every plugin — - * MUST make sure its own binary is never unmapped, or one of those threads - * will be executing code at an address that no longer exists (the DEV-1118 - * crash: an execute access violation on an unmapped image). Pin the module - * once, at load: - * - * * Windows: `GetModuleHandleExW` with `GET_MODULE_HANDLE_EX_FLAG_PIN` - * (plus `GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS`) on an address in your - * own module. - * * macOS / Linux: `dlopen` your own image with `RTLD_NOLOAD | RTLD_NODELETE`. - * - * Pinning is the primary defence, not a workaround. A host process that only - * ever exits (a standalone app) does not need it; anything a host can unload - * does. - * - * UPGRADING FROM 0.6.2 OR EARLIER WITHOUT PINNING IS A REGRESSION. The old - * destroy dropped the handle, which joined the tokio runtime's threads before - * returning and left only sqlx's workers running; this one returns with the - * background init, both pools, the runtime's threads and the sqlx workers all - * still live. The total time any thread of ours is alive goes down — the pools - * are now actually closed, so nothing lingers for the life of the process — but - * the exposure *at the moment destroy returns* goes up. - * - * A second instance may be created immediately; it shares nothing with the one - * being torn down. Note only that the outgoing instance may still be writing to - * the same database file for a moment, so an immediate re-create against it can - * see sqlite contention (and `replicant_create` returns NULL if that outlasts - * sqlite's 5s busy timeout). `replicant_destroy_and_wait` avoids the overlap. - * - * Callers that *can* afford to wait — standalone apps shutting down, tests — - * should use `replicant_destroy_and_wait` instead, which reports whether the - * teardown actually finished. + * `replicant_destroy`, then waits up to `timeout_ms` for the engine to stop and its runtime to + * shut down. True when it has, or when other handles keep the engine running. It does not make + * unloading safe (see `replicant_destroy`). The same overlap rule applies. Called from inside + * one of this handle's callbacks it cannot wait: it returns false at once and the handle is + * freed when `replicant_process_events` returns. * * # Safety - * Caller must ensure engine pointer was created by replicant_create and hasn't been freed. - * As with any free function, no other call on this handle may be in progress on - * any thread — which includes destroying it from inside a Replicant callback - * while the call that dispatched that callback is still on the stack. + * As `replicant_destroy`. */ -void replicant_destroy(struct Replicant *engine); +bool replicant_destroy_and_wait(struct Replicant *handle, uint32_t timeout_ms); /** - * Destroy a sync engine instance and wait for its teardown to finish. - * - * Same as `replicant_destroy` (the handle is invalid either way, whatever this - * returns) but BLOCKS for up to `timeout_ms` waiting for Replicant to close - * its sqlite pools and join its threads. - * - * Returns `true` when the teardown completed: no REPLICANT-OWNED thread — its - * tokio runtime, its sqlite workers — is running any more. That is the claim, - * and it is narrower than "this module has no threads in it": libraries linked - * into Replicant (the WebSocket and TLS stacks in particular) may keep threads - * of their own that Replicant neither owns nor can join, so a module that has - * ever connected should still be pinned rather than unloaded. - * - * Returns `false` if the wait ran out, or if the caller cannot wait (destroy - * from inside a Replicant callback on a Replicant runtime thread) — the teardown - * then carries on in the background. - * - * `timeout_ms` of 0 does not wait at all; it reports whether the teardown had - * already finished. Pass a large value (`UINT32_MAX`) to wait in effect - * indefinitely. - * - * Intended for standalone applications and tests. A plugin should call - * `replicant_destroy` and pin its module instead of blocking a host thread. + * Creates a document owned by the signed-in (or provisional) user; writes its id into + * `out_document_id` (`REPLICANT_DOCUMENT_ID_LEN + 1` bytes). * * # Safety - * Caller must ensure engine pointer was created by replicant_create and hasn't been freed + * Valid handle, C string, and a `REPLICANT_DOCUMENT_ID_LEN + 1`-byte buffer. */ -bool replicant_destroy_and_wait(struct Replicant *engine, uint32_t timeout_ms); +ReplicantSyncResult replicant_create_document(struct Replicant *handle, + const char *content_json, + char *out_document_id); /** - * Create a new document - * - * # Arguments - * * `engine` - Sync engine instance - * * `content_json` - Document content as JSON string (should include any title as part of the JSON) - * * `out_document_id` - Output buffer for document ID (must be at least 37 chars) - * - * # Returns - * * CSyncResult indicating success or failure + * Creates a document with a chosen id. Refused (`ErrorAlreadyExists`) when the id exists here + * or was deleted. * * # Safety - * Caller must ensure engine is valid, content_json is a valid C string, and out_document_id has space for 37 bytes + * Valid handle and C strings. */ -enum ReplicantSyncResult replicant_create_document(struct Replicant *engine, - const char *content_json, - char *out_document_id); +ReplicantSyncResult replicant_create_document_with_id(struct Replicant *handle, + const char *document_id, + const char *content_json); /** - * Create a new document with a specified ID - * - * # Arguments - * * `engine` - Sync engine instance - * * `document_id` - UUID string to use as the document ID - * * `content_json` - Document content as JSON string - * - * # Returns - * * `SyncResult::Success` - Document created successfully - * * `SyncResult::ErrorInvalidInput` - Invalid UUID format or null pointers - * * `SyncResult::ErrorSerialization` - Invalid JSON content - * * `SyncResult::ErrorDatabase` - Database operation failed - * * `SyncResult::ErrorConnection` - Sync to server failed (document saved locally) - * - * # Note - * If a document with the specified ID already exists, it will be overwritten (upsert behavior). - * Use this for ID preservation during data migration or import scenarios. - * * # Safety - * Caller must ensure engine is valid, document_id and content_json are valid C strings + * Valid handle and C strings. */ -enum ReplicantSyncResult replicant_create_document_with_id(struct Replicant *engine, - const char *document_id, - const char *content_json); +ReplicantSyncResult replicant_update_document(struct Replicant *handle, + const char *document_id, + const char *content_json); /** - * Update an existing document - * - * # Arguments - * * `engine` - Sync engine instance - * * `document_id` - Document ID to update - * * `content_json` - New document content as JSON string - * - * # Returns - * * CSyncResult indicating success or failure - * * # Safety - * Caller must ensure engine is valid and both document_id and content_json are valid C strings + * Valid handle and C string. */ -enum ReplicantSyncResult replicant_update_document(struct Replicant *engine, - const char *document_id, - const char *content_json); +ReplicantSyncResult replicant_delete_document(struct Replicant *handle, const char *document_id); /** - * Delete a document + * The document as a JSON object. `ErrorNotFound` when missing or deleted. * - * # Arguments - * * `engine` - Sync engine instance - * * `document_id` - Document ID to delete - * - * # Returns - * * CSyncResult indicating success or failure + * JSON naming in this API: a document's own id is `id`; any other record that refers to a + * document names it `doc_id`. Ids are lowercase hyphenated UUID strings. A document object has: + * - `id` (string): the document's id. + * - `owner_id` (string or null): the owning user; null only for a legacy document with none. + * - `author_id` (string or null): the author the server reports; null until it has synced. + * - `title` (string or null): the string at `ReplicantConfig.title_pointer` in the content, + * cut to 128 characters; null without a pointer or when that value is not a string. + * - `content`: the host's own JSON value, as last written or synced. + * - `read_only` (bool): the server marked it read-only (a publication); writes are refused. + * - `visibility` (string): `public` (curated or read-only) or `private`. + * - `source_doc_id`, `derived_from` (string or null): ids of the documents it came from. + * - `created_at`, `updated_at` (string): RFC 3339 in UTC, e.g. + * `2026-10-01T07:51:25.301096+00:00`. Times on this device: when the document was first + * stored here, and when its content last changed here (a local edit or a synced change). * * # Safety - * Caller must ensure engine is valid and document_id is a valid C string + * Valid handle, C string and out pointer; free the result with `replicant_string_free`. */ -enum ReplicantSyncResult replicant_delete_document(struct Replicant *engine, - const char *document_id); +ReplicantSyncResult replicant_get_document(struct Replicant *handle, + const char *document_id, + char **out_json); /** - * Free a C string allocated by this library + * Every visible document as a JSON array of document objects (see `replicant_get_document`). * * # Safety - * Caller must ensure the string was allocated by this library and hasn't been freed + * Valid handle and out pointer; free the result with `replicant_string_free`. */ -void replicant_string_free(char *s); +ReplicantSyncResult replicant_get_all_documents(struct Replicant *handle, char **out_json); /** - * Get the engine's own frozen user UUID - * - * # Arguments - * * `engine` - Sync engine instance - * * `out_user_id` - Output pointer for user UUID string (caller must free with replicant_string_free) - * - * # Returns - * * SyncResult::Success if the user ID was retrieved - * * SyncResult::ErrorInvalidInput if engine or out_user_id is null - * * SyncResult::ErrorDatabase if the user ID could not be read + * Document ids as a JSON array of strings; `include_deleted` adds documents whose delete is not + * yet sent. * * # Safety - * Caller must ensure engine is valid and out_user_id is a valid pointer + * Valid handle and out pointer; free the result with `replicant_string_free`. */ -enum ReplicantSyncResult replicant_get_user_id(struct Replicant *engine, - char **out_user_id); +ReplicantSyncResult replicant_get_all_document_ids(struct Replicant *handle, + bool include_deleted, + char **out_json); /** - * Get library version string + * # Safety + * Valid handle and out pointer. */ -char *replicant_get_version(void); +ReplicantSyncResult replicant_count_documents(struct Replicant *handle, uint64_t *out_count); /** - * Register a callback for document events (Created, Updated, Deleted) - * - * # Arguments - * * `engine` - Sync engine instance - * * `callback` - C callback function to invoke for document events - * * `context` - User-defined context pointer passed to callback - * * `event_filter` - Optional filter: 0=Created, 1=Updated, 2=Deleted, -1=all document events - * - * # Returns - * * SyncResult indicating success or failure + * Documents with changes the server has not acknowledged, except parked ones + * (`replicant_list_parked`). * * # Safety - * Caller must ensure engine is valid, callback is a valid function pointer, and context pointer outlives the callback registration + * Valid handle and out pointer. */ -enum ReplicantSyncResult replicant_register_document_callback(struct Replicant *engine, - DocumentEventCallback callback, - void *context, - int32_t event_filter); +ReplicantSyncResult replicant_count_pending_sync(struct Replicant *handle, uint64_t *out_count); /** - * Register a callback for sync events (Started, Completed) - * - * # Arguments - * * `engine` - Sync engine instance - * * `callback` - C callback function to invoke for sync events - * * `context` - User-defined context pointer passed to callback - * - * # Returns - * * SyncResult indicating success or failure + * Whether the engine is connected now. `replicant_get_state` says more. * * # Safety - * Caller must ensure engine is valid, callback is a valid function pointer, and context pointer outlives the callback registration + * `handle` must be valid or null. */ -enum ReplicantSyncResult replicant_register_sync_callback(struct Replicant *engine, - SyncEventCallback callback, - void *context); +bool replicant_is_connected(struct Replicant *handle); /** - * Register a callback for error events (SyncError) - * - * # Arguments - * * `engine` - Sync engine instance - * * `callback` - C callback function to invoke for error events - * * `context` - User-defined context pointer passed to callback - * - * # Returns - * * SyncResult indicating success or failure + * Connection, sync phase and halt reason, for a status indicator. Set + * `out_state->struct_size = sizeof(ReplicantState)` first. True from the moment + * `replicant_create` returns: with no usable stored credentials it is already + * `Halted`/`NotEnrolled`. * * # Safety - * Caller must ensure engine is valid, callback is a valid function pointer, and context pointer outlives the callback registration + * Valid handle and out pointer. */ -enum ReplicantSyncResult replicant_register_error_callback(struct Replicant *engine, - ErrorEventCallback callback, - void *context); +ReplicantSyncResult replicant_get_state(struct Replicant *handle, struct ReplicantState *out_state); /** - * Register a callback for connection events (Lost, Attempted, Succeeded) - * - * # Arguments - * * `engine` - Sync engine instance - * * `callback` - C callback function to invoke for connection events - * * `context` - User-defined context pointer passed to callback - * - * # Returns - * * SyncResult indicating success or failure + * Leaves `Halted` or retries now. Safe to call repeatedly: the engine dials at most once a second. * * # Safety - * Caller must ensure engine is valid, callback is a valid function pointer, and context pointer outlives the callback registration + * Valid handle. */ -enum ReplicantSyncResult replicant_register_connection_callback(struct Replicant *engine, - ConnectionEventCallback callback, - void *context); +ReplicantSyncResult replicant_reconnect(struct Replicant *handle); /** - * Register a callback for conflict events (ConflictDetected) - * - * # Arguments - * * `engine` - Sync engine instance - * * `callback` - C callback function to invoke for conflict events - * * `context` - User-defined context pointer passed to callback - * - * # Returns - * * SyncResult indicating success or failure + * The data dir's user id (provisional until the first join). * * # Safety - * Caller must ensure engine is valid, callback is a valid function pointer, and context pointer outlives the callback registration + * Valid handle and out pointer; free the result with `replicant_string_free`. */ -enum ReplicantSyncResult replicant_register_conflict_callback(struct Replicant *engine, - ConflictEventCallback callback, - void *context); +ReplicantSyncResult replicant_get_user_id(struct Replicant *handle, char **out_user_id); /** - * Register a callback for IdentityChanged events - * - * # Arguments - * * `engine` - Sync engine instance - * * `callback` - Function to call when the server-authoritative id is adopted - * * `context` - User-defined context pointer passed to callback - * - * # Returns - * * SyncResult indicating success or failure - * * # Safety - * Caller must ensure engine is valid, callback is a valid function pointer, and context pointer outlives the callback registration + * Valid handle and C string (a JSON array of JSON paths, e.g. `["$.body"]`). */ -enum ReplicantSyncResult replicant_register_identity_callback(struct Replicant *engine, - IdentityEventCallback callback, - void *context); +ReplicantSyncResult replicant_configure_search(struct Replicant *handle, const char *paths_json); /** - * Process all queued events on the current thread - * - * # Arguments - * * `engine` - Sync engine instance - * * `out_processed_count` - Output pointer for number of events processed (optional) - * - * # Returns - * * CSyncResult indicating success or failure - * - * # Important - * This function MUST be called on the same thread where callbacks were registered. - * Events are queued from any thread but only processed on the callback thread. + * FTS5 query (`music`, `tun*`, `"a phrase"`, `a AND b`, `title:word`, where `title` is the + * document's title from `ReplicantConfig.title_pointer`); `limit` 0 means 100. A + * query FTS5 cannot parse is `ErrorInvalidInput`. The result is a JSON array of document + * objects (see `replicant_get_document`), best match first. * * # Safety - * Caller must ensure engine is valid and out_processed_count points to valid memory (if not null) + * Valid handle, C string and out pointer; free the result with `replicant_string_free`. */ -enum ReplicantSyncResult replicant_process_events(struct Replicant *engine, - uint32_t *out_processed_count); +ReplicantSyncResult replicant_search_documents(struct Replicant *handle, + const char *query, + uint32_t limit, + char **out_json); /** - * Get a document by ID - * - * # Arguments - * * `engine` - Sync engine instance - * * `document_id` - Document ID as UUID string - * * `out_content` - Output pointer for document JSON content (caller must free with replicant_string_free) - * - * # Returns - * * SyncResult::Success if document found and content returned - * * SyncResult::ErrorInvalidInput if document not found or invalid ID - * * # Safety - * Caller must ensure engine is valid, document_id is a valid C string, and out_content is a valid pointer + * Valid handle. */ -enum ReplicantSyncResult replicant_get_document(struct Replicant *engine, - const char *document_id, - char **out_content); +ReplicantSyncResult replicant_rebuild_search_index(struct Replicant *handle); /** - * Get all documents as a JSON array - * - * # Arguments - * * `engine` - Sync engine instance - * * `out_documents` - Output pointer for JSON array of documents (caller must free with replicant_string_free) + * Kept copies (local content sync set aside), newest first, as a JSON array of objects: + * - `recovered_id` (integer): the copy's id, for `replicant_dismiss_recovered` and the restores. + * - `doc_id` (string): the document the copy was kept from (it may since have been deleted). + * - `title` (string or null): the kept content's title, derived as a document's is. + * - `reason` (string): `conflict`, `field_conflict`, `delete_wins`, `delete_superseded`, + * `delete_refused`, `delete_publication`, `became_publication`, `create_rejected` or + * `unmigratable` (set aside while upgrading the database). + * - `recovered_at` (integer): when it was kept, in Unix seconds. + * - `content`: the host's own JSON value, as it was locally when kept. + * - `fields` (array or null): null for a whole-document copy; else one object per conflicting + * path: `path` (string, a JSON Pointer), `local_value` (any JSON; null when removed locally), + * `local_removed` (bool: the local side removed the path). * - * # Returns - * * SyncResult::Success with JSON array (empty array [] if no documents) + * Copies never expire: they stay until dismissed or restored. A copy that cannot be read is left + * out (and logged). * * # Safety - * Caller must ensure engine is valid and out_documents is a valid pointer + * Valid handle and out pointer; free the result with `replicant_string_free`. */ -enum ReplicantSyncResult replicant_get_all_documents(struct Replicant *engine, - char **out_documents); +ReplicantSyncResult replicant_list_recovered(struct Replicant *handle, char **out_json); /** - * Get all document ids as a JSON array - * - * # Arguments - * * `engine` - Sync engine instance - * * `include_deleted` - If true, include tombstoned (deleted) documents - * * `out_ids` - Output pointer for JSON array of id strings (caller must free with replicant_string_free) - * - * # Returns - * * SyncResult::Success with JSON array (empty array [] if no documents) + * Deletes a kept copy for good; `ErrorNotFound` when it is already gone. * * # Safety - * Caller must ensure engine is valid and out_ids is a valid pointer + * Valid handle. */ -enum ReplicantSyncResult replicant_get_all_document_ids(struct Replicant *engine, - bool include_deleted, - char **out_ids); +ReplicantSyncResult replicant_dismiss_recovered(struct Replicant *handle, int64_t recovered_id); /** - * Get the count of local documents - * - * # Arguments - * * `engine` - Sync engine instance - * * `out_count` - Output pointer for document count - * - * # Returns - * * SyncResult::Success with count written to out_count + * Re-creates a kept copy's full content as a new document, with a new id written to + * `out_document_id` (`REPLICANT_DOCUMENT_ID_LEN + 1` bytes), and removes the copy. Works for + * any copy, a field copy too (the way out when its document is gone). `ErrorNotFound` when the + * copy is gone (another process may have dismissed or restored it). * * # Safety - * Caller must ensure engine is valid and out_count is a valid pointer + * Valid handle and a `REPLICANT_DOCUMENT_ID_LEN + 1`-byte buffer. */ -enum ReplicantSyncResult replicant_count_documents(struct Replicant *engine, uint64_t *out_count); +ReplicantSyncResult replicant_restore_document(struct Replicant *handle, + int64_t recovered_id, + char *out_document_id); /** - * Check if the sync engine is connected to the server - * - * # Arguments - * * `engine` - Sync engine instance + * Writes a field copy's kept values back at their paths as a local edit (every other field + * keeps its current value) and removes the copy. * - * # Returns - * * true if connected, false if disconnected or engine is null + * A list conflict is kept as the whole list, so restoring it puts that list back exactly. + * `ErrorNotFound`: the copy is gone. `ErrorInvalidInput`: it is a whole-document copy (its + * `fields` is null); it stays, and `replicant_restore_document` restores it. + * `ErrorDocumentGone`: its document was deleted, and `ErrorNotWritable`: it became read-only; + * the copy stays in both cases, and `replicant_restore_document` brings it back as a new + * document. * * # Safety - * Caller must ensure engine was created by replicant_create + * Valid handle. */ -bool replicant_is_connected(struct Replicant *engine); +ReplicantSyncResult replicant_restore_fields(struct Replicant *handle, int64_t recovered_id); /** - * Get the count of documents pending sync to server - * - * # Arguments - * * `engine` - Sync engine instance - * * `out_count` - Output pointer for pending document count - * - * # Returns - * * SyncResult::Success with count written to out_count + * Documents that stopped uploading until their next local edit, as a JSON array of objects: + * `doc_id` (string, the parked document) and `code` (string: `validation`, `forbidden`, + * `too_large` or `diverged`). Parked documents are not counted in + * `replicant_count_pending_sync`; a new local edit un-parks one. * * # Safety - * Caller must ensure engine is valid and out_count is a valid pointer + * Valid handle and out pointer; free the result with `replicant_string_free`. */ -enum ReplicantSyncResult replicant_count_pending_sync(struct Replicant *engine, - uint64_t *out_count); +ReplicantSyncResult replicant_list_parked(struct Replicant *handle, char **out_json); /** - * Configure which JSON paths to index for full-text search - * - * # Arguments - * * `engine` - Replicant client instance - * * `paths_json` - JSON array of JSON paths to index (e.g., '["$.body", "$.notes"]') - * - * # Returns - * * SyncResult::Success if configuration succeeded - * * SyncResult::ErrorInvalidInput if paths_json is invalid - * * SyncResult::ErrorDatabase if index rebuild fails - * - * # Note - * This replaces any existing configuration and rebuilds the search index. + * `event_filter`: -1 every document event, 1 `DocumentChanged` only, 2 `DocumentDeleted` only. + * One callback per kind: registering again replaces the previous callback, filter and context; + * a null callback removes it (its events are then dropped when pumped). Once this returns, the + * old context is never called again, even for the rest of a batch being pumped. The first + * registration on a handle, of any kind and even a null one, binds its thread: later + * registrations and `replicant_process_events` must come from that thread + * (`ErrorWrongThread` otherwise). * * # Safety - * Caller must ensure engine is valid and paths_json is a valid C string + * Valid handle; `context` must outlive the handle. */ -enum ReplicantSyncResult replicant_configure_search(struct Replicant *engine, - const char *paths_json); +ReplicantSyncResult replicant_register_document_callback(struct Replicant *handle, + ReplicantDocumentEventCallback callback, + void *context, + int32_t event_filter); /** - * Search documents using FTS5 full-text search - * - * # Arguments - * * `engine` - Replicant client instance - * * `query` - FTS5 query string (e.g., "music", "tun*", "\"exact phrase\"") - * * `limit` - Maximum number of results (0 for default of 100) - * * `out_documents` - Output pointer for JSON array of matching documents - * - * # Returns - * * SyncResult::Success with JSON array in out_documents - * * SyncResult::ErrorInvalidInput if query is invalid - * * SyncResult::ErrorDatabase if search fails - * - * # FTS5 Query Syntax - * * Simple terms: "music" matches documents containing "music" - * * Prefix: "tun*" matches "tuning", "tune", etc. - * * Phrase: "\"equal temperament\"" matches exact phrase - * * Boolean: "music AND theory", "piano OR keyboard" - * * Column filter: "title:beethoven" searches only title field + * `SyncStarted`, `SyncCompleted`, `DatabaseChanged` and `IdentityAdopted`. Replaces or removes + * as `replicant_register_document_callback` does. * * # Safety - * Caller must ensure engine is valid, query is a valid C string, - * and out_documents is a valid pointer. Caller must free result with replicant_string_free. + * Valid handle; `context` must outlive the handle. */ -enum ReplicantSyncResult replicant_search_documents(struct Replicant *engine, - const char *query, - uint32_t limit, - char **out_documents); +ReplicantSyncResult replicant_register_sync_callback(struct Replicant *handle, + ReplicantSyncEventCallback callback, + void *context); /** - * Rebuild the full-text search index - * - * # Arguments - * * `engine` - Replicant client instance - * - * # Returns - * * SyncResult::Success if rebuild succeeded - * * SyncResult::ErrorDatabase if rebuild fails - * - * # Note - * This is called automatically by replicant_configure_search, but can be - * called manually if needed (e.g., after bulk document imports). + * Replaces or removes as `replicant_register_document_callback` does. * * # Safety - * Caller must ensure engine is valid + * Valid handle; `context` must outlive the handle. */ -enum ReplicantSyncResult replicant_rebuild_search_index(struct Replicant *engine); +ReplicantSyncResult replicant_register_error_callback(struct Replicant *handle, + ReplicantErrorEventCallback callback, + void *context); /** - * Requests an enrollment token be emailed to `email`. Standalone HTTP call - * (no engine handle); runs on a dedicated thread with its own short-lived - * runtime so this is safe to call even from inside an async runtime context. - * - * BLOCKING: waits for the HTTP round-trip (connect ~10s / request ~30s - * timeouts). TODO(#40): add a completion-callback async variant - * (`replicant_enroll_request_async`) so consumers don't block a caller thread. + * Replaces or removes as `replicant_register_document_callback` does. * * # Safety - * `base_url` and `email` must be valid, non-null C strings. + * Valid handle; `context` must outlive the handle. */ -enum ReplicantSyncResult replicant_enroll_request(const char *base_url, const char *email); +ReplicantSyncResult replicant_register_connection_callback(struct Replicant *handle, + ReplicantConnectionEventCallback callback, + void *context); /** - * Exchanges an enrollment token for a per-user credential. On success writes - * the api_key, secret, and canonical user id (36-char UUID string) into the - * out buffers; each `*_cap` is the writable size of its buffer in bytes and - * the call fails (without overflowing) when a value does not fit. - * - * BLOCKING: waits for the HTTP round-trip (connect ~10s / request ~30s - * timeouts). TODO(#40): add a completion-callback async variant - * (`replicant_enroll_claim_async`) so consumers don't block a caller thread. + * Replaces or removes as `replicant_register_document_callback` does. * * # Safety - * All string pointers must be valid, non-null C strings; each out pointer - * must reference a writable buffer of at least its stated capacity. + * Valid handle; `context` must outlive the handle. */ -enum ReplicantSyncResult replicant_enroll_claim(const char *base_url, - const char *email, - const char *token, - char *out_api_key, - uintptr_t api_key_cap, - char *out_secret, - uintptr_t secret_cap, - char *out_user_id, - uintptr_t user_id_cap); +ReplicantSyncResult replicant_register_conflict_callback(struct Replicant *handle, + ReplicantConflictEventCallback callback, + void *context); /** - * Loads stored credentials from `data_dir`. Returns Success and fills the - * out buffers (api_key, secret, canonical user id), or ErrorDatabase if none - * are stored / unreadable. Each `*_cap` is the writable size of its buffer; - * the call fails (without overflowing) when a value does not fit. + * Runs the callbacks for every queued event. Must be called on the thread of the handle's first + * registration (a null one binds too): refused with `ErrorWrongThread` elsewhere, without + * disturbing a pump running on the bound thread, and `ErrorNoCallbacks` before any + * registration; nothing is lost either way. The thread cannot be changed later. A + * `replicant_destroy` of this handle from inside a callback frees it when this call returns; + * the rest of the batch is not delivered. A call from inside a callback returns + * `ErrorInvalidInput`. No rebind: if the registering thread ends, register again on a new + * handle. + * + * Callbacks must not throw or unwind. Inside a callback every call is allowed except + * `replicant_process_events` on the same handle; a `replicant_destroy` of it is deferred. * * # Safety - * `data_dir` must be a valid, non-null C string; each out pointer must - * reference a writable buffer of at least its stated capacity. + * Valid handle; `out_processed_count` may be null. */ -enum ReplicantSyncResult replicant_load_credentials(const char *data_dir, - char *out_api_key, - uintptr_t api_key_cap, - char *out_secret, - uintptr_t secret_cap, - char *out_user_id, - uintptr_t user_id_cap); +ReplicantSyncResult replicant_process_events(struct Replicant *handle, + uint32_t *out_processed_count); /** - * Stores credentials to `data_dir` (encrypted at rest). `user_id` is the - * canonical id delivered by enrollment claim (36-char UUID string); a nil or - * unparseable id is rejected — credentials are never stored without a real - * identity. - * * # Safety - * All pointers must be valid, non-null C strings. + * `s` must come from this library and not be freed twice. */ -enum ReplicantSyncResult replicant_store_credentials(const char *data_dir, - const char *api_key, - const char *secret, - const char *user_id); +void replicant_string_free(char *s); /** - * Clears any stored credentials in `data_dir`. - * - * # Safety - * `data_dir` must be a valid, non-null C string. + * This library's version, e.g. "0.7.0". Static: never free it. */ -enum ReplicantSyncResult replicant_clear_credentials(const char *data_dir); +const char *replicant_get_version(void); /** - * Trigger a test event (for development/testing purposes) + * Asks the server to email an enrollment code to `email`. Needs no handle. Results: + * - `Success`: the server accepted the request (HTTP 202). + * - `ErrorInvalidInput`: a null or non-UTF-8 argument, an empty or over-long email, or a + * `base_url` that is not https (`http://localhost` and `http://127.0.0.1` excepted). The + * server is not contacted. + * - `ErrorConnection`: the server could not be reached, timed out, or answered with any status + * other than 202: a 4xx (429 when rate limited) or a 5xx. Retry later. + * - `ErrorUnknown`: the library could not start the request. * - * # Arguments - * * `engine` - Replicant client instance - * * `event_type` - Event type to emit (0-7) - * - * # Returns - * * SyncResult indicating success or failure - * - * # Event Types - * * 0 - DocumentCreated - * * 1 - DocumentUpdated - * * 2 - DocumentDeleted - * * 3 - SyncStarted - * * 4 - SyncCompleted - * * 5 - SyncError - * * 6 - ConflictDetected - * * 7 - ConnectionLost - * * 8 - ConnectionAttempted - * * 9 - ConnectionSucceeded + * Blocks the calling thread for the HTTP round trip (up to about 10 s to connect and 30 s for + * the request); never call it from an audio or UI thread. * * # Safety - * Caller must ensure engine is a valid pointer + * `base_url` and `email` must be valid, non-null C strings. */ -enum ReplicantSyncResult replicant_emit_test_event(struct Replicant *engine, int32_t event_type); +ReplicantSyncResult replicant_enroll_request(const char *base_url, const char *email); /** - * Trigger multiple test events in sequence (for stress testing callbacks) + * Exchanges an enrollment code for credentials and stores them in `data_dir` (encrypted at rest) + * with `email`; the api key and secret never leave the library. Writes the user id into + * `out_user_id` (`user_id_cap` bytes, at least `REPLICANT_USER_ID_LEN + 1`). + * Results: + * - `Success`: stored; this process's engines on `data_dir` sign in at once, and engines in + * other processes within about 3 s. + * - `ErrorInvalidInput`: a null or non-UTF-8 argument, an empty or over-long email, or a + * `base_url` that is not https (localhost excepted). The server is not contacted. + * - `ErrorBufferTooSmall`: `user_id_cap` is too small. The server is not contacted. + * - `ErrorDatabase`: `data_dir` cannot hold credentials. This is checked before the server is + * contacted, so the code stays valid; if storing still fails after the claim, the code was + * used: request a new one. + * - `ErrorTokenRejected`: the server refused the code (wrong or expired). + * - `ErrorConnection`: the server could not be reached, timed out, or answered with any status + * other than 200 or 401 (429 when rate limited). Retry later. + * - `ErrorSerialization`: the server's reply was not valid credentials. * - * # Arguments - * * `engine` - Replicant client instance - * * `count` - Number of events to emit (1-100) + * Blocks the calling thread for the HTTP round trip (up to about 10 s to connect and 30 s for + * the request); never call it from an audio or UI thread. * - * # Returns - * * SyncResult indicating success or failure + * # Safety + * All string pointers must be valid, non-null C strings; `out_user_id` must reference a + * writable buffer of at least `user_id_cap` bytes. + */ +ReplicantSyncResult replicant_enroll_claim(const char *base_url, + const char *data_dir, + const char *email, + const char *token, + char *out_user_id, + size_t user_id_cap); + +/** + * Removes the stored credentials (sign-out) and tells this process's engines on `data_dir`: + * they halt as not enrolled and never join with the removed credentials. An engine in another + * process ends its live connection within about a second and never joins with the removed + * credentials. * * # Safety - * Caller must ensure engine is a valid pointer + * Valid C string. */ -enum ReplicantSyncResult replicant_emit_test_event_burst(struct Replicant *engine, int32_t count); +ReplicantSyncResult replicant_clear_credentials(const char *data_dir); #ifdef __cplusplus } // extern "C" diff --git a/replicant-client/include/replicant.hpp b/replicant-client/include/replicant.hpp index 51b9f2d..dee954d 100644 --- a/replicant-client/include/replicant.hpp +++ b/replicant-client/include/replicant.hpp @@ -1,10 +1,11 @@ #pragma once #include "replicant.h" + +#include <cstdint> #include <memory> -#include <string> #include <stdexcept> -#include <functional> +#include <string> namespace replicant { @@ -12,594 +13,338 @@ namespace replicant using EventType = ReplicantEventType; using EventOrigin = ReplicantEventOrigin; using SyncResult = ReplicantSyncResult; +using State = ReplicantState; -/** - * Exception thrown by replicant operations - */ +/** Thrown when a call does not return ReplicantSyncResult_Success; result() says which failure. */ class SyncException : public std::runtime_error { public: - explicit SyncException(const std::string& message) : std::runtime_error(message) {} - explicit SyncException(SyncResult result) : std::runtime_error(get_error_message(result)) {} + explicit SyncException(const SyncResult result) : std::runtime_error(message_for(result)), m_result(result) {} + + SyncResult result() const { return m_result; } private: - static std::string get_error_message(SyncResult result) + static const char* message_for(const SyncResult result) { switch (result) { - case Success: return "Success"; - case ErrorInvalidInput: return "Invalid input"; - case ErrorConnection: return "Connection error"; - case ErrorDatabase: return "Database error"; - case ErrorSerialization: return "Serialization error"; - case ErrorUnknown: return "Unknown error"; - default: return "Unrecognized error code"; + case ReplicantSyncResult_Success: return "Success"; + case ReplicantSyncResult_ErrorInvalidInput: return "Invalid input"; + case ReplicantSyncResult_ErrorConnection: return "Could not reach the server"; + case ReplicantSyncResult_ErrorDatabase: return "Database error"; + case ReplicantSyncResult_ErrorSerialization: return "Invalid JSON"; + case ReplicantSyncResult_ErrorNewerSchema: return "The database was migrated by a newer version"; + case ReplicantSyncResult_ErrorConfigMismatch: return "The data dir is open with a different configuration"; + case ReplicantSyncResult_ErrorNotFound: return "Not found"; + case ReplicantSyncResult_ErrorNotWritable: return "This document cannot be changed"; + case ReplicantSyncResult_ErrorAlreadyExists: return "A document with this id exists or was deleted"; + case ReplicantSyncResult_ErrorMigrationFailed: return "The library could not be upgraded"; + case ReplicantSyncResult_ErrorBusy: return "The database is busy in another program"; + case ReplicantSyncResult_ErrorWrongThread: return "Callbacks must be registered and events processed on the bound thread"; + case ReplicantSyncResult_ErrorNoCallbacks: return "No callbacks are registered"; + case ReplicantSyncResult_ErrorDocumentGone: return "The document was deleted"; + case ReplicantSyncResult_ErrorBufferTooSmall: return "A buffer is too small"; + case ReplicantSyncResult_ErrorTokenRejected: return "The enrollment code was refused"; + case ReplicantSyncResult_ErrorUnknown: return "Unknown error"; + default: return "Unrecognized result"; } } + + SyncResult m_result; }; /** - * RAII wrapper for the Replicant client with modern C++ interface - * - * This class provides a clean, exception-safe interface to the Replicant library. - * It automatically handles resource management and provides type-safe operations. - * - * Example usage: - * ```cpp - * replicant::Client client("sqlite:client.db?mode=rwc", "ws://localhost:8080/ws", "user@example.com", "rpa_key", "rps_secret"); - * auto doc_id = client.create_document(R"({"title":"My Document","content":"Hello World"})"); - * client.update_document(doc_id, R"({"content":"Updated content"})"); - * ``` + * One handle on the engine for a data dir. Every Client in this binary on the same data dir + * shares that engine (one socket, one sync); the last one destroyed stops it. * - * ## Destruction: YOUR MODULE MUST NEVER BE UNLOADED + * Never unload the library while the process runs (see replicant_destroy in replicant.h). * - * The destructor calls `replicant_destroy`, which is deliberately - * non-blocking — a plugin host destroys clients on its UI thread during scans - * and project close, and must not be stalled there. Replicant-owned threads - * therefore keep running for a short time AFTER this object is gone: normally - * milliseconds, seconds if another process has the sqlite database locked. - * - * Those threads execute code inside YOUR binary. If a host unloads it in that - * window, they run at addresses that no longer exist — an execute access - * violation on an unmapped image (DEV-1118). So every plugin MUST pin its own - * module once, at load: - * - * ```cpp - * #if defined(_WIN32) - * #include <windows.h> - * #else - * #include <dlfcn.h> - * #endif - * - * // Call once, from plugin entry / module init. Safe to call more than once. - * void pinThisModule() - * { - * #if defined(_WIN32) - * HMODULE self {}; - * // PIN makes the loader keep this module mapped for the process lifetime. - * GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_PIN - * | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, - * reinterpret_cast<LPCWSTR>(&pinThisModule), - * &self); - * #else - * Dl_info info {}; - * if (dladdr(reinterpret_cast<void*>(&pinThisModule), &info) != 0 - * && info.dli_fname != nullptr) - * { - * // The handle is retained deliberately and never dlclose()d: holding it - * // with RTLD_NODELETE is what keeps the image mapped. - * static void* pinned = nullptr; - * if (pinned == nullptr) - * pinned = dlopen(info.dli_fname, RTLD_NOLOAD | RTLD_NODELETE); - * (void) pinned; - * } - * #endif - * } - * ``` - * - * This is the primary defence, not a workaround. A process that only ever exits - * (a standalone application) does not need it; anything a host can unload does. - * Upgrading from 0.6.2 or earlier without pinning is a regression: the old - * destroy left fewer threads running at the moment it returned, even though it - * leaked them for the life of the process. - * - * A standalone application that wants to wait instead can call - * `replicant_destroy_and_wait(handle, timeout_ms)` directly; it returns true - * once no Replicant-owned thread is left. Plugins should not: it blocks. + * Callbacks are raw C function pointers with a context pointer that must outlive the Client. + * They must not throw; Client methods throw SyncException, so catch inside a callback. The + * context must not be the Client itself: a Client can be moved, which leaves the pointer stale. + * Registering a kind again replaces its callback; a null callback + * removes it. The first register_*_callback call binds the calling thread; every later + * register_*_callback and process_events must run on that thread, or they throw + * SyncException with ReplicantSyncResult_ErrorWrongThread. */ class Client { -private: - std::unique_ptr<Replicant, std::function<void(Replicant*)>> handle; - - void check_result(SyncResult result) const +public: + struct Config { - if (result != Success) - { - throw SyncException(result); - } + std::string data_dir; ///< Holds the database and the stored credentials. + std::string database_file; ///< e.g. "tonaldb.sqlite3" + std::string server_url; + std::string email; ///< Used only when stored credentials carry none; may be empty. + ///< 0.6-stored credentials carry none: pass it when upgrading. + std::string host_app; ///< Named in the User-Agent, e.g. "Entonal Studio". + std::string host_version; ///< e.g. "2.0.1 CLAP" + ReplicantListMerge list_merge = ReplicantListMerge_Append; ///< When no rule matches. + std::string list_merge_rules_json; ///< e.g. [{"path":"/pitches","policy":"append"}]; may be empty. + std::string title_pointer; ///< JSON Pointer to each document's title, e.g. "/title"; empty: no titles. + }; + + /** Opens the data dir (migrating it after an upgrade). Throws SyncException; its result() + is ReplicantSyncResult_ErrorNewerSchema when a newer build migrated the database, and + ReplicantSyncResult_ErrorInvalidInput for a list merge config the engine refuses + (ReplicantListMerge_Full, a malformed rule) or a title_pointer that is not a JSON Pointer. It is also ErrorInvalidInput when the + library's ABI major differs from this header's or its minor is older: the library and + header were packaged from different versions. Call state() next. */ + explicit Client(const Config& config) + { + const uint32_t abi_version = replicant_abi_version(); + if ((abi_version >> 16) != REPLICANT_ABI_VERSION_MAJOR + || abi_version < ((uint32_t {REPLICANT_ABI_VERSION_MAJOR} << 16) | REPLICANT_ABI_VERSION_MINOR)) + throw SyncException(ReplicantSyncResult_ErrorInvalidInput); + ReplicantConfig c_config {}; + c_config.struct_size = static_cast<uint32_t>(sizeof(ReplicantConfig)); + c_config.data_dir = config.data_dir.c_str(); + c_config.database_file = config.database_file.c_str(); + c_config.server_url = config.server_url.c_str(); + c_config.email = config.email.empty() ? nullptr : config.email.c_str(); + c_config.host_app = config.host_app.c_str(); + c_config.host_version = config.host_version.c_str(); + c_config.list_merge = static_cast<int32_t>(config.list_merge); + c_config.list_merge_rules_json = config.list_merge_rules_json.empty() ? nullptr : config.list_merge_rules_json.c_str(); + c_config.title_pointer = config.title_pointer.empty() ? nullptr : config.title_pointer.c_str(); + Replicant* raw_handle = nullptr; + check(replicant_create(&c_config, &raw_handle)); + m_handle.reset(raw_handle); } -public: - /** - * Create a new Replicant client instance with HMAC authentication - * - * @param database_url SQLite database URL (e.g., "sqlite:client.db?mode=rwc") - * @param server_url WebSocket server URL (e.g., "ws://localhost:8080/ws") - * @param email User email address for identification - * @param api_key Application API key (rpa_ prefix) - * @param api_secret Application API secret (rps_ prefix) - * @param user_id Canonical user id from stored credentials (UUID string); - * empty when no enrolled identity exists (offline/local-only). - * Adoption of this id happens during construction, before - * any sync connection. - * @throws SyncException if creation fails - */ - Client(const std::string& database_url, - const std::string& server_url, - const std::string& email, - const std::string& api_key, - const std::string& api_secret, - const std::string& user_id = {}) - { - Replicant* raw_handle = replicant_create( - database_url.c_str(), - server_url.c_str(), - email.c_str(), - api_key.c_str(), - api_secret.c_str(), - user_id.empty() ? nullptr : user_id.c_str() - ); - - if (!raw_handle) - { - throw SyncException("Failed to create Replicant client"); - } + Client(const Client&) = delete; + Client& operator=(const Client&) = delete; + Client(Client&&) noexcept = default; + Client& operator=(Client&&) noexcept = default; - handle = std::unique_ptr<Replicant, std::function<void(Replicant*)>>( - raw_handle, - [](Replicant* r) - { - if (r) - { - // Non-blocking by design: Replicant-owned threads may still - // be running briefly after this returns, so the module that - // contains them must be pinned. See the class docs above. - replicant_destroy(r); - } - } - ); - } + /** Releases the handle now and waits up to timeout_ms for the engine to stop; true when it + has (or when other handles keep it running). The Client is unusable afterwards. */ + bool destroy_and_wait(const uint32_t timeout_ms) { return replicant_destroy_and_wait(m_handle.release(), timeout_ms); } - /** - * Create a new document - * - * @param content_json Document content as JSON string (should include any title as part of the JSON) - * @return Document ID - * @throws SyncException if creation fails - */ std::string create_document(const std::string& content_json) { - char doc_id[37] = {0}; // UUID string + null terminator - SyncResult result = replicant_create_document( - handle.get(), - content_json.c_str(), - doc_id - ); - - check_result(result); - return std::string(doc_id); + char document_id[REPLICANT_DOCUMENT_ID_LEN + 1] = {}; + check(replicant_create_document(raw(), content_json.c_str(), document_id)); + return document_id; } - /** - * Create a new document with a specified ID - * - * @param document_id UUID string to use as the document ID - * @param content_json Document content as JSON string - * @throws SyncException if creation fails or document_id is not a valid UUID - */ + /** Throws with ReplicantSyncResult_ErrorAlreadyExists when the id exists here or was deleted. */ void create_document_with_id(const std::string& document_id, const std::string& content_json) { - SyncResult result = replicant_create_document_with_id( - handle.get(), - document_id.c_str(), - content_json.c_str() - ); - - check_result(result); + check(replicant_create_document_with_id(raw(), document_id.c_str(), content_json.c_str())); } - /** - * Update an existing document - * - * @param document_id Document ID to update - * @param content_json New document content as JSON string - * @throws SyncException if update fails - */ + /** Throws with ReplicantSyncResult_ErrorNotWritable for another account's document or a publication. */ void update_document(const std::string& document_id, const std::string& content_json) { - SyncResult result = replicant_update_document( - handle.get(), - document_id.c_str(), - content_json.c_str() - ); - - check_result(result); + check(replicant_update_document(raw(), document_id.c_str(), content_json.c_str())); } - /** - * Delete a document - * - * @param document_id Document ID to delete - * @throws SyncException if deletion fails - */ - void delete_document(const std::string& document_id) - { - SyncResult result = replicant_delete_document( - handle.get(), - document_id.c_str() - ); + void delete_document(const std::string& document_id) { check(replicant_delete_document(raw(), document_id.c_str())); } - check_result(result); + /** The document as JSON (see replicant_get_document); throws with ReplicantSyncResult_ErrorNotFound. */ + std::string get_document(const std::string& document_id) const + { + char* json = nullptr; + check(replicant_get_document(raw(), document_id.c_str(), &json)); + return take_string(json); } - /** - * Get the engine's own frozen user UUID - * - * @return User UUID string - * @throws SyncException if retrieval fails - */ - std::string get_user_id() + std::string get_all_documents() const { - char* user_id = nullptr; - SyncResult result = replicant_get_user_id(handle.get(), &user_id); - - check_result(result); - - std::string id(user_id); - replicant_string_free(user_id); - return id; + char* json = nullptr; + check(replicant_get_all_documents(raw(), &json)); + return take_string(json); } - /** - * Get the library version - * - * @return Version string - */ - static std::string get_version() + std::string get_all_document_ids(const bool include_deleted) const { - char* version_str = replicant_get_version(); - if (!version_str) - { - return "unknown"; - } - - std::string version(version_str); - replicant_string_free(version_str); - return version; + char* json = nullptr; + check(replicant_get_all_document_ids(raw(), include_deleted, &json)); + return take_string(json); } - /** - * Get a document by ID - * - * @param document_id Document ID (UUID string) - * @return Document as JSON string (includes id, title, content, sync_revision, etc.) - * @throws SyncException if document not found or retrieval fails - */ - std::string get_document(const std::string& document_id) + uint64_t count_documents() const { - char* content = nullptr; - SyncResult result = replicant_get_document( - handle.get(), - document_id.c_str(), - &content - ); - - check_result(result); - - std::string doc(content); - replicant_string_free(content); - return doc; + uint64_t count = 0; + check(replicant_count_documents(raw(), &count)); + return count; } - /** - * Get all documents as a JSON array - * - * @return JSON array of all documents (empty array [] if no documents) - * @throws SyncException if retrieval fails - */ - std::string get_all_documents() + /** Documents with changes the server has not acknowledged, except parked ones (list_parked). */ + uint64_t count_pending_sync() const { - char* docs = nullptr; - SyncResult result = replicant_get_all_documents( - handle.get(), - &docs - ); + uint64_t count = 0; + check(replicant_count_pending_sync(raw(), &count)); + return count; + } - check_result(result); + bool is_connected() const { return replicant_is_connected(raw()); } - std::string all_docs(docs); - replicant_string_free(docs); - return all_docs; + State state() const + { + State current {}; + current.struct_size = static_cast<uint32_t>(sizeof(State)); + check(replicant_get_state(raw(), ¤t)); + return current; } - /** - * Get all document ids as a JSON array - * - * @param include_deleted If true, include tombstoned (deleted) documents - * @return JSON array of id strings (empty array [] if no documents) - * @throws SyncException if retrieval fails - */ - std::string get_all_document_ids(bool include_deleted) + /** Leaves Halted or retries now; at most one dial per second, so repeated calls are safe. */ + void reconnect() { check(replicant_reconnect(raw())); } + + std::string get_user_id() const { - char* ids = nullptr; - SyncResult result = replicant_get_all_document_ids( - handle.get(), - include_deleted, - &ids - ); - - check_result(result); - - std::string all_ids(ids); - replicant_string_free(ids); - return all_ids; + char* user_id = nullptr; + check(replicant_get_user_id(raw(), &user_id)); + return take_string(user_id); } - /** - * Get the count of local documents - * - * @return Number of documents in local database - * @throws SyncException if count fails - */ - uint64_t count_documents() + void configure_search(const std::string& paths_json) { check(replicant_configure_search(raw(), paths_json.c_str())); } + + std::string search_documents(const std::string& query, const uint32_t limit = 0) const { - uint64_t count = 0; - SyncResult result = replicant_count_documents(handle.get(), &count); - check_result(result); - return count; + char* json = nullptr; + check(replicant_search_documents(raw(), query.c_str(), limit, &json)); + return take_string(json); } - /** - * Check if connected to the sync server - * - * @return true if connected, false otherwise - */ - bool is_connected() + void rebuild_search_index() { check(replicant_rebuild_search_index(raw())); } + + /** Kept copies, newest first, as JSON (see replicant_list_recovered). */ + std::string list_recovered() const { - return replicant_is_connected(handle.get()); + char* json = nullptr; + check(replicant_list_recovered(raw(), &json)); + return take_string(json); } - /** - * Get the count of documents pending sync to server - * - * @return Number of documents waiting to be synced - * @throws SyncException if count fails - */ - uint64_t count_pending_sync() + void dismiss_recovered(const int64_t recovered_id) { check(replicant_dismiss_recovered(raw(), recovered_id)); } + + /** Re-creates any kept copy's full content as a new document; returns its new id. */ + std::string restore_document(const int64_t recovered_id) { - uint64_t count = 0; - SyncResult result = replicant_count_pending_sync(handle.get(), &count); - check_result(result); - return count; + char document_id[REPLICANT_DOCUMENT_ID_LEN + 1] = {}; + check(replicant_restore_document(raw(), recovered_id, document_id)); + return document_id; } - /** - * Configure which JSON paths to index for full-text search - * - * @param paths_json JSON array of paths (e.g., '["$.title", "$.description"]') - * @throws SyncException if configuration fails - */ - void configure_search(const std::string& paths_json) + /** Writes a field copy's kept values back; a kept list comes back whole and exact. Throws + with ReplicantSyncResult_ErrorDocumentGone or ReplicantSyncResult_ErrorNotWritable when + its document is gone or read-only (the copy stays: use restore_document). */ + void restore_fields(const int64_t recovered_id) { check(replicant_restore_fields(raw(), recovered_id)); } + + /** Documents that stopped uploading until their next edit, as JSON [{doc_id, code}]. */ + std::string list_parked() const { - SyncResult result = replicant_configure_search(handle.get(), paths_json.c_str()); - check_result(result); + char* json = nullptr; + check(replicant_list_parked(raw(), &json)); + return take_string(json); } - /** - * Search documents using FTS5 full-text search - * - * @param query FTS5 query string - * @param limit Maximum results (0 for default of 100) - * @return JSON array of matching documents - * @throws SyncException if search fails - */ - std::string search_documents(const std::string& query, uint32_t limit = 0) + /** event_filter: -1 all document events, 1 DocumentChanged, 2 DocumentDeleted. The first + registration of any kind fixes the thread that must call process_events. */ + void register_document_callback(const ReplicantDocumentEventCallback callback, void* context, const int32_t event_filter = -1) { - char* docs = nullptr; - SyncResult result = replicant_search_documents(handle.get(), query.c_str(), limit, &docs); - check_result(result); - std::string results(docs); - replicant_string_free(docs); - return results; + check(replicant_register_document_callback(raw(), callback, context, event_filter)); } - /** - * Register a callback for document events (Created, Updated, Deleted) - * - * @param callback Function to call for document events. Receives - * (event_type, document_id, title, content, user_id, author_name, visibility, - * origin, context). user_id, author_name, and visibility are null when - * unknown/not yet synced. The callback fires for this client's OWN writes as - * well as for changes from sync: origin is Local for the former and Remote for - * the latter, and is the only reliable way to tell them apart. - * @param context User-defined context pointer passed to callback - * @param event_filter Optional filter: 0=Created, 1=Updated, 2=Deleted, -1=all - * @throws SyncException if registration fails - */ - void register_document_callback(DocumentEventCallback callback, void* context, int32_t event_filter = -1) + /** SyncStarted, SyncCompleted, DatabaseChanged (reload every list) and IdentityAdopted + (re-read get_user_id and reload lists). */ + void register_sync_callback(const ReplicantSyncEventCallback callback, void* context) { - SyncResult result = replicant_register_document_callback(handle.get(), callback, context, event_filter); - check_result(result); + check(replicant_register_sync_callback(raw(), callback, context)); } - /** - * Register a callback for sync events (Started, Completed) - * - * @param callback Function to call for sync events - * @param context User-defined context pointer passed to callback - * @throws SyncException if registration fails - */ - void register_sync_callback(SyncEventCallback callback, void* context) + /** The callback's scope is null unless the error is about one subscribed scope. */ + void register_error_callback(const ReplicantErrorEventCallback callback, void* context) { - SyncResult result = replicant_register_sync_callback(handle.get(), callback, context); - check_result(result); + check(replicant_register_error_callback(raw(), callback, context)); } - /** - * Register a callback for error events (SyncError) - * - * @param callback Function to call for error events - * @param context User-defined context pointer passed to callback - * @throws SyncException if registration fails - */ - void register_error_callback(ErrorEventCallback callback, void* context) + void register_connection_callback(const ReplicantConnectionEventCallback callback, void* context) { - SyncResult result = replicant_register_error_callback(handle.get(), callback, context); - check_result(result); + check(replicant_register_connection_callback(raw(), callback, context)); } - /** - * Register a callback for identity changes (provisional id adopted into - * the canonical one). Consumers caching the user id must refresh it — - * and any owner-filtered views — when this fires. - * - * @param callback Function to call with (event_type, old_user_id, - * new_user_id, email, context) - * @param context User-defined context pointer passed to callback - * @throws SyncException if registration fails - */ - void register_identity_callback(IdentityEventCallback callback, void* context) + void register_conflict_callback(const ReplicantConflictEventCallback callback, void* context) { - SyncResult result = replicant_register_identity_callback(handle.get(), callback, context); - check_result(result); + check(replicant_register_conflict_callback(raw(), callback, context)); } - /** - * Register a callback for connection events (Lost, Attempted, Succeeded) - * - * @param callback Function to call for connection events - * @param context User-defined context pointer passed to callback - * @throws SyncException if registration fails - */ - void register_connection_callback(ConnectionEventCallback callback, void* context) + /** Runs the callbacks for every queued event, on the registering thread. */ + uint32_t process_events() { - SyncResult result = replicant_register_connection_callback(handle.get(), callback, context); - check_result(result); + uint32_t processed = 0; + check(replicant_process_events(raw(), &processed)); + return processed; } - /** - * Register a callback for conflict events (ConflictDetected) - * - * @param callback Function to call for conflict events - * @param context User-defined context pointer passed to callback - * @throws SyncException if registration fails - */ - void register_conflict_callback(ConflictEventCallback callback, void* context) + static std::string get_version() { return replicant_get_version(); } + +private: + struct Destroy + { + void operator()(Replicant* handle) const { replicant_destroy(handle); } + }; + + static void check(const SyncResult result) { - SyncResult result = replicant_register_conflict_callback(handle.get(), callback, context); - check_result(result); + if (result != ReplicantSyncResult_Success) + throw SyncException(result); } - /** - * Process all queued events on the current thread - * - * @return Number of events processed - * @throws SyncException if processing fails - */ - uint32_t process_events() + static std::string take_string(char* text) { - uint32_t count = 0; - SyncResult result = replicant_process_events(handle.get(), &count); - check_result(result); - return count; + const std::unique_ptr<char, decltype(&replicant_string_free)> owned(text, &replicant_string_free); + if (owned == nullptr) + return {}; + return std::string(owned.get()); } - // Disable copy operations (move-only type) - Client(const Client&) = delete; - Client& operator=(const Client&) = delete; + Replicant* raw() const { return m_handle.get(); } - // Enable move operations - Client(Client&&) = default; - Client& operator=(Client&&) = default; + std::unique_ptr<Replicant, Destroy> m_handle; }; //============================================================================== -// Enrollment + credential storage -// -// Standalone helpers (no Client instance): a device exchanges an emailed, -// one-time token for its own per-user credential, stored encrypted at rest. - -struct Credentials -{ - std::string api_key; - std::string secret; - std::string user_id; ///< Canonical user id (UUID) delivered by enrollment claim. -}; - -/** Requests an enrollment token be emailed to `email`. Returns true if the - server accepted the request (HTTP 202). */ -inline bool request_enrollment(const std::string& base_url, const std::string& email) -{ - return replicant_enroll_request(base_url.c_str(), email.c_str()) == Success; -} - -/** Exchanges a one-time token for a per-user credential. On success fills `out` - (including the canonical user id) and returns true; returns false if the - token is invalid/expired or the request failed. */ -// Returns Success and fills `out` on success; otherwise returns the specific -// SyncResult (ErrorInvalidInput for a bad/expired token, ErrorSerialization for -// a malformed response, ErrorConnection for transport failures) so callers can -// give a precise error rather than a single catch-all. -inline SyncResult claim_enrollment(const std::string& base_url, const std::string& email, - const std::string& token, Credentials& out) +// Enrollment and sign-out (no Client needed). The api key and secret never leave the library; +// a Client's state() and get_user_id() say who is signed in. + +/** Asks the server to email a one-time enrollment code. Blocks for the HTTP round trip. + Returns ErrorInvalidInput for a bad email or a non-https base_url (localhost excepted) and + ErrorConnection when the server cannot be reached or refuses (429 included); see + replicant_enroll_request. */ +inline SyncResult request_enrollment(const std::string& base_url, const std::string& email) { - char api_key[129] = {0}; - char secret[129] = {0}; - char user_id[37] = {0}; - SyncResult result = - replicant_enroll_claim(base_url.c_str(), email.c_str(), token.c_str(), api_key, - sizeof(api_key), secret, sizeof(secret), user_id, sizeof(user_id)); - if (result != Success) - return result; - - out.api_key = api_key; - out.secret = secret; - out.user_id = user_id; - return Success; + return replicant_enroll_request(base_url.c_str(), email.c_str()); } -/** Loads the stored credential from `data_dir`. On success fills `out` and - returns true; returns false if none is stored / it is unreadable. */ -inline bool load_credentials(const std::string& data_dir, Credentials& out) +/** Exchanges the code for a credential and stores it in data_dir with the email; the api key + and secret never leave the library. Fills out_user_id on Success. Returns + ErrorTokenRejected for a wrong or expired code, ErrorSerialization for a malformed + response, ErrorConnection when the server cannot be reached, ErrorDatabase when data_dir + cannot hold credentials. Blocks for the HTTP round trip. */ +inline SyncResult claim_enrollment(const std::string& base_url, const std::string& data_dir, const std::string& email, + const std::string& token, std::string& out_user_id) { - char api_key[129] = {0}; - char secret[129] = {0}; - char user_id[37] = {0}; - if (replicant_load_credentials(data_dir.c_str(), api_key, sizeof(api_key), secret, - sizeof(secret), user_id, sizeof(user_id)) - != Success) - return false; - - out.api_key = api_key; - out.secret = secret; - out.user_id = user_id; - return true; + char user_id[REPLICANT_USER_ID_LEN + 1] = {}; + const SyncResult result = + replicant_enroll_claim(base_url.c_str(), data_dir.c_str(), email.c_str(), token.c_str(), user_id, sizeof(user_id)); + if (result == ReplicantSyncResult_Success) + out_user_id = user_id; + return result; } -/** Stores a credential in `data_dir`, encrypted at rest. `creds.user_id` must - be a real (non-nil) UUID string. Returns true on success. */ -inline bool store_credentials(const std::string& data_dir, const Credentials& creds) +/** Removes the stored credential (sign-out); this binary's engines on data_dir halt as not + enrolled and never join with it again. */ +inline SyncResult clear_credentials(const std::string& data_dir) { - return replicant_store_credentials(data_dir.c_str(), creds.api_key.c_str(), - creds.secret.c_str(), creds.user_id.c_str()) - == Success; + return replicant_clear_credentials(data_dir.c_str()); } -/** Clears any stored credential in `data_dir`. Returns true on success. */ -inline bool clear_credentials(const std::string& data_dir) +inline bool is_credential_rejection(const int32_t error_code) { - return replicant_clear_credentials(data_dir.c_str()) == Success; + return replicant_error_is_credential_rejection(error_code); } } // namespace replicant diff --git a/replicant-client/migrations/016_title_pointer.sql b/replicant-client/migrations/016_title_pointer.sql new file mode 100644 index 0000000..d279fca --- /dev/null +++ b/replicant-client/migrations/016_title_pointer.sql @@ -0,0 +1,3 @@ +-- The JSON Pointer the stored titles were derived from: '' for none, NULL until first recorded. +-- An open with a different pointer recomputes every title and the search index. +ALTER TABLE user_config ADD COLUMN title_pointer TEXT; diff --git a/replicant-client/src/client.rs b/replicant-client/src/client.rs deleted file mode 100644 index 00faeaa..0000000 --- a/replicant-client/src/client.rs +++ /dev/null @@ -1,6015 +0,0 @@ -use crate::{ - database::{ClientDatabase, DocumentPreImage}, - error_code::ReplicantErrorCode, - events::{EventDispatcher, EventOrigin}, - websocket::WebSocketClient, -}; -use replicant_core::{ - errors::ClientError, - models::{Document, SyncStatus}, - patches::{apply_patch, calculate_checksum, create_patch}, - protocol::{ClientMessage, ErrorCode, ServerMessage}, - SyncResult, -}; -use sqlx::Row; -use std::collections::HashMap; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, -}; -use std::time::{Duration, Instant}; -use tokio::sync::{mpsc, Mutex, Notify}; -use uuid::Uuid; - -// Ping intervals for heartbeat detection -const PING_INTERVAL: Duration = Duration::from_secs(10); // Send ping every 10 seconds - -/// How long `shutdown` waits for the `ws_client` guard before giving up on -/// closing the socket cleanly. Short on purpose: it is a courtesy, not a step -/// anything else depends on (see `Client::shutdown`). -const SOCKET_RELEASE_TIMEOUT: Duration = Duration::from_millis(500); - -/// Rebase rounds a single document may spend on `hash_mismatch` in one session. -/// -/// Two clients writing to the same document in a tight loop can reject each -/// other indefinitely; the budget turns that live-lock into a `Conflict` the -/// user can see. -const MAX_REBASE_ATTEMPTS: u32 = 3; - -/// Whether `shutdown` has begun. If so, also marks the client disconnected, -/// since a connect that finished meanwhile may have marked it connected. -fn shutdown_requested(stopped: &AtomicBool, is_connected: &AtomicBool) -> bool { - if stopped.load(Ordering::SeqCst) { - is_connected.store(false, Ordering::SeqCst); - true - } else { - false - } -} - -#[derive(Debug, Clone)] -struct PendingUpload { - operation_type: UploadType, - sent_at: Instant, -} - -#[derive(Debug, Clone)] -enum UploadType { - Create, - Update, - Delete, -} - -/// What the next upload for a pending document has to be. -enum PendingUploadKind { - Create, - Update { - patch: json_patch::Patch, - old_content_hash: Option<String>, - }, -} - -/// Decide whether a pending document uploads as a create or an update. -/// -/// An unconsumed `create` row wins over anything else queued: the document has -/// never reached the server, so an update for it is answered `not_found` and the -/// document sits Pending forever. The create sends the document's CURRENT -/// content, which already folds in every local edit made since — and the ack -/// clears the whole queue for the document, so the update row those edits built -/// has nothing left to say. -/// -/// Without a create row the queued update is the signal, exactly as before: a -/// cumulative patch means update, nothing queued means the document is new to -/// the server. A queue that cannot be read falls back to create, which is what -/// the reconnect and immediate-sync paths have always done. -async fn classify_pending_upload(db: &ClientDatabase, document_id: &Uuid) -> PendingUploadKind { - match db.has_queued_create(document_id).await { - Ok(true) => return PendingUploadKind::Create, - Ok(false) => {} - Err(e) => { - tracing::warn!( - "CLIENT: Could not read the queued create for {} ({}), classifying by patch", - document_id, - e - ); - } - } - - match db.get_queued_patch(document_id).await { - Ok(Some((patch, old_content_hash))) => PendingUploadKind::Update { - patch, - old_content_hash, - }, - Ok(None) => PendingUploadKind::Create, - Err(e) => { - tracing::warn!( - "CLIENT: Could not read the queued patch for {} ({}), sending a create", - document_id, - e - ); - PendingUploadKind::Create - } - } -} - -/// Where `resync_document` gets authoritative document state. -/// -/// `Socket` is the production source. The socket handle is cloned out from -/// under a short-lived guard so the 30s call is never made while holding the -/// `ws_client` mutex, which reconnection and the heartbeat also need. -pub(crate) enum ResyncSource { - Socket(Arc<Mutex<Option<WebSocketClient>>>), - /// Test-only: records every resync attempt and returns a canned reply, so - /// tests can assert that a resync was attempted rather than inferring it - /// from the absence of a write. - #[cfg(test)] - Canned(Arc<CannedResync>), -} - -#[cfg(test)] -pub(crate) struct CannedResync { - pub attempts: Mutex<Vec<Uuid>>, - /// Replies handed out one per fetch, in order. An exhausted queue behaves - /// like an unavailable socket, which is how "offline" is modelled. - pub replies: Mutex<std::collections::VecDeque<ServerMessage>>, -} - -/// One-shot, per-document recovery for uploads the server rejected. -/// -/// Settling a failed ack (removing the pending entry so later patches stop -/// deferring behind it) would otherwise cost the retry that the initial-sync -/// timeout used to provide. The document is still `Pending` with its sync_queue -/// row intact, so re-running the pending-document upload pass is enough. -/// -/// Bounded to one trigger per document per session so a permanently rejected -/// document cannot spin the retry loop. -#[derive(Clone)] -pub(crate) struct UploadRetry { - trigger: mpsc::Sender<()>, - already_retried: Arc<Mutex<std::collections::HashSet<Uuid>>>, - rebase_attempts: Arc<Mutex<HashMap<Uuid, u32>>>, -} - -impl UploadRetry { - fn new(trigger: mpsc::Sender<()>) -> Self { - Self { - trigger, - already_retried: Arc::new(Mutex::new(std::collections::HashSet::new())), - rebase_attempts: Arc::new(Mutex::new(HashMap::new())), - } - } - - /// Claim the single recovery attempt a document gets per session. - /// - /// `false` means it has already been spent and the caller must not try - /// again — the bound that keeps a repeating rejection from spinning. - async fn claim_once(&self, client_id: Uuid, document_id: Uuid) -> bool { - if !self.already_retried.lock().await.insert(document_id) { - tracing::warn!( - "CLIENT {}: Upload for {} failed again, not retrying a second time", - client_id, - document_id - ); - return false; - } - true - } - - async fn schedule(&self, client_id: Uuid, document_id: Uuid) { - if !self.claim_once(client_id, document_id).await { - return; - } - - self.trigger_upload_pass(client_id, document_id).await; - } - - /// Claim one of [`MAX_REBASE_ATTEMPTS`] rebase rounds for `document_id`. - /// - /// A rebase-resend is not the blind retry `schedule` bounds: every round - /// rewrites the queued patch against fresher server state, so repeating it - /// is progress rather than a loop. The budget exists only so two clients - /// writing in lockstep cannot reject each other forever. `false` means the - /// budget is spent and the caller must settle the document as a conflict. - async fn claim_rebase(&self, client_id: Uuid, document_id: Uuid) -> bool { - let mut attempts = self.rebase_attempts.lock().await; - let used = attempts.entry(document_id).or_insert(0); - if *used >= MAX_REBASE_ATTEMPTS { - tracing::warn!( - "CLIENT {}: Document {} has used all {} rebase attempts this session", - client_id, - document_id, - MAX_REBASE_ATTEMPTS - ); - return false; - } - *used += 1; - true - } - - /// Re-run the pending-upload pass so a freshly rebased patch is sent. - /// - /// Deliberately not once-per-session like `schedule`: the queued patch has - /// changed, so a second send is legitimate. `claim_rebase` is the bound. - async fn resend_after_rebase(&self, client_id: Uuid, document_id: Uuid) { - self.trigger_upload_pass(client_id, document_id).await; - } - - async fn trigger_upload_pass(&self, client_id: Uuid, document_id: Uuid) { - match self.trigger.try_send(()) { - Ok(()) => tracing::info!( - "CLIENT {}: Scheduled an upload pass for {}", - client_id, - document_id - ), - Err(e) => tracing::warn!( - "CLIENT {}: Could not schedule an upload pass for {}: {}", - client_id, - document_id, - e - ), - } - } -} - -/// The status to write when server state is adopted wholesale. -/// -/// An unresolved `Conflict` survives: the flag says the user still has to look -/// at this document, and taking the server's content does not answer that. Only -/// a genuine local edit clears it. -fn settled_status(current: SyncStatus) -> SyncStatus { - match current { - SyncStatus::Conflict => SyncStatus::Conflict, - _ => SyncStatus::Synced, - } -} - -/// The server state carried by a `hash_mismatch` rejection. -/// -/// The server sends what it currently holds so the client can rebase onto it; -/// every other rejection reason arrives without these fields. -struct HashMismatch { - current_revision: i64, - current_content: serde_json::Value, - current_hash: String, -} - -impl ResyncSource { - fn socket(ws_client: &Arc<Mutex<Option<WebSocketClient>>>) -> Self { - Self::Socket(ws_client.clone()) - } - - /// `None` means "no source available" (offline), which callers treat as - /// leave-it-unsynced rather than as an error. - async fn fetch(&self, document_id: Uuid) -> Option<ServerMessage> { - match self { - Self::Socket(ws_client) => { - let ws = ws_client.lock().await.clone()?; - Some(ws.fetch_document(document_id).await) - } - #[cfg(test)] - Self::Canned(canned) => { - canned.attempts.lock().await.push(document_id); - canned.replies.lock().await.pop_front() - } - } - } -} - -pub struct Client { - db: Arc<ClientDatabase>, - ws_client: Arc<Mutex<Option<WebSocketClient>>>, - user_id: Uuid, - client_id: Uuid, - message_rx: Option<mpsc::Receiver<ServerMessage>>, - event_dispatcher: Arc<EventDispatcher>, - pending_uploads: Arc<Mutex<HashMap<Uuid, PendingUpload>>>, - upload_complete_notifier: Arc<Notify>, - sync_protection_mode: Arc<AtomicBool>, - is_connected: Arc<AtomicBool>, - last_ping_time: Arc<Mutex<Option<Instant>>>, - server_url: String, - email: String, - api_key: String, - api_secret: String, - // Channel for triggering pending sync after reconnection - reconnect_sync_tx: mpsc::Sender<()>, - reconnect_sync_rx: Option<mpsc::Receiver<()>>, - // Queue for deferred sync messages during upload protection - deferred_messages: Arc<Mutex<Vec<ServerMessage>>>, - // One-shot recovery for uploads the server rejects - upload_retry: UploadRetry, - // Sync is possible for this instance (credentials + adopted identity). - // Immutable for the client's lifetime: enrollment recreates the client. - sync_enabled: bool, - /// Set by `shutdown`; the reconnection monitor stops when it sees it. - stopped: Arc<AtomicBool>, -} - -impl Client { - pub async fn new( - database_url: &str, - server_url: &str, - email: &str, - api_key: &str, - api_secret: &str, - canonical_user_id: Option<Uuid>, - ) -> SyncResult<Self> { - Self::with_event_dispatcher( - database_url, - server_url, - email, - api_key, - api_secret, - canonical_user_id, - None, - ) - .await - } - - /// Opens the database, connects if sync is enabled, then announces the - /// connection and runs the initial sync (see [`Client::start`]). - pub async fn with_event_dispatcher( - database_url: &str, - server_url: &str, - email: &str, - api_key: &str, - api_secret: &str, - canonical_user_id: Option<Uuid>, - event_dispatcher: Option<Arc<EventDispatcher>>, - ) -> SyncResult<Self> { - let client = Self::open( - database_url, - server_url, - email, - api_key, - api_secret, - canonical_user_id, - event_dispatcher, - ) - .await?; - client.start().await; - Ok(client) - } - - /// Opens the database and connects if sync is enabled, without emitting - /// ConnectionSucceeded, monitoring the connection or syncing. The caller - /// must call [`Client::start`] once the client is ready for use. - pub(crate) async fn open( - database_url: &str, - server_url: &str, - email: &str, - api_key: &str, - api_secret: &str, - canonical_user_id: Option<Uuid>, - event_dispatcher: Option<Arc<EventDispatcher>>, - ) -> SyncResult<Self> { - let db = Arc::new(ClientDatabase::new(database_url).await?); - - // Everything after the pool exists runs in `build`, so that a failure - // anywhere in it still closes the pool. Dropping a `ClientDatabase` - // un-closed leaves one detached sqlx worker thread per connection - // running this module's code for the life of the process — only - // `Pool::close()` joins them (DEV-1118). Failing here is not exotic: it - // is what a contended migration does once it outlasts sqlite's busy - // timeout, which is the very scenario that produced the crash. - match Self::build( - db.clone(), - server_url, - email, - api_key, - api_secret, - canonical_user_id, - event_dispatcher, - ) - .await - { - Ok(client) => Ok(client), - Err(e) => { - db.close().await; - Err(e) - } - } - } - - #[allow(clippy::too_many_arguments)] - async fn build( - db: Arc<ClientDatabase>, - server_url: &str, - email: &str, - api_key: &str, - api_secret: &str, - canonical_user_id: Option<Uuid>, - event_dispatcher: Option<Arc<EventDispatcher>>, - ) -> SyncResult<Self> { - db.run_migrations().await?; - - // Ensure user_config exists with deterministic user ID based on email - db.ensure_user_config_with_identifier(server_url, email) - .await?; - - let (user_id, client_id) = db.get_user_and_client_id().await?; - - let event_dispatcher = event_dispatcher.unwrap_or_else(|| Arc::new(EventDispatcher::new())); - - // Create a channel for messages - let (tx, rx) = mpsc::channel(100); - - // Create a channel for reconnection sync triggers - let (reconnect_sync_tx, reconnect_sync_rx) = mpsc::channel(10); - let reconnect_sync_tx_for_retry = reconnect_sync_tx.clone(); - - let is_connected = Arc::new(AtomicBool::new(false)); - - // Adopt the canonical identity (from stored credentials) BEFORE any - // WebSocket work: no live sync exists yet and the handle hasn't been - // returned, so adoption cannot race document creation. - let identity_adopted = db.is_identity_adopted().await.unwrap_or(false); - match (canonical_user_id, identity_adopted) { - (Some(canonical), false) => { - db.adopt_identity(user_id, canonical).await?; - event_dispatcher.emit_identity_changed(&user_id, &canonical, email); - } - (Some(canonical), true) if canonical != user_id => { - return Err(replicant_core::errors::SyncError::InvalidOperation( - format!( - "account switch not supported: credentials belong to user {} but this \ - database is owned by user {}; reset local data to enroll a different account", - canonical, user_id - ), - )); - } - _ => {} - } - let user_id = db.get_user_id().await?; - - // Sync requires credentials and an adopted (server-confirmed) - // identity; otherwise stay local-only and never join a sync topic. - let sync_enabled = !api_key.is_empty() && db.is_identity_adopted().await.unwrap_or(false); - - let (ws_client, initial_ping_time) = if sync_enabled { - // Try to connect to WebSocket, but don't fail if offline - match WebSocketClient::connect( - server_url, - email, - client_id, - user_id, - api_key, - api_secret, - Some(event_dispatcher.clone()), - is_connected.clone(), - ) - .await - { - Ok((client, receiver)) => { - // Start forwarding WebSocket messages to our channel - tokio::spawn(async move { - if let Err(e) = receiver.forward_to(tx).await { - tracing::error!("WebSocket receiver error: {}", e); - } - }); - (Some(client), Some(Instant::now())) - } - Err(e) => { - eprintln!("Failed to connect to server (will retry): {}", e); - (None, None) - } - } - } else { - (None, None) - }; - - let mut engine = Self { - db: db.clone(), - ws_client: Arc::new(Mutex::new(ws_client)), - user_id, - client_id, - message_rx: Some(rx), - event_dispatcher: event_dispatcher.clone(), - pending_uploads: Arc::new(Mutex::new(HashMap::new())), - upload_complete_notifier: Arc::new(Notify::new()), - sync_protection_mode: Arc::new(AtomicBool::new(false)), - is_connected: is_connected, - last_ping_time: Arc::new(Mutex::new(initial_ping_time)), - server_url: server_url.to_string(), - email: email.to_string(), - api_key: api_key.to_string(), - api_secret: api_secret.to_string(), - reconnect_sync_tx, - reconnect_sync_rx: Some(reconnect_sync_rx), - deferred_messages: Arc::new(Mutex::new(Vec::new())), - upload_retry: UploadRetry::new(reconnect_sync_tx_for_retry), - sync_enabled, - stopped: Arc::new(AtomicBool::new(false)), - }; - - // Automatically start background tasks - engine.spawn_background_tasks().await?; - - Ok(engine) - } - - pub fn event_dispatcher(&self) -> Arc<EventDispatcher> { - self.event_dispatcher.clone() - } - - /// Releases everything this client holds that outlives a plain drop: the - /// WebSocket and, most importantly, the sqlite pool. - /// - /// Dropping the client only drops its `Pool` handle, which marks the pool - /// closed without waiting for anything; each sqlite connection lives on a - /// dedicated OS thread that sqlx spawns and never joins, so a drop can - /// leave those threads running. Awaiting `close()` waits for them to - /// finish, which matters when the caller is a dynamically loaded module - /// about to be unmapped (DEV-1118). - /// - /// Idempotent, and safe to call while background tasks are still running: - /// they see a closed pool and error out rather than hang. - /// - /// The pool is closed FIRST and unconditionally. Taking the socket first - /// looks tidier — background work would stop querying a pool that is about - /// to close — but `ws_client` is an async mutex that live tasks hold across - /// un-timed network sends (`send`, the heartbeat, `fetch_document`), so on a - /// half-open socket that guard may never arrive. Making it a precondition - /// for closing the pool would mean a stalled socket keeps every sqlx worker - /// thread alive for the life of the process, which is the very failure this - /// shutdown exists to prevent. - pub async fn shutdown(&self) { - self.stopped.store(true, Ordering::SeqCst); - self.is_connected.store(false, Ordering::SeqCst); - self.db.close().await; - - // Best effort, and bounded: nothing downstream depends on it, since a - // closed pool already stops the background tasks, and the socket goes - // down with the runtime moments later either way. - match tokio::time::timeout(SOCKET_RELEASE_TIMEOUT, self.ws_client.lock()).await { - Ok(mut guard) => { - guard.take(); - } - Err(_) => { - tracing::warn!( - "CLIENT {}: socket still in use after {:?} during shutdown; \ - leaving it to the runtime", - self.client_id, - SOCKET_RELEASE_TIMEOUT - ); - } - } - } - - async fn spawn_background_tasks(&mut self) -> SyncResult<()> { - // Take the receiver - can only start once - let rx = self - .message_rx - .take() - .ok_or_else(|| ClientError::WebSocket("Client already started".to_string()))?; - - // Take the reconnect sync receiver - let reconnect_sync_rx = self.reconnect_sync_rx.take().ok_or_else(|| { - ClientError::WebSocket("Client reconnect sync already started".to_string()) - })?; - - let db = self.db.clone(); - let client_id = self.client_id; - let event_dispatcher = self.event_dispatcher.clone(); - let pending_uploads = self.pending_uploads.clone(); - let upload_complete_notifier = self.upload_complete_notifier.clone(); - let sync_protection_mode = self.sync_protection_mode.clone(); - let ws_client = self.ws_client.clone(); - let ws_client_for_handler = ws_client.clone(); - let deferred_messages = self.deferred_messages.clone(); - let upload_retry = self.upload_retry.clone(); - - // Clone variables for the reconnection sync handler - let db_for_reconnect_sync = db.clone(); - let pending_uploads_for_reconnect_sync = pending_uploads.clone(); - let ws_client_for_reconnect_sync = ws_client.clone(); - let event_dispatcher_for_reconnect_sync = event_dispatcher.clone(); - - // Spawn message handler with upload tracking - tokio::spawn(async move { - let mut rx = rx; - tracing::info!("CLIENT {}: Message handler started", client_id); - while let Some(msg) = rx.recv().await { - tracing::info!( - "CLIENT {}: Processing server message: {:?}", - client_id, - std::mem::discriminant(&msg) - ); - if let Err(e) = Self::handle_server_message_with_tracking( - msg, - &db, - client_id, - &event_dispatcher, - &pending_uploads, - &upload_complete_notifier, - &sync_protection_mode, - &deferred_messages, - &upload_retry, - &ResyncSource::socket(&ws_client_for_handler), - ) - .await - { - tracing::error!("CLIENT {}: Error handling server message: {}", client_id, e); - } else { - tracing::info!( - "CLIENT {}: Successfully processed server message", - client_id - ); - } - } - tracing::warn!("CLIENT {}: Message handler terminated", client_id); - }); - - // Spawn reconnection sync handler - tokio::spawn(async move { - let mut reconnect_sync_rx = reconnect_sync_rx; - tracing::info!("CLIENT {}: Reconnection sync handler started", client_id); - - #[allow(clippy::redundant_pattern_matching)] // Preserve drop order - while let Some(_) = reconnect_sync_rx.recv().await { - tracing::info!("CLIENT {}: Received reconnection sync trigger", client_id); - - // Perform pending sync using the actual engine components - if let Err(e) = Self::perform_pending_sync_after_reconnection( - &db_for_reconnect_sync, - &ws_client_for_reconnect_sync, - client_id, - &pending_uploads_for_reconnect_sync, - ) - .await - { - tracing::error!( - "CLIENT {}: Failed to sync pending documents after reconnection: {}", - client_id, - e - ); - } else { - tracing::info!( - "✅ CLIENT {}: Pending documents sync completed after reconnection", - client_id - ); - - // NOW request full sync after uploads are complete - tracing::info!( - "🔄 CLIENT {}: Requesting full sync to get missed updates", - client_id - ); - if let Some(client) = ws_client_for_reconnect_sync.lock().await.as_ref() { - event_dispatcher_for_reconnect_sync.emit_sync_started(); - if let Err(e) = client.send(ClientMessage::RequestFullSync).await { - tracing::error!( - "CLIENT {}: Failed to request full sync after reconnection: {}", - client_id, - e - ); - } else { - tracing::info!( - "✅ CLIENT {}: RequestFullSync sent after pending uploads complete", - client_id - ); - } - } - } - } - - tracing::warn!("CLIENT {}: Reconnection sync handler terminated", client_id); - }); - - Ok(()) - } - - /// Emits ConnectionSucceeded if the start-up connect succeeded, starts the - /// reconnection monitor, then runs the initial upload-first sync. - /// - /// A failure is reported as SyncError and leaves a working client: upload - /// protection is off, and the monitor and later upload passes carry on. - pub(crate) async fn start(&self) { - let connected = self.is_connected(); - if connected { - self.event_dispatcher - .emit_connection_succeeded(&self.server_url); - } - self.start_reconnection_loop(); - - if !connected { - tracing::info!( - "CLIENT {}: Starting in offline mode - will sync when connection available", - self.client_id - ); - return; - } - - // Upload-first strategy with protection - self.event_dispatcher.emit_sync_started(); - - // Enable protection mode during upload phase - self.sync_protection_mode.store(true, Ordering::Relaxed); - tracing::info!( - "CLIENT {}: Protection mode ENABLED - blocking server overwrites during upload", - self.client_id - ); - - // First: Upload any pending documents that were created/modified offline - tracing::info!( - "CLIENT {}: Starting upload-first sync - uploading pending changes", - self.client_id - ); - if let Err(e) = self.sync_pending_documents().await { - self.end_upload_protection().await; - self.event_dispatcher.emit_sync_error( - ReplicantErrorCode::Unknown, - &format!("Initial upload failed: {}", e), - ); - return; - } - - // Wait for upload confirmations with timeout - if !self.pending_uploads.lock().await.is_empty() { - let upload_count = self.pending_uploads.lock().await.len(); - tracing::info!( - "CLIENT {}: Waiting for {} upload confirmations", - self.client_id, - upload_count - ); - - tokio::select! { - _ = self.upload_complete_notifier.notified() => { - tracing::info!("CLIENT {}: All uploads settled", self.client_id); - } - _ = tokio::time::sleep(tokio::time::Duration::from_secs(10)) => { - let remaining = self.pending_uploads.lock().await.len(); - if remaining > 0 { - tracing::warn!("CLIENT {}: Upload timeout - {} uploads still pending", self.client_id, remaining); - - // Enhanced fallback: Retry failed uploads before proceeding - tracing::info!("CLIENT {}: Retrying failed uploads before sync", self.client_id); - if let Err(e) = self.retry_failed_uploads().await { - tracing::error!("CLIENT {}: Retry failed: {}", self.client_id, e); - } - } else { - tracing::info!("CLIENT {}: Upload timeout but all uploads completed", self.client_id); - } - } - } - } else { - tracing::info!("CLIENT {}: No pending uploads to wait for", self.client_id); - } - - self.end_upload_protection().await; - - // Second: Download current server state (which now includes our uploaded documents) - tracing::info!( - "CLIENT {}: Upload phase complete, requesting server state", - self.client_id - ); - if let Err(e) = self.sync_all().await { - self.event_dispatcher.emit_sync_error( - ReplicantErrorCode::ConnectionFailed, - &format!("Full sync failed: {}", e), - ); - } - } - - /// Ends upload protection and applies the server messages it deferred. - async fn end_upload_protection(&self) { - self.sync_protection_mode.store(false, Ordering::Relaxed); - tracing::info!( - "CLIENT {}: Protection mode DISABLED - server sync now allowed", - self.client_id - ); - - if let Err(e) = Self::process_deferred_messages( - &self.deferred_messages, - &self.db, - self.client_id, - &self.event_dispatcher, - &self.pending_uploads, - &ResyncSource::socket(&self.ws_client), - ) - .await - { - tracing::error!( - "CLIENT {}: Error processing deferred messages: {}", - self.client_id, - e - ); - } - } - - pub async fn create_document(&self, content: serde_json::Value) -> SyncResult<Document> { - self.create_document_with_id(Uuid::new_v4(), content).await - } - - /// Server-authoritative user id, fixed for the client's lifetime. - /// Identity adoption runs in `Client::new` before this is read; - /// enrolling afterwards recreates the client. - pub fn user_id(&self) -> Uuid { - self.user_id - } - - pub async fn create_document_with_id( - &self, - id: Uuid, - content: serde_json::Value, - ) -> SyncResult<Document> { - let doc = Document { - id, - user_id: Some(self.user_id()), - content, - sync_revision: 1, - content_hash: None, - title: None, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }; - - tracing::info!( - "CLIENT {}: Creating document locally: {}", - self.client_id, - doc.id - ); - // Queue the create alongside the save: until the server acks it, that - // row is what tells the upload path this document must be created and - // not patched, however many local edits land in the meantime. - self.db.save_new_document_and_queue_create(&doc).await?; - - self.event_dispatcher - .emit_document_created_with_attribution( - &doc.id, - &doc.content, - doc.user_id.as_ref(), - doc.author_name.as_deref(), - doc.visibility.as_deref(), - EventOrigin::Local, - ); - - if let Err(e) = self.try_immediate_sync(&doc).await { - tracing::warn!( - "CLIENT {}: Failed to immediately sync new document {}: {}. Will retry later.", - self.client_id, - doc.id, - e - ); - } - - Ok(doc) - } - - pub async fn update_document( - &self, - id: Uuid, - new_content: serde_json::Value, - ) -> SyncResult<()> { - let mut doc = self.db.get_document(&id).await?; - let old_content = doc.content.clone(); - let old_version = doc.sync_revision; - - tracing::info!("CLIENT {}: 📝 UPDATING DOCUMENT {}", self.client_id, id); - tracing::info!( - "CLIENT {}: OLD: content={:?}, version={}", - self.client_id, - old_content, - old_version - ); - tracing::info!("CLIENT {}: NEW: content={:?}", self.client_id, new_content); - - // Create patch for sync - let patch = create_patch(&old_content, &new_content)?; - - // Update document - doc.content = new_content.clone(); - // DON'T increment version locally - server is authoritative for versions - // Server will increment atomically and broadcast back to all clients - doc.content_hash = None; // Will be recalculated - doc.updated_at = chrono::Utc::now(); - - tracing::info!( - "CLIENT {}: 💾 SAVING LOCALLY: version={}, marking as pending", - self.client_id, - doc.sync_revision - ); - - // CRITICAL: Atomically save document and queue patch - // This prevents data loss if app crashes between operations - use replicant_core::protocol::ChangeEventType; - - tracing::info!( - "CLIENT {}: 📋 Atomically saving document and queueing patch for doc {}", - self.client_id, - doc.id - ); - // The queue keeps the base of the FIRST unsent edit, so a run of offline - // edits uploads as one diff against the state the server holds. - self.db - .save_document_and_queue_patch( - &doc, - &patch, - ChangeEventType::Update, - Some(&old_content), - ) - .await?; - tracing::info!( - "CLIENT {}: ✅ Successfully saved document and queued patch atomically", - self.client_id - ); - - // Verify it was saved correctly and check its sync status - let saved_doc = self.db.get_document(&id).await?; - tracing::info!( - "CLIENT {}: ✅ SAVED: content={:?}, version={}", - self.client_id, - saved_doc.content, - saved_doc.sync_revision - ); - - // Check sync status after save - let sync_status_result = sqlx::query("SELECT sync_status FROM documents WHERE id = ?") - .bind(id.to_string()) - .fetch_one(&self.db.pool) - .await; - - match sync_status_result { - Ok(row) => { - let sync_status: String = row - .try_get("sync_status") - .unwrap_or_else(|_| "unknown".to_string()); - tracing::info!( - "CLIENT {}: 📊 Document {} sync_status after save: {}", - self.client_id, - id, - sync_status - ); - } - Err(e) => { - tracing::error!( - "CLIENT {}: Failed to check sync_status: {}", - self.client_id, - e - ); - } - } - - // Emit event - self.event_dispatcher - .emit_document_updated_with_attribution( - &doc.id, - &doc.content, - doc.user_id.as_ref(), - doc.author_name.as_deref(), - doc.visibility.as_deref(), - EventOrigin::Local, - ); - - // Attempt immediate sync if connected - tracing::info!( - "CLIENT {}: 🚀 Attempting immediate sync for updated document {}", - self.client_id, - doc.id - ); - if let Err(e) = self.try_immediate_sync(&doc).await { - tracing::warn!("CLIENT {}: ⚠️ OFFLINE EDIT - Failed to immediately sync updated document {}: {}. Changes saved locally for later sync.", - self.client_id, doc.id, e); - // Document stays in "pending" status for next sync attempt - - // Double-check sync status after failed immediate sync - let sync_status_result = sqlx::query("SELECT sync_status FROM documents WHERE id = ?") - .bind(id.to_string()) - .fetch_one(&self.db.pool) - .await; - - match sync_status_result { - Ok(row) => { - let sync_status: String = row - .try_get("sync_status") - .unwrap_or_else(|_| "unknown".to_string()); - tracing::warn!( - "CLIENT {}: 📊 Document {} sync_status after FAILED immediate sync: {}", - self.client_id, - id, - sync_status - ); - } - Err(e) => { - tracing::error!( - "CLIENT {}: Failed to check sync_status after failed sync: {}", - self.client_id, - e - ); - } - } - } else { - tracing::info!( - "CLIENT {}: ✅ Immediate sync successful for document {}", - self.client_id, - doc.id - ); - } - - Ok(()) - } - - pub async fn delete_document(&self, id: Uuid) -> SyncResult<()> { - // Mark as deleted locally first - self.db.delete_document(&id).await?; - - // Emit event - self.event_dispatcher - .emit_document_deleted(&id, EventOrigin::Local); - - // Try to send delete to server if connected - let ws_client = self.ws_client.lock().await; - if let Some(client) = ws_client.as_ref() { - if let Err(e) = client - .send(ClientMessage::DeleteDocument { document_id: id }) - .await - { - tracing::warn!( - "CLIENT {}: Failed to send delete to server: {}. Will sync later.", - self.client_id, - e - ); - self.is_connected.store(false, Ordering::Relaxed); - self.event_dispatcher.emit_connection_lost(&self.server_url); - drop(ws_client); - self.start_reconnection_loop(); - } - } else { - tracing::info!( - "CLIENT {}: Offline - delete will sync when connection available", - self.client_id - ); - } - - Ok(()) - } - - pub async fn get_all_documents(&self) -> SyncResult<Vec<Document>> { - let docs = self.db.get_all_documents().await?; - tracing::info!( - "CLIENT: get_all_documents() returning {} documents", - docs.len() - ); - for doc in &docs { - tracing::info!( - "CLIENT: - Document: {} (updated: {})", - doc.id, - doc.updated_at - ); - } - Ok(docs) - } - - pub async fn get_all_document_ids(&self, include_deleted: bool) -> SyncResult<Vec<Uuid>> { - let ids = self.db.get_all_document_ids(include_deleted).await?; - tracing::info!( - "CLIENT: get_all_document_ids({}) returning {} ids", - include_deleted, - ids.len() - ); - Ok(ids) - } - - pub async fn count_documents(&self) -> SyncResult<usize> { - let docs = self.db.get_all_documents().await?; - Ok(docs.len()) - } - - pub async fn count_pending_sync(&self) -> SyncResult<usize> { - let pending_docs = self.db.get_pending_documents().await?; - Ok(pending_docs.len()) - } - - async fn sync_pending_documents(&self) -> SyncResult<()> { - let pending_docs = self.db.get_pending_documents().await?; - // Also check sync_queue for debugging - let sync_queue_result = sqlx::query("SELECT COUNT(*) as count FROM sync_queue") - .fetch_one(&self.db.pool) - .await; - - match sync_queue_result { - Ok(row) => { - let count: i64 = row.try_get("count").unwrap_or(0); - tracing::info!( - "CLIENT {}: 📋 sync_queue contains {} entries", - self.client_id, - count - ); - - if count > 0 { - // Show what's in the sync_queue - let queue_entries = sqlx::query( - "SELECT document_id, operation_type, created_at FROM sync_queue", - ) - .fetch_all(&self.db.pool) - .await; - - match queue_entries { - Ok(rows) => { - for row in rows { - let doc_id: String = row - .try_get("document_id") - .unwrap_or_else(|_| "unknown".to_string()); - let op_type: String = row - .try_get("operation_type") - .unwrap_or_else(|_| "unknown".to_string()); - let created_at: String = row - .try_get("created_at") - .unwrap_or_else(|_| "unknown".to_string()); - tracing::info!("CLIENT {}: 📋 sync_queue entry: doc_id={}, op_type={}, created_at={}", - self.client_id, doc_id, op_type, created_at); - } - } - Err(e) => { - tracing::error!( - "CLIENT {}: Failed to query sync_queue entries: {}", - self.client_id, - e - ); - } - } - } - } - Err(e) => { - tracing::error!( - "CLIENT {}: Failed to count sync_queue: {}", - self.client_id, - e - ); - } - } - - if pending_docs.is_empty() { - tracing::info!("CLIENT {}: No pending documents to sync", self.client_id); - return Ok(()); - } - - tracing::info!( - "CLIENT {}: 📤 UPLOADING {} PENDING DOCUMENTS", - self.client_id, - pending_docs.len() - ); - - // Show details of each pending document - for (i, pending_info) in pending_docs.iter().enumerate() { - if let Ok(doc) = self.db.get_document(&pending_info.id).await { - tracing::info!( - "CLIENT {}: PENDING {}/{}: doc_id={}, content={:?}, version={}", - self.client_id, - i + 1, - pending_docs.len(), - pending_info.id, - doc.content, - doc.sync_revision - ); - } - } - - for pending_info in pending_docs { - match self.db.get_document(&pending_info.id).await { - Ok(doc) => { - let upload_type = if pending_info.is_deleted { - // Handle pending delete - tracing::info!( - "CLIENT {}: Uploading pending delete for doc {}", - self.client_id, - pending_info.id - ); - - // Track this upload - self.pending_uploads.lock().await.insert( - pending_info.id, - PendingUpload { - operation_type: UploadType::Delete, - sent_at: Instant::now(), - }, - ); - - let ws_client = self.ws_client.lock().await; - if let Some(client) = ws_client.as_ref() { - if let Err(e) = client - .send(ClientMessage::DeleteDocument { - document_id: pending_info.id, - }) - .await - { - self.pending_uploads.lock().await.remove(&pending_info.id); - return Err(e); - } - } else { - self.pending_uploads.lock().await.remove(&pending_info.id); - return Err(ClientError::WebSocket("Not connected".to_string()))?; - } - - UploadType::Delete - } else { - // A queued create outranks a queued patch; see - // `classify_pending_upload`. - match classify_pending_upload(&self.db, &pending_info.id).await { - PendingUploadKind::Update { - patch: stored_patch, - old_content_hash: old_hash_opt, - } => { - tracing::info!( - "CLIENT {}: 📋 Found stored patch in sync_queue for doc {} - treating as UPDATE", - self.client_id, - pending_info.id - ); - - // Track this upload - self.pending_uploads.lock().await.insert( - pending_info.id, - PendingUpload { - operation_type: UploadType::Update, - sent_at: Instant::now(), - }, - ); - - // Use the stored patch for UpdateDocument - use replicant_core::models::DocumentPatch; - use replicant_core::patches::calculate_checksum; - - let content_hash = old_hash_opt - .unwrap_or_else(|| calculate_checksum(&doc.content)); - - let document_patch = DocumentPatch { - document_id: pending_info.id, - patch: stored_patch, - content_hash, - }; - - let ws_client = self.ws_client.lock().await; - if let Some(client) = ws_client.as_ref() { - tracing::info!( - "CLIENT {}: ✅ Sending UpdateDocument with stored patch", - self.client_id - ); - if let Err(e) = client - .send(ClientMessage::UpdateDocument { - patch: document_patch, - }) - .await - { - self.pending_uploads.lock().await.remove(&pending_info.id); - return Err(e); - } - } else { - self.pending_uploads.lock().await.remove(&pending_info.id); - return Err(ClientError::WebSocket( - "Not connected".to_string(), - ))?; - } - - UploadType::Update - } - PendingUploadKind::Create => { - tracing::info!( - "CLIENT {}: Doc {} has not reached the server - treating as CREATE", - self.client_id, - pending_info.id - ); - - // Track this upload - self.pending_uploads.lock().await.insert( - pending_info.id, - PendingUpload { - operation_type: UploadType::Create, - sent_at: Instant::now(), - }, - ); - - let ws_client = self.ws_client.lock().await; - if let Some(client) = ws_client.as_ref() { - if let Err(e) = client - .send(ClientMessage::CreateDocument { - document: doc.clone(), - }) - .await - { - self.pending_uploads.lock().await.remove(&pending_info.id); - return Err(e); - } - } else { - self.pending_uploads.lock().await.remove(&pending_info.id); - return Err(ClientError::WebSocket( - "Not connected".to_string(), - ))?; - } - - UploadType::Create - } - } - }; - - tracing::debug!( - "CLIENT {}: Tracked upload for document {} ({:?})", - self.client_id, - pending_info.id, - upload_type - ); - } - Err(e) => { - tracing::error!( - "CLIENT {}: Failed to get pending document {}: {}", - self.client_id, - pending_info.id, - e - ); - } - } - } - - tracing::info!( - "CLIENT {}: Upload tracking: {} operations pending confirmation", - self.client_id, - self.pending_uploads.lock().await.len() - ); - Ok(()) - } - - // Enhanced message handler with upload tracking and protection - async fn handle_server_message_with_tracking( - msg: ServerMessage, - db: &Arc<ClientDatabase>, - client_id: Uuid, - event_dispatcher: &Arc<EventDispatcher>, - pending_uploads: &Arc<Mutex<HashMap<Uuid, PendingUpload>>>, - upload_complete_notifier: &Arc<Notify>, - sync_protection_mode: &Arc<AtomicBool>, - deferred_messages: &Arc<Mutex<Vec<ServerMessage>>>, - upload_retry: &UploadRetry, - source: &ResyncSource, - ) -> SyncResult<()> { - match &msg { - // Handle upload confirmations first - ServerMessage::DocumentCreatedResponse { - document_id, - success, - .. - } - | ServerMessage::DocumentUpdatedResponse { - document_id, - success, - .. - } - | ServerMessage::DocumentDeletedResponse { - document_id, - success, - .. - } => { - // The upload is over either way. A failed ack must clear the - // pending entry and drain the queue exactly like a successful - // one: otherwise every later patch for this document defers - // forever behind an upload that will never complete. - // A hash_mismatch is not a plain rejection: the server told us - // what it currently holds, so the queued patch can be rebased - // onto it and resent. That path triggers its own upload pass, - // so the blind retry must not also fire. - let rebase = if *success { - None - } else { - Self::hash_mismatch_details(&msg) - }; - - // A rejected create cannot be fixed by resending it: the server - // either already holds the document (lost ack, or the - // content-hash dedupe) or refuses it outright, and both survive - // a blind retry. It gets its own recovery below instead. - let document_id = *document_id; - let failed_create = !*success - && rebase.is_none() - && matches!(msg, ServerMessage::DocumentCreatedResponse { .. }); - - if !*success { - tracing::error!( - "CLIENT {}: Upload failed for document {}", - client_id, - document_id - ); - if rebase.is_none() && !failed_create { - // The document is still Pending with its queue row - // intact, so a retry pass will pick it up. - upload_retry.schedule(client_id, document_id).await; - } - } - - let mut uploads = pending_uploads.lock().await; - if let Some(upload) = uploads.remove(&document_id) { - let elapsed = upload.sent_at.elapsed(); - tracing::info!( - "CLIENT {}: Upload settled for {} ({:?}, success={}) in {:?}", - client_id, - document_id, - upload.operation_type, - success, - elapsed - ); - - // If this was the last pending upload, notify - if uploads.is_empty() { - tracing::info!( - "CLIENT {}: All uploads settled - notifying completion", - client_id - ); - upload_complete_notifier.notify_one(); - } - } - // Release the lock before processing deferred messages - drop(uploads); - - // Replay anything queued while this document was uploading. - if let Err(e) = Self::process_deferred_messages( - deferred_messages, - db, - client_id, - event_dispatcher, - pending_uploads, - source, - ) - .await - { - tracing::error!( - "CLIENT {}: Error processing deferred messages after upload: {}", - client_id, - e - ); - } - - // The pending entry is cleared and the queue drained, so a - // resend can re-register cleanly. - if let Some(server) = rebase { - return Self::rebase_after_hash_mismatch( - db, - client_id, - event_dispatcher, - upload_retry, - document_id, - server, - ) - .await; - } - - // Normal processing first, so the plain failure event still - // reaches the consumer before any recovery event. - Self::handle_server_message(msg, db, client_id, event_dispatcher, source).await?; - - if failed_create { - return Self::recover_failed_create( - db, - source, - client_id, - event_dispatcher, - upload_retry, - document_id, - ) - .await; - } - - return Ok(()); - } - - // A patch for a document we are still uploading is almost always - // our own broadcast echoed back (DEV-1038). Applying the guard now - // would see status=Pending and burn a resync round-trip on every - // local edit; deferring lets the ack land first, after which the - // echo hits the idempotency drop for free. - ServerMessage::DocumentUpdated { patch } => { - if Self::has_pending_upload(pending_uploads, &patch.document_id).await { - tracing::info!( - "CLIENT {}: Deferring patch for {} (upload in progress)", - client_id, - patch.document_id - ); - Self::defer_message( - deferred_messages, - client_id, - ServerMessage::DocumentUpdated { - patch: patch.clone(), - }, - ) - .await; - return Ok(()); - } - - return Self::handle_server_message(msg, db, client_id, event_dispatcher, source) - .await; - } - - // Apply protection for sync messages during upload phase - ServerMessage::SyncDocument { document } => { - // Check if we're in protection mode - if sync_protection_mode.load(Ordering::Relaxed) { - tracing::info!( - "CLIENT {}: 🔒 QUEUEING sync for {} v{} (protection mode active)", - client_id, - document.id, - document.sync_revision - ); - Self::defer_message( - deferred_messages, - client_id, - ServerMessage::SyncDocument { - document: document.clone(), - }, - ) - .await; - return Ok(()); - } - - // Check if document has pending changes - if let Ok(_local_doc) = db.get_document(&document.id).await { - // Check if this document has an active upload in progress - // This is our primary protection mechanism - if Self::has_pending_upload(pending_uploads, &document.id).await { - tracing::info!( - "CLIENT {}: 🔒 QUEUEING sync for {} v{} (upload in progress)", - client_id, - document.id, - document.sync_revision - ); - Self::defer_message( - deferred_messages, - client_id, - ServerMessage::SyncDocument { - document: document.clone(), - }, - ) - .await; - return Ok(()); - } - } - - // Safe to proceed with sync - return Self::handle_server_message(msg, db, client_id, event_dispatcher, source) - .await; - } - - _ => { - // For all other messages, use normal handling - return Self::handle_server_message(msg, db, client_id, event_dispatcher, source) - .await; - } - } - } - - /// Process all deferred sync messages that were queued during upload protection - async fn process_deferred_messages( - deferred_messages: &Arc<Mutex<Vec<ServerMessage>>>, - db: &Arc<ClientDatabase>, - client_id: Uuid, - event_dispatcher: &Arc<EventDispatcher>, - pending_uploads: &Arc<Mutex<HashMap<Uuid, PendingUpload>>>, - source: &ResyncSource, - ) -> SyncResult<()> { - // Drain into a local batch and release the lock before handling: the - // handlers below may defer again, which needs the same lock. - let batch: Vec<ServerMessage> = { - let mut messages = deferred_messages.lock().await; - if messages.is_empty() { - return Ok(()); - } - messages.drain(..).collect() - }; - - tracing::info!( - "CLIENT {}: Processing {} deferred sync messages", - client_id, - batch.len() - ); - - for msg in batch { - // One ack drains the whole queue, but other documents may still be - // uploading. Replaying their messages now would resync against - // state we are about to overwrite, so re-defer them instead. - if let Some((_, document_id)) = Self::deferral_key(&msg) { - if Self::has_pending_upload(pending_uploads, &document_id).await { - tracing::info!( - "CLIENT {}: Re-deferring message for {} (upload still in progress)", - client_id, - document_id - ); - Self::redefer_message(deferred_messages, client_id, msg).await; - continue; - } - } - - if let Err(e) = - Self::handle_server_message(msg, db, client_id, event_dispatcher, source).await - { - tracing::error!( - "CLIENT {}: Error processing deferred message: {}", - client_id, - e - ); - // Continue processing remaining messages even if one fails - } - } - - tracing::info!( - "CLIENT {}: Completed processing deferred messages", - client_id - ); - - Ok(()) - } - - /// Identity of a deferrable message: its variant plus the document it - /// concerns. Two messages with the same key are interchangeable — both - /// carry "the server's latest state for this document" — so the newer one - /// can replace the older. - fn deferral_key(msg: &ServerMessage) -> Option<(&'static str, Uuid)> { - match msg { - ServerMessage::DocumentUpdated { patch } => { - Some(("document_updated", patch.document_id)) - } - ServerMessage::SyncDocument { document } => Some(("sync_document", document.id)), - _ => None, - } - } - - /// The server revision a deferrable message carries, used to decide which - /// of two entries for the same document is newer. - fn deferral_revision(msg: &ServerMessage) -> i64 { - match msg { - ServerMessage::DocumentUpdated { patch } => patch.sync_revision, - ServerMessage::SyncDocument { document } => document.sync_revision, - _ => 0, - } - } - - /// Queue a message for replay once the in-flight upload completes. - /// - /// Entries are collapsed per (variant, document), keeping whichever carries - /// the higher revision. Collapsing is what keeps a burst of echoes for one - /// document from evicting a `SyncDocument` for another — and a dropped - /// `SyncDocument` never self-heals, it just leaves that document silently - /// stale. - /// - /// Dropping intermediate revisions is safe but not free: the survivor will - /// usually fail the contiguity check on replay and cost one resync - /// round-trip, which is still cheaper than replaying every echo. - /// - /// Producers run on several tasks (initial-sync drain, handler, and the - /// post-reconnect handler), and the drain no longer holds the lock, so the - /// revision comparison — not arrival order — is what decides the winner. - async fn defer_message( - deferred_messages: &Arc<Mutex<Vec<ServerMessage>>>, - client_id: Uuid, - msg: ServerMessage, - ) { - Self::defer_message_inner(deferred_messages, client_id, msg, false).await - } - - /// Re-queue a message pulled off the queue that turned out not to be - /// replayable yet. A queued entry is never older than what we are putting - /// back, so this must not overwrite a newer entry that landed meanwhile. - async fn redefer_message( - deferred_messages: &Arc<Mutex<Vec<ServerMessage>>>, - client_id: Uuid, - msg: ServerMessage, - ) { - Self::defer_message_inner(deferred_messages, client_id, msg, true).await - } - - async fn defer_message_inner( - deferred_messages: &Arc<Mutex<Vec<ServerMessage>>>, - client_id: Uuid, - msg: ServerMessage, - insert_if_absent_only: bool, - ) { - const MAX_DEFERRED_MESSAGES: usize = 100; - let mut queue = deferred_messages.lock().await; - - if let Some(key) = Self::deferral_key(&msg) { - if let Some(slot) = queue - .iter_mut() - .find(|queued| Self::deferral_key(queued) == Some(key)) - { - if insert_if_absent_only { - return; - } - if Self::deferral_revision(&msg) > Self::deferral_revision(slot) { - *slot = msg; - } - return; - } - } - - if queue.len() >= MAX_DEFERRED_MESSAGES { - tracing::warn!( - "CLIENT {}: Deferred queue full ({} messages), dropping oldest", - client_id, - queue.len() - ); - queue.remove(0); - } - queue.push(msg); - } - - // Check if a document has an active upload in progress - async fn has_pending_upload( - pending_uploads: &Arc<Mutex<HashMap<Uuid, PendingUpload>>>, - document_id: &Uuid, - ) -> bool { - let uploads = pending_uploads.lock().await; - uploads.contains_key(document_id) - } - - /// The server state a `hash_mismatch` rejection carries, or `None` for any - /// other ack. All three fields are required: a partial reply cannot be - /// rebased onto and falls back to the blind retry. - fn hash_mismatch_details(msg: &ServerMessage) -> Option<HashMismatch> { - let ServerMessage::DocumentUpdatedResponse { - reason, - current_revision, - current_content, - current_hash, - .. - } = msg - else { - return None; - }; - - if reason.as_deref() != Some("hash_mismatch") { - return None; - } - - Some(HashMismatch { - current_revision: (*current_revision)?, - current_content: current_content.clone()?, - current_hash: current_hash.clone()?, - }) - } - - /// Turn a `hash_mismatch` rejection into a rebase-and-resend. - /// - /// The queued patch is replayed onto the server's current content. If it - /// applies, the merged result becomes local truth at the server's revision, - /// a fresh patch is queued against `current_hash`, and the upload pass runs - /// again. Because the replay starts from the server's content, an edit to a - /// different field survives the round trip instead of being overwritten. - /// - /// Anything that stops the rebase — a patch that no longer applies, a - /// missing queue row, an exhausted attempt budget — settles the document as - /// `Conflict` with the server's copy as local truth. - async fn rebase_after_hash_mismatch( - db: &Arc<ClientDatabase>, - client_id: Uuid, - event_dispatcher: &Arc<EventDispatcher>, - upload_retry: &UploadRetry, - document_id: Uuid, - server: HashMismatch, - ) -> SyncResult<()> { - let Some(status) = db.get_sync_status(&document_id).await? else { - tracing::warn!( - "CLIENT {}: Rejected update for {} has no local row, nothing to rebase", - client_id, - document_id - ); - return Ok(()); - }; - - let local = match db.get_document(&document_id).await { - Ok(doc) => doc, - Err(e) => { - // Writing on a failed read would overwrite state we never saw. - tracing::warn!( - "CLIENT {}: Could not read {} to rebase it ({}), leaving it untouched", - client_id, - document_id, - e - ); - return Ok(()); - } - }; - - let expected = DocumentPreImage { - sync_revision: local.sync_revision, - content: local.content.clone(), - sync_status: status, - }; - - // Read the patch before spending a budget unit: a document with nothing - // queued is unresolvable, not live-locked. - let Ok(Some((queued, _))) = db.get_queued_patch(&document_id).await else { - return Self::settle_as_conflict( - db, - client_id, - event_dispatcher, - document_id, - &server, - &expected, - "there is no queued patch to rebase", - ) - .await; - }; - - if !upload_retry.claim_rebase(client_id, document_id).await { - return Self::settle_as_conflict( - db, - client_id, - event_dispatcher, - document_id, - &server, - &expected, - "the rebase attempt budget is exhausted", - ) - .await; - } - - let mut rebased = server.current_content.clone(); - if let Err(e) = apply_patch(&mut rebased, &queued) { - return Self::settle_as_conflict( - db, - client_id, - event_dispatcher, - document_id, - &server, - &expected, - &format!("the queued patch no longer applies ({})", e), - ) - .await; - } - - // Diff from the server's content rather than resending the original - // patch: the server will apply this to exactly `current_content`. - let forward = create_patch(&server.current_content, &rebased)?; - - if forward.0.is_empty() { - // The winner's update already contains this edit — replaying it - // would upload a no-op and take a full round trip to learn that. - return Self::adopt_without_resending( - db, - client_id, - event_dispatcher, - document_id, - upload_retry, - &server, - &expected, - local, - ) - .await; - } - - if !db - .rebase_pending_document_if_unchanged( - &document_id, - &rebased, - server.current_revision, - &server.current_content, - &server.current_hash, - SyncStatus::Pending, - &expected, - ) - .await? - { - // A newer local edit landed mid-rebase. It is queued against the - // old base, so resending it earns another hash_mismatch and the - // next round rebases from the newer state. The attempt budget - // guarantees this terminates. - tracing::warn!( - "CLIENT {}: Rebase of {} raced a local edit, resending for another round", - client_id, - document_id - ); - upload_retry - .resend_after_rebase(client_id, document_id) - .await; - return Ok(()); - } - - tracing::info!( - "CLIENT {}: Rebased {} onto server revision {} and requeued the edit", - client_id, - document_id, - server.current_revision - ); - - event_dispatcher.emit_document_updated_with_attribution( - &document_id, - &rebased, - local.user_id.as_ref(), - local.author_name.as_deref(), - local.visibility.as_deref(), - EventOrigin::Remote, - ); - - upload_retry - .resend_after_rebase(client_id, document_id) - .await; - Ok(()) - } - - /// The rebase produced exactly the server's content: the edit is already - /// there. Take the server's revision, drop the queue row, and skip the - /// upload entirely rather than paying a round trip for a no-op patch. - #[allow(clippy::too_many_arguments)] - async fn adopt_without_resending( - db: &Arc<ClientDatabase>, - client_id: Uuid, - event_dispatcher: &Arc<EventDispatcher>, - document_id: Uuid, - upload_retry: &UploadRetry, - server: &HashMismatch, - expected: &DocumentPreImage, - mut doc: Document, - ) -> SyncResult<()> { - doc.content = server.current_content.clone(); - doc.sync_revision = server.current_revision; - doc.updated_at = chrono::Utc::now(); - - // One transaction: a local edit landing between the write and the - // delete would otherwise have its fresh queue row deleted, leaving a - // Pending document with nothing queued. - if !db - .save_document_and_clear_queue_if_unchanged( - &doc, - settled_status(expected.sync_status), - expected, - ) - .await? - { - tracing::warn!( - "CLIENT {}: Adopting {} raced a local edit, resending instead", - client_id, - document_id - ); - upload_retry - .resend_after_rebase(client_id, document_id) - .await; - return Ok(()); - } - - tracing::info!( - "CLIENT {}: Rejected update for {} was already in the server's revision {}, nothing to resend", - client_id, - document_id, - server.current_revision - ); - - event_dispatcher.emit_document_updated_with_attribution( - &doc.id, - &doc.content, - doc.user_id.as_ref(), - doc.author_name.as_deref(), - doc.visibility.as_deref(), - EventOrigin::Remote, - ); - Ok(()) - } - - /// Give up on reconciling a rejected edit: the server's copy becomes local - /// truth, the document is marked `Conflict`, and the host app is told. - /// - /// The queued patch is dropped — it is built on a base that no longer - /// exists, so leaving it would resend and be rejected forever. - #[allow(clippy::too_many_arguments)] - async fn settle_as_conflict( - db: &Arc<ClientDatabase>, - client_id: Uuid, - event_dispatcher: &Arc<EventDispatcher>, - document_id: Uuid, - server: &HashMismatch, - expected: &DocumentPreImage, - why: &str, - ) -> SyncResult<()> { - tracing::error!( - "CLIENT {}: Cannot rebase {}: {}. Keeping the server's copy.", - client_id, - document_id, - why - ); - - let mut doc = match db.get_document(&document_id).await { - Ok(doc) => doc, - Err(e) => { - tracing::warn!( - "CLIENT {}: Could not read {} to settle the conflict ({})", - client_id, - document_id, - e - ); - return Ok(()); - } - }; - doc.content = server.current_content.clone(); - doc.sync_revision = server.current_revision; - doc.updated_at = chrono::Utc::now(); - - // One transaction, for the same reason as `adopt_without_resending`: - // dropping the queue row must not outlive a failed swap, and must not - // take a queue row a concurrent local edit just inserted. - if !db - .save_document_and_clear_queue_if_unchanged(&doc, SyncStatus::Conflict, expected) - .await? - { - // A newer local edit landed; it will be uploaded and get its own - // rejection, so do not clobber it here. - tracing::warn!( - "CLIENT {}: Conflict settlement for {} raced a local edit, leaving it pending", - client_id, - document_id - ); - return Ok(()); - } - - event_dispatcher.emit_conflict_detected(&document_id); - event_dispatcher.emit_sync_error( - ReplicantErrorCode::UpdateConflict, - &format!( - "Update for {} could not be rebased ({}); the server's copy is now local truth", - document_id, why - ), - ); - event_dispatcher.emit_document_updated_with_attribution( - &doc.id, - &doc.content, - doc.user_id.as_ref(), - doc.author_name.as_deref(), - doc.visibility.as_deref(), - EventOrigin::Remote, - ); - - Ok(()) - } - - /// Recover a document whose create the server rejected. - /// - /// A create fails for two very different reasons. Either the server already - /// holds the document — a lost ack, or its content-hash dedupe returning an - /// existing row — in which case the local copy must adopt server state and - /// upload its edits as an update. Or the create was genuinely refused - /// (validation, quota), which no amount of resending fixes. - /// - /// Fetching the document settles which it is. Adopting server state also - /// consumes the queued `create`, so the retry sends an update rather than a - /// second doomed create. - /// - /// One attempt per document per session, so a repeating refusal cannot spin. - async fn recover_failed_create( - db: &Arc<ClientDatabase>, - source: &ResyncSource, - client_id: Uuid, - event_dispatcher: &Arc<EventDispatcher>, - upload_retry: &UploadRetry, - document_id: Uuid, - ) -> SyncResult<()> { - if !upload_retry.claim_once(client_id, document_id).await { - return Ok(()); - } - - let Some(result) = source.fetch(document_id).await else { - tracing::warn!( - "CLIENT {}: Cannot check {} after a rejected create while offline, leaving it pending", - client_id, - document_id - ); - return Ok(()); - }; - - if let ServerMessage::Error { - code: ErrorCode::DocumentNotFound, - .. - } = result - { - return Self::settle_rejected_create(db, client_id, event_dispatcher, document_id) - .await; - } - - tracing::info!( - "CLIENT {}: Server already holds {}, adopting its state and resending as an update", - client_id, - document_id - ); - Self::apply_resync_result(db, client_id, event_dispatcher, document_id, result).await?; - upload_retry - .resend_after_rebase(client_id, document_id) - .await; - Ok(()) - } - - /// Park a document the server refused to create and does not hold. - /// - /// `Conflict` takes it out of the pending set, so the upload pass stops - /// resending a create that is only going to be refused again. Deliberately - /// NOT soft-deleted the way a resync treats a missing document: this content - /// has never reached the server, so the local copy is the only one there is. - /// - /// The queued rows stay. The document genuinely still owes the server a - /// create, and a later session — after whatever refused it is resolved — can - /// send one once a local edit makes it pending again. - async fn settle_rejected_create( - db: &Arc<ClientDatabase>, - client_id: Uuid, - event_dispatcher: &Arc<EventDispatcher>, - document_id: Uuid, - ) -> SyncResult<()> { - tracing::error!( - "CLIENT {}: Server refused to create {} and does not hold it, parking it as a conflict", - client_id, - document_id - ); - - let doc = match db.get_document(&document_id).await { - Ok(doc) => doc, - Err(e) => { - tracing::warn!( - "CLIENT {}: Could not read {} to park the rejected create ({})", - client_id, - document_id, - e - ); - return Ok(()); - } - }; - db.save_document_with_status(&doc, Some(SyncStatus::Conflict)) - .await?; - - event_dispatcher.emit_conflict_detected(&document_id); - event_dispatcher.emit_sync_error( - ReplicantErrorCode::UpdateConflict, - &format!( - "The server refused to create {} and does not hold it; the local copy is unsynced", - document_id - ), - ); - Ok(()) - } - - /// Fetch the authoritative document from the server and reconcile it with - /// local state. Used whenever a broadcast patch cannot be trusted to apply - /// (unknown doc, revision gap, unsent local edits, diverged result). - /// - /// Offline, this is a no-op: the document simply stays unsynced until a - /// later resync. - async fn resync_document( - db: &Arc<ClientDatabase>, - source: &ResyncSource, - client_id: Uuid, - event_dispatcher: &Arc<EventDispatcher>, - document_id: Uuid, - ) -> SyncResult<()> { - // A lost compare-and-swap means a local edit landed mid-resync. Re-fetch - // once and reconcile against the newer local state rather than leaving a - // stale queued patch stranded until some unrelated broadcast arrives. - const RESYNC_ATTEMPTS: usize = 2; - - for attempt in 1..=RESYNC_ATTEMPTS { - let Some(result) = source.fetch(document_id).await else { - tracing::warn!( - "CLIENT {}: Cannot resync {} while offline, leaving it unsynced", - client_id, - document_id - ); - return Ok(()); - }; - - if Self::apply_resync_result(db, client_id, event_dispatcher, document_id, result) - .await? - { - return Ok(()); - } - - tracing::info!( - "CLIENT {}: Resync attempt {}/{} for {} lost to a local edit", - client_id, - attempt, - RESYNC_ATTEMPTS, - document_id - ); - } - - tracing::warn!( - "CLIENT {}: Giving up resyncing {} for now, leaving it for the next broadcast", - client_id, - document_id - ); - Ok(()) - } - - /// Reconcile a `get_document` reply with local state. - /// - /// A missing document (`DocumentNotFound`) is authoritative — the server - /// checked both the user's own documents and the public set — so the local - /// copy is soft-deleted. Any other error is transient and MUST leave local - /// state untouched: deleting on a network blip destroys user data. - /// Returns `true` when the document is settled (written, deleted, or - /// deliberately left alone) and `false` when a concurrent local write won - /// the compare-and-swap, meaning a retry against fresher server state is - /// worthwhile. - async fn apply_resync_result( - db: &Arc<ClientDatabase>, - client_id: Uuid, - event_dispatcher: &Arc<EventDispatcher>, - document_id: Uuid, - result: ServerMessage, - ) -> SyncResult<bool> { - let (content, sync_revision, content_hash, deleted) = match result { - ServerMessage::GetDocumentResponse { - content, - sync_revision, - content_hash, - deleted, - .. - } => (content, sync_revision, content_hash, deleted), - ServerMessage::Error { - code: ErrorCode::DocumentNotFound, - .. - } => { - // A document that still owes a create has never been offered to - // the server, so "not found" is the expected answer rather than - // an authoritative deletion. Soft-deleting here would destroy - // content that exists nowhere else. - if db.has_queued_create(&document_id).await.unwrap_or(false) { - tracing::info!( - "CLIENT {}: Server does not have {} because its create is still queued, keeping the local copy", - client_id, - document_id - ); - return Ok(true); - } - - tracing::info!( - "CLIENT {}: Server does not have {}, soft-deleting locally", - client_id, - document_id - ); - db.delete_document(&document_id).await?; - db.mark_synced(&document_id).await?; - event_dispatcher.emit_document_deleted(&document_id, EventOrigin::Remote); - return Ok(true); - } - other => { - tracing::warn!( - "CLIENT {}: Resync of {} failed transiently ({:?}), local state unchanged", - client_id, - document_id, - other - ); - return Ok(true); - } - }; - - if deleted { - tracing::info!( - "CLIENT {}: Resync of {} returned a tombstone, soft-deleting locally", - client_id, - document_id - ); - db.delete_document(&document_id).await?; - db.mark_synced(&document_id).await?; - event_dispatcher.emit_document_deleted(&document_id, EventOrigin::Remote); - return Ok(true); - } - - // Read the status first: it is what distinguishes "no such row" from - // "the row is there but the read failed". - let status = db.get_sync_status(&document_id).await?; - let local = match db.get_document(&document_id).await { - Ok(doc) => Some(doc), - Err(_) if status.is_none() => None, - Err(e) => { - // The row exists, so this is a read failure, not an absence. - // Writing here would overwrite local state we never saw. - tracing::warn!( - "CLIENT {}: Could not read {} for resync ({}), leaving it untouched", - client_id, - document_id, - e - ); - return Ok(true); - } - }; - - let Some(mut doc) = local else { - // Genuinely new: status is None AND the row is absent, so there is - // no local state to protect and an unconditional insert is safe. - let doc = Document { - id: document_id, - user_id: None, - content, - sync_revision, - content_hash: Some(content_hash), - title: None, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }; - db.save_document_with_status(&doc, Some(SyncStatus::Synced)) - .await?; - event_dispatcher.emit_document_updated_with_attribution( - &doc.id, - &doc.content, - doc.user_id.as_ref(), - doc.author_name.as_deref(), - doc.visibility.as_deref(), - EventOrigin::Remote, - ); - return Ok(true); - }; - - // Everything below writes conditionally on the state just read, so a - // local edit landing between the read and the write loses the swap - // instead of being silently overwritten. - let expected = DocumentPreImage { - sync_revision: doc.sync_revision, - content: doc.content.clone(), - sync_status: status.unwrap_or(SyncStatus::Conflict), - }; - // A `Conflict` settled by a failed rebase holds the server's content and - // has nothing queued, so there is nothing to protect: take the server's - // state and keep the flag. Only a genuine local edit resolves a conflict. - // - // A `Conflict` settled by a REFUSED CREATE is the exception: it holds - // purely local content the server has never seen, and its queue still - // owes a create. That must be protected like any unsent edit, so an - // unconsumed create counts as a local edit in its own right — with or - // without a queued patch beside it. - let has_local_edits = match expected.sync_status { - SyncStatus::Pending => true, - SyncStatus::Conflict => { - db.has_queued_create(&document_id).await.unwrap_or(false) - || db - .get_queued_patch(&document_id) - .await - .ok() - .flatten() - .is_some() - } - _ => false, - }; - - let local_content = doc.content.clone(); - doc.sync_revision = sync_revision; - doc.updated_at = chrono::Utc::now(); - - let committed = if has_local_edits { - // Keep the user's edits visible, but rebase them: the local copy now - // sits on the server's revision, and the queued patch carries the - // edits forward from the server's current content. - doc.content_hash = None; - db.rebase_pending_document_if_unchanged( - &document_id, - &local_content, - sync_revision, - &content, - &content_hash, - expected.sync_status, - &expected, - ) - .await? - } else { - doc.content = content; - doc.content_hash = Some(content_hash); - db.save_document_if_unchanged(&doc, settled_status(expected.sync_status), &expected) - .await? - }; - - if !committed { - tracing::warn!( - "CLIENT {}: Resync of {} raced a local edit", - client_id, - document_id - ); - return Ok(false); - } - - tracing::info!( - "CLIENT {}: Resynced {} to server revision {} (rebased local edits: {})", - client_id, - document_id, - sync_revision, - has_local_edits - ); - - event_dispatcher.emit_document_updated_with_attribution( - &doc.id, - &doc.content, - doc.user_id.as_ref(), - doc.author_name.as_deref(), - doc.visibility.as_deref(), - EventOrigin::Remote, - ); - - Ok(true) - } - - async fn handle_server_message( - msg: ServerMessage, - db: &Arc<ClientDatabase>, - client_id: Uuid, - event_dispatcher: &Arc<EventDispatcher>, - source: &ResyncSource, - ) -> SyncResult<()> { - match msg { - ServerMessage::DocumentUpdated { patch } => { - let document_id = patch.document_id; - tracing::info!( - "CLIENT {}: Received DocumentUpdated for doc {} (revision {})", - client_id, - document_id, - patch.sync_revision - ); - - let Ok(mut doc) = db.get_document(&document_id).await else { - tracing::info!( - "CLIENT {}: Patch for unknown doc {}, resyncing", - client_id, - document_id - ); - return Self::resync_document( - db, - source, - client_id, - event_dispatcher, - document_id, - ) - .await; - }; - - // Already at or past this revision: a duplicate or our own - // broadcast echoed back. Re-applying would corrupt the doc. - if doc.sync_revision >= patch.sync_revision { - tracing::info!( - "CLIENT {}: Dropping duplicate patch for {} (local revision {} >= {})", - client_id, - document_id, - doc.sync_revision, - patch.sync_revision - ); - return Ok(()); - } - - // Unsent local edits, or a gap in the revision stream: the - // patch's base is not what we hold, so never blind-apply it. - // - // A `Conflict` settled by a failed rebase holds the SERVER's - // content — only the flag differs — so a contiguous patch - // applies to it exactly as safely as to a `Synced` document. The - // flag is preserved on the write below: only a genuine local - // edit resolves a conflict. - // - // A `Conflict` settled by a refused create holds purely local - // content instead, so this would not be safe for it — but the - // server cannot broadcast a patch for a document it does not - // have, so no such patch can reach this branch. - let status = db.get_sync_status(&document_id).await?; - let appliable = matches!( - status, - Some(SyncStatus::Synced) | Some(SyncStatus::Conflict) - ); - if !appliable || doc.sync_revision + 1 != patch.sync_revision { - tracing::info!( - "CLIENT {}: Cannot apply patch for {} (status {:?}, local revision {}, patch revision {}), resyncing", - client_id, - document_id, - status, - doc.sync_revision, - patch.sync_revision - ); - return Self::resync_document( - db, - source, - client_id, - event_dispatcher, - document_id, - ) - .await; - } - - let mut new_content = doc.content.clone(); - if apply_patch(&mut new_content, &patch.patch).is_err() - || calculate_checksum(&new_content) != patch.content_hash - { - tracing::warn!( - "CLIENT {}: Patch diverged for {}, resyncing", - client_id, - document_id - ); - return Self::resync_document( - db, - source, - client_id, - event_dispatcher, - document_id, - ) - .await; - } - - // Commit conditionally on the state the guard just verified: a - // local update landing since then must not be overwritten. The - // status is carried through unchanged so a document the user - // still has to resolve does not silently become `Synced`. - let status = status.unwrap_or(SyncStatus::Conflict); - let expected = DocumentPreImage { - sync_revision: doc.sync_revision, - content: doc.content.clone(), - sync_status: status, - }; - - doc.content = new_content; - doc.sync_revision = patch.sync_revision; - doc.content_hash = Some(patch.content_hash); - doc.updated_at = chrono::Utc::now(); - - if !db - .save_document_if_unchanged(&doc, status, &expected) - .await? - { - tracing::warn!( - "CLIENT {}: Patch for {} raced a local edit, resyncing instead", - client_id, - document_id - ); - return Self::resync_document( - db, - source, - client_id, - event_dispatcher, - document_id, - ) - .await; - } - - event_dispatcher.emit_document_updated_with_attribution( - &doc.id, - &doc.content, - doc.user_id.as_ref(), - doc.author_name.as_deref(), - doc.visibility.as_deref(), - EventOrigin::Remote, - ); - } - ServerMessage::DocumentCreated { document } => { - // New document from server - check if we already have it to avoid duplicates - tracing::info!( - "CLIENT: Received DocumentCreated from server: {}", - document.id - ); - - // Check if we already have this document (e.g., if we were the creator) - match db.get_document(&document.id).await { - Ok(existing_doc) => { - // We already have this document - just ensure it's marked as synced - if existing_doc.sync_revision == document.sync_revision { - tracing::info!("CLIENT: Document {} already exists locally with same sync_revision, marking as synced", document.id); - db.mark_synced(&document.id).await?; - } else { - // Different revision - update it - tracing::info!("CLIENT: Document {} exists locally but has different revision, updating", document.id); - db.save_document_with_status(&document, Some(SyncStatus::Synced)) - .await?; - - // Emit event for updated document - event_dispatcher.emit_document_updated_with_attribution( - &document.id, - &document.content, - document.user_id.as_ref(), - document.author_name.as_deref(), - document.visibility.as_deref(), - EventOrigin::Remote, - ); - } - } - Err(_) => { - // Document doesn't exist locally - save it - tracing::info!( - "CLIENT: Document {} is new, saving to local database", - document.id - ); - db.save_document_with_status(&document, Some(SyncStatus::Synced)) - .await?; - - // Emit event for new document from server - event_dispatcher.emit_document_created_with_attribution( - &document.id, - &document.content, - document.user_id.as_ref(), - document.author_name.as_deref(), - document.visibility.as_deref(), - EventOrigin::Remote, - ); - } - } - } - ServerMessage::DocumentDeleted { document_id } => { - // Document deleted from server - we need to delete it locally - tracing::info!( - "CLIENT {}: Received DocumentDeleted for doc {}", - client_id, - document_id - ); - - // Delete the document locally (soft delete) - db.delete_document(&document_id).await?; - - // Mark it as synced so we don't try to sync the delete again - db.mark_synced(&document_id).await?; - - // Emit event for deleted document - event_dispatcher.emit_document_deleted(&document_id, EventOrigin::Remote); - } - ServerMessage::ConflictDetected { document_id, .. } => { - tracing::warn!("Conflict detected for document {}", document_id); - - // Emit conflict event - event_dispatcher.emit_conflict_detected(&document_id); - } - ServerMessage::SyncDocument { document } => { - // Document sync - check if it's newer than what we have - tracing::info!( - "CLIENT {}: 📥 RECEIVED SyncDocument: {} (sync_revision: {})", - client_id, - document.id, - document.sync_revision - ); - - match db.get_document(&document.id).await { - Ok(local_doc) => { - tracing::info!( - "CLIENT {}: LOCAL DOCUMENT: content={:?}, version={}", - client_id, - local_doc.content, - local_doc.sync_revision - ); - tracing::info!( - "CLIENT {}: SERVER DOCUMENT: content={:?}, version={}", - client_id, - document.content, - document.sync_revision - ); - - // Compare versions - server wins if version is >= local - // This handles the case where server broadcasts back our own update - let should_update = document.sync_revision >= local_doc.sync_revision; - - tracing::info!( - "CLIENT {}: 📊 VERSION COMPARISON for doc {}: server v{} vs local v{} → should_update={}", - client_id, - document.id, - document.sync_revision, - local_doc.sync_revision, - should_update - ); - - if should_update { - // Check if this might be overwriting local changes by comparing content - if local_doc.content != document.content { - tracing::warn!( - "CLIENT {}: ⚠️ SERVER OVERWRITING LOCAL CHANGES!", - client_id - ); - tracing::warn!( - "CLIENT {}: LOCAL: {:?} → SERVER: {:?}", - client_id, - local_doc.content, - document.content - ); - } - - tracing::info!( - "CLIENT {}: 🔄 Updating to newer version ({} -> {})", - client_id, - local_doc.sync_revision, - document.sync_revision - ); - db.save_document_with_status(&document, Some(SyncStatus::Synced)) - .await?; - - // Emit event for updated document - event_dispatcher.emit_document_updated_with_attribution( - &document.id, - &document.content, - document.user_id.as_ref(), - document.author_name.as_deref(), - document.visibility.as_deref(), - EventOrigin::Remote, - ); - } else { - tracing::info!( - "CLIENT {}: Skipping older sync (local version {} >= sync version {})", - client_id, - local_doc.sync_revision, - document.sync_revision - ); - } - } - Err(_) => { - // Document doesn't exist locally - save it - tracing::info!( - "CLIENT {}: Document {} is new, saving", - client_id, - document.id - ); - db.save_document_with_status(&document, Some(SyncStatus::Synced)) - .await?; - - // Emit event for new document - event_dispatcher.emit_document_created_with_attribution( - &document.id, - &document.content, - document.user_id.as_ref(), - document.author_name.as_deref(), - document.visibility.as_deref(), - EventOrigin::Remote, - ); - } - } - } - ServerMessage::SyncComplete { synced_count } => { - tracing::debug!("Sync complete, received {} documents", synced_count); - - // Emit sync completed event - event_dispatcher.emit_sync_completed(synced_count as u64); - } - - // Handle document operation confirmations - ServerMessage::DocumentCreatedResponse { - document_id, - success, - error, - author_name, - visibility, - provenance, - } => { - if success { - tracing::info!( - "CLIENT {}: Document creation confirmed by server: {}", - client_id, - document_id - ); - db.update_attribution( - &document_id, - author_name.clone(), - visibility.clone(), - provenance.clone(), - ) - .await?; - db.mark_synced(&document_id).await?; - // Clean up sync_queue - db.remove_from_sync_queue(&document_id).await?; - } else { - tracing::error!( - "CLIENT {}: Document creation failed on server: {} - {}", - client_id, - document_id, - error.as_deref().unwrap_or("unknown error") - ); - // Could emit an error event here - event_dispatcher.emit_sync_error( - ReplicantErrorCode::Unknown, - &format!("Create failed: {}", error.as_deref().unwrap_or("unknown")), - ); - } - } - - ServerMessage::DocumentUpdatedResponse { - document_id, - success, - error, - sync_revision, - .. - } => { - if success { - tracing::info!( - "CLIENT {}: Document update confirmed by server: {}", - client_id, - document_id - ); - // Update local sync_revision if provided by server - if let Some(new_revision) = sync_revision { - tracing::info!( - "CLIENT {}: Updating local sync_revision to {} for doc {}", - client_id, - new_revision, - document_id - ); - db.update_sync_revision(&document_id, new_revision).await?; - } - db.mark_synced(&document_id).await?; - // Clean up sync_queue - db.remove_from_sync_queue(&document_id).await?; - tracing::info!( - "CLIENT {}: Removed doc {} from sync_queue", - client_id, - document_id - ); - } else { - tracing::error!( - "CLIENT {}: Document update failed on server: {} - {}", - client_id, - document_id, - error.as_deref().unwrap_or("unknown error") - ); - event_dispatcher.emit_sync_error( - ReplicantErrorCode::Unknown, - &format!("Update failed: {}", error.as_deref().unwrap_or("unknown")), - ); - } - } - - ServerMessage::DocumentDeletedResponse { - document_id, - success, - error, - } => { - if success { - tracing::info!( - "CLIENT {}: Document deletion confirmed by server: {}", - client_id, - document_id - ); - db.mark_synced(&document_id).await?; - // Clean up sync_queue - db.remove_from_sync_queue(&document_id).await?; - } else { - tracing::error!( - "CLIENT {}: Document deletion failed on server: {} - {}", - client_id, - document_id, - error.as_deref().unwrap_or("unknown error") - ); - event_dispatcher.emit_sync_error( - ReplicantErrorCode::Unknown, - &format!("Delete failed: {}", error.as_deref().unwrap_or("unknown")), - ); - } - } - - _ => {} - } - - Ok(()) - } - - // Retry failed uploads by re-checking pending documents - async fn retry_failed_uploads(&self) -> SyncResult<()> { - tracing::info!( - "CLIENT {}: Starting upload retry for failed operations", - self.client_id - ); - - // Get current pending uploads (these are the ones that timed out) - let timed_out_uploads: Vec<Uuid> = { - let uploads = self.pending_uploads.lock().await; - uploads.keys().cloned().collect() - }; - - if timed_out_uploads.is_empty() { - tracing::info!("CLIENT {}: No timed out uploads to retry", self.client_id); - return Ok(()); - } - - tracing::info!( - "CLIENT {}: Retrying {} timed out uploads", - self.client_id, - timed_out_uploads.len() - ); - - // Clear the pending uploads (we'll re-add them during retry). - // Clearing without an ack means nothing else drains the deferred queue, - // so drain it here or those messages wait for an unrelated document's ack. - self.pending_uploads.lock().await.clear(); - if let Err(e) = Self::process_deferred_messages( - &self.deferred_messages, - &self.db, - self.client_id, - &self.event_dispatcher, - &self.pending_uploads, - &ResyncSource::socket(&self.ws_client), - ) - .await - { - tracing::error!( - "CLIENT {}: Error draining deferred messages before retry: {}", - self.client_id, - e - ); - } - - // Re-run sync_pending_documents to retry uploads - // This will re-query the database for documents with pending status - // and re-upload them with fresh tracking - self.sync_pending_documents().await?; - - // Quick wait for the retry confirmations (shorter timeout) - if !self.pending_uploads.lock().await.is_empty() { - let retry_count = self.pending_uploads.lock().await.len(); - tracing::info!( - "CLIENT {}: Waiting for {} retry confirmations (short timeout)", - self.client_id, - retry_count - ); - - tokio::select! { - _ = self.upload_complete_notifier.notified() => { - tracing::info!("CLIENT {}: All retry uploads confirmed", self.client_id); - } - _ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => { - let remaining = self.pending_uploads.lock().await.len(); - tracing::warn!("CLIENT {}: Retry timeout - {} uploads still failing", self.client_id, remaining); - // Don't retry again - proceed with partial failure - } - } - } - - Ok(()) - } - - pub async fn sync_all(&self) -> SyncResult<()> { - // Request full sync on startup to get all documents - tracing::debug!("Requesting full sync from server"); - - let ws_client = self.ws_client.lock().await; - if let Some(client) = ws_client.as_ref() { - client.send(ClientMessage::RequestFullSync).await?; - } else { - tracing::warn!("CLIENT {}: Cannot sync - not connected", self.client_id); - return Err(ClientError::WebSocket("Not connected".to_string()))?; - } - - Ok(()) - } - - /// Check if the WebSocket connection is active - pub fn is_connected(&self) -> bool { - self.is_connected.load(Ordering::Relaxed) - } - - /// Attempt to sync a single document immediately if connected - async fn try_immediate_sync(&self, document: &Document) -> SyncResult<()> { - let connected = self.is_connected(); - tracing::info!( - "CLIENT {}: 🔍 Connection status check: connected={}", - self.client_id, - connected - ); - - if !connected { - tracing::warn!("CLIENT {}: 📴 OFFLINE - Document {} cannot sync immediately, returning error to mark as pending", - self.client_id, document.id); - return Err(ClientError::WebSocket( - "Client is offline - document should remain pending".to_string(), - ))?; - } - - tracing::info!( - "CLIENT {}: 🚀 IMMEDIATE SYNC attempt for document {}", - self.client_id, - document.id - ); - tracing::info!( - "CLIENT {}: Document sync_revision: {}, content: {:?}", - self.client_id, - document.sync_revision, - document.content - ); - - // A queued create outranks a queued patch; see `classify_pending_upload`. - let (operation_type, message) = match classify_pending_upload(&self.db, &document.id).await - { - PendingUploadKind::Update { - patch, - old_content_hash, - } => { - tracing::info!( - "CLIENT {}: Sending UPDATE with queued patch for doc {}", - self.client_id, - document.id - ); - - use replicant_core::models::DocumentPatch; - use replicant_core::patches::calculate_checksum; - - // Use the stored old content hash, or calculate from current content as fallback - let content_hash = - old_content_hash.unwrap_or_else(|| calculate_checksum(&document.content)); - - ( - UploadType::Update, - ClientMessage::UpdateDocument { - patch: DocumentPatch { - document_id: document.id, - patch, - content_hash, - }, - }, - ) - } - PendingUploadKind::Create => { - tracing::info!( - "CLIENT {}: Sending CREATE for doc {} (the server does not hold it yet)", - self.client_id, - document.id - ); - ( - UploadType::Create, - ClientMessage::CreateDocument { - document: document.clone(), - }, - ) - } - }; - - // Add to pending uploads for tracking - { - let mut uploads = self.pending_uploads.lock().await; - uploads.insert( - document.id, - PendingUpload { - operation_type, - sent_at: Instant::now(), - }, - ); - } - - let ws_client = self.ws_client.lock().await; - match ws_client.as_ref() { - Some(client) => { - match client.send(message).await { - Ok(_) => { - tracing::info!( - "CLIENT {}: ✅ Immediate sync request sent for document {}", - self.client_id, - document.id - ); - Ok(()) - } - Err(e) => { - // Connection failed - mark as disconnected and remove from pending uploads - self.is_connected.store(false, Ordering::Relaxed); - self.event_dispatcher.emit_connection_lost(&self.server_url); - { - let mut uploads = self.pending_uploads.lock().await; - uploads.remove(&document.id); - } - tracing::warn!( - "CLIENT {}: WebSocket send failed, marked as disconnected", - self.client_id - ); - // Start reconnection loop if not already running - drop(ws_client); // Release lock before starting reconnection - self.start_reconnection_loop(); - Err(e) - } - } - } - None => { - tracing::warn!( - "CLIENT {}: No WebSocket connection available for immediate sync", - self.client_id - ); - { - let mut uploads = self.pending_uploads.lock().await; - uploads.remove(&document.id); - } - Err(ClientError::WebSocket("Not connected".to_string()))? - } - } - } - - /// Start the reconnection loop if not already running - fn start_reconnection_loop(&self) { - if self.stopped.load(Ordering::SeqCst) { - return; - } - if !self.sync_enabled { - tracing::info!( - "CLIENT {}: sync disabled (no credentials or identity not adopted) — \ - skipping reconnection monitor", - self.client_id - ); - return; - } - let is_connected = self.is_connected.clone(); - let ws_client = self.ws_client.clone(); - let server_url = self.server_url.clone(); - let email = self.email.clone(); - let api_key = self.api_key.clone(); - let api_secret = self.api_secret.clone(); - let client_id = self.client_id; - let user_id = self.user_id; - let event_dispatcher = self.event_dispatcher.clone(); - let db = self.db.clone(); - let pending_uploads = self.pending_uploads.clone(); - let upload_complete_notifier = self.upload_complete_notifier.clone(); - let reconnect_sync_tx = self.reconnect_sync_tx.clone(); - let sync_protection_mode = self.sync_protection_mode.clone(); - let last_ping_time = self.last_ping_time.clone(); - let deferred_messages = self.deferred_messages.clone(); - let upload_retry = self.upload_retry.clone(); - let stopped = self.stopped.clone(); - - tracing::info!( - "🔄 CLIENT {}: Starting continuous reconnection monitor (5-second intervals)", - client_id - ); - - tokio::spawn(async move { - const RECONNECTION_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5); - let mut connection_attempts = 0; - - loop { - if shutdown_requested(&stopped, &is_connected) { - tracing::info!( - "CLIENT {}: Client shut down, reconnection monitor stopping", - client_id - ); - break; - } - let currently_connected = is_connected.load(Ordering::Relaxed); - - if !currently_connected { - connection_attempts += 1; - tracing::info!( - "🔌 CLIENT {}: Connection attempt #{} to {}", - client_id, - connection_attempts, - server_url - ); - - // Try to connect - match WebSocketClient::connect( - &server_url, - &email, - client_id, - user_id, - &api_key, - &api_secret, - Some(event_dispatcher.clone()), - is_connected.clone(), - ) - .await - { - Ok((new_client, receiver)) => { - if shutdown_requested(&stopped, &is_connected) { - break; - } - tracing::info!( - "✅ CLIENT {}: Reconnection successful after {} attempts!", - client_id, - connection_attempts - ); - connection_attempts = 0; - - // Update the client - { - let mut guard = ws_client.lock().await; - if shutdown_requested(&stopped, &is_connected) { - break; - } - *guard = Some(new_client); - } - is_connected.store(true, Ordering::Relaxed); - // Shutdown may have run while the lock was held. - if shutdown_requested(&stopped, &is_connected) { - break; - } - - // Reset ping timer on successful connection - *last_ping_time.lock().await = Some(Instant::now()); - - if shutdown_requested(&stopped, &is_connected) { - break; - } - event_dispatcher.emit_connection_succeeded(&server_url); - - if shutdown_requested(&stopped, &is_connected) { - break; - } - // Start message receiver forwarding with connection monitoring - let (tx, mut rx) = mpsc::channel(100); - let receiver_is_connected = is_connected.clone(); - let receiver_client_id = client_id; - let receiver_event_dispatcher = event_dispatcher.clone(); - let receiver_server_url = server_url.clone(); - tokio::spawn(async move { - match receiver.forward_to(tx).await { - Ok(_) => { - tracing::info!( - "🔌 CLIENT {}: WebSocket receiver completed normally", - receiver_client_id - ); - } - Err(e) => { - tracing::warn!("❌ CLIENT {}: WebSocket receiver error: {} - marking as disconnected", receiver_client_id, e); - receiver_is_connected.store(false, Ordering::Relaxed); - receiver_event_dispatcher - .emit_connection_lost(&receiver_server_url); - } - } - }); - - // Process messages in background with connection monitoring - let db_clone = db.clone(); - let event_dispatcher_clone = event_dispatcher.clone(); - let pending_uploads_clone = pending_uploads.clone(); - let upload_complete_notifier_clone = upload_complete_notifier.clone(); - let sync_protection_mode_clone = sync_protection_mode.clone(); - let deferred_messages_clone = deferred_messages.clone(); - let upload_retry_clone = upload_retry.clone(); - let ws_client_for_handler = ws_client.clone(); - let handler_is_connected = is_connected.clone(); - let handler_client_id = client_id; - let handler_server_url = server_url.clone(); - tokio::spawn(async move { - while let Some(msg) = rx.recv().await { - if let Err(e) = Self::handle_server_message_with_tracking( - msg, - &db_clone, - handler_client_id, - &event_dispatcher_clone, - &pending_uploads_clone, - &upload_complete_notifier_clone, - &sync_protection_mode_clone, - &deferred_messages_clone, - &upload_retry_clone, - &ResyncSource::socket(&ws_client_for_handler), - ) - .await - { - tracing::error!( - "CLIENT {}: Error handling server message: {}", - handler_client_id, - e - ); - } - } - tracing::warn!("📪 CLIENT {}: Message handler terminated - marking as disconnected", handler_client_id); - handler_is_connected.store(false, Ordering::Relaxed); - event_dispatcher_clone.emit_connection_lost(&handler_server_url); - }); - - // Clear any stale pending uploads from before disconnection - // These are invalid now and will be re-uploaded if needed - { - let mut uploads = pending_uploads.lock().await; - if !uploads.is_empty() { - tracing::info!("CLIENT {}: Clearing {} stale pending uploads from before reconnection", - client_id, uploads.len()); - uploads.clear(); - } - } - - // Those uploads will never be acked, so nothing else - // would drain messages deferred behind them. - if let Err(e) = Self::process_deferred_messages( - &deferred_messages, - &db, - client_id, - &event_dispatcher, - &pending_uploads, - &ResyncSource::socket(&ws_client), - ) - .await - { - tracing::error!( - "CLIENT {}: Error draining deferred messages after reconnection: {}", - client_id, - e - ); - } - - // Trigger pending sync on the real sync engine via channel - // This will upload any pending documents and THEN request full sync - tracing::info!( - "📤 CLIENT {}: Triggering post-reconnection sync on real engine", - client_id - ); - - if shutdown_requested(&stopped, &is_connected) { - break; - } - if let Err(e) = reconnect_sync_tx.try_send(()) { - tracing::error!( - "CLIENT {}: Failed to trigger reconnection sync: {}", - client_id, - e - ); - } else { - tracing::info!( - "✅ CLIENT {}: Reconnection sync trigger sent to real engine", - client_id - ); - } - // The pending sync handler will request full sync after uploads complete - } - Err(e) => { - tracing::debug!("❌ CLIENT {}: Connection attempt #{} failed: {} - will retry in {}s", client_id, connection_attempts, e, RECONNECTION_INTERVAL.as_secs()); - event_dispatcher.emit_connection_attempted(&server_url); - } - } - } else { - // Connection is supposedly active - perform heartbeat check - let mut should_ping = false; - { - let last_ping = last_ping_time.lock().await; - match *last_ping { - Some(last_time) => { - if last_time.elapsed() >= PING_INTERVAL { - should_ping = true; - } - } - None => { - // Never pinged before, time to start - should_ping = true; - } - } - } - - if should_ping { - // Try to send a ping to verify connection is alive - tracing::info!( - "💓 CLIENT {}: Sending heartbeat ping to verify connection", - client_id - ); - let client_guard = ws_client.lock().await; - match client_guard.as_ref() { - Some(client) => { - match client.send(ClientMessage::Ping).await { - Ok(_) => { - // Ping successful, update last ping time - *last_ping_time.lock().await = Some(Instant::now()); - tracing::info!("✅ CLIENT {}: Heartbeat ping successful - connection alive", client_id); - } - Err(e) => { - // Ping failed - connection is broken - tracing::error!("💥 CLIENT {}: Heartbeat ping FAILED: {} - marking as disconnected and starting reconnection", client_id, e); - is_connected.store(false, Ordering::Relaxed); - event_dispatcher.emit_connection_lost(&server_url); - } - } - } - None => { - // No client but connection flag says connected - inconsistent state - tracing::error!("⚠️ CLIENT {}: Connection flag says connected but no client found - marking as disconnected", client_id); - is_connected.store(false, Ordering::Relaxed); - event_dispatcher.emit_connection_lost(&server_url); - } - } - } else { - // Not time to ping yet - let last_ping = last_ping_time.lock().await; - match *last_ping { - Some(last_time) => { - let elapsed = last_time.elapsed(); - tracing::debug!("💤 CLIENT {}: Heartbeat check - last ping was {:.1}s ago (will ping in {:.1}s)", - client_id, elapsed.as_secs_f32(), (PING_INTERVAL - elapsed).as_secs_f32()); - } - None => { - tracing::debug!( - "💤 CLIENT {}: Heartbeat check - no ping sent yet", - client_id - ); - } - } - } - } - - // Wait before next check/retry - tokio::time::sleep(RECONNECTION_INTERVAL).await; - } - }); - } - - /// Static method to perform pending sync after reconnection - /// This is called from the reconnection loop and operates on real engine components - async fn perform_pending_sync_after_reconnection( - db: &Arc<ClientDatabase>, - ws_client: &Arc<Mutex<Option<WebSocketClient>>>, - client_id: Uuid, - pending_uploads: &Arc<Mutex<HashMap<Uuid, PendingUpload>>>, - ) -> SyncResult<()> { - tracing::info!( - "CLIENT {}: Starting post-reconnection pending sync using real engine components", - client_id - ); - - let pending_docs = db.get_pending_documents().await?; - - if pending_docs.is_empty() { - tracing::info!( - "CLIENT {}: No pending documents to sync after reconnection", - client_id - ); - return Ok(()); - } - - tracing::info!( - "CLIENT {}: 📤 UPLOADING {} PENDING DOCUMENTS after reconnection", - client_id, - pending_docs.len() - ); - - for pending_info in pending_docs { - match db.get_document(&pending_info.id).await { - Ok(doc) => { - if pending_info.is_deleted { - // Handle pending delete - tracing::info!( - "CLIENT {}: Uploading pending delete for doc {}", - client_id, - pending_info.id - ); - - // Track this upload - pending_uploads.lock().await.insert( - pending_info.id, - PendingUpload { - operation_type: UploadType::Delete, - sent_at: Instant::now(), - }, - ); - - let ws_client_guard = ws_client.lock().await; - if let Some(client) = ws_client_guard.as_ref() { - client - .send(ClientMessage::DeleteDocument { - document_id: pending_info.id, - }) - .await?; - } else { - return Err(ClientError::WebSocket( - "Not connected during reconnection sync".to_string(), - ))?; - } - } else { - // A queued create outranks a queued patch; see - // `classify_pending_upload`. - match classify_pending_upload(db, &pending_info.id).await { - PendingUploadKind::Update { - patch: json_patch, - old_content_hash: old_hash_opt, - } => { - tracing::info!( - "CLIENT {}: Found queued patch for doc {} - using UpdateDocument", - client_id, - pending_info.id - ); - - // Convert the stored patch to DocumentPatch - use replicant_core::models::DocumentPatch; - use replicant_core::patches::calculate_checksum; - - let content_hash = old_hash_opt - .unwrap_or_else(|| calculate_checksum(&doc.content)); - - let patch_result = DocumentPatch { - document_id: pending_info.id, - patch: json_patch, - content_hash, - }; - - // Track this upload - pending_uploads.lock().await.insert( - pending_info.id, - PendingUpload { - operation_type: UploadType::Update, - sent_at: Instant::now(), - }, - ); - - let ws_client_guard = ws_client.lock().await; - if let Some(client) = ws_client_guard.as_ref() { - client - .send(ClientMessage::UpdateDocument { - patch: patch_result, - }) - .await?; - } else { - return Err(ClientError::WebSocket( - "Not connected during reconnection sync".to_string(), - ))?; - } - } - PendingUploadKind::Create => { - tracing::info!( - "CLIENT {}: Doc {} has not reached the server - using CreateDocument", - client_id, - pending_info.id - ); - - // Track this upload - pending_uploads.lock().await.insert( - pending_info.id, - PendingUpload { - operation_type: UploadType::Create, - sent_at: Instant::now(), - }, - ); - - let ws_client_guard = ws_client.lock().await; - if let Some(client) = ws_client_guard.as_ref() { - client - .send(ClientMessage::CreateDocument { - document: doc.clone(), - }) - .await?; - } else { - return Err(ClientError::WebSocket( - "Not connected during reconnection sync".to_string(), - ))?; - } - } - } - } - } - Err(e) => { - tracing::error!( - "CLIENT {}: Failed to get pending document {}: {}", - client_id, - pending_info.id, - e - ); - } - } - } - - tracing::info!( - "CLIENT {}: ✅ Completed uploading pending documents after reconnection", - client_id - ); - Ok(()) - } -} - -/// The create-vs-update decision the upload paths share. -#[cfg(test)] -mod upload_classification_tests { - use super::*; - use replicant_core::patches::{apply_patch, create_patch}; - use replicant_core::protocol::ChangeEventType; - use serde_json::json; - - async fn test_db() -> ClientDatabase { - let db = ClientDatabase::new(":memory:").await.unwrap(); - db.run_migrations().await.unwrap(); - db - } - - fn doc_with(id: Uuid, content: serde_json::Value) -> Document { - Document { - id, - user_id: Some(Uuid::new_v4()), - content, - sync_revision: 1, - content_hash: None, - title: None, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - } - } - - /// One local edit, exactly as `Client::update_document` performs it. - async fn edit(db: &ClientDatabase, id: Uuid, new_content: serde_json::Value) { - let mut doc = db.get_document(&id).await.unwrap(); - let old_content = doc.content.clone(); - let patch = create_patch(&old_content, &new_content).unwrap(); - doc.content = new_content; - db.save_document_and_queue_patch(&doc, &patch, ChangeEventType::Update, Some(&old_content)) - .await - .unwrap(); - } - - /// The DEV-1040 case: created offline, then edited offline twice. The queued - /// update must not win, or the server is asked to patch a document it has - /// never seen and answers `not_found` forever. - #[tokio::test] - async fn a_document_created_offline_uploads_as_a_create_carrying_its_edits() { - let db = test_db().await; - let id = Uuid::new_v4(); - db.save_new_document_and_queue_create(&doc_with(id, json!({"title": "draft"}))) - .await - .unwrap(); - - edit(&db, id, json!({"title": "draft", "a": 1})).await; - let final_content = json!({"title": "draft", "a": 1, "b": 2}); - edit(&db, id, final_content.clone()).await; - - assert!( - matches!( - classify_pending_upload(&db, &id).await, - PendingUploadKind::Create - ), - "an unconsumed create must outrank the queued patch" - ); - assert_eq!( - db.get_document(&id).await.unwrap().content, - final_content, - "the create sends current content, which must hold both edits" - ); - } - - /// The regression guard: a document the server already acknowledged still - /// uploads its edits as a cumulative update. - #[tokio::test] - async fn a_document_the_server_holds_uploads_as_an_update() { - let db = test_db().await; - let id = Uuid::new_v4(); - let acknowledged = json!({"title": "draft"}); - db.save_new_document_and_queue_create(&doc_with(id, acknowledged.clone())) - .await - .unwrap(); - db.remove_from_sync_queue(&id).await.unwrap(); // the create ack - - edit(&db, id, json!({"title": "draft", "a": 1})).await; - let final_content = json!({"title": "draft", "a": 1, "b": 2}); - edit(&db, id, final_content.clone()).await; - - let PendingUploadKind::Update { patch, .. } = classify_pending_upload(&db, &id).await - else { - panic!("an acknowledged document must upload as an update"); - }; - - let mut replayed = acknowledged; - apply_patch(&mut replayed, &patch).unwrap(); - assert_eq!( - replayed, final_content, - "the update must still be one cumulative patch off the acknowledged content" - ); - } - - /// A document that is pending with nothing queued at all — a pre-upgrade - /// database, or a create whose row predates this fix — still reads as a - /// create, exactly as before. - #[tokio::test] - async fn a_pending_document_with_an_empty_queue_uploads_as_a_create() { - let db = test_db().await; - let id = Uuid::new_v4(); - db.save_document_with_status( - &doc_with(id, json!({"title": "draft"})), - Some(SyncStatus::Pending), - ) - .await - .unwrap(); - - assert!(matches!( - classify_pending_upload(&db, &id).await, - PendingUploadKind::Create - )); - } -} - -#[cfg(test)] -mod broadcast_guard_tests { - use super::*; - use crate::events::{EventOrigin, SyncEvent}; - use replicant_core::models::ServerDocumentPatch; - use replicant_core::patches::calculate_checksum; - use replicant_core::protocol::ErrorCode; - use serde_json::{json, Value}; - - async fn test_db() -> Arc<ClientDatabase> { - let db = ClientDatabase::new(":memory:").await.unwrap(); - db.run_migrations().await.unwrap(); - Arc::new(db) - } - - fn dispatcher() -> Arc<EventDispatcher> { - Arc::new(EventDispatcher::new()) - } - - /// A resync source that records attempts and returns nothing, so a test can - /// assert a resync WAS attempted rather than inferring it from the absence - /// of a write. - fn recording_source() -> (ResyncSource, Arc<CannedResync>) { - canned_source(Vec::new()) - } - - /// A resync source that hands out `replies` one per fetch attempt. - fn canned_source(replies: Vec<ServerMessage>) -> (ResyncSource, Arc<CannedResync>) { - let canned = Arc::new(CannedResync { - attempts: Mutex::new(Vec::new()), - replies: Mutex::new(replies.into()), - }); - (ResyncSource::Canned(canned.clone()), canned) - } - - /// An `UploadRetry` plus the receiving end of its trigger channel. - fn retry_probe() -> (UploadRetry, mpsc::Receiver<()>) { - let (tx, rx) = mpsc::channel(10); - (UploadRetry::new(tx), rx) - } - - /// A rejected update ack. `current` carries the server's state at rejection - /// time, which the server only sends for `hash_mismatch`. - fn update_rejected( - document_id: Uuid, - reason: &str, - current: Option<(i64, Value, String)>, - ) -> ServerMessage { - let (current_revision, current_content, current_hash) = match current { - Some((revision, content, hash)) => (Some(revision), Some(content), Some(hash)), - None => (None, None, None), - }; - ServerMessage::DocumentUpdatedResponse { - document_id, - success: false, - error: Some(format!("server rejected update: {}", reason)), - sync_revision: None, - reason: Some(reason.to_string()), - current_revision, - current_content, - current_hash, - } - } - - /// Deliver an upload ack through the tracking handler with the document's - /// upload in flight, exactly as the message loop would. - async fn deliver_ack( - db: &Arc<ClientDatabase>, - events: &Arc<EventDispatcher>, - upload_retry: &UploadRetry, - id: Uuid, - ack: ServerMessage, - ) { - let (source, _canned) = recording_source(); - deliver_ack_from(db, events, upload_retry, id, ack, &source).await; - } - - /// `deliver_ack` against a chosen resync source, for acks whose recovery - /// path fetches the document from the server. - async fn deliver_ack_from( - db: &Arc<ClientDatabase>, - events: &Arc<EventDispatcher>, - upload_retry: &UploadRetry, - id: Uuid, - ack: ServerMessage, - source: &ResyncSource, - ) { - let (pending_uploads, deferred_messages, notifier, protection) = tracking_args(); - pending_uploads.lock().await.insert(id, in_flight_upload()); - - Client::handle_server_message_with_tracking( - ack, - db, - Uuid::new_v4(), - events, - &pending_uploads, - ¬ifier, - &protection, - &deferred_messages, - upload_retry, - source, - ) - .await - .unwrap(); - - assert!( - pending_uploads.lock().await.is_empty(), - "an ack must settle the upload before anything is resent" - ); - } - - /// A create the server refused. - fn create_rejected(document_id: Uuid, error: &str) -> ServerMessage { - ServerMessage::DocumentCreatedResponse { - document_id, - success: false, - error: Some(error.to_string()), - author_name: None, - visibility: None, - provenance: None, - } - } - - async fn queued_base_hash(db: &Arc<ClientDatabase>, id: &Uuid) -> Option<String> { - db.get_queued_patch(id).await.unwrap().and_then(|(_, h)| h) - } - - /// The stored content text, not the parsed value: the compare-and-swap - /// compares text, so "unchanged" has to be asserted at that level. - async fn raw_content(db: &Arc<ClientDatabase>, id: &Uuid) -> String { - sqlx::query("SELECT content FROM documents WHERE id = ?") - .bind(id.to_string()) - .fetch_one(&db.pool) - .await - .unwrap() - .try_get("content") - .unwrap() - } - - async fn resync_attempts(canned: &Arc<CannedResync>) -> Vec<Uuid> { - canned.attempts.lock().await.clone() - } - - fn make_doc(id: Uuid, content: Value, sync_revision: i64) -> Document { - Document { - id, - user_id: Some(Uuid::new_v4()), - content, - sync_revision, - content_hash: None, - title: None, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - } - } - - async fn seed(db: &Arc<ClientDatabase>, doc: &Document, status: SyncStatus) { - db.save_document_with_status(doc, Some(status)) - .await - .unwrap(); - } - - /// Seed a document the way a real local edit does: save it Pending AND - /// queue its patch, so the sync_queue is in its production shape. - async fn seed_pending_edit( - db: &Arc<ClientDatabase>, - id: Uuid, - base: &Value, - edited: &Value, - sync_revision: i64, - ) { - seed( - db, - &make_doc(id, base.clone(), sync_revision), - SyncStatus::Synced, - ) - .await; - let doc = make_doc(id, edited.clone(), sync_revision); - db.save_document_and_queue_patch( - &doc, - &create_patch(base, edited).unwrap(), - replicant_core::protocol::ChangeEventType::Update, - Some(base), - ) - .await - .unwrap(); - } - - async fn queued_update_rows(db: &Arc<ClientDatabase>, id: &Uuid) -> i64 { - sqlx::query( - "SELECT COUNT(*) as c FROM sync_queue WHERE document_id = ? AND operation_type = 'update'", - ) - .bind(id.to_string()) - .fetch_one(&db.pool) - .await - .unwrap() - .try_get("c") - .unwrap() - } - - async fn deleted_at(db: &Arc<ClientDatabase>, id: &Uuid) -> Option<String> { - sqlx::query("SELECT deleted_at FROM documents WHERE id = ?") - .bind(id.to_string()) - .fetch_one(&db.pool) - .await - .unwrap() - .try_get("deleted_at") - .unwrap() - } - - fn server_patch( - document_id: Uuid, - before: &Value, - after: &Value, - sync_revision: i64, - ) -> ServerDocumentPatch { - ServerDocumentPatch { - document_id, - patch: create_patch(before, after).unwrap(), - sync_revision, - content_hash: calculate_checksum(after), - } - } - - /// Deliver a broadcast patch and report which documents the handler tried - /// to resync as a result. - async fn deliver( - db: &Arc<ClientDatabase>, - events: &Arc<EventDispatcher>, - patch: ServerDocumentPatch, - ) -> Vec<Uuid> { - let (source, canned) = recording_source(); - Client::handle_server_message( - ServerMessage::DocumentUpdated { patch }, - db, - Uuid::new_v4(), - events, - &source, - ) - .await - .unwrap(); - resync_attempts(&canned).await - } - - #[tokio::test] - async fn contiguous_broadcast_with_matching_hash_applies() { - let db = test_db().await; - let id = Uuid::new_v4(); - let before = json!({"title": "A"}); - let after = json!({"title": "B"}); - seed(&db, &make_doc(id, before.clone(), 5), SyncStatus::Synced).await; - - let resynced = deliver(&db, &dispatcher(), server_patch(id, &before, &after, 6)).await; - - assert!(resynced.is_empty(), "a clean apply must not resync"); - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, after); - assert_eq!(doc.sync_revision, 6); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Synced) - ); - } - - #[tokio::test] - async fn duplicate_delivery_of_same_revision_is_a_noop() { - let db = test_db().await; - let events = dispatcher(); - let id = Uuid::new_v4(); - let before = json!({"title": "A"}); - let after = json!({"title": "B"}); - seed(&db, &make_doc(id, before.clone(), 5), SyncStatus::Synced).await; - - let patch = server_patch(id, &before, &after, 6); - deliver(&db, &events, patch.clone()).await; - let resynced = deliver(&db, &events, patch).await; - - assert!(resynced.is_empty(), "a duplicate must drop, not resync"); - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, after, "second delivery must not re-patch"); - assert_eq!(doc.sync_revision, 6); - } - - #[tokio::test] - async fn self_broadcast_at_or_below_local_revision_is_dropped() { - let db = test_db().await; - let id = Uuid::new_v4(); - let before = json!({"title": "A"}); - let after = json!({"title": "B"}); - // Local already advanced to 6 (our own update was confirmed); the - // server echoes our own broadcast back at 6. - seed(&db, &make_doc(id, after.clone(), 6), SyncStatus::Synced).await; - - let resynced = deliver(&db, &dispatcher(), server_patch(id, &before, &after, 6)).await; - - assert!(resynced.is_empty(), "a self-echo must drop, not resync"); - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, after); - assert_eq!(doc.sync_revision, 6); - } - - #[tokio::test] - async fn revision_gap_does_not_blind_apply() { - let db = test_db().await; - let id = Uuid::new_v4(); - let local = json!({"title": "A"}); - seed(&db, &make_doc(id, local.clone(), 5), SyncStatus::Synced).await; - - // u1 was missed; only u2's broadcast (revision 7) arrives. - let skipped_base = json!({"title": "B"}); - let resynced = deliver( - &db, - &dispatcher(), - server_patch(id, &skipped_base, &json!({"title": "C"}), 7), - ) - .await; - - assert_eq!(resynced, vec![id], "the gap must trigger a resync"); - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, local, "gap must resync, never frankenpatch"); - assert_eq!(doc.sync_revision, 5); - } - - #[tokio::test] - async fn pending_local_edits_are_never_overwritten_by_a_broadcast() { - let db = test_db().await; - let id = Uuid::new_v4(); - let local = json!({"title": "local edit"}); - seed(&db, &make_doc(id, local.clone(), 5), SyncStatus::Pending).await; - - let resynced = deliver( - &db, - &dispatcher(), - server_patch(id, &json!({"title": "A"}), &json!({"title": "B"}), 6), - ) - .await; - - assert_eq!( - resynced, - vec![id], - "pending local edits must trigger a resync" - ); - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, local); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending) - ); - } - - #[tokio::test] - async fn hash_mismatch_after_apply_does_not_commit_the_patch() { - let db = test_db().await; - let id = Uuid::new_v4(); - let before = json!({"title": "A"}); - seed(&db, &make_doc(id, before.clone(), 5), SyncStatus::Synced).await; - - let mut patch = server_patch(id, &before, &json!({"title": "B"}), 6); - patch.content_hash = - "0000000000000000000000000000000000000000000000000000000000000000".to_string(); - deliver(&db, &dispatcher(), patch).await; - - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, before, "diverged patch must resync, not apply"); - assert_eq!(doc.sync_revision, 5); - } - - #[tokio::test] - async fn resync_response_replaces_a_synced_local_document() { - let db = test_db().await; - let id = Uuid::new_v4(); - seed( - &db, - &make_doc(id, json!({"title": "stale"}), 5), - SyncStatus::Synced, - ) - .await; - - let fresh = json!({"title": "fresh", "n": 2}); - Client::apply_resync_result( - &db, - Uuid::new_v4(), - &dispatcher(), - id, - ServerMessage::GetDocumentResponse { - id, - content: fresh.clone(), - sync_revision: 9, - content_hash: calculate_checksum(&fresh), - deleted: false, - }, - ) - .await - .unwrap(); - - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, fresh); - assert_eq!(doc.sync_revision, 9); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Synced) - ); - } - - #[tokio::test] - async fn resync_rebases_pending_local_edits_onto_the_fresh_base() { - let db = test_db().await; - let id = Uuid::new_v4(); - let local = json!({"title": "stale", "mine": true}); - // Seed the way production does: a local edit saves the document AND - // queues its patch, so the rebase below has a prior row to replace. - seed_pending_edit(&db, id, &json!({"title": "stale"}), &local, 5).await; - - let fresh = json!({"title": "fresh"}); - let fresh_hash = calculate_checksum(&fresh); - Client::apply_resync_result( - &db, - Uuid::new_v4(), - &dispatcher(), - id, - ServerMessage::GetDocumentResponse { - id, - content: fresh.clone(), - sync_revision: 9, - content_hash: fresh_hash.clone(), - deleted: false, - }, - ) - .await - .unwrap(); - - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, local, "local edits must survive the rebase"); - assert_eq!(doc.sync_revision, 9, "base revision adopted from server"); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending) - ); - - assert_eq!( - queued_update_rows(&db, &id).await, - 1, - "the rebase must replace the queued patch, not append a second row" - ); - let (patch, base_hash) = db.get_queued_patch(&id).await.unwrap().unwrap(); - assert_eq!(base_hash.as_deref(), Some(fresh_hash.as_str())); - let mut replayed = fresh; - apply_patch(&mut replayed, &patch).unwrap(); - assert_eq!(replayed, local, "queued patch must rebuild local content"); - } - - /// Build the argument bundle `handle_server_message_with_tracking` needs. - fn tracking_args() -> ( - Arc<Mutex<HashMap<Uuid, PendingUpload>>>, - Arc<Mutex<Vec<ServerMessage>>>, - Arc<Notify>, - Arc<AtomicBool>, - ) { - ( - Arc::new(Mutex::new(HashMap::new())), - Arc::new(Mutex::new(Vec::new())), - Arc::new(Notify::new()), - Arc::new(AtomicBool::new(false)), - ) - } - - fn in_flight_upload() -> PendingUpload { - PendingUpload { - operation_type: UploadType::Update, - sent_at: Instant::now(), - } - } - - #[tokio::test] - async fn a_failed_ack_clears_the_pending_upload_and_drains_the_queue() { - let db = test_db().await; - let id = Uuid::new_v4(); - let before = json!({"title": "A"}); - let after = json!({"title": "B"}); - seed_pending_edit(&db, id, &before, &after, 5).await; - - let (pending_uploads, deferred_messages, notifier, protection) = tracking_args(); - pending_uploads.lock().await.insert(id, in_flight_upload()); - let (source, canned) = recording_source(); - - // The echo arrives while the upload is in flight, so it defers. - Client::handle_server_message_with_tracking( - ServerMessage::DocumentUpdated { - patch: server_patch(id, &before, &after, 6), - }, - &db, - Uuid::new_v4(), - &dispatcher(), - &pending_uploads, - ¬ifier, - &protection, - &deferred_messages, - &retry_probe().0, - &source, - ) - .await - .unwrap(); - assert_eq!(deferred_messages.lock().await.len(), 1); - - // The server rejects the upload. The entry must still be cleared and - // the queue drained, or this document defers forever. - db.update_sync_revision(&id, 6).await.unwrap(); - db.mark_synced(&id).await.unwrap(); - let (upload_retry, mut retry_rx) = retry_probe(); - Client::handle_server_message_with_tracking( - update_rejected(id, "server_error", None), - &db, - Uuid::new_v4(), - &dispatcher(), - &pending_uploads, - ¬ifier, - &protection, - &deferred_messages, - &upload_retry, - &source, - ) - .await - .unwrap(); - - assert!( - retry_rx.try_recv().is_ok(), - "a failed ack must schedule an upload retry pass" - ); - - assert!( - pending_uploads.lock().await.is_empty(), - "a failed ack must clear the pending upload" - ); - assert!( - deferred_messages.lock().await.is_empty(), - "a failed ack must drain the deferred queue" - ); - assert!( - resync_attempts(&canned).await.is_empty(), - "the replayed echo must drop as a duplicate, not resync" - ); - } - - #[tokio::test] - async fn deferred_echoes_dedupe_and_cannot_evict_a_sync_document() { - let db = test_db().await; - let echo_id = Uuid::new_v4(); - let sync_id = Uuid::new_v4(); - let before = json!({"title": "A"}); - seed_pending_edit(&db, echo_id, &before, &json!({"title": "B"}), 5).await; - - let (pending_uploads, deferred_messages, notifier, protection) = tracking_args(); - { - let mut uploads = pending_uploads.lock().await; - uploads.insert(echo_id, in_flight_upload()); - uploads.insert(sync_id, in_flight_upload()); - } - let (source, _canned) = recording_source(); - - // A SyncDocument for another document gets deferred first. - let mut sync_doc = make_doc(sync_id, json!({"title": "sync me"}), 2); - sync_doc.title = None; - db.save_document_with_status(&sync_doc, Some(SyncStatus::Synced)) - .await - .unwrap(); - Client::handle_server_message_with_tracking( - ServerMessage::SyncDocument { - document: sync_doc.clone(), - }, - &db, - Uuid::new_v4(), - &dispatcher(), - &pending_uploads, - ¬ifier, - &protection, - &deferred_messages, - &retry_probe().0, - &source, - ) - .await - .unwrap(); - - // Then a burst of echoes for the other document. - for revision in 6..206 { - Client::handle_server_message_with_tracking( - ServerMessage::DocumentUpdated { - patch: server_patch(echo_id, &before, &json!({"n": revision}), revision), - }, - &db, - Uuid::new_v4(), - &dispatcher(), - &pending_uploads, - ¬ifier, - &protection, - &deferred_messages, - &retry_probe().0, - &source, - ) - .await - .unwrap(); - } - - let queue = deferred_messages.lock().await; - assert_eq!( - queue.len(), - 2, - "200 echoes for one document must collapse to a single entry" - ); - assert!( - queue.iter().any( - |m| matches!(m, ServerMessage::SyncDocument { document } if document.id == sync_id) - ), - "echo pressure must not evict the SyncDocument" - ); - // The surviving echo is the newest one. - let latest = queue - .iter() - .find_map(|m| match m { - ServerMessage::DocumentUpdated { patch } => Some(patch.sync_revision), - _ => None, - }) - .unwrap(); - assert_eq!(latest, 205, "the newest echo must replace older ones"); - } - - #[tokio::test] - async fn a_document_read_failure_during_resync_writes_nothing() { - let db = test_db().await; - let id = Uuid::new_v4(); - let local = json!({"title": "unsent edit"}); - seed(&db, &make_doc(id, local.clone(), 5), SyncStatus::Pending).await; - - // A row whose content is not valid JSON: the status reads back fine - // (Pending) but parsing the document fails, which must NOT be mistaken - // for "no such document". - sqlx::query("UPDATE documents SET content = 'not json' WHERE id = ?") - .bind(id.to_string()) - .execute(&db.pool) - .await - .unwrap(); - assert!(db.get_document(&id).await.is_err(), "the read must fail"); - - let fresh = json!({"title": "server"}); - let settled = Client::apply_resync_result( - &db, - Uuid::new_v4(), - &dispatcher(), - id, - ServerMessage::GetDocumentResponse { - id, - content: fresh.clone(), - sync_revision: 9, - content_hash: calculate_checksum(&fresh), - deleted: false, - }, - ) - .await - .unwrap(); - - assert!(settled, "a read failure must not trigger a retry loop"); - let raw: String = sqlx::query("SELECT content FROM documents WHERE id = ?") - .bind(id.to_string()) - .fetch_one(&db.pool) - .await - .unwrap() - .try_get("content") - .unwrap(); - assert_eq!( - raw, "not json", - "nothing may be written after a read failure" - ); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending), - "the pending edit must survive" - ); - } - - #[tokio::test] - async fn a_redeferred_message_never_overwrites_a_newer_queued_one() { - let client_id = Uuid::new_v4(); - let queue = Arc::new(Mutex::new(Vec::new())); - let id = Uuid::new_v4(); - - // A newer SyncDocument landed from another producer task while an older - // copy was in flight back to the queue. - Client::defer_message( - &queue, - client_id, - ServerMessage::SyncDocument { - document: make_doc(id, json!({"title": "newer"}), 9), - }, - ) - .await; - - Client::redefer_message( - &queue, - client_id, - ServerMessage::SyncDocument { - document: make_doc(id, json!({"title": "older"}), 4), - }, - ) - .await; - - let queued = queue.lock().await; - assert_eq!(queued.len(), 1); - match &queued[0] { - ServerMessage::SyncDocument { document } => { - assert_eq!(document.sync_revision, 9, "the newer entry must survive"); - assert_eq!(document.content, json!({"title": "newer"})); - } - other => panic!("expected SyncDocument, got {:?}", other), - } - } - - #[tokio::test] - async fn deferring_an_older_message_never_downgrades_a_newer_one() { - let client_id = Uuid::new_v4(); - let queue = Arc::new(Mutex::new(Vec::new())); - let id = Uuid::new_v4(); - let before = json!({"title": "A"}); - - Client::defer_message( - &queue, - client_id, - ServerMessage::DocumentUpdated { - patch: server_patch(id, &before, &json!({"n": 9}), 9), - }, - ) - .await; - Client::defer_message( - &queue, - client_id, - ServerMessage::DocumentUpdated { - patch: server_patch(id, &before, &json!({"n": 4}), 4), - }, - ) - .await; - - let queued = queue.lock().await; - assert_eq!(queued.len(), 1); - assert_eq!(Client::deferral_revision(&queued[0]), 9); - } - - #[tokio::test] - async fn a_resync_that_settles_first_time_only_fetches_once() { - let db = test_db().await; - let id = Uuid::new_v4(); - seed( - &db, - &make_doc(id, json!({"title": "stale"}), 5), - SyncStatus::Synced, - ) - .await; - - let fresh = json!({"title": "server"}); - let (source, canned) = canned_source(vec![ServerMessage::GetDocumentResponse { - id, - content: fresh.clone(), - sync_revision: 9, - content_hash: calculate_checksum(&fresh), - deleted: false, - }]); - - Client::resync_document(&db, &source, Uuid::new_v4(), &dispatcher(), id) - .await - .unwrap(); - - assert_eq!( - resync_attempts(&canned).await, - vec![id], - "a swap that wins must not re-fetch" - ); - assert_eq!(db.get_document(&id).await.unwrap().content, fresh); - } - - #[tokio::test] - async fn a_resync_whose_swap_keeps_losing_refetches_once_then_gives_up() { - let db = test_db().await; - let id = Uuid::new_v4(); - let local = json!({"title": "local"}); - seed(&db, &make_doc(id, local.clone(), 5), SyncStatus::Synced).await; - - // Store the content with whitespace serde_json would never emit. The - // row still parses to `local`, so the resync reads that as its - // pre-image, but the compare-and-swap binds the canonical form and can - // never match the stored text — a permanently losing swap. - sqlx::query(r#"UPDATE documents SET content = '{"title": "local"}' WHERE id = ?"#) - .bind(id.to_string()) - .execute(&db.pool) - .await - .unwrap(); - - let fresh = json!({"title": "server"}); - let reply = || ServerMessage::GetDocumentResponse { - id, - content: fresh.clone(), - sync_revision: 9, - content_hash: calculate_checksum(&fresh), - deleted: false, - }; - let (source, canned) = canned_source(vec![reply(), reply(), reply()]); - - Client::resync_document(&db, &source, Uuid::new_v4(), &dispatcher(), id) - .await - .unwrap(); - - assert_eq!( - resync_attempts(&canned).await, - vec![id, id], - "a lost swap must re-fetch exactly once, then give up" - ); - - let raw: String = sqlx::query("SELECT content FROM documents WHERE id = ?") - .bind(id.to_string()) - .fetch_one(&db.pool) - .await - .unwrap() - .try_get("content") - .unwrap(); - assert_eq!( - raw, r#"{"title": "local"}"#, - "a losing swap must never write" - ); - } - - #[tokio::test] - async fn compare_and_swap_refuses_to_clobber_a_concurrent_local_edit() { - let db = test_db().await; - let id = Uuid::new_v4(); - let base = json!({"title": "A"}); - seed(&db, &make_doc(id, base.clone(), 5), SyncStatus::Synced).await; - - // What the guard verified before deciding to write. - let expected = DocumentPreImage { - sync_revision: 5, - content: base.clone(), - sync_status: SyncStatus::Synced, - }; - - // A local edit lands in the read-check-write window. - let local = json!({"title": "local edit"}); - seed_pending_edit(&db, id, &base, &local, 5).await; - - let committed = db - .save_document_if_unchanged( - &make_doc(id, json!({"title": "B"}), 6), - SyncStatus::Synced, - &expected, - ) - .await - .unwrap(); - - assert!(!committed, "the swap must lose to the concurrent edit"); - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, local, "the local edit must survive"); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending) - ); - } - - #[tokio::test] - async fn unparseable_sync_status_fails_closed_as_pending() { - let db = test_db().await; - let id = Uuid::new_v4(); - seed(&db, &make_doc(id, json!({}), 1), SyncStatus::Synced).await; - // The CHECK constraint blocks a garbage status today; bypass it so the - // read path's fallback is exercised against schema drift. - // The PRAGMA is per-connection, so both statements share one. - let mut conn = db.pool.acquire().await.unwrap(); - sqlx::query("PRAGMA ignore_check_constraints = ON") - .execute(&mut *conn) - .await - .unwrap(); - sqlx::query("UPDATE documents SET sync_status = 'banana' WHERE id = ?") - .bind(id.to_string()) - .execute(&mut *conn) - .await - .unwrap(); - drop(conn); - - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending), - "an unknown status must read back as the only non-appliable, \ - non-destructive value: Synced and Conflict both authorise an apply" - ); - - // And prove it: a contiguous broadcast onto the drifted row resyncs - // instead of applying, so unsent local edits cannot be overwritten. - let resynced = deliver( - &db, - &dispatcher(), - server_patch(id, &json!({}), &json!({"title": "from the server"}), 2), - ) - .await; - assert_eq!( - resynced, - vec![id], - "a drifted status must route to a resync, never a blind apply" - ); - } - - #[tokio::test] - async fn echo_during_an_in_flight_upload_is_deferred_not_resynced() { - let db = test_db().await; - let id = Uuid::new_v4(); - let before = json!({"title": "A"}); - let after = json!({"title": "B"}); - seed_pending_edit(&db, id, &before, &after, 5).await; - - let pending_uploads = Arc::new(Mutex::new(HashMap::from([( - id, - PendingUpload { - operation_type: UploadType::Update, - sent_at: Instant::now(), - }, - )]))); - let deferred_messages = Arc::new(Mutex::new(Vec::new())); - let (source, canned) = recording_source(); - - Client::handle_server_message_with_tracking( - ServerMessage::DocumentUpdated { - patch: server_patch(id, &before, &after, 6), - }, - &db, - Uuid::new_v4(), - &dispatcher(), - &pending_uploads, - &Arc::new(Notify::new()), - &Arc::new(AtomicBool::new(false)), - &deferred_messages, - &retry_probe().0, - &source, - ) - .await - .unwrap(); - - assert!( - resync_attempts(&canned).await.is_empty(), - "an echo for an in-flight upload must not cost a resync" - ); - assert_eq!( - deferred_messages.lock().await.len(), - 1, - "the echo must be deferred until the ack lands" - ); - assert_eq!( - queued_update_rows(&db, &id).await, - 1, - "deferring must not add a queue row" - ); - - // After the ack, the deferred echo replays and hits the idempotency drop. - db.update_sync_revision(&id, 6).await.unwrap(); - db.mark_synced(&id).await.unwrap(); - Client::process_deferred_messages( - &deferred_messages, - &db, - Uuid::new_v4(), - &dispatcher(), - &pending_uploads, - &source, - ) - .await - .unwrap(); - - assert!( - resync_attempts(&canned).await.is_empty(), - "the replayed echo must drop as a duplicate" - ); - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, after); - assert_eq!(doc.sync_revision, 6); - } - - #[tokio::test] - async fn resync_tombstone_soft_deletes_locally() { - let db = test_db().await; - let id = Uuid::new_v4(); - seed( - &db, - &make_doc(id, json!({"title": "doomed"}), 5), - SyncStatus::Synced, - ) - .await; - - Client::apply_resync_result( - &db, - Uuid::new_v4(), - &dispatcher(), - id, - ServerMessage::GetDocumentResponse { - id, - content: Value::Null, - sync_revision: 6, - content_hash: String::new(), - deleted: true, - }, - ) - .await - .unwrap(); - - assert!(deleted_at(&db, &id).await.is_some()); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Synced) - ); - } - - #[tokio::test] - async fn resync_not_found_soft_deletes_locally() { - let db = test_db().await; - let id = Uuid::new_v4(); - seed( - &db, - &make_doc(id, json!({"title": "gone"}), 5), - SyncStatus::Synced, - ) - .await; - - Client::apply_resync_result( - &db, - Uuid::new_v4(), - &dispatcher(), - id, - ServerMessage::Error { - code: ErrorCode::DocumentNotFound, - message: "Document not found".to_string(), - }, - ) - .await - .unwrap(); - - assert!(deleted_at(&db, &id).await.is_some()); - } - - /// The exception to the rule above: a document that still owes a create has - /// never been offered to the server, so `not_found` is the expected answer, - /// not an authoritative deletion. Soft-deleting it would destroy the only - /// copy of content the user just created. - #[tokio::test] - async fn resync_not_found_keeps_a_document_whose_create_is_still_queued() { - let db = test_db().await; - let id = Uuid::new_v4(); - let created = json!({"title": "never uploaded"}); - db.save_new_document_and_queue_create(&make_doc(id, created.clone(), 1)) - .await - .unwrap(); - - Client::apply_resync_result( - &db, - Uuid::new_v4(), - &dispatcher(), - id, - ServerMessage::Error { - code: ErrorCode::DocumentNotFound, - message: "Document not found".to_string(), - }, - ) - .await - .unwrap(); - - assert_eq!( - deleted_at(&db, &id).await, - None, - "content the server has never seen must not be deleted for being absent" - ); - assert_eq!(db.get_document(&id).await.unwrap().content, created); - assert!( - db.has_queued_create(&id).await.unwrap(), - "the create is still owed" - ); - } - - #[tokio::test] - async fn resync_server_error_leaves_local_state_untouched() { - let db = test_db().await; - let id = Uuid::new_v4(); - let local = json!({"title": "unsent edit"}); - seed(&db, &make_doc(id, local.clone(), 5), SyncStatus::Pending).await; - - Client::apply_resync_result( - &db, - Uuid::new_v4(), - &dispatcher(), - id, - ServerMessage::Error { - code: ErrorCode::ServerError, - message: "timeout".to_string(), - }, - ) - .await - .unwrap(); - - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, local); - assert_eq!(doc.sync_revision, 5); - assert!(deleted_at(&db, &id).await.is_none(), "must not delete"); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending) - ); - } - - #[tokio::test] - async fn resync_of_an_unknown_document_saves_it_as_synced() { - let db = test_db().await; - let id = Uuid::new_v4(); - let fresh = json!({"title": "brand new"}); - - Client::apply_resync_result( - &db, - Uuid::new_v4(), - &dispatcher(), - id, - ServerMessage::GetDocumentResponse { - id, - content: fresh.clone(), - sync_revision: 3, - content_hash: calculate_checksum(&fresh), - deleted: false, - }, - ) - .await - .unwrap(); - - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, fresh); - assert_eq!(doc.sync_revision, 3); - } - - #[tokio::test] - async fn remote_delete_of_a_pending_document_still_emits_deleted() { - let db = test_db().await; - let id = Uuid::new_v4(); - seed( - &db, - &make_doc(id, json!({"title": "edited then deleted"}), 5), - SyncStatus::Pending, - ) - .await; - - // Remote deletes are user intent and stay unconditional: the pending - // local edit is dropped and the host app is told the doc is gone. - Client::handle_server_message( - ServerMessage::DocumentDeleted { document_id: id }, - &db, - Uuid::new_v4(), - &dispatcher(), - &recording_source().0, - ) - .await - .unwrap(); - - assert!(deleted_at(&db, &id).await.is_some()); - } - - // --------------------------------------------------------------------- - // hash_mismatch -> rebase and resend (Task 5) - // --------------------------------------------------------------------- - - /// Collect emitted events. Emission only queues them; `drain_events` runs - /// the pump, which must happen on the registering thread (here, the - /// single-threaded test runtime). - fn event_probe(events: &Arc<EventDispatcher>) -> Arc<std::sync::Mutex<Vec<SyncEvent>>> { - let seen = Arc::new(std::sync::Mutex::new(Vec::new())); - let sink = seen.clone(); - events - .register_rust_callback(move |event| sink.lock().unwrap().push(event)) - .unwrap(); - seen - } - - fn drain_events( - events: &Arc<EventDispatcher>, - seen: &Arc<std::sync::Mutex<Vec<SyncEvent>>>, - ) -> Vec<SyncEvent> { - events.process_events().unwrap(); - seen.lock().unwrap().clone() - } - - fn base_content() -> Value { - json!({"title": "base", "referenceFrequency": 440.0}) - } - - #[tokio::test] - async fn hash_mismatch_rebases_the_queued_patch_onto_the_server_content() { - let db = test_db().await; - let id = Uuid::new_v4(); - let base = base_content(); - // This client edited referenceFrequency; the winner edited title. - let local = json!({"title": "base", "referenceFrequency": 441.0}); - seed_pending_edit(&db, id, &base, &local, 1).await; - - let server = json!({"title": "winner", "referenceFrequency": 440.0}); - let server_hash = calculate_checksum(&server); - let (upload_retry, mut retry_rx) = retry_probe(); - - deliver_ack( - &db, - &dispatcher(), - &upload_retry, - id, - update_rejected( - id, - "hash_mismatch", - Some((2, server.clone(), server_hash.clone())), - ), - ) - .await; - - let doc = db.get_document(&id).await.unwrap(); - assert_eq!( - doc.content, - json!({"title": "winner", "referenceFrequency": 441.0}), - "the rebase must carry BOTH edits, not overwrite the winner's" - ); - assert_eq!( - doc.sync_revision, 2, - "the server's revision must be adopted" - ); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending), - "the rebased edit is still unsent" - ); - assert_eq!( - queued_update_rows(&db, &id).await, - 1, - "the rebase must replace the queue row, not add to it" - ); - assert_eq!( - queued_base_hash(&db, &id).await.as_deref(), - Some(server_hash.as_str()), - "the resend must be locked to the server's current hash" - ); - assert!( - retry_rx.try_recv().is_ok(), - "a successful rebase must trigger the resend" - ); - } - - #[tokio::test] - async fn a_patch_that_no_longer_applies_settles_as_a_conflict() { - let db = test_db().await; - let id = Uuid::new_v4(); - let base = base_content(); - let local = json!({"title": "base", "referenceFrequency": 441.0}); - seed_pending_edit(&db, id, &base, &local, 1).await; - - // The winner removed the very field this client edited, so the queued - // `replace /referenceFrequency` can no longer apply. - let server = json!({"title": "winner"}); - let events = dispatcher(); - let seen = event_probe(&events); - let (upload_retry, mut retry_rx) = retry_probe(); - - deliver_ack( - &db, - &events, - &upload_retry, - id, - update_rejected( - id, - "hash_mismatch", - Some((2, server.clone(), calculate_checksum(&server))), - ), - ) - .await; - - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, server, "the server's copy becomes local truth"); - assert_eq!(doc.sync_revision, 2); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Conflict) - ); - assert_eq!( - queued_update_rows(&db, &id).await, - 0, - "an unresolvable patch must not stay queued" - ); - assert!( - retry_rx.try_recv().is_err(), - "a conflict must not schedule a resend" - ); - let emitted = drain_events(&events, &seen); - assert!( - emitted - .iter() - .any(|e| matches!(e, SyncEvent::ConflictDetected { .. })), - "the host app must be told about the conflict: {:?}", - emitted - ); - assert!( - emitted.iter().any(|e| matches!( - e, - SyncEvent::SyncError { - code: ReplicantErrorCode::UpdateConflict, - .. - } - )), - "the conflict must carry a structured error code: {:?}", - emitted - ); - } - - /// A create rejected because the server already holds the document (a lost - /// ack, or its content-hash dedupe). Resending the create would be refused - /// again forever, so the client must adopt the server's copy — which - /// consumes the create row — and resend the local edits as an update. - #[tokio::test] - async fn a_create_rejected_for_a_document_the_server_holds_becomes_an_update() { - let db = test_db().await; - let id = Uuid::new_v4(); - let created = json!({"title": "draft"}); - db.save_new_document_and_queue_create(&make_doc(id, created.clone(), 1)) - .await - .unwrap(); - - // Edit it while the create is still unsent — deliberately NOT via - // `seed_pending_edit`, whose Synced seed would consume the create row - // and leave nothing for this test to prove. - let edited = json!({"title": "edited"}); - db.save_document_and_queue_patch( - &make_doc(id, edited.clone(), 1), - &create_patch(&created, &edited).unwrap(), - replicant_core::protocol::ChangeEventType::Update, - Some(&created), - ) - .await - .unwrap(); - assert!( - db.has_queued_create(&id).await.unwrap(), - "the create must still be owed going in, or this test proves nothing" - ); - - let server = json!({"title": "draft", "server": true}); - let (source, canned) = canned_source(vec![ServerMessage::GetDocumentResponse { - id, - content: server.clone(), - sync_revision: 4, - content_hash: calculate_checksum(&server), - deleted: false, - }]); - let events = dispatcher(); - let (upload_retry, mut retry_rx) = retry_probe(); - - deliver_ack_from( - &db, - &events, - &upload_retry, - id, - create_rejected(id, "id_taken"), - &source, - ) - .await; - - assert_eq!( - resync_attempts(&canned).await, - vec![id], - "a rejected create must ask the server what it actually holds" - ); - assert!( - !db.has_queued_create(&id).await.unwrap(), - "adopting the server's copy must consume the create row" - ); - assert!( - matches!( - classify_pending_upload(&db, &id).await, - PendingUploadKind::Update { .. } - ), - "the next upload must be an update, not a second doomed create" - ); - assert!( - retry_rx.try_recv().is_ok(), - "the rebased update must be resent" - ); - } - - /// A create the server refused outright and does not hold. Resending cannot - /// help, so the document is parked as a conflict — and NOT soft-deleted, the - /// way a resync treats a missing document: this content never reached the - /// server, so the local copy is the only one there is. - #[tokio::test] - async fn a_create_refused_outright_settles_as_a_conflict_without_losing_the_content() { - let db = test_db().await; - let id = Uuid::new_v4(); - let created = json!({"title": "draft"}); - db.save_new_document_and_queue_create(&make_doc(id, created.clone(), 1)) - .await - .unwrap(); - - let (source, canned) = canned_source(vec![ServerMessage::Error { - code: ErrorCode::DocumentNotFound, - message: "not found".to_string(), - }]); - let events = dispatcher(); - let seen = event_probe(&events); - let (upload_retry, mut retry_rx) = retry_probe(); - - deliver_ack_from( - &db, - &events, - &upload_retry, - id, - create_rejected(id, "quota_exceeded"), - &source, - ) - .await; - - assert_eq!(resync_attempts(&canned).await, vec![id]); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Conflict), - "a refused create must leave the pending set so it stops being resent" - ); - assert_eq!( - db.get_document(&id).await.unwrap().content, - created, - "the only copy of this content must survive" - ); - assert_eq!( - deleted_at(&db, &id).await, - None, - "a refused create is not a deletion" - ); - assert!( - retry_rx.try_recv().is_err(), - "a refused create must not schedule a resend" - ); - - let emitted = drain_events(&events, &seen); - assert!( - emitted - .iter() - .any(|e| matches!(e, SyncEvent::ConflictDetected { .. })), - "the host app must be told: {:?}", - emitted - ); - assert!( - emitted.iter().any(|e| matches!( - e, - SyncEvent::SyncError { - code: ReplicantErrorCode::UpdateConflict, - .. - } - )), - "the refusal must carry a structured error code: {:?}", - emitted - ); - } - - /// The bound: a create that keeps being refused must not spin. - #[tokio::test] - async fn a_repeatedly_refused_create_recovers_only_once() { - let db = test_db().await; - let id = Uuid::new_v4(); - db.save_new_document_and_queue_create(&make_doc(id, json!({"title": "draft"}), 1)) - .await - .unwrap(); - - let not_found = || ServerMessage::Error { - code: ErrorCode::DocumentNotFound, - message: "not found".to_string(), - }; - let (source, canned) = canned_source(vec![not_found(), not_found()]); - let events = dispatcher(); - let (upload_retry, _retry_rx) = retry_probe(); - - for _ in 0..2 { - deliver_ack_from( - &db, - &events, - &upload_retry, - id, - create_rejected(id, "quota_exceeded"), - &source, - ) - .await; - } - - assert_eq!( - resync_attempts(&canned).await, - vec![id], - "the second refusal must not fetch again" - ); - - // The first refusal spends the budget and resolves: the fetch proved the - // server does not hold the document, so it is parked. The second changes - // nothing beyond the generic failure event. - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Conflict), - "the resolved refusal stays parked, out of the pending set" - ); - assert_eq!( - db.get_document(&id).await.unwrap().content, - json!({"title": "draft"}), - "the local content must survive a refusal it cannot recover from" - ); - assert!( - db.has_queued_create(&id).await.unwrap(), - "the create is still genuinely owed, so its row must stay" - ); - assert_eq!(deleted_at(&db, &id).await, None); - } - - /// The budget is per session and spent on the ATTEMPT, not on resolving it. - /// A refusal that arrives while the client cannot reach the server therefore - /// leaves the document pending with its create still owed, and no second try - /// this session. That is the known limit: the next session gets a fresh - /// budget, which is how a transient refusal eventually succeeds. - #[tokio::test] - async fn a_refusal_that_cannot_reach_the_server_leaves_the_document_for_next_session() { - let db = test_db().await; - let id = Uuid::new_v4(); - let created = json!({"title": "draft"}); - db.save_new_document_and_queue_create(&make_doc(id, created.clone(), 1)) - .await - .unwrap(); - - // An empty canned source records the attempt and answers like an - // unreachable server. - let (source, canned) = canned_source(vec![]); - let events = dispatcher(); - let (upload_retry, _retry_rx) = retry_probe(); - - for _ in 0..2 { - deliver_ack_from( - &db, - &events, - &upload_retry, - id, - create_rejected(id, "quota_exceeded"), - &source, - ) - .await; - } - - assert_eq!( - resync_attempts(&canned).await, - vec![id], - "the budget is spent on the attempt, so the second refusal does not try" - ); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending), - "an unresolved refusal stays pending so a later session retries it" - ); - assert!( - db.has_queued_create(&id).await.unwrap(), - "the create is still owed" - ); - assert_eq!( - db.get_document(&id).await.unwrap().content, - created, - "the local content must survive" - ); - assert_eq!(deleted_at(&db, &id).await, None); - } - - #[tokio::test] - async fn rebases_are_capped_then_the_document_conflicts() { - let db = test_db().await; - let id = Uuid::new_v4(); - let base = base_content(); - let local = json!({"title": "base", "referenceFrequency": 441.0}); - seed_pending_edit(&db, id, &base, &local, 1).await; - - // One shared UploadRetry: the attempt budget is per document per session. - let (upload_retry, _retry_rx) = retry_probe(); - let events = dispatcher(); - - for round in 1..=MAX_REBASE_ATTEMPTS { - let server = json!({"title": format!("winner {}", round), "referenceFrequency": 440.0}); - deliver_ack( - &db, - &events, - &upload_retry, - id, - update_rejected( - id, - "hash_mismatch", - Some(( - 1 + round as i64, - server.clone(), - calculate_checksum(&server), - )), - ), - ) - .await; - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending), - "round {} is within budget and must rebase", - round - ); - } - - let last = json!({"title": "winner last", "referenceFrequency": 440.0}); - deliver_ack( - &db, - &events, - &upload_retry, - id, - update_rejected( - id, - "hash_mismatch", - Some((99, last.clone(), calculate_checksum(&last))), - ), - ) - .await; - - let doc = db.get_document(&id).await.unwrap(); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Conflict), - "the budget must terminate the live-lock" - ); - assert_eq!(doc.content, last, "the server's copy becomes local truth"); - assert_eq!(doc.sync_revision, 99); - } - - /// Drive a document into a settled `Conflict` through the real path. - async fn seed_settled_conflict( - db: &Arc<ClientDatabase>, - id: Uuid, - server: &Value, - revision: i64, - ) { - let base = base_content(); - // An edit to a field the winner removed cannot be rebased. - let local = json!({"title": "base", "referenceFrequency": 441.0}); - seed_pending_edit(db, id, &base, &local, revision - 1).await; - - let (upload_retry, _rx) = retry_probe(); - deliver_ack( - db, - &dispatcher(), - &upload_retry, - id, - update_rejected( - id, - "hash_mismatch", - Some((revision, server.clone(), calculate_checksum(server))), - ), - ) - .await; - - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Conflict), - "the fixture must actually reach Conflict" - ); - } - - #[tokio::test] - async fn a_broadcast_onto_a_settled_conflict_advances_content_and_keeps_the_flag() { - let db = test_db().await; - let id = Uuid::new_v4(); - let server = json!({"title": "winner"}); - seed_settled_conflict(&db, id, &server, 5).await; - - // The conflicted copy IS the server's content, so a contiguous patch - // applies cleanly. What must NOT happen is the flag being cleared or - // flipped to Pending, which would resend and lose the user's signal. - let next = json!({"title": "winner", "note": "later"}); - let resynced = deliver(&db, &dispatcher(), server_patch(id, &server, &next, 6)).await; - - assert!( - resynced.is_empty(), - "a contiguous patch onto a conflict must apply, not resync" - ); - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, next, "content must keep following the server"); - assert_eq!(doc.sync_revision, 6); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Conflict), - "only a local edit may resolve a conflict" - ); - assert_eq!( - queued_update_rows(&db, &id).await, - 0, - "nothing may be queued for upload" - ); - } - - #[tokio::test] - async fn a_resync_of_a_settled_conflict_keeps_the_flag() { - let db = test_db().await; - let id = Uuid::new_v4(); - let server = json!({"title": "winner"}); - seed_settled_conflict(&db, id, &server, 5).await; - - let fresh = json!({"title": "winner", "note": "much later"}); - Client::apply_resync_result( - &db, - Uuid::new_v4(), - &dispatcher(), - id, - ServerMessage::GetDocumentResponse { - id, - content: fresh.clone(), - sync_revision: 9, - content_hash: calculate_checksum(&fresh), - deleted: false, - }, - ) - .await - .unwrap(); - - let doc = db.get_document(&id).await.unwrap(); - assert_eq!( - doc.content, fresh, - "a conflict with nothing queued has no local edits to protect" - ); - assert_eq!(doc.sync_revision, 9); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Conflict) - ); - } - - #[tokio::test] - async fn a_local_edit_resolves_a_conflict_into_pending() { - let db = test_db().await; - let id = Uuid::new_v4(); - let server = json!({"title": "winner"}); - seed_settled_conflict(&db, id, &server, 5).await; - - // Editing the document is the user's implicit resolution. - let resolved = json!({"title": "winner", "referenceFrequency": 441.0}); - let mut doc = db.get_document(&id).await.unwrap(); - let previous = doc.content.clone(); - doc.content = resolved.clone(); - db.save_document_and_queue_patch( - &doc, - &create_patch(&previous, &resolved).unwrap(), - replicant_core::protocol::ChangeEventType::Update, - Some(&previous), - ) - .await - .unwrap(); - - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending), - "a local edit must clear the conflict" - ); - assert_eq!(queued_update_rows(&db, &id).await, 1); - } - - /// Store content in a form `serde_json` would never emit. The row still - /// parses to the same `Value`, so it reads as the pre-image, but the CAS - /// binds the canonical text and can never match — a permanently losing - /// swap, with no races involved. - async fn wedge_the_swap(db: &Arc<ClientDatabase>, id: &Uuid, content: &Value) { - sqlx::query("UPDATE documents SET content = ? WHERE id = ?") - .bind(format!("{:#}", content)) - .bind(id.to_string()) - .execute(&db.pool) - .await - .unwrap(); - } - - #[tokio::test] - async fn a_rebase_that_loses_the_swap_resends_and_spends_one_attempt() { - let db = test_db().await; - let id = Uuid::new_v4(); - let base = base_content(); - let local = json!({"title": "base", "referenceFrequency": 441.0}); - seed_pending_edit(&db, id, &base, &local, 1).await; - wedge_the_swap(&db, &id, &local).await; - let before = raw_content(&db, &id).await; - - let (upload_retry, mut retry_rx) = retry_probe(); - let server = json!({"title": "winner", "referenceFrequency": 440.0}); - deliver_ack( - &db, - &dispatcher(), - &upload_retry, - id, - update_rejected( - id, - "hash_mismatch", - Some((2, server.clone(), calculate_checksum(&server))), - ), - ) - .await; - - assert!( - retry_rx.try_recv().is_ok(), - "a lost swap must resend so the newer edit gets its own round" - ); - assert!( - retry_rx.try_recv().is_err(), - "exactly one resend per lost round" - ); - assert_eq!( - raw_content(&db, &id).await, - before, - "a lost swap must write nothing" - ); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending) - ); - - // Two more rounds spend the rest of the budget; the fourth has none - // left and falls through to the conflict settlement. - for _ in 0..2 { - deliver_ack( - &db, - &dispatcher(), - &upload_retry, - id, - update_rejected( - id, - "hash_mismatch", - Some((2, server.clone(), calculate_checksum(&server))), - ), - ) - .await; - assert!(retry_rx.try_recv().is_ok(), "each round resends once"); - } - - deliver_ack( - &db, - &dispatcher(), - &upload_retry, - id, - update_rejected( - id, - "hash_mismatch", - Some((2, server.clone(), calculate_checksum(&server))), - ), - ) - .await; - assert!( - retry_rx.try_recv().is_err(), - "the budget is spent, so no fourth resend" - ); - } - - #[tokio::test] - async fn a_conflict_settlement_that_loses_the_swap_leaves_the_document_alone() { - let db = test_db().await; - let id = Uuid::new_v4(); - let base = base_content(); - let local = json!({"title": "base", "referenceFrequency": 441.0}); - seed_pending_edit(&db, id, &base, &local, 1).await; - wedge_the_swap(&db, &id, &local).await; - let before = raw_content(&db, &id).await; - - // The winner removed the edited field, so the queued patch cannot - // apply: this goes straight to settle_as_conflict, whose own swap is - // wedged too. - let server = json!({"title": "winner"}); - let (upload_retry, mut retry_rx) = retry_probe(); - deliver_ack( - &db, - &dispatcher(), - &upload_retry, - id, - update_rejected( - id, - "hash_mismatch", - Some((2, server.clone(), calculate_checksum(&server))), - ), - ) - .await; - - assert_eq!( - raw_content(&db, &id).await, - before, - "a settlement that loses the swap must not clobber the newer edit" - ); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending), - "the document stays pending for its own upload round" - ); - assert_eq!( - queued_update_rows(&db, &id).await, - 1, - "the queue row survives an uncommitted settlement" - ); - assert!( - retry_rx.try_recv().is_err(), - "the settlement path terminates rather than resending" - ); - } - - #[tokio::test] - async fn a_rebase_that_changes_nothing_is_adopted_without_a_resend() { - let db = test_db().await; - let id = Uuid::new_v4(); - let base = base_content(); - let local = json!({"title": "base", "referenceFrequency": 441.0}); - seed_pending_edit(&db, id, &base, &local, 1).await; - - // The winner's update already carries this client's edit. - let server = json!({"title": "winner", "referenceFrequency": 441.0}); - let (upload_retry, mut retry_rx) = retry_probe(); - deliver_ack( - &db, - &dispatcher(), - &upload_retry, - id, - update_rejected( - id, - "hash_mismatch", - Some((2, server.clone(), calculate_checksum(&server))), - ), - ) - .await; - - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, server); - assert_eq!(doc.sync_revision, 2); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Synced), - "nothing is outstanding once the edit is already on the server" - ); - assert_eq!(queued_update_rows(&db, &id).await, 0); - assert!( - retry_rx.try_recv().is_err(), - "a no-op patch must not cost an upload round trip" - ); - } - - #[tokio::test] - async fn an_adoption_that_loses_the_swap_keeps_the_queue_row() { - let db = test_db().await; - let id = Uuid::new_v4(); - let base = base_content(); - let local = json!({"title": "base", "referenceFrequency": 441.0}); - seed_pending_edit(&db, id, &base, &local, 1).await; - wedge_the_swap(&db, &id, &local).await; - let before = raw_content(&db, &id).await; - - // Server content already equals the local edit, so the rebase diff is - // empty and this takes the adopt path — whose swap is wedged. - let (upload_retry, mut retry_rx) = retry_probe(); - deliver_ack( - &db, - &dispatcher(), - &upload_retry, - id, - update_rejected( - id, - "hash_mismatch", - Some((2, local.clone(), calculate_checksum(&local))), - ), - ) - .await; - - assert_eq!( - queued_update_rows(&db, &id).await, - 1, - "clearing the queue must not outlive a failed swap: a Pending document \ - with no queue row would be re-sent as a create" - ); - assert_eq!(raw_content(&db, &id).await, before); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending) - ); - assert!( - retry_rx.try_recv().is_ok(), - "a lost adoption falls back to resending" - ); - } - - #[tokio::test] - async fn clearing_the_queue_is_coupled_to_the_swap_winning() { - let db = test_db().await; - let id = Uuid::new_v4(); - let base = base_content(); - let local = json!({"title": "base", "referenceFrequency": 441.0}); - seed_pending_edit(&db, id, &base, &local, 1).await; - - let doc = db.get_document(&id).await.unwrap(); - let stale = DocumentPreImage { - sync_revision: doc.sync_revision, - content: json!({"title": "never stored"}), - sync_status: SyncStatus::Pending, - }; - - assert!( - !db.save_document_and_clear_queue_if_unchanged(&doc, SyncStatus::Synced, &stale) - .await - .unwrap(), - "a stale pre-image must lose" - ); - assert_eq!( - queued_update_rows(&db, &id).await, - 1, - "a rolled-back transaction must delete nothing" - ); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending) - ); - - let current = DocumentPreImage { - sync_revision: doc.sync_revision, - content: doc.content.clone(), - sync_status: SyncStatus::Pending, - }; - assert!(db - .save_document_and_clear_queue_if_unchanged(&doc, SyncStatus::Synced, ¤t) - .await - .unwrap()); - assert_eq!( - queued_update_rows(&db, &id).await, - 0, - "a winning swap clears the queue in the same transaction" - ); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Synced) - ); - } - - #[tokio::test] - async fn a_rejection_without_server_state_falls_back_to_a_plain_retry() { - let db = test_db().await; - let id = Uuid::new_v4(); - let base = base_content(); - let local = json!({"title": "base", "referenceFrequency": 441.0}); - seed_pending_edit(&db, id, &base, &local, 1).await; - - let (upload_retry, mut retry_rx) = retry_probe(); - - // A hash_mismatch whose current_* fields are missing cannot be rebased. - deliver_ack( - &db, - &dispatcher(), - &upload_retry, - id, - update_rejected(id, "hash_mismatch", None), - ) - .await; - - assert!( - retry_rx.try_recv().is_ok(), - "without server state there is nothing to rebase, so retry blindly" - ); - let doc = db.get_document(&id).await.unwrap(); - assert_eq!(doc.content, local, "local state must be left alone"); - assert_eq!( - db.get_sync_status(&id).await.unwrap(), - Some(SyncStatus::Pending) - ); - } - - // --------------------------------------------------------------------- - // Event origin (#44) - // --------------------------------------------------------------------- - - /// A consumer that wants to react only to changes made elsewhere needs the - /// event itself to say where the write came from; the payload alone cannot - /// tell an echo of a local save from a genuine remote edit. - #[tokio::test] - async fn a_broadcast_applied_from_the_server_is_reported_as_remote() { - let db = test_db().await; - let id = Uuid::new_v4(); - let before = json!({"title": "A"}); - let after = json!({"title": "B"}); - seed(&db, &make_doc(id, before.clone(), 1), SyncStatus::Synced).await; - - let events = dispatcher(); - let seen = event_probe(&events); - - deliver(&db, &events, server_patch(id, &before, &after, 2)).await; - - let emitted = drain_events(&events, &seen); - assert!( - emitted.iter().any(|e| matches!( - e, - SyncEvent::DocumentUpdated { - origin: EventOrigin::Remote, - .. - } - )), - "a patch applied from the server must be reported as Remote: {:?}", - emitted - ); - } - - /// A document deleted by another client arrives the same way and must also - /// be reported as Remote. - #[tokio::test] - async fn a_delete_applied_from_the_server_is_reported_as_remote() { - let db = test_db().await; - let id = Uuid::new_v4(); - seed( - &db, - &make_doc(id, json!({"title": "A"}), 1), - SyncStatus::Synced, - ) - .await; - - let events = dispatcher(); - let seen = event_probe(&events); - - Client::handle_server_message( - ServerMessage::DocumentDeleted { document_id: id }, - &db, - Uuid::new_v4(), - &events, - &recording_source().0, - ) - .await - .unwrap(); - - let emitted = drain_events(&events, &seen); - assert!( - emitted.iter().any(|e| matches!( - e, - SyncEvent::DocumentDeleted { - origin: EventOrigin::Remote, - .. - } - )), - "a delete applied from the server must be reported as Remote: {:?}", - emitted - ); - } -} - -#[cfg(test)] -mod local_write_origin_tests { - use super::*; - use crate::events::{EventOrigin, SyncEvent}; - use serde_json::json; - - /// A client with no reachable server: every event it emits therefore - /// originated from this process's own writes. - async fn offline_client(events: Arc<EventDispatcher>) -> Client { - Client::with_event_dispatcher( - ":memory:", - "ws://127.0.0.1:1", - "origin-test@example.com", - "key", - "secret", - None, - Some(events), - ) - .await - .unwrap() - } - - fn event_probe(events: &Arc<EventDispatcher>) -> Arc<std::sync::Mutex<Vec<SyncEvent>>> { - let seen = Arc::new(std::sync::Mutex::new(Vec::new())); - let sink = seen.clone(); - events - .register_rust_callback(move |event| sink.lock().unwrap().push(event)) - .unwrap(); - seen - } - - fn drain_events( - events: &Arc<EventDispatcher>, - seen: &Arc<std::sync::Mutex<Vec<SyncEvent>>>, - ) -> Vec<SyncEvent> { - events.process_events().unwrap(); - seen.lock().unwrap().clone() - } - - #[tokio::test] - async fn a_local_write_is_reported_as_local() { - let events = Arc::new(EventDispatcher::new()); - let seen = event_probe(&events); - let client = offline_client(events.clone()).await; - - let doc = client - .create_document(json!({"title": "mine"})) - .await - .unwrap(); - client - .update_document(doc.id, json!({"title": "mine, edited"})) - .await - .unwrap(); - - let emitted = drain_events(&events, &seen); - assert!( - emitted.iter().any(|e| matches!( - e, - SyncEvent::DocumentCreated { - origin: EventOrigin::Local, - .. - } - )), - "this client's own create must be reported as Local: {:?}", - emitted - ); - assert!( - emitted.iter().any(|e| matches!( - e, - SyncEvent::DocumentUpdated { - origin: EventOrigin::Local, - .. - } - )), - "this client's own update must be reported as Local: {:?}", - emitted - ); - } - - #[tokio::test] - async fn a_local_delete_is_reported_as_local() { - let events = Arc::new(EventDispatcher::new()); - let seen = event_probe(&events); - let client = offline_client(events.clone()).await; - - let doc = client - .create_document(json!({"title": "mine"})) - .await - .unwrap(); - client.delete_document(doc.id).await.unwrap(); - - let emitted = drain_events(&events, &seen); - assert!( - emitted.iter().any(|e| matches!( - e, - SyncEvent::DocumentDeleted { - origin: EventOrigin::Local, - .. - } - )), - "this client's own delete must be reported as Local: {:?}", - emitted - ); - } -} - -#[cfg(test)] -mod start_and_shutdown_tests { - use super::*; - use crate::events::{EventType, SyncEvent}; - - /// A client with credentials whose server refuses every connection. - async fn offline_client(dir: &tempfile::TempDir) -> (Client, Arc<EventDispatcher>) { - let db_url = format!( - "sqlite:{}?mode=rwc", - dir.path().join("client.sqlite3").display() - ); - let dispatcher = Arc::new(EventDispatcher::new()); - let client = Client::open( - &db_url, - "ws://127.0.0.1:1/socket/websocket", - "start-test@example.com", - "rpa_test_key", - "rps_test_secret", - Some(Uuid::new_v4()), - Some(dispatcher.clone()), - ) - .await - .unwrap(); - (client, dispatcher) - } - - fn record_events(dispatcher: &EventDispatcher) -> Arc<std::sync::Mutex<Vec<EventType>>> { - let seen = Arc::new(std::sync::Mutex::new(Vec::new())); - let sink = seen.clone(); - dispatcher - .register_rust_callback(move |event: SyncEvent| { - sink.lock().unwrap().push(event.event_type()) - }) - .unwrap(); - seen - } - - #[tokio::test] - async fn a_failed_initial_upload_leaves_a_working_client() { - let dir = tempfile::tempdir().unwrap(); - let (client, dispatcher) = offline_client(&dir).await; - let seen = record_events(&dispatcher); - client - .create_document(serde_json::json!({"title": "queued"})) - .await - .unwrap(); - - // Connected but with no socket: the first pending upload fails. - client.is_connected.store(true, Ordering::SeqCst); - client.start().await; - dispatcher.process_events().unwrap(); - - assert!(!client.sync_protection_mode.load(Ordering::SeqCst)); - assert!( - client.pending_uploads.lock().await.is_empty(), - "a failed upload must not stay in flight" - ); - let events = seen.lock().unwrap().clone(); - let started = events.iter().position(|e| *e == EventType::SyncStarted); - let failed = events.iter().rposition(|e| *e == EventType::SyncError); - assert!( - started.is_some() && started < failed, - "expected SyncStarted then SyncError: {:?}", - events - ); - assert!(!events.contains(&EventType::SyncCompleted)); - client - .create_document(serde_json::json!({"title": "after start"})) - .await - .unwrap(); - - client.shutdown().await; - } - - #[tokio::test] - async fn shutdown_stops_the_reconnection_monitor() { - let dir = tempfile::tempdir().unwrap(); - let (client, dispatcher) = offline_client(&dir).await; - let seen = record_events(&dispatcher); - let attempts = |seen: &std::sync::Mutex<Vec<EventType>>| { - dispatcher.process_events().unwrap(); - seen.lock() - .unwrap() - .iter() - .filter(|e| **e == EventType::ConnectionAttempted) - .count() - }; - - client.start().await; - let deadline = Instant::now() + Duration::from_secs(5); - while attempts(&seen) == 0 { - assert!( - Instant::now() < deadline, - "the monitor never tried to connect" - ); - tokio::time::sleep(Duration::from_millis(20)).await; - } - - client.shutdown().await; - let at_shutdown = attempts(&seen); - // The monitor would try again within its 5 s interval if still running. - tokio::time::sleep(Duration::from_secs(6)).await; - assert_eq!(attempts(&seen), at_shutdown); - assert!(!client.is_connected()); - } -} diff --git a/replicant-client/src/database.rs b/replicant-client/src/database.rs deleted file mode 100644 index 4e1698e..0000000 --- a/replicant-client/src/database.rs +++ /dev/null @@ -1,1677 +0,0 @@ -use crate::queries::{DbHelpers, Queries}; -use json_patch; -use replicant_core::patches::{calculate_checksum, create_patch}; -use replicant_core::protocol::ChangeEventType; -use replicant_core::{ - models::{Document, SyncStatus}, - SyncError, SyncResult, -}; -use sqlx::{ - sqlite::{SqliteConnectOptions, SqlitePoolOptions}, - Row, SqlitePool, -}; -use std::str::FromStr; -use std::sync::atomic::{AtomicU64, Ordering}; -use uuid::Uuid; - -/// Number of sqlx-sqlite worker threads this process has started for a -/// Replicant pool. -/// -/// sqlx dedicates one OS thread to every sqlite connection and never hands out -/// a `JoinHandle` for it (`sqlx_sqlite::connection::worker`), so this counter -/// exists to make crash dumps and shutdown logs readable: it says how many -/// such threads Replicant is responsible for having started. -static SQLITE_WORKERS_STARTED: AtomicU64 = AtomicU64::new(0); - -/// How many sqlx-sqlite worker threads Replicant has started in this process. -pub fn sqlite_workers_started() -> u64 { - SQLITE_WORKERS_STARTED.load(Ordering::Relaxed) -} - -/// How long sqlite waits on a lock another process holds before returning -/// SQLITE_BUSY. This is sqlx's own default, restated so the bound on a -/// contended connection setup is visible at the call site. -const SQLITE_BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); - -/// The state a caller verified before deciding to write. Passing it back to a -/// `*_if_unchanged` write turns read-check-write into a compare-and-swap, so a -/// local edit landing in between is never silently clobbered. -/// -/// `content` compares as its stored JSON text: `serde_json` orders object keys -/// deterministically, so equal `Value`s always serialise to equal strings. -#[derive(Debug, Clone)] -pub struct DocumentPreImage { - pub sync_revision: i64, - pub content: serde_json::Value, - pub sync_status: SyncStatus, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct PendingDocumentInfo { - pub id: Uuid, - pub is_deleted: bool, -} - -pub struct ClientDatabase { - pub pool: SqlitePool, -} - -/// Dropping a pool without closing it is the DEV-1118 bug in miniature. -/// -/// NOTHING JOINS sqlx's per-connection worker threads — sqlx spawns them -/// detached and keeps no `JoinHandle`. Awaiting `Pool::close()` is as close as -/// the API gets: it resolves only once the pool's size reaches zero, so every -/// connection, including ones still inside `establish()` or mid-statement, has -/// finished and had `sqlite3_close` called on it. What remains after that is the -/// worker's own thread epilogue, which is libstd code linked into this module -/// and runs for a few microseconds more. Closing shrinks the window; it does not -/// remove it, which is why a module that can be unloaded must still be pinned -/// (see `replicant_destroy`). -/// -/// A plain drop is much worse than that: it ends an *idle* worker (the command -/// channel closes and its loop exits) but tells a busy one nothing, so a worker -/// blocked on SQLITE_BUSY keeps running here for as long as sqlite takes. -/// -/// Nothing in this impl can close the pool — `close()` is async and `Drop` is -/// not — so it only says so, loudly, for the next person to find in a log. -impl Drop for ClientDatabase { - fn drop(&mut self) { - if !self.pool.is_closed() { - tracing::warn!( - "DB: a ClientDatabase was dropped without close(); a busy sqlite \ - worker thread can outlive it and keep executing this module" - ); - } - } -} - -impl ClientDatabase { - pub async fn new(database_url: &str) -> SyncResult<Self> { - // Same URL parsing `SqlitePoolOptions::connect` would do; spelled out so - // the connect options can be named: - // - // * `thread_name` labels sqlx's per-connection worker threads as ours, - // which is how DEV-1118 was identified in a minidump, and counts - // them so shutdown can report how many we started. - // * `busy_timeout` is sqlx's own default, restated here because it is - // the bound on how long a contended `establish()` can keep a worker - // thread alive inside this module (see `ffi::Replicant::shutdown`). - let connect_options = SqliteConnectOptions::from_str(database_url)? - .busy_timeout(SQLITE_BUSY_TIMEOUT) - .thread_name(|id| { - SQLITE_WORKERS_STARTED.fetch_add(1, Ordering::Relaxed); - format!("replicant-sqlite-{id}") - }); - - let pool = SqlitePoolOptions::new() - .max_connections(5) - .connect_with(connect_options) - .await?; - - Ok(Self { pool }) - } - - /// Closes the pool, awaiting every connection's shutdown. - /// - /// `Pool::close()` only resolves once the pool's size reaches zero, which - /// covers connections still inside `establish()` as well as live ones, so - /// awaiting it is what stops a sqlite worker thread from running on past - /// the handle that started it. Idempotent. - pub async fn close(&self) { - self.pool.close().await; - } - - pub async fn run_migrations(&self) -> SyncResult<()> { - sqlx::migrate!("./migrations").run(&self.pool).await?; - Ok(()) - } - - pub async fn ensure_user_config(&self, server_url: &str) -> SyncResult<()> { - // Check if user_config already exists - let exists = sqlx::query("SELECT COUNT(*) as count FROM user_config") - .fetch_one(&self.pool) - .await?; - - let count: i64 = exists.try_get("count")?; - - if count == 0 { - // No user config exists, create default - let user_id = Uuid::new_v4(); - let client_id = Uuid::new_v4(); - - sqlx::query( - "INSERT INTO user_config (user_id, client_id, server_url) VALUES (?1, ?2, ?3)", - ) - .bind(user_id.to_string()) - .bind(client_id.to_string()) - .bind(server_url) - .execute(&self.pool) - .await?; - } - - Ok(()) - } - - pub async fn ensure_user_config_with_identifier( - &self, - server_url: &str, - _user_identifier: &str, - ) -> SyncResult<()> { - // Check if user_config already exists - let exists = sqlx::query("SELECT COUNT(*) as count FROM user_config") - .fetch_one(&self.pool) - .await?; - - let count: i64 = exists.try_get("count")?; - - if count == 0 { - // Provisional random id; the server's canonical id is adopted on first contact. - let user_id = Uuid::new_v4(); - let client_id = Uuid::new_v4(); // Client ID should always be unique per instance - - sqlx::query( - "INSERT INTO user_config (user_id, client_id, server_url, identity_adopted) VALUES (?1, ?2, ?3, 0)", - ) - .bind(user_id.to_string()) - .bind(client_id.to_string()) - .bind(server_url) - .execute(&self.pool) - .await?; - } - - Ok(()) - } - - pub async fn get_user_id(&self) -> SyncResult<Uuid> { - let row = sqlx::query(Queries::GET_USER_ID) - .fetch_one(&self.pool) - .await?; - - let user_id: String = row.try_get("user_id")?; - Ok(Uuid::parse_str(&user_id)?) - } - - pub async fn is_identity_adopted(&self) -> SyncResult<bool> { - let row = sqlx::query("SELECT identity_adopted FROM user_config LIMIT 1") - .fetch_one(&self.pool) - .await?; - let adopted: i64 = row.try_get("identity_adopted")?; - Ok(adopted != 0) - } - - pub async fn get_client_id(&self) -> SyncResult<Uuid> { - let row = sqlx::query(Queries::GET_CLIENT_ID) - .fetch_one(&self.pool) - .await?; - - let client_id: String = row.try_get("client_id")?; - Ok(Uuid::parse_str(&client_id)?) - } - - pub async fn get_user_and_client_id(&self) -> SyncResult<(Uuid, Uuid)> { - let row = sqlx::query(Queries::GET_USER_AND_CLIENT_ID) - .fetch_one(&self.pool) - .await?; - - let user_id: String = row.try_get("user_id")?; - let client_id: String = row.try_get("client_id")?; - Ok((Uuid::parse_str(&user_id)?, Uuid::parse_str(&client_id)?)) - } - - /// Atomically adopt the server's canonical id: re-stamp local documents - /// owned by `old_id` and flip `user_config` to `canonical_id` with - /// `identity_adopted = 1`. A crash mid-adoption leaves the old id intact. - /// - /// # Errors - /// - /// Returns [`SyncError::InvalidOperation`] if `canonical_id` is nil, equals - /// `old_id`, the identity was already adopted, or no `user_config` row - /// matches `old_id`; database failures surface as the underlying error. - pub async fn adopt_identity(&self, old_id: Uuid, canonical_id: Uuid) -> SyncResult<()> { - if canonical_id.is_nil() || old_id == canonical_id { - return Err(SyncError::InvalidOperation( - "adopt_identity: canonical_id must be non-nil and differ from old_id".to_string(), - )); - } - if self.is_identity_adopted().await? { - return Err(SyncError::InvalidOperation( - "adopt_identity: identity has already been adopted".to_string(), - )); - } - - let mut tx = self.pool.begin().await?; - - sqlx::query(Queries::RESTAMP_DOCUMENTS_USER_ID) - .bind(canonical_id.to_string()) - .bind(old_id.to_string()) - .execute(&mut *tx) - .await?; - - let result = sqlx::query(Queries::ADOPT_USER_CONFIG_IDENTITY) - .bind(canonical_id.to_string()) - .bind(old_id.to_string()) - .execute(&mut *tx) - .await?; - - if result.rows_affected() != 1 { - return Err(SyncError::InvalidOperation(format!( - "adopt_identity: expected to update 1 user_config row for old_id {}, got {}", - old_id, - result.rows_affected() - ))); - } - - tx.commit().await?; - Ok(()) - } - - pub async fn get_document(&self, id: &Uuid) -> SyncResult<Document> { - let row = sqlx::query(Queries::GET_DOCUMENT) - .bind(id.to_string()) - .fetch_one(&self.pool) - .await?; - - DbHelpers::parse_document(&row) - } - - /// Read the row's `sync_status`. `Ok(None)` means the document is not in - /// the local database. `Document` does not carry the status, but the - /// broadcast guard has to know whether local edits are outstanding. - /// - /// An unparseable status fails CLOSED as `Pending`. Both `Synced` and - /// `Conflict` authorise a guarded patch apply — a settled conflict holds the - /// server's content, so applying to it is safe — and neither may be reached - /// by way of a fallback: a drifted row might be hiding unsent local edits, - /// and the compare-and-swap cannot tell, because its content would match. - /// `Pending` is the only status that is both non-appliable and - /// non-destructive: it routes the document to a resync, which rebases the - /// local content forward rather than overwriting it. - pub async fn get_sync_status(&self, id: &Uuid) -> SyncResult<Option<SyncStatus>> { - let row = sqlx::query("SELECT sync_status FROM documents WHERE id = ?") - .bind(id.to_string()) - .fetch_optional(&self.pool) - .await?; - - match row { - Some(row) => { - let raw: String = row.try_get("sync_status")?; - Ok(Some(raw.parse::<SyncStatus>().unwrap_or_else(|_| { - tracing::warn!( - "DATABASE: Unrecognised sync_status {:?} for {}, treating as pending", - raw, - id - ); - SyncStatus::Pending - }))) - } - None => Ok(None), - } - } - - /// Overwrite a document only if it still matches `expected`. Returns - /// `false` when a concurrent local write landed first, in which case the - /// caller must back off instead of overwriting it. - /// - /// Attribution columns are deliberately left untouched: this write carries - /// sync state, not authorship. - pub async fn save_document_if_unchanged( - &self, - doc: &Document, - new_status: SyncStatus, - expected: &DocumentPreImage, - ) -> SyncResult<bool> { - let params = DbHelpers::document_to_params(doc, Some(new_status))?; - - let result = sqlx::query( - "UPDATE documents SET content = ?, sync_revision = ?, updated_at = ?, sync_status = ?, title = ? \ - WHERE id = ? AND content = ? AND sync_revision = ? AND sync_status = ?", - ) - .bind(params.2) // content - .bind(params.3) // sync_revision - .bind(params.5) // updated_at - .bind(params.7) // sync_status - .bind(params.8) // title - .bind(doc.id.to_string()) - .bind(serde_json::to_string(&expected.content)?) - .bind(expected.sync_revision) - .bind(expected.sync_status.to_string()) - .execute(&self.pool) - .await?; - - if result.rows_affected() == 0 { - tracing::warn!( - "DATABASE: Compare-and-swap for {} lost to a concurrent local write", - doc.id - ); - return Ok(false); - } - - if let Err(e) = self.update_fts_for_document(&doc.id).await { - tracing::warn!("FTS: Failed to update index for {}: {:?}", doc.id, e); - } - - Ok(true) - } - - /// Adopt server state and clear the document's sync queue, in one - /// transaction. - /// - /// Doing these as two statements leaves a window: a local edit landing - /// between them sets `Pending` and inserts a fresh queue row, which the - /// unscoped delete then removes — leaving a `Pending` document with nothing - /// queued, which the next upload pass sends as a `CreateDocument` for a - /// document the server already has. - /// - /// Returns `false` if the row no longer matches `expected`, in which case - /// nothing is deleted. - pub async fn save_document_and_clear_queue_if_unchanged( - &self, - doc: &Document, - new_status: SyncStatus, - expected: &DocumentPreImage, - ) -> SyncResult<bool> { - let params = DbHelpers::document_to_params(doc, Some(new_status))?; - let mut tx = self.pool.begin().await?; - - let result = sqlx::query( - "UPDATE documents SET content = ?, sync_revision = ?, updated_at = ?, sync_status = ?, title = ? \ - WHERE id = ? AND content = ? AND sync_revision = ? AND sync_status = ?", - ) - .bind(params.2) // content - .bind(params.3) // sync_revision - .bind(params.5) // updated_at - .bind(params.7) // sync_status - .bind(params.8) // title - .bind(doc.id.to_string()) - .bind(serde_json::to_string(&expected.content)?) - .bind(expected.sync_revision) - .bind(expected.sync_status.to_string()) - .execute(&mut *tx) - .await?; - - if result.rows_affected() == 0 { - tx.rollback().await?; - tracing::warn!( - "DATABASE: Compare-and-swap for {} lost to a concurrent local write", - doc.id - ); - return Ok(false); - } - - sqlx::query("DELETE FROM sync_queue WHERE document_id = ?") - .bind(doc.id.to_string()) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - if let Err(e) = self.update_fts_for_document(&doc.id).await { - tracing::warn!("FTS: Failed to update index for {}: {:?}", doc.id, e); - } - - Ok(true) - } - - /// Rebase a document carrying unsent local edits onto a fresh server base: - /// adopt `new_content` at the server's revision and replace the queued - /// update patch, in one transaction. - /// - /// `new_content` is what the local copy should now show. A resync passes the - /// content it already has (the user's unsent edit stays visible); a - /// `hash_mismatch` rebase passes the merged result of replaying the queued - /// patch onto the server's content. - /// - /// `base_content` is the server content being rebased onto, and - /// `base_content_hash` its hash. The queued patch is the diff between them, - /// so it is derived here rather than passed in — the same base the row - /// stores for later regeneration by [`Self::get_queued_patch`]. - /// - /// Prior `update` rows for the document are deleted rather than added to. - /// `get_queued_patch` reads exactly one row, so a leftover row with an - /// outdated base hash would be retried — and rejected — forever. Any - /// `create` row goes too: adopting server content is the create's ack. - /// - /// `new_status` is what the row should carry afterwards: `Pending` for a - /// rebase whose patch is about to be sent, or the document's existing status - /// where that must survive (an unresolved `Conflict` is not cleared by - /// anything but a local edit). - /// - /// Returns `false` if the row no longer matches `expected`. - #[allow(clippy::too_many_arguments)] - pub async fn rebase_pending_document_if_unchanged( - &self, - document_id: &Uuid, - new_content: &serde_json::Value, - new_sync_revision: i64, - base_content: &serde_json::Value, - base_content_hash: &str, - new_status: SyncStatus, - expected: &DocumentPreImage, - ) -> SyncResult<bool> { - let mut tx = self.pool.begin().await?; - - let result = sqlx::query( - "UPDATE documents SET content = ?, sync_revision = ?, updated_at = ?, sync_status = ? \ - WHERE id = ? AND content = ? AND sync_revision = ? AND sync_status = ?", - ) - .bind(serde_json::to_string(new_content)?) - .bind(new_sync_revision) - .bind(chrono::Utc::now().to_rfc3339()) - .bind(new_status.to_string()) - .bind(document_id.to_string()) - .bind(serde_json::to_string(&expected.content)?) - .bind(expected.sync_revision) - .bind(expected.sync_status.to_string()) - .execute(&mut *tx) - .await?; - - if result.rows_affected() == 0 { - tx.rollback().await?; - tracing::warn!( - "DATABASE: Rebase of {} lost to a concurrent local write", - document_id - ); - return Ok(false); - } - - sqlx::query("DELETE FROM sync_queue WHERE document_id = ? AND operation_type = ?") - .bind(document_id.to_string()) - .bind(ChangeEventType::Update.to_string()) - .execute(&mut *tx) - .await?; - - // Rebasing onto server content proves the server holds this document, - // which is exactly what an unconsumed `create` row denies. Leaving it - // would make the next upload send a create the server answers with a - // primary-key conflict, and nothing clears a rejected create. - sqlx::query("DELETE FROM sync_queue WHERE document_id = ? AND operation_type = ?") - .bind(document_id.to_string()) - .bind(ChangeEventType::Create.to_string()) - .execute(&mut *tx) - .await?; - - sqlx::query( - "INSERT INTO sync_queue (document_id, operation_type, patch, old_content_hash, base_content) VALUES (?, ?, ?, ?, ?)", - ) - .bind(document_id.to_string()) - .bind(ChangeEventType::Update.to_string()) - .bind(serde_json::to_string(&create_patch(base_content, new_content)?)?) - .bind(base_content_hash) - .bind(serde_json::to_string(base_content)?) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - if let Err(e) = self.update_fts_for_document(document_id).await { - tracing::warn!("FTS: Failed to update index for {}: {:?}", document_id, e); - } - - Ok(true) - } - - pub async fn save_document(&self, doc: &Document) -> SyncResult<()> { - self.save_document_with_status(doc, None).await - } - - pub(crate) async fn save_document_with_status( - &self, - doc: &Document, - sync_status: Option<SyncStatus>, - ) -> SyncResult<()> { - let status_str = sync_status - .as_ref() - .map(|s| s.to_string()) - .unwrap_or_else(|| "synced".to_string()); - tracing::info!( - "DATABASE: 💾 Saving document {} with status: {}, sync_revision: {}", - doc.id, - status_str, - doc.sync_revision - ); - - let adopts_server_state = matches!(sync_status, Some(SyncStatus::Synced)); - let params = DbHelpers::document_to_params(doc, sync_status)?; - - // One transaction: a crash between the write and the queue clear would - // leave a Synced document still owing a create, which is exactly the - // invariant violation the create row exists to prevent. - let mut tx = self.pool.begin().await?; - - sqlx::query(Queries::UPSERT_DOCUMENT) - .bind(params.0) // id - .bind(params.1) // user_id - .bind(params.2) // content - .bind(params.3) // version - .bind(params.4) // created_at - .bind(params.5) // updated_at - .bind(params.6) // deleted_at - .bind(params.7) // sync_status - .bind(params.8) // title - .bind(params.9) // author_name - .bind(params.10) // visibility - .bind(params.11) // provenance - .execute(&mut *tx) - .await?; - - // Writing a document as Synced means the server holds it, so it no - // longer owes a create. The broadcast and full-sync paths adopt server - // state this way; a `create` row left behind here would make a later - // local edit upload as a create the server rejects as a duplicate. - if adopts_server_state { - sqlx::query("DELETE FROM sync_queue WHERE document_id = ? AND operation_type = ?") - .bind(doc.id.to_string()) - .bind(ChangeEventType::Create.to_string()) - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - - tracing::info!("DATABASE: ✅ Document {} saved successfully", doc.id); - - // Update FTS index for this document - if let Err(e) = self.update_fts_for_document(&doc.id).await { - tracing::warn!("FTS: Failed to update index for {}: {:?}", doc.id, e); - } - - Ok(()) - } - - pub async fn get_pending_documents(&self) -> SyncResult<Vec<PendingDocumentInfo>> { - tracing::info!("DATABASE: 🔍 Querying for pending documents..."); - let rows = sqlx::query(Queries::GET_PENDING_DOCUMENTS) - .bind(SyncStatus::Pending.to_string()) - .fetch_all(&self.pool) - .await?; - - tracing::info!("DATABASE: Found {} pending documents", rows.len()); - - let mut pending_docs = Vec::new(); - for row in rows { - let id: String = row.try_get("id")?; - let deleted_at: Option<String> = row.try_get("deleted_at")?; - - let doc_info = PendingDocumentInfo { - id: Uuid::parse_str(&id)?, - is_deleted: deleted_at.is_some(), - }; - - tracing::info!( - "DATABASE: Pending doc: {} | Deleted: {}", - doc_info.id, - doc_info.is_deleted - ); - - pending_docs.push(doc_info); - } - - Ok(pending_docs) - } - - /// Settle a document against the server. - /// - /// Every caller is an ack, an adopt, or a local soft-delete that matches the - /// server — contexts in which the document, one way or another, no longer - /// owes the server a create. So this consumes any `create` row: leaving one - /// would make the next local edit upload as a create the server rejects as a - /// duplicate. - pub async fn mark_synced(&self, document_id: &Uuid) -> SyncResult<()> { - tracing::info!("DATABASE: 🔄 Marking document {} as synced", document_id); - - // One transaction, for the same reason as `save_document_with_status`: - // a crash between the two would leave a Synced document still owing a - // create. - let mut tx = self.pool.begin().await?; - - let result = sqlx::query(Queries::MARK_DOCUMENT_SYNCED) - .bind(SyncStatus::Synced.to_string()) - .bind(document_id.to_string()) - .execute(&mut *tx) - .await?; - - sqlx::query("DELETE FROM sync_queue WHERE document_id = ? AND operation_type = ?") - .bind(document_id.to_string()) - .bind(ChangeEventType::Create.to_string()) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - tracing::info!( - "DATABASE: ✅ Marked {} as synced, rows affected: {}", - document_id, - result.rows_affected() - ); - - Ok(()) - } - - pub async fn update_sync_revision( - &self, - document_id: &Uuid, - sync_revision: i64, - ) -> SyncResult<()> { - tracing::info!( - "DATABASE: 🔄 Updating document {} sync_revision to {}", - document_id, - sync_revision - ); - - let result = sqlx::query("UPDATE documents SET sync_revision = ? WHERE id = ?") - .bind(sync_revision) - .bind(document_id.to_string()) - .execute(&self.pool) - .await?; - - tracing::info!( - "DATABASE: ✅ Updated {} sync_revision, rows affected: {}", - document_id, - result.rows_affected() - ); - - Ok(()) - } - - pub async fn update_attribution( - &self, - document_id: &Uuid, - author_name: Option<String>, - visibility: Option<String>, - provenance: Option<serde_json::Value>, - ) -> SyncResult<()> { - tracing::info!("DATABASE: 🔄 Updating document {} attribution", document_id); - - let provenance_str = provenance.map(|v| v.to_string()); - - let result = sqlx::query( - "UPDATE documents SET author_name = ?, visibility = ?, provenance = ? WHERE id = ?", - ) - .bind(author_name) - .bind(visibility) - .bind(provenance_str) - .bind(document_id.to_string()) - .execute(&self.pool) - .await?; - - tracing::info!( - "DATABASE: ✅ Updated {} attribution, rows affected: {}", - document_id, - result.rows_affected() - ); - - Ok(()) - } - - pub async fn delete_document(&self, document_id: &Uuid) -> SyncResult<()> { - sqlx::query("UPDATE documents SET deleted_at = ?, sync_status = ? WHERE id = ?") - .bind(chrono::Utc::now()) - .bind(SyncStatus::Pending.to_string()) - .bind(document_id.to_string()) - .execute(&self.pool) - .await?; - - // Remove from FTS index - if let Err(e) = self.update_fts_for_document(document_id).await { - tracing::warn!("FTS: Failed to remove {} from index: {:?}", document_id, e); - } - - Ok(()) - } - - pub async fn get_all_documents(&self) -> SyncResult<Vec<Document>> { - let rows = sqlx::query("SELECT * FROM documents WHERE deleted_at IS NULL") - .fetch_all(&self.pool) - .await?; - - rows.into_iter() - .map(|row| DbHelpers::parse_document(&row)) - .collect() - } - - pub async fn get_all_document_ids(&self, include_deleted: bool) -> SyncResult<Vec<Uuid>> { - let query = if include_deleted { - "SELECT id FROM documents" - } else { - "SELECT id FROM documents WHERE deleted_at IS NULL" - }; - - let rows = sqlx::query(query).fetch_all(&self.pool).await?; - - rows.into_iter() - .map(|row| { - let id: String = row.get("id"); - Ok(Uuid::parse_str(&id)?) - }) - .collect() - } - - pub async fn count_documents(&self) -> SyncResult<i64> { - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM documents WHERE deleted_at IS NULL") - .fetch_one(&self.pool) - .await?; - Ok(count) - } - - pub async fn queue_sync_operation( - &self, - document_id: &Uuid, - operation_type: ChangeEventType, - patch: Option<&json_patch::Patch>, - ) -> SyncResult<()> { - let patch_json = patch.map(serde_json::to_string).transpose()?; - - tracing::info!( - "DATABASE: queue_sync_operation called: doc_id={}, op_type={}, patch_size={}", - document_id, - operation_type.to_string(), - patch_json.as_ref().map(|p| p.len()).unwrap_or(0) - ); - - let result = sqlx::query(Queries::INSERT_SYNC_QUEUE) - .bind(document_id.to_string()) - .bind(operation_type.to_string()) - .bind(patch_json.clone()) - .execute(&self.pool) - .await?; - - tracing::info!( - "DATABASE: sync_queue insert successful: rows_affected={}, doc_id={}", - result.rows_affected(), - document_id - ); - - // Verify the insert by immediately querying - let count_result = - sqlx::query("SELECT COUNT(*) as count FROM sync_queue WHERE document_id = ?") - .bind(document_id.to_string()) - .fetch_one(&self.pool) - .await; - - match count_result { - Ok(row) => { - let count: i64 = row.try_get("count").unwrap_or(0); - tracing::info!( - "DATABASE: sync_queue verification: {} entries for doc_id={}", - count, - document_id - ); - } - Err(e) => { - tracing::error!("DATABASE: Failed to verify sync_queue insert: {}", e); - } - } - - Ok(()) - } - - /// Save a newly created document and queue its `create` in one transaction, - /// so a crash between the two can never leave a document the server will - /// never be told about. - /// - /// The queued row is the durable record that this document has NOT reached - /// the server. Local edits made before it is sent queue their own `update` - /// row beside it and leave it alone; [`Self::has_queued_create`] is what the - /// upload path reads to know it must send a create, and the create it sends - /// carries the document's current content, which already folds those edits - /// in. - /// - /// The row carries no patch: a create uploads the whole document. - pub async fn save_new_document_and_queue_create(&self, doc: &Document) -> SyncResult<()> { - let params = DbHelpers::document_to_params(doc, Some(SyncStatus::Pending))?; - let mut tx = self.pool.begin().await?; - - sqlx::query(Queries::UPSERT_DOCUMENT) - .bind(params.0) // id - .bind(params.1) // user_id - .bind(params.2) // content - .bind(params.3) // version - .bind(params.4) // created_at - .bind(params.5) // updated_at - .bind(params.6) // deleted_at - .bind(params.7) // sync_status - .bind(params.8) // title - .bind(params.9) // author_name - .bind(params.10) // visibility - .bind(params.11) // provenance - .execute(&mut *tx) - .await?; - - sqlx::query(Queries::INSERT_SYNC_QUEUE) - .bind(doc.id.to_string()) - .bind(ChangeEventType::Create.to_string()) - .bind(Option::<String>::None) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - tracing::info!( - "DATABASE: Atomically saved new document {} with pending status and queued its create", - doc.id - ); - - if let Err(e) = self.update_fts_for_document(&doc.id).await { - tracing::warn!("FTS: Failed to update index for {}: {:?}", doc.id, e); - } - - Ok(()) - } - - /// Whether the document still carries an unconsumed `create` row — it was - /// created locally and the server has never acknowledged it. - /// - /// An ack clears every row for the document, so this goes false exactly when - /// the create lands. Until then it outranks any queued update: the server - /// cannot patch a document it does not hold, and answers `not_found`. - pub async fn has_queued_create(&self, document_id: &Uuid) -> SyncResult<bool> { - let row = sqlx::query( - "SELECT 1 FROM sync_queue WHERE document_id = ? AND operation_type = 'create' LIMIT 1", - ) - .bind(document_id.to_string()) - .fetch_optional(&self.pool) - .await?; - - Ok(row.is_some()) - } - - /// Atomically save a document and queue the edit for upload, so a crash - /// between the two can never lose the edit. - /// - /// `base_content` is the content this edit was made against. A document - /// keeps exactly ONE queued `update` row, whose base is the last state the - /// server acknowledged: the first pending edit stores `base_content`, and - /// every later edit made before that row is sent keeps the base it already - /// has. Appending a row per edit instead would send the newest fragment - /// paired with a base the server never had, which the server rejects as a - /// `hash_mismatch`. - /// - /// The base only moves when the row is cleared (an ack) or rebased onto - /// fresh server content; see [`Self::rebase_pending_document_if_unchanged`]. - /// - /// `patch` is stored as given only for rows with no base (`create` and - /// `delete`). For an update the stored patch is derived from the retained - /// base and `doc.content`, which is what [`Self::get_queued_patch`] serves. - /// - /// A pre-upgrade row with no `base_content` cannot be extended — its base is - /// unrecoverable — so it is replaced by a row based on this edit. The - /// earlier offline edit then resolves through the `hash_mismatch` rebase. - pub async fn save_document_and_queue_patch( - &self, - doc: &Document, - patch: &json_patch::Patch, - operation_type: ChangeEventType, - base_content: Option<&serde_json::Value>, - ) -> SyncResult<()> { - // Start a transaction for atomicity - let mut tx = self.pool.begin().await?; - - // Save document with pending status (in transaction) - let params = DbHelpers::document_to_params(doc, Some(SyncStatus::Pending))?; - - sqlx::query(Queries::UPSERT_DOCUMENT) - .bind(params.0) // id - .bind(params.1) // user_id - .bind(params.2) // content - .bind(params.3) // version - .bind(params.4) // created_at - .bind(params.5) // updated_at - .bind(params.6) // deleted_at - .bind(params.7) // sync_status - .bind(params.8) // title - .execute(&mut *tx) - .await?; - - // Queue sync operation (in transaction) - if let Some(edit_base) = base_content { - // An update: collapse onto the single row, keeping whatever base it - // already carries. Oldest-first is the earliest base, which only - // several pre-upgrade rows can offer — this build keeps exactly one, - // so the reader's newest-first order picks the same row. - let existing = sqlx::query( - "SELECT base_content, old_content_hash FROM sync_queue \ - WHERE document_id = ? AND operation_type = ? ORDER BY id ASC LIMIT 1", - ) - .bind(doc.id.to_string()) - .bind(operation_type.to_string()) - .fetch_optional(&mut *tx) - .await?; - - let retained = match existing { - Some(row) => { - let base_json: Option<String> = row.try_get("base_content")?; - let hash: Option<String> = row.try_get("old_content_hash")?; - base_json - .map(|json| serde_json::from_str::<serde_json::Value>(&json)) - .transpose()? - .map(|base| { - let hash = hash.unwrap_or_else(|| calculate_checksum(&base)); - (base, hash) - }) - } - None => None, - }; - - let (base, base_hash) = - retained.unwrap_or_else(|| (edit_base.clone(), calculate_checksum(edit_base))); - - sqlx::query("DELETE FROM sync_queue WHERE document_id = ? AND operation_type = ?") - .bind(doc.id.to_string()) - .bind(operation_type.to_string()) - .execute(&mut *tx) - .await?; - - sqlx::query( - "INSERT INTO sync_queue (document_id, operation_type, patch, old_content_hash, base_content) VALUES (?, ?, ?, ?, ?)" - ) - .bind(doc.id.to_string()) - .bind(operation_type.to_string()) - .bind(serde_json::to_string(&create_patch(&base, &doc.content)?)?) - .bind(base_hash) - .bind(serde_json::to_string(&base)?) - .execute(&mut *tx) - .await?; - } else { - sqlx::query(Queries::INSERT_SYNC_QUEUE) - .bind(doc.id.to_string()) // document_id - .bind(operation_type.to_string()) // operation_type - .bind(serde_json::to_string(patch)?) // patch - .execute(&mut *tx) - .await?; - } - - // Commit atomically - both operations succeed or both fail - tx.commit().await?; - - tracing::info!( - "DATABASE: Atomically saved document {} with pending status and queued patch", - doc.id - ); - - // Update FTS index for this document - if let Err(e) = self.update_fts_for_document(&doc.id).await { - tracing::warn!("FTS: Failed to update index for {}: {:?}", doc.id, e); - } - - Ok(()) - } - - /// The document's queued update as one cumulative diff, with the hash of the - /// base it applies to. - /// - /// The patch is regenerated here from the row's stored `base_content` and - /// the document's CURRENT content, so however many local edits accumulated - /// since the base was set, the upload is a single diff the server can apply - /// to the state it actually holds. - /// - /// `Ok(None)` means nothing is queued — the caller's signal that a pending - /// document is a create, not an update. - /// - /// A row written before `base_content` existed falls back to its stored - /// patch and hash, which is what a pre-upgrade client would have sent. A NULL - /// base is that legacy case; a column that fails to read is corruption and is - /// reported, not quietly treated as legacy. - /// - /// Newest-first ordering only matters for a pre-upgrade document holding - /// several rows, where the newest is what the old reader would have sent; - /// every row this build writes is the document's only one. - pub async fn get_queued_patch( - &self, - document_id: &Uuid, - ) -> SyncResult<Option<(json_patch::Patch, Option<String>)>> { - let row = sqlx::query( - "SELECT patch, old_content_hash, base_content FROM sync_queue WHERE document_id = ? AND operation_type = 'update' ORDER BY created_at DESC, id DESC LIMIT 1" - ) - .bind(document_id.to_string()) - .fetch_optional(&self.pool) - .await?; - - let Some(row) = row else { - return Ok(None); - }; - - let base_json: Option<String> = row.try_get("base_content")?; - let old_hash: Option<String> = row.try_get("old_content_hash")?; - - if let Some(base_json) = base_json { - let base: serde_json::Value = serde_json::from_str(&base_json)?; - let current: Option<String> = sqlx::query("SELECT content FROM documents WHERE id = ?") - .bind(document_id.to_string()) - .fetch_optional(&self.pool) - .await? - .map(|row| row.try_get("content")) - .transpose()?; - - if let Some(current) = current { - let current: serde_json::Value = serde_json::from_str(¤t)?; - let hash = old_hash.unwrap_or_else(|| calculate_checksum(&base)); - return Ok(Some((create_patch(&base, ¤t)?, Some(hash)))); - } - tracing::warn!( - "DATABASE: Queued patch for {} has no document row, serving the stored patch", - document_id - ); - } - - let patch_json: Option<String> = row.try_get("patch")?; - match patch_json { - Some(json) => Ok(Some((serde_json::from_str(&json)?, old_hash))), - None => Ok(None), - } - } - - pub async fn remove_from_sync_queue(&self, document_id: &Uuid) -> SyncResult<()> { - sqlx::query("DELETE FROM sync_queue WHERE document_id = ?") - .bind(document_id.to_string()) - .execute(&self.pool) - .await?; - Ok(()) - } - - // ===== FTS (Full-Text Search) Methods ===== - - /// Configure which JSON paths to index for full-text search. - /// Replaces existing configuration and rebuilds the index. - pub async fn configure_search(&self, json_paths: &[String]) -> SyncResult<()> { - // Use transaction to ensure config and index stay in sync - let mut tx = self.pool.begin().await?; - - // Clear existing config - sqlx::query(Queries::CLEAR_SEARCH_CONFIG) - .execute(&mut *tx) - .await?; - - // Insert new paths - for path in json_paths { - sqlx::query(Queries::INSERT_SEARCH_PATH) - .bind(path) - .execute(&mut *tx) - .await?; - } - - // Rebuild the index with new configuration (inline to use same transaction) - sqlx::query(Queries::CLEAR_FTS_INDEX) - .execute(&mut *tx) - .await?; - - sqlx::query(Queries::REBUILD_FTS_INDEX) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - tracing::info!( - "FTS: Configured search with {} paths and rebuilt index", - json_paths.len() - ); - Ok(()) - } - - /// Update the FTS index entry for a single document. - /// Call this after creating or updating a document. - pub async fn update_fts_for_document(&self, document_id: &Uuid) -> SyncResult<()> { - // Skip FTS update if no search paths are configured - let has_config: (i32,) = sqlx::query_as(Queries::HAS_SEARCH_CONFIG) - .fetch_one(&self.pool) - .await?; - if has_config.0 == 0 { - return Ok(()); - } - - let doc_id_str = document_id.to_string(); - - // Use transaction to ensure atomicity (no orphaned entries on crash) - let mut tx = self.pool.begin().await?; - - // Delete existing entry - sqlx::query(Queries::DELETE_FTS_ENTRY) - .bind(&doc_id_str) - .execute(&mut *tx) - .await?; - - // Insert new entry (query handles deleted_at check internally) - sqlx::query(Queries::UPDATE_FTS_ENTRY) - .bind(&doc_id_str) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - Ok(()) - } - - /// Rebuild the entire FTS index from all documents. - pub async fn rebuild_fts_index(&self) -> SyncResult<()> { - // Clear existing index - sqlx::query(Queries::CLEAR_FTS_INDEX) - .execute(&self.pool) - .await?; - - // Rebuild from all non-deleted documents - sqlx::query(Queries::REBUILD_FTS_INDEX) - .execute(&self.pool) - .await?; - - tracing::info!("FTS: Index rebuilt"); - Ok(()) - } - - /// Search documents using FTS5 full-text search. - /// Returns all documents matching the query. - pub async fn search_documents(&self, query: &str, limit: i64) -> SyncResult<Vec<Document>> { - let rows = sqlx::query(Queries::SEARCH_DOCUMENTS) - .bind(query) - .bind(limit) - .fetch_all(&self.pool) - .await?; - - rows.into_iter() - .map(|row| DbHelpers::parse_document(&row)) - .collect() - } -} - -#[cfg(test)] -mod sync_queue_tests { - use super::*; - use replicant_core::patches::apply_patch; - use serde_json::json; - - async fn fresh_db() -> ClientDatabase { - let db = ClientDatabase::new(":memory:").await.unwrap(); - db.run_migrations().await.unwrap(); - db - } - - fn doc_with(id: Uuid, content: serde_json::Value) -> Document { - Document { - id, - user_id: Some(Uuid::new_v4()), - content, - sync_revision: 1, - content_hash: None, - title: None, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - } - } - - /// One local edit, exactly as `Client::update_document` performs it. - async fn edit(db: &ClientDatabase, id: Uuid, new_content: serde_json::Value) { - let mut doc = db.get_document(&id).await.unwrap(); - let old_content = doc.content.clone(); - let patch = create_patch(&old_content, &new_content).unwrap(); - doc.content = new_content; - db.save_document_and_queue_patch(&doc, &patch, ChangeEventType::Update, Some(&old_content)) - .await - .unwrap(); - } - - async fn update_rows(db: &ClientDatabase, id: Uuid) -> i64 { - sqlx::query( - "SELECT COUNT(*) as c FROM sync_queue WHERE document_id = ? AND operation_type = 'update'", - ) - .bind(id.to_string()) - .fetch_one(&db.pool) - .await - .unwrap() - .try_get("c") - .unwrap() - } - - async fn stored_base(db: &ClientDatabase, id: Uuid) -> Option<serde_json::Value> { - let raw: Option<String> = sqlx::query( - "SELECT base_content FROM sync_queue WHERE document_id = ? AND operation_type = 'update'", - ) - .bind(id.to_string()) - .fetch_one(&db.pool) - .await - .unwrap() - .try_get("base_content") - .unwrap(); - raw.map(|json| serde_json::from_str(&json).unwrap()) - } - - #[tokio::test] - async fn three_offline_edits_collapse_into_one_cumulative_patch() { - let db = fresh_db().await; - let id = Uuid::new_v4(); - let base = json!({"title": "base", "n": 0}); - db.save_document_with_status(&doc_with(id, base.clone()), Some(SyncStatus::Synced)) - .await - .unwrap(); - - edit(&db, id, json!({"title": "base", "n": 1})).await; - edit(&db, id, json!({"title": "edited", "n": 1})).await; - let final_content = json!({"title": "edited", "n": 2}); - edit(&db, id, final_content.clone()).await; - - assert_eq!( - update_rows(&db, id).await, - 1, - "offline edits must share one queue row" - ); - assert_eq!( - stored_base(&db, id).await, - Some(base.clone()), - "the base must stay the last state the server acknowledged" - ); - - let (patch, hash) = db.get_queued_patch(&id).await.unwrap().unwrap(); - assert_eq!(hash.as_deref(), Some(calculate_checksum(&base).as_str())); - - let mut replayed = base; - apply_patch(&mut replayed, &patch).unwrap(); - assert_eq!( - replayed, final_content, - "the queued patch must carry all three edits" - ); - } - - /// A document created offline keeps its `create` row while later offline - /// edits pile up beside it, so the flush knows to send a create. The edits - /// still collapse into one update row, and an ack clears both — leaving the - /// update row with nothing to fire afterwards. - #[tokio::test] - async fn offline_edits_leave_the_create_row_for_the_upload_to_find() { - let db = fresh_db().await; - let id = Uuid::new_v4(); - let created = json!({"title": "draft"}); - db.save_new_document_and_queue_create(&doc_with(id, created)) - .await - .unwrap(); - - assert!( - db.has_queued_create(&id).await.unwrap(), - "a locally created document starts out owing the server a create" - ); - - edit(&db, id, json!({"title": "draft", "a": 1})).await; - let final_content = json!({"title": "draft", "a": 1, "b": 2}); - edit(&db, id, final_content.clone()).await; - - assert!( - db.has_queued_create(&id).await.unwrap(), - "editing must not consume the create the server still has not seen" - ); - assert_eq!( - update_rows(&db, id).await, - 1, - "offline edits must still share one queue row" - ); - assert_eq!( - db.get_document(&id).await.unwrap().content, - final_content, - "the create uploads current content, so it must carry both edits" - ); - - // What the create ack does. - db.remove_from_sync_queue(&id).await.unwrap(); - assert!( - !db.has_queued_create(&id).await.unwrap(), - "the ack consumes the create" - ); - assert!( - db.get_queued_patch(&id).await.unwrap().is_none(), - "the ack also clears the update row, which the create already carried" - ); - } - - /// The lost-ack trace: the server committed the create but the ack never - /// arrived, so the create row survives. A later edit, then a resync that - /// rebases onto the server's copy, must consume that create row — otherwise - /// the next upload sends a create the server rejects as a duplicate, and a - /// rejected create clears nothing. - #[tokio::test] - async fn rebasing_onto_server_content_consumes_the_create_row() { - let db = fresh_db().await; - let id = Uuid::new_v4(); - let created = json!({"title": "draft"}); - db.save_new_document_and_queue_create(&doc_with(id, created.clone())) - .await - .unwrap(); - - // The ack was lost, so the create row is still owed. Then a local edit. - let edited = json!({"title": "draft", "a": 1}); - edit(&db, id, edited.clone()).await; - assert!(db.has_queued_create(&id).await.unwrap()); - - // A resync delivers the server's copy and rebases the local edit onto it. - let server_content = json!({"title": "draft", "server": true}); - let expected = DocumentPreImage { - sync_revision: 1, - content: edited, - sync_status: SyncStatus::Pending, - }; - assert!(db - .rebase_pending_document_if_unchanged( - &id, - &json!({"title": "draft", "server": true, "a": 1}), - 7, - &server_content, - &calculate_checksum(&server_content), - SyncStatus::Pending, - &expected, - ) - .await - .unwrap()); - - assert!( - !db.has_queued_create(&id).await.unwrap(), - "adopting server content is the create's ack" - ); - assert!( - db.get_queued_patch(&id).await.unwrap().is_some(), - "the rebased edit must still be queued as an update" - ); - } - - /// Adopting server state through the broadcast/full-sync route — a plain - /// write at `Synced` — must also consume the create row, or a later edit - /// uploads as a duplicate create. - #[tokio::test] - async fn saving_a_document_as_synced_consumes_the_create_row() { - let db = fresh_db().await; - let id = Uuid::new_v4(); - let created = json!({"title": "draft"}); - db.save_new_document_and_queue_create(&doc_with(id, created.clone())) - .await - .unwrap(); - - db.save_document_with_status(&doc_with(id, created), Some(SyncStatus::Synced)) - .await - .unwrap(); - - assert!( - !db.has_queued_create(&id).await.unwrap(), - "a document written as Synced is one the server holds" - ); - } - - /// The `DocumentCreated` echo settles a document with a bare `mark_synced`, - /// so that too must consume the create row — otherwise a lost ack costs a - /// doomed duplicate create and a spurious error event. - #[tokio::test] - async fn marking_a_document_synced_consumes_the_create_row() { - let db = fresh_db().await; - let id = Uuid::new_v4(); - db.save_new_document_and_queue_create(&doc_with(id, json!({"title": "draft"}))) - .await - .unwrap(); - - db.mark_synced(&id).await.unwrap(); - - assert!( - !db.has_queued_create(&id).await.unwrap(), - "settling a document against the server is the create's ack" - ); - } - - /// A document the server has acknowledged owes no create, so its later - /// edits upload as updates. - #[tokio::test] - async fn an_acknowledged_document_owes_no_create() { - let db = fresh_db().await; - let id = Uuid::new_v4(); - let created = json!({"title": "draft"}); - db.save_new_document_and_queue_create(&doc_with(id, created)) - .await - .unwrap(); - db.remove_from_sync_queue(&id).await.unwrap(); - - edit(&db, id, json!({"title": "edited"})).await; - - assert!(!db.has_queued_create(&id).await.unwrap()); - assert!( - db.get_queued_patch(&id).await.unwrap().is_some(), - "the edit still queues an update" - ); - } - - #[tokio::test] - async fn a_legacy_row_without_a_base_falls_back_to_its_stored_patch() { - let db = fresh_db().await; - let id = Uuid::new_v4(); - db.save_document_with_status( - &doc_with(id, json!({"title": "current"})), - Some(SyncStatus::Pending), - ) - .await - .unwrap(); - - // A row as a pre-upgrade client wrote it: a patch and a hash, no base. - let legacy = create_patch(&json!({"title": "old"}), &json!({"title": "current"})).unwrap(); - sqlx::query( - "INSERT INTO sync_queue (document_id, operation_type, patch, old_content_hash) VALUES (?, 'update', ?, ?)", - ) - .bind(id.to_string()) - .bind(serde_json::to_string(&legacy).unwrap()) - .bind("legacy-hash") - .execute(&db.pool) - .await - .unwrap(); - - let (patch, hash) = db.get_queued_patch(&id).await.unwrap().unwrap(); - assert_eq!(patch, legacy, "the stored patch must be served verbatim"); - assert_eq!(hash.as_deref(), Some("legacy-hash")); - } - - #[tokio::test] - async fn a_local_edit_after_a_rebase_keeps_the_server_base() { - let db = fresh_db().await; - let id = Uuid::new_v4(); - let base = json!({"title": "base", "n": 0}); - db.save_document_with_status(&doc_with(id, base.clone()), Some(SyncStatus::Synced)) - .await - .unwrap(); - edit(&db, id, json!({"title": "base", "n": 1})).await; - - // A rebase moves the base forward to fresh server content. - let server = json!({"title": "server", "n": 0}); - let rebased = json!({"title": "server", "n": 1}); - let local = db.get_document(&id).await.unwrap(); - let expected = DocumentPreImage { - sync_revision: local.sync_revision, - content: local.content.clone(), - sync_status: SyncStatus::Pending, - }; - assert!(db - .rebase_pending_document_if_unchanged( - &id, - &rebased, - 9, - &server, - &calculate_checksum(&server), - SyncStatus::Pending, - &expected, - ) - .await - .unwrap()); - - // A later local edit extends that base; it does not reset it to the - // content the user happened to be looking at. - let final_content = json!({"title": "server", "n": 2}); - edit(&db, id, final_content.clone()).await; - - assert_eq!(update_rows(&db, id).await, 1); - assert_eq!(stored_base(&db, id).await, Some(server.clone())); - - let (patch, hash) = db.get_queued_patch(&id).await.unwrap().unwrap(); - assert_eq!(hash.as_deref(), Some(calculate_checksum(&server).as_str())); - let mut replayed = server; - apply_patch(&mut replayed, &patch).unwrap(); - assert_eq!(replayed, final_content); - } - - #[tokio::test] - async fn clearing_the_queue_rebases_the_next_edit_on_the_synced_content() { - let db = fresh_db().await; - let id = Uuid::new_v4(); - let base = json!({"title": "base", "n": 0}); - db.save_document_with_status(&doc_with(id, base.clone()), Some(SyncStatus::Synced)) - .await - .unwrap(); - - let acked = json!({"title": "base", "n": 1}); - edit(&db, id, acked.clone()).await; - // The ack clears the queue: the edit is now the server's state. - db.remove_from_sync_queue(&id).await.unwrap(); - assert!(db.get_queued_patch(&id).await.unwrap().is_none()); - - let next = json!({"title": "base", "n": 2}); - edit(&db, id, next.clone()).await; - - assert_eq!(stored_base(&db, id).await, Some(acked.clone())); - let (patch, hash) = db.get_queued_patch(&id).await.unwrap().unwrap(); - assert_eq!(hash.as_deref(), Some(calculate_checksum(&acked).as_str())); - let mut replayed = acked; - apply_patch(&mut replayed, &patch).unwrap(); - assert_eq!(replayed, next); - } -} - -#[cfg(test)] -mod identity_tests { - use super::*; - - async fn fresh_db() -> ClientDatabase { - let db = ClientDatabase::new(":memory:").await.unwrap(); - db.run_migrations().await.unwrap(); - db - } - - #[tokio::test] - async fn user_config_has_identity_adopted_defaulting_to_zero() { - let db = fresh_db().await; - db.ensure_user_config("ws://localhost/ws").await.unwrap(); - - let row = sqlx::query("SELECT identity_adopted FROM user_config LIMIT 1") - .fetch_one(&db.pool) - .await - .unwrap(); - let adopted: i64 = row.try_get("identity_adopted").unwrap(); - assert_eq!(adopted, 0); - } - - #[tokio::test] - async fn ensure_user_config_with_identifier_generates_random_v4_id() { - let db = fresh_db().await; - db.ensure_user_config_with_identifier("ws://localhost/ws", "test@example.com") - .await - .unwrap(); - - let user_id = db.get_user_id().await.unwrap(); - // No longer derived from the email. - assert_ne!(user_id.to_string(), "71b2b712-7878-56ee-8323-43809b8198a5"); - assert_eq!(user_id.get_version(), Some(uuid::Version::Random)); - - let row = sqlx::query("SELECT identity_adopted FROM user_config LIMIT 1") - .fetch_one(&db.pool) - .await - .unwrap(); - let adopted: i64 = row.try_get("identity_adopted").unwrap(); - assert_eq!(adopted, 0); - } - - async fn seed_document(db: &ClientDatabase, owner: Option<Uuid>) -> Uuid { - let id = Uuid::new_v4(); - sqlx::query("INSERT INTO documents (id, user_id, content) VALUES (?1, ?2, ?3)") - .bind(id.to_string()) - .bind(owner.map(|u| u.to_string())) - .bind("{}") - .execute(&db.pool) - .await - .unwrap(); - id - } - - async fn count_docs_for(db: &ClientDatabase, owner: Uuid) -> i64 { - sqlx::query("SELECT COUNT(*) as c FROM documents WHERE user_id = ?1") - .bind(owner.to_string()) - .fetch_one(&db.pool) - .await - .unwrap() - .try_get("c") - .unwrap() - } - - #[tokio::test] - async fn adopt_identity_restamps_docs_and_flips_flag() { - let db = fresh_db().await; - db.ensure_user_config("ws://localhost/ws").await.unwrap(); - let provisional = db.get_user_id().await.unwrap(); - let canonical = Uuid::new_v4(); - - seed_document(&db, Some(provisional)).await; - seed_document(&db, Some(provisional)).await; - let public_id = seed_document(&db, None).await; - - db.adopt_identity(provisional, canonical).await.unwrap(); - - // Identity flipped in user_config. - assert_eq!(db.get_user_id().await.unwrap(), canonical); - let adopted: i64 = sqlx::query("SELECT identity_adopted FROM user_config LIMIT 1") - .fetch_one(&db.pool) - .await - .unwrap() - .try_get("identity_adopted") - .unwrap(); - assert_eq!(adopted, 1); - - // Owned documents re-stamped; none remain under the provisional id. - assert_eq!(count_docs_for(&db, canonical).await, 2); - assert_eq!(count_docs_for(&db, provisional).await, 0); - - // Public (null-owner) document untouched. - let pub_null: i64 = - sqlx::query("SELECT COUNT(*) as c FROM documents WHERE id = ?1 AND user_id IS NULL") - .bind(public_id.to_string()) - .fetch_one(&db.pool) - .await - .unwrap() - .try_get("c") - .unwrap(); - assert_eq!(pub_null, 1); - } - - #[tokio::test] - async fn adopt_identity_with_no_documents_still_flips_flag() { - let db = fresh_db().await; - db.ensure_user_config("ws://localhost/ws").await.unwrap(); - let provisional = db.get_user_id().await.unwrap(); - let canonical = Uuid::new_v4(); - - db.adopt_identity(provisional, canonical).await.unwrap(); - - assert_eq!(db.get_user_id().await.unwrap(), canonical); - let adopted: i64 = sqlx::query("SELECT identity_adopted FROM user_config LIMIT 1") - .fetch_one(&db.pool) - .await - .unwrap() - .try_get("identity_adopted") - .unwrap(); - assert_eq!(adopted, 1); - } - - #[tokio::test] - async fn adopt_identity_rejects_nil_canonical_id() { - let db = fresh_db().await; - db.ensure_user_config("ws://localhost/ws").await.unwrap(); - let provisional = db.get_user_id().await.unwrap(); - assert!(db.adopt_identity(provisional, Uuid::nil()).await.is_err()); - } - - #[tokio::test] - async fn adopt_identity_rejects_second_adoption() { - let db = fresh_db().await; - db.ensure_user_config("ws://localhost/ws").await.unwrap(); - let provisional = db.get_user_id().await.unwrap(); - let canonical = Uuid::new_v4(); - db.adopt_identity(provisional, canonical).await.unwrap(); - assert!(db.adopt_identity(canonical, Uuid::new_v4()).await.is_err()); - } -} diff --git a/replicant-client/src/driver/engine.rs b/replicant-client/src/driver/engine.rs index d05ded5..f24bb94 100644 --- a/replicant-client/src/driver/engine.rs +++ b/replicant-client/src/driver/engine.rs @@ -4,6 +4,7 @@ use std::collections::VecDeque; use std::future::Future; +use std::io; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -17,13 +18,16 @@ use tracing::{debug, info, warn}; use uuid::Uuid; use super::timers::Timers; +use crate::engine::list_merge::ListMergeConfig; use crate::engine::machine::{ BuildOutcome, ConnectionView, Core, Effect, EngineState, HaltReason, Input, Lifecycle, Request, Response, SettleOutcome, TimerId, }; use crate::engine::types::ServerError; use crate::store::change_log::{ChangeLogReader, ChangeOrigin, DocChange, LogRead}; -use crate::store::{now_unix, DocNotice, IdentityCheck, Store, StoreError, StoreResult}; +use crate::store::{ + is_json_pointer, now_unix, DocNotice, IdentityCheck, Store, StoreError, StoreResult, +}; use crate::transport::connection::{Connection, Received}; use crate::transport::socket::SocketEvent; use crate::transport::wire::{socket_url, user_agent, JoinAuth}; @@ -42,20 +46,25 @@ mod tests; const COMMAND_BUFFER: usize = 32; /// How often the change log is read when nothing else wakes the owner (spec §8). const LOG_TICK: Duration = Duration::from_millis(250); +/// While connected, the stored credentials are read every this many ticks (once a second). +const SIGNED_IN_CHECK_TICKS: u32 = 4; -/// Reads the stored credentials: at start, on `Command::CredentialsChanged`, and on a halted -/// engine's periodic credential check. -pub type CredentialLoader = Arc<dyn Fn() -> Option<JoinAuth> + Send + Sync>; +/// Reads the stored credentials: `Ok(None)` = signed out, or stored but damaged beyond use; +/// `Err` = present but unreadable right now (e.g. a locked file), which never counts as a sign-out. +pub type CredentialLoader = Arc<dyn Fn() -> io::Result<Option<JoinAuth>> + Send + Sync>; pub struct EngineConfig { /// http(s) or ws(s), with or without `/socket/websocket`. pub server_url: String, - pub client_id: Uuid, /// Names the host in the `User-Agent`, e.g. "Entonal Studio" and "2.0.1". pub host_app: String, pub host_version: String, pub credentials: CredentialLoader, pub jitter_seed: u64, + /// How lists that both this device and another changed are merged. Local to this device. + pub list_merge: ListMergeConfig, + /// JSON Pointer to each document's title in its content; `None`: documents have no title. + pub title_pointer: Option<String>, } /// Idempotent, so a full command queue loses nothing. @@ -75,6 +84,11 @@ pub enum EngineEvent { Changed(DocChange), /// Changes were trimmed before this engine read them; the host reloads its lists. DatabaseChanged, + /// The join's user id differs from the one this engine started with: this engine or another + /// process adopted it and restamped the owned documents. + IdentityAdopted { + user_id: Uuid, + }, } #[derive(Debug, thiserror::Error)] @@ -100,14 +114,16 @@ struct Controls { } impl Engine { - /// Opens the store at `db_path` (which needs a `user_config` row) and starts the owner. - /// Returns without waiting for the network. + /// Opens the store at `db_path` (creating its `user_config` row if needed) and starts the + /// owner. Returns without waiting for the network, once the stored credentials were read: + /// `state` is then `Halted(NotEnrolled)` with none usable, else `Connecting`. pub async fn start( db_path: &Path, config: EngineConfig, events: mpsc::UnboundedSender<EngineEvent>, ) -> Result<Engine, EngineError> { - let (owner, controls) = Owner::open(db_path, config, events).await?; + let (mut owner, controls) = Owner::open(db_path, config, events).await?; + owner.start().await; let task = tokio::spawn(owner.run()); Ok(Engine { controls, task }) } @@ -131,12 +147,17 @@ impl Engine { } /// Stops the owner without waiting on the network, then closes the store. - pub async fn stop(mut self) { + pub async fn stop(self) { + self.stop_owner().await.close().await; + } + + /// Stops the owner without waiting on the network; the caller closes the returned store. + pub async fn stop_owner(mut self) -> Arc<Store> { self.controls.cancel.cancel(); if let Err(error) = (&mut self.task).await { warn!(%error, "engine owner task failed"); } - self.controls.store.close().await; + self.controls.store.clone() } } @@ -176,8 +197,11 @@ struct Owner { /// Fingerprint of the credentials that signed the most recently sent join; may differ from /// `auth_fingerprint` if credentials changed while that join was outstanding. join_fingerprint: Option<[u8; 32]>, - /// Fingerprint of credentials the server rejected with `auth_invalid`. + /// Fingerprint of credentials the server refused (`auth_invalid`, `account_disabled`), or that joined as + /// another user (`identity_drift`). Cleared by a sign-out: the same keys stored again are dialled. rejected: Option<[u8; 32]>, + /// Seconds added to this machine's clock when signing a join; learnt from `clock_skew`. + clock_offset: i64, socket_gen: u64, queue: VecDeque<Queued>, events: mpsc::UnboundedSender<EngineEvent>, @@ -186,6 +210,8 @@ struct Owner { outbox: Arc<Notify>, cancel: CancellationToken, tick: Interval, + /// Ticks since the stored credentials were last read while connected. + ticks_since_signed_in_check: u32, #[cfg(test)] dropped_stale: usize, } @@ -196,16 +222,44 @@ impl Owner { config: EngineConfig, events: mpsc::UnboundedSender<EngineEvent>, ) -> Result<(Owner, Controls), EngineError> { - let url = socket_url(&config.server_url, config.client_id).map_err(EngineError::Config)?; - let store = Arc::new(retry_migrate_once(|| Store::open(db_path)).await?); + config.list_merge.validate().map_err(EngineError::Config)?; + socket_url(&config.server_url, Uuid::nil()).map_err(EngineError::Config)?; + if let Some(pointer) = config.title_pointer.as_deref() { + if !is_json_pointer(pointer) { + return Err(EngineError::Config(format!( + "title pointer is not a JSON Pointer: {pointer}" + ))); + } + } + let mut store = retry_open(|| Store::open(db_path)).await?; + store.list_merge = config.list_merge.clone(); + store.title_pointer = config.title_pointer.clone(); + let store = Arc::new(store); + match Self::prepare(store.clone(), config, events).await { + Ok(opened) => Ok(opened), + Err(error) => { + store.close().await; + Err(error) + } + } + } + + async fn prepare( + store: Arc<Store>, + config: EngineConfig, + events: mpsc::UnboundedSender<EngineEvent>, + ) -> Result<(Owner, Controls), EngineError> { + let client_id = store.ensure_user_config(&config.server_url).await?; + if store.apply_title_pointer().await? { + info!(pointer = ?store.title_pointer, "recomputed every title for a new title pointer"); + } + let url = socket_url(&config.server_url, client_id).map_err(EngineError::Config)?; let me = store.user_id().await?; let scopes = store.subscribed_scopes().await?; let reader = ChangeLogReader::open(&store, now_unix()).await?; - let auth = (config.credentials)(); - let auth_fingerprint = auth.as_ref().map(fingerprint); let connection = Connection::new( url, - auth, + None, user_agent(&config.host_app, &config.host_version), ); let core = Core::new(scopes, config.jitter_seed); @@ -223,9 +277,10 @@ impl Owner { timers: Timers::default(), reader, credentials: config.credentials, - auth_fingerprint, + auth_fingerprint: None, join_fingerprint: None, rejected: None, + clock_offset: 0, socket_gen: 0, queue: VecDeque::new(), events, @@ -234,6 +289,7 @@ impl Owner { outbox: outbox.clone(), cancel: cancel.clone(), tick, + ticks_since_signed_in_check: 0, #[cfg(test)] dropped_stale: 0, }; @@ -247,14 +303,18 @@ impl Owner { Ok((owner, controls)) } + /// Call `start` first. async fn run(mut self) { - self.start().await; while self.turn().await {} self.shutdown().await; } + /// A credential file unreadable right now at start is not a sign-out: the join-time read decides. async fn start(&mut self) { - let has_credentials = self.auth_fingerprint.is_some(); + let has_credentials = self.load_credentials().unwrap_or_else(|error| { + warn!(%error, "stored credentials unreadable at start"); + true + }); self.feed(Input::Start { has_credentials }).await; } @@ -295,11 +355,13 @@ impl Owner { match wake { Wake::Cancelled => {} Wake::Command(Command::Reconnect) => self.feed(Input::Reconnect).await, - Wake::Command(Command::CredentialsChanged) => { - let has_credentials = self.reload_credentials(); - self.feed(Input::CredentialsChanged { has_credentials }) - .await; - } + Wake::Command(Command::CredentialsChanged) => match self.load_credentials() { + Ok(has_credentials) => { + self.feed(Input::CredentialsChanged { has_credentials }) + .await + } + Err(error) => warn!(%error, "stored credentials unreadable; keeping the last ones"), + }, Wake::Socket(event) => { if let Some(received) = self.connection.receive(event) { let input = self.on_received(received).await; @@ -315,6 +377,46 @@ impl Owner { if self.read_change_log().await { self.feed(Input::OutboxChanged).await; } + self.check_still_signed_in().await; + } + } + } + + /// A sign-out in another process is never announced here: while connected, the stored + /// credentials are read once a second and their removal ends the connection. New or + /// unreadable credentials change nothing (the next join reads them again). + async fn check_still_signed_in(&mut self) { + if self.core.state().connection != ConnectionView::Connected { + self.ticks_since_signed_in_check = 0; + return; + } + self.ticks_since_signed_in_check += 1; + if self.ticks_since_signed_in_check < SIGNED_IN_CHECK_TICKS { + return; + } + self.ticks_since_signed_in_check = 0; + if let Ok(None) = (self.credentials)() { + self.feed(Input::CredentialsChanged { + has_credentials: false, + }) + .await; + } + } + + /// A `clock_skew` join reply carries the server's clock; every later join is signed with it. + fn learn_clock_offset(&mut self, input: &Input) { + if let Input::Reply { + result: + Err(ServerError { + code, + server_time: Some(server_time), + .. + }), + .. + } = input + { + if code == "clock_skew" { + self.clock_offset = server_time - now_unix(); } } } @@ -323,7 +425,10 @@ impl Owner { /// adopted on the first join, and any other id afterwards halts as `identity_drift`. async fn on_received(&mut self, received: Received) -> Input { let (req, user_id) = match received { - Received::Input(input) => return input, + Received::Input(input) => { + self.learn_clock_offset(&input); + return input; + } Received::Joined { req, user_id } => (req, user_id), }; let result = match self.store.check_identity(user_id).await { @@ -344,35 +449,60 @@ impl Owner { } }; if result.is_ok() { + if self.me != user_id { + self.emit(EngineEvent::IdentityAdopted { user_id }); + } self.me = user_id; self.rejected = None; } Input::Reply { req, result } } - /// An explicit credential change: whatever is stored now signs the next join. - fn reload_credentials(&mut self) -> bool { - let Some(auth) = (self.credentials)() else { - return false; - }; - self.auth_fingerprint = Some(fingerprint(&auth)); - self.connection.set_auth(auth); - true + /// Reads the stored credentials: what is stored now signs the next join, and with nothing + /// stored nothing may join. Runs on every explicit change and before every join, so a + /// sign-out in another process is honoured at this engine's next dial. An unreadable file + /// changes nothing: the last credentials loaded stay. + fn load_credentials(&mut self) -> io::Result<bool> { + match (self.credentials)()? { + Some(auth) => { + self.auth_fingerprint = Some(fingerprint(&auth)); + self.connection.set_auth(auth); + Ok(true) + } + None => { + self.auth_fingerprint = None; + self.rejected = None; + Ok(false) + } + } } - /// A halted engine's periodic check: credentials the server rejected with `auth_invalid` - /// are not tried again until they change. - fn check_credentials(&mut self) -> bool { - let Some(auth) = (self.credentials)() else { - return false; + /// A halted engine's periodic check: credentials the server rejected with `auth_invalid` or + /// `account_disabled`, or that joined as another user, are not tried again until they change. + fn check_credentials(&mut self) -> Input { + let auth = match (self.credentials)() { + Ok(Some(auth)) => auth, + Ok(None) => { + self.auth_fingerprint = None; + self.rejected = None; + return Input::CredentialsChanged { + has_credentials: false, + }; + } + Err(error) => { + warn!(%error, "stored credentials unreadable"); + return Input::CredentialsUnchanged; + } }; let print = fingerprint(&auth); if self.rejected == Some(print) { - return false; + return Input::CredentialsUnchanged; } self.auth_fingerprint = Some(print); self.connection.set_auth(auth); - true + Input::CredentialsChanged { + has_credentials: true, + } } async fn feed(&mut self, input: Input) { @@ -427,7 +557,30 @@ impl Owner { Effect::CloseSocket { gen } => self.connection.close(gen), Effect::Send { req, request } => { if matches!(request, Request::Join) { - self.join_fingerprint = self.auth_fingerprint; + match self.load_credentials() { + Ok(true) => self.join_fingerprint = self.auth_fingerprint, + Ok(false) => { + // Signed out since this dial began: the core halts instead of joining. + self.queue.push_back(Queued { + epoch: None, + input: Input::CredentialsChanged { + has_credentials: false, + }, + }); + return None; + } + Err(error) => { + // Not a sign-out: drop this socket so the core backs off and dials again. + warn!(%error, "stored credentials unreadable; retrying the dial"); + let gen = self.socket_gen; + self.connection.close(gen); + self.queue.push_back(Queued { + epoch: Some(gen), + input: Input::SocketClosed { gen }, + }); + return None; + } + } } // Marked before it can reach the server: an upload must never be sent unmarked. if let Request::Upload(upload) = &request { @@ -438,16 +591,15 @@ impl Owner { return None; } } - return self.connection.send(req, &request, now_unix()); + return self + .connection + .send(req, &request, now_unix() + self.clock_offset); } Effect::Schedule { timer, after } => self.timers.schedule(timer, after), Effect::Cancel(timer) => self.timers.cancel(&timer), Effect::CheckCredentials => { - let has_credentials = self.check_credentials(); - self.queue.push_back(Queued { - epoch: None, - input: Input::CredentialsChanged { has_credentials }, - }); + let input = self.check_credentials(); + self.queue.push_back(Queued { epoch: None, input }); } Effect::LoadCursors => match twice(|| store.load_cursors()).await { Ok(cursors) => self.answer(Input::Cursors(cursors)), @@ -505,9 +657,17 @@ impl Owner { inflight, reply, mismatch_attempts, + divergent_replies, } => { let outcome = match store - .settle_upload(me, doc_id, &inflight, &reply, mismatch_attempts) + .settle_upload( + me, + doc_id, + &inflight, + &reply, + mismatch_attempts, + divergent_replies, + ) .await { Ok((outcome, notices)) => { @@ -546,7 +706,15 @@ impl Owner { } Effect::Emit(lifecycle) => self.emit(EngineEvent::Lifecycle(lifecycle)), Effect::SetState(state) => { - if state.connection == ConnectionView::Halted(HaltReason::AuthInvalid) { + let refused = match &state.connection { + ConnectionView::Halted(HaltReason::Other(code)) => code == "identity_drift", + ConnectionView::Halted(reason) => matches!( + reason, + HaltReason::AuthInvalid | HaltReason::AccountDisabled + ), + _ => false, + }; + if refused { self.rejected = self.join_fingerprint; } self.state.send_replace(state); @@ -615,6 +783,8 @@ fn fingerprint(auth: &JoinAuth) -> [u8; 32] { hasher.update(auth.email.as_bytes()); hasher.update([0]); hasher.update(auth.api_key.as_bytes()); + hasher.update([0]); + hasher.update(auth.api_secret.as_bytes()); hasher.finalize().into() } @@ -632,17 +802,30 @@ where } } -/// Two processes opening a pre-v2 data dir at once can race the migration (sqlx's SQLite -/// migrator takes no lock); the loser's second attempt finds it applied. -async fn retry_migrate_once<T, Fut>(mut open: impl FnMut() -> Fut) -> StoreResult<T> +const OPEN_ATTEMPTS: u64 = 4; + +/// Two processes opening a pre-v2 data dir at once race the migrations (sqlx's SQLite migrator +/// takes no lock) and the connect; the loser retries until the winner has finished. +async fn retry_open<T, Fut>(mut open: impl FnMut() -> Fut) -> StoreResult<T> where Fut: Future<Output = StoreResult<T>>, { - match open().await { - Err(StoreError::Migrate(error)) => { - warn!(%error, "store migration failed; retrying once"); - open().await + let mut attempt = 1; + loop { + match open().await { + Err(error) if attempt < OPEN_ATTEMPTS && lost_an_open_race(&error) => { + warn!(%error, attempt, "store open raced another process; retrying"); + tokio::time::sleep(Duration::from_millis(50 * attempt)).await; + attempt += 1; + } + done => return done, } - done => done, + } +} + +fn lost_an_open_race(error: &StoreError) -> bool { + match error { + StoreError::Migrate(_) => !error.is_newer_schema(), + _ => error.is_busy(), } } diff --git a/replicant-client/src/driver/engine/e2e_tests.rs b/replicant-client/src/driver/engine/e2e_tests.rs index 995ab15..a0c27dd 100644 --- a/replicant-client/src/driver/engine/e2e_tests.rs +++ b/replicant-client/src/driver/engine/e2e_tests.rs @@ -9,6 +9,7 @@ use super::{Engine, EngineEvent}; use crate::driver::test_server::ScriptedServer; use crate::driver::test_support::{config, credentials, eventually, jump, seeded_db, wait_for}; use crate::engine::doc::DocEvent; +use crate::engine::doc_upload::MAX_DIVERGENT_REPLIES; use crate::engine::machine::Lifecycle; use crate::store::change_log::{ChangeOrigin, DocChange}; use crate::store::test_support::{count, snapshot, ME}; @@ -113,6 +114,18 @@ async fn push_gap_triggers_catch_up() { let server = ScriptedServer::start(ME).await; let (_dir, path) = seeded_db(ME, true).await; let (engine, _events) = live_engine(&server, &path).await; + // A catch-up from cursor 0 starts with a snapshot, so both request kinds count. + let own_catch_ups = || { + server + .frames() + .iter() + .filter(|frame| { + matches!(frame.event.as_str(), "get_changes_since" | "get_snapshot") + && frame.payload["scope"] == "own" + }) + .count() + }; + let before = own_catch_ups(); let (first, second) = (Uuid::from_u128(0xD1), Uuid::from_u128(0xD2)); server.drop_next_push(); server.put_doc(first, json!({"a": 1})); @@ -122,12 +135,9 @@ async fn push_gap_triggers_catch_up() { snapshot(&store, first).await.exists && snapshot(&store, second).await.exists }) .await; - let own_catch_ups = server - .frames() - .iter() - .filter(|frame| frame.event == "get_changes_since" && frame.payload["scope"] == "own") - .count(); - assert!(own_catch_ups >= 2, "the gap started a second catch-up"); + // The first document's push was dropped, so it can only have arrived by a catch-up, whose + // request the server recorded before answering it. + assert!(own_catch_ups() > before, "the gap started a catch-up"); engine.stop().await; } @@ -138,7 +148,7 @@ async fn local_edit_uploads_and_settles() { let (engine, mut events) = live_engine(&server, &path).await; let store = engine.store(); let doc_id = store - .create_document(ME, None, json!({"title": "Scale"})) + .create_document(None, json!({"title": "Scale"})) .await .unwrap(); engine.notify_outbox(); @@ -170,7 +180,7 @@ async fn killed_mid_upload_resends_same_upload_id() { let (first, _first_events) = live_engine(&server, &path).await; let doc_id = first .store() - .create_document(ME, None, json!({"n": 1})) + .create_document(None, json!({"n": 1})) .await .unwrap(); first.notify_outbox(); @@ -217,7 +227,7 @@ async fn cursor_too_old_resync_sweeps_missing_docs() { // Let the catch-up's pump timers run out, so the edit below stays pending when we stop. tokio::time::sleep(Duration::from_millis(1200)).await; store - .update_document(ME, edited, json!({"w": 2})) + .update_document(edited, json!({"w": 2})) .await .unwrap(); engine.stop().await; @@ -237,11 +247,11 @@ async fn cursor_too_old_resync_sweeps_missing_docs() { &mut events, "ConflictDetected for the edited document", |event| { - *event - == EngineEvent::Doc(DocNotice { - doc_id: edited, - event: DocEvent::ConflictDetected, - }) + matches!( + event, + EngineEvent::Doc(DocNotice { doc_id, event: DocEvent::ConflictDetected, .. }) + if *doc_id == edited + ) }, ) .await; @@ -263,7 +273,7 @@ async fn integral_float_create_uploads_once_after_a_lost_reply() { server.drop_after_next_upload(); let store = engine.store(); let doc_id = store - .create_document(ME, None, json!({"n": 1200.0})) + .create_document(None, json!({"n": 1200.0})) .await .unwrap(); engine.notify_outbox(); @@ -303,7 +313,7 @@ async fn integral_float_edits_upload_once_per_edit(push_first: bool) { server.hold_uploads(); } let doc_id = store - .create_document(ME, None, json!({"n": 1200.0, "m": 1})) + .create_document(None, json!({"n": 1200.0, "m": 1})) .await .unwrap(); engine.notify_outbox(); @@ -319,7 +329,7 @@ async fn integral_float_edits_upload_once_per_edit(push_first: bool) { server.hold_uploads(); } store - .update_document(ME, doc_id, json!({"n": 1200.0, "m": 2})) + .update_document(doc_id, json!({"n": 1200.0, "m": 2})) .await .unwrap(); engine.notify_outbox(); @@ -374,7 +384,7 @@ async fn local_float_edit_survives_another_devices_edit(push_first: bool) { server.hold_uploads(); } let doc_id = store - .create_document(ME, None, json!({"n": 1200.0, "m": 2})) + .create_document(None, json!({"n": 1200.0, "m": 2})) .await .unwrap(); engine.notify_outbox(); @@ -388,7 +398,7 @@ async fn local_float_edit_survives_another_devices_edit(push_first: bool) { server.hold_uploads(); store - .update_document(ME, doc_id, json!({"n": 1300.0, "m": 2})) + .update_document(doc_id, json!({"n": 1300.0, "m": 2})) .await .unwrap(); engine.notify_outbox(); @@ -462,7 +472,7 @@ async fn another_devices_integral_float_does_not_collide_with_a_local_edit() { server.hold_uploads(); store - .update_document(ME, doc_id, json!({"n": 1300, "m": 1})) + .update_document(doc_id, json!({"n": 1300, "m": 1})) .await .unwrap(); engine.notify_outbox(); @@ -498,7 +508,7 @@ async fn lost_upload_reply_then_new_edit_appends_once() { let (engine, mut events) = live_engine(&server, &path).await; let store = engine.store(); let doc_id = store - .create_document(ME, None, json!({"items": []})) + .create_document(None, json!({"items": []})) .await .unwrap(); engine.notify_outbox(); @@ -509,7 +519,7 @@ async fn lost_upload_reply_then_new_edit_appends_once() { server.lose_next_upload(); store - .update_document(ME, doc_id, json!({"items": ["a"]})) + .update_document(doc_id, json!({"items": ["a"]})) .await .unwrap(); engine.notify_outbox(); @@ -520,7 +530,7 @@ async fn lost_upload_reply_then_new_edit_appends_once() { // The reply never comes: the request times out and the document backs off for 1 s. jump(Duration::from_secs(31)).await; store - .update_document(ME, doc_id, json!({"items": ["a", "b"]})) + .update_document(doc_id, json!({"items": ["a", "b"]})) .await .unwrap(); engine.notify_outbox(); @@ -547,7 +557,7 @@ async fn lost_create_reply_then_edit_settles_without_conflict() { let store = engine.store(); server.lose_next_upload(); let doc_id = store - .create_document(ME, None, json!({"items": ["a"]})) + .create_document(None, json!({"items": ["a"]})) .await .unwrap(); engine.notify_outbox(); @@ -557,7 +567,7 @@ async fn lost_create_reply_then_edit_settles_without_conflict() { .await; jump(Duration::from_secs(31)).await; store - .update_document(ME, doc_id, json!({"items": ["a", "b"]})) + .update_document(doc_id, json!({"items": ["a", "b"]})) .await .unwrap(); engine.notify_outbox(); @@ -584,7 +594,7 @@ async fn snapshot_of_a_doc_with_only_a_pending_create_surfaces_conflict_detected let (_dir, path) = seeded_db(ME, true).await; let store = Store::open(&path).await.unwrap(); store - .create_document(ME, Some(doc_id), json!({"v": "local"})) + .create_document(Some(doc_id), json!({"v": "local"})) .await .unwrap(); store.close().await; @@ -593,11 +603,11 @@ async fn snapshot_of_a_doc_with_only_a_pending_create_surfaces_conflict_detected .await .unwrap(); wait_for(&mut events, "ConflictDetected", |event| { - *event - == EngineEvent::Doc(DocNotice { - doc_id, - event: DocEvent::ConflictDetected, - }) + matches!( + event, + EngineEvent::Doc(DocNotice { doc_id: noticed, event: DocEvent::ConflictDetected, .. }) + if *noticed == doc_id + ) }) .await; let store = engine.store(); @@ -625,19 +635,14 @@ async fn new_edit_unparks_a_rejected_document() { let (engine, mut events) = live_engine(&server, &path).await; server.reject_next_upload("validation"); let store = engine.store(); - let doc_id = store - .create_document(ME, None, json!({"n": 1})) - .await - .unwrap(); + let doc_id = store.create_document(None, json!({"n": 1})).await.unwrap(); engine.notify_outbox(); wait_for(&mut events, "the park", |event| { - *event - == EngineEvent::Doc(DocNotice { - doc_id, - event: DocEvent::SyncError { - code: "validation".into(), - }, - }) + matches!( + event, + EngineEvent::Doc(DocNotice { doc_id: noticed, event: DocEvent::SyncError { code }, .. }) + if *noticed == doc_id && code == "validation" + ) }) .await; assert_eq!( @@ -649,7 +654,7 @@ async fn new_edit_unparks_a_rejected_document() { 1 ); store - .update_document(ME, doc_id, json!({"n": 2})) + .update_document(doc_id, json!({"n": 2})) .await .unwrap(); engine.notify_outbox(); @@ -667,15 +672,12 @@ async fn delete_of_a_parked_document_is_uploaded() { let (_dir, path) = seeded_db(ME, true).await; let (engine, _events) = live_engine(&server, &path).await; let store = engine.store(); - let doc_id = store - .create_document(ME, None, json!({"n": 1})) - .await - .unwrap(); + let doc_id = store.create_document(None, json!({"n": 1})).await.unwrap(); engine.notify_outbox(); eventually("synced", || async { outbox_rows(&store).await == 0 }).await; server.reject_next_upload("validation"); store - .update_document(ME, doc_id, json!({"n": 2})) + .update_document(doc_id, json!({"n": 2})) .await .unwrap(); engine.notify_outbox(); @@ -688,7 +690,7 @@ async fn delete_of_a_parked_document_is_uploaded() { == 1 }) .await; - store.delete_document(ME, doc_id).await.unwrap(); + store.delete_document(doc_id).await.unwrap(); engine.notify_outbox(); eventually("the delete is uploaded and settled", || async { server.doc(doc_id).is_some_and(|(_, _, deleted)| deleted) && outbox_rows(&store).await == 0 @@ -712,15 +714,12 @@ async fn update_after_local_delete_is_refused_and_the_delete_uploads() { let (_dir, path) = seeded_db(ME, true).await; let (engine, _events) = live_engine(&server, &path).await; let store = engine.store(); - let doc_id = store - .create_document(ME, None, json!({"n": 1})) - .await - .unwrap(); + let doc_id = store.create_document(None, json!({"n": 1})).await.unwrap(); engine.notify_outbox(); eventually("synced", || async { outbox_rows(&store).await == 0 }).await; - store.delete_document(ME, doc_id).await.unwrap(); + store.delete_document(doc_id).await.unwrap(); assert!(matches!( - store.update_document(ME, doc_id, json!({"n": 2})).await, + store.update_document(doc_id, json!({"n": 2})).await, Err(StoreError::NotFound(id)) if id == doc_id )); engine.notify_outbox(); @@ -736,11 +735,8 @@ async fn offline_create_then_delete_uploads_one_delete_and_tombstones_at_zero() let server = ScriptedServer::start(ME).await; let (_dir, path) = seeded_db(ME, true).await; let store = Store::open(&path).await.unwrap(); - let doc_id = store - .create_document(ME, None, json!({"n": 1})) - .await - .unwrap(); - store.delete_document(ME, doc_id).await.unwrap(); + let doc_id = store.create_document(None, json!({"n": 1})).await.unwrap(); + store.delete_document(doc_id).await.unwrap(); store.close().await; let (engine, _events) = live_engine(&server, &path).await; let store = engine.store(); @@ -766,6 +762,68 @@ async fn offline_create_then_delete_uploads_one_delete_and_tombstones_at_zero() engine.stop().await; } +#[tokio::test] +async fn a_delete_after_a_lost_create_reply_never_destroys_another_devices_edit() { + let server = ScriptedServer::start(ME).await; + let (_dir, path) = seeded_db(ME, true).await; + let (engine, _events) = live_engine(&server, &path).await; + let store = engine.store(); + server.lose_next_upload(); + let doc_id = store.create_document(None, json!({"n": 1})).await.unwrap(); + engine.notify_outbox(); + eventually("the create is applied", || async { + server.doc(doc_id).is_some() + }) + .await; + engine.stop().await; + + let theirs = json!({"n": 2, "note": "theirs"}); + server.put_doc(doc_id, theirs.clone()); + let store = Store::open(&path).await.unwrap(); + store.delete_document(doc_id).await.unwrap(); + store.close().await; + + let (events_tx, mut events) = mpsc::unbounded_channel(); + let engine = Engine::start(&path, config(&server.url, credentials("k1")), events_tx) + .await + .unwrap(); + let store = engine.store(); + wait_for(&mut events, "DeleteSuperseded", |event| { + matches!( + event, + EngineEvent::Doc(DocNotice { doc_id: noticed, event: DocEvent::DeleteSuperseded, .. }) + if *noticed == doc_id + ) + }) + .await; + eventually("settled", || async { outbox_rows(&store).await == 0 }).await; + assert_eq!( + server + .doc(doc_id) + .map(|(content, _, deleted)| (content, deleted)), + Some((theirs.clone(), false)), + "the other device's edit survives on the server" + ); + assert!(server + .uploads_for(doc_id) + .iter() + .all(|upload| upload["kind"] != "delete")); + let after = snapshot(&store, doc_id).await; + assert!(after.exists && !after.soft_deleted); + assert_eq!(after.content, theirs); + let kept: Vec<_> = store + .list_recovered() + .await + .unwrap() + .into_iter() + .filter(|copy| copy.doc_id == doc_id) + .collect(); + assert_eq!(kept.len(), 1, "with no shadow the local content is kept"); + assert_eq!(kept[0].content, json!({"n": 1})); + assert_eq!(kept[0].reason, "delete_superseded"); + engine.stop().await; +} + #[tokio::test] async fn app_and_daw_on_one_data_dir_upload_an_edit_once() { let server = ScriptedServer::start(ME).await; @@ -774,7 +832,7 @@ async fn app_and_daw_on_one_data_dir_upload_an_edit_once() { let (daw, _daw_events) = live_engine(&server, &path).await; let doc_id = app .store() - .create_document(ME, None, json!({"n": 1})) + .create_document(None, json!({"n": 1})) .await .unwrap(); app.notify_outbox(); @@ -801,7 +859,7 @@ async fn lost_create_reply_across_a_reconnect_settles_without_conflict() { server.drop_after_next_upload(); let store = engine.store(); let doc_id = store - .create_document(ME, None, json!({"items": ["a"]})) + .create_document(None, json!({"items": ["a"]})) .await .unwrap(); engine.notify_outbox(); @@ -837,7 +895,7 @@ async fn returning_user_offline_edits_rebase_onto_a_changed_snapshot() { // and the server trimmed the log past this data dir's cursor. let store = Store::open(&path).await.unwrap(); store - .update_document(ME, doc_id, json!({"a": 2, "b": 1})) + .update_document(doc_id, json!({"a": 2, "b": 1})) .await .unwrap(); store.close().await; @@ -866,3 +924,459 @@ async fn returning_user_offline_edits_rebase_onto_a_changed_snapshot() { ); engine.stop().await; } + +#[tokio::test] +async fn an_edit_from_another_device_supersedes_an_offline_delete() { + let server = ScriptedServer::start(ME).await; + let doc_id = Uuid::from_u128(0xDE1); + server.put_doc(doc_id, json!({"title": "Scale", "n": 1})); + let (_dir, path) = seeded_db(ME, true).await; + let (engine, _events) = live_engine(&server, &path).await; + engine.stop().await; + + // Deleted offline here while another device edits the document. + let store = Store::open(&path).await.unwrap(); + store.delete_document(doc_id).await.unwrap(); + store.close().await; + server.put_doc(doc_id, json!({"title": "Scale", "n": 2})); + + let (events_tx, mut events) = mpsc::unbounded_channel(); + let engine = Engine::start(&path, config(&server.url, credentials("k1")), events_tx) + .await + .unwrap(); + let store = engine.store(); + let superseded = wait_for(&mut events, "DeleteSuperseded", |event| { + matches!( + event, + EngineEvent::Doc(DocNotice { doc_id: noticed, event: DocEvent::DeleteSuperseded, .. }) + if *noticed == doc_id + ) + }) + .await; + assert!( + matches!(superseded, EngineEvent::Doc(DocNotice { kept: None, .. })), + "unedited: no kept copy is named" + ); + eventually("settled", || async { outbox_rows(&store).await == 0 }).await; + let after = snapshot(&store, doc_id).await; + assert!( + after.exists && !after.soft_deleted, + "the newer version is back" + ); + assert_eq!(after.content, json!({"title": "Scale", "n": 2})); + assert!( + !server.doc(doc_id).unwrap().2, + "the other device's edit survives" + ); + assert_eq!(recovered_rows(&store, doc_id).await, 0); + engine.stop().await; +} + +#[tokio::test] +async fn an_offline_edit_then_delete_superseded_by_another_device_keeps_the_edit() { + let server = ScriptedServer::start(ME).await; + let doc_id = Uuid::from_u128(0xDE3); + server.put_doc(doc_id, json!({"title": "Scale", "n": 1})); + let (_dir, path) = seeded_db(ME, true).await; + let (engine, _events) = live_engine(&server, &path).await; + engine.stop().await; + + let edited = json!({"title": "Scale", "n": 1, "note": "mine"}); + let store = Store::open(&path).await.unwrap(); + store.update_document(doc_id, edited.clone()).await.unwrap(); + store.delete_document(doc_id).await.unwrap(); + store.close().await; + server.put_doc(doc_id, json!({"title": "Scale", "n": 2})); + + let (events_tx, mut events) = mpsc::unbounded_channel(); + let engine = Engine::start(&path, config(&server.url, credentials("k1")), events_tx) + .await + .unwrap(); + let store = engine.store(); + let EngineEvent::Doc(superseded) = wait_for(&mut events, "DeleteSuperseded", |event| { + matches!( + event, + EngineEvent::Doc(DocNotice { doc_id: noticed, event: DocEvent::DeleteSuperseded, .. }) + if *noticed == doc_id + ) + }) + .await + else { + unreachable!() + }; + assert_eq!( + superseded.kept.map(|kept| kept.reason).as_deref(), + Some("delete_superseded") + ); + eventually("settled", || async { outbox_rows(&store).await == 0 }).await; + let after = snapshot(&store, doc_id).await; + assert!(after.exists && !after.soft_deleted); + assert_eq!(after.content, json!({"title": "Scale", "n": 2})); + assert!(!server.doc(doc_id).unwrap().2); + let kept: Vec<_> = store + .list_recovered() + .await + .unwrap() + .into_iter() + .filter(|copy| copy.doc_id == doc_id) + .collect(); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].content, edited); + assert_eq!(kept[0].reason, "delete_superseded"); + engine.stop().await; +} + +#[tokio::test] +async fn a_delete_on_a_stale_version_is_refused_and_can_be_repeated() { + let server = ScriptedServer::start(ME).await; + let doc_id = Uuid::from_u128(0xDE2); + server.put_doc(doc_id, json!({"n": 1})); + let (_dir, path) = seeded_db(ME, true).await; + let (engine, mut events) = live_engine(&server, &path).await; + let store = engine.store(); + assert!(snapshot(&store, doc_id).await.exists); + // Another device's edit this client has not heard of yet. + server.drop_next_push(); + server.put_doc(doc_id, json!({"n": 2})); + store.delete_document(doc_id).await.unwrap(); + engine.notify_outbox(); + wait_for(&mut events, "DeleteSuperseded", |event| { + matches!( + event, + EngineEvent::Doc(DocNotice { doc_id: noticed, event: DocEvent::DeleteSuperseded, .. }) + if *noticed == doc_id + ) + }) + .await; + let uploads = server.uploads_for(doc_id); + assert_eq!(uploads.len(), 1); + assert_eq!(uploads[0]["kind"], "delete"); + assert!( + uploads[0]["base_hash"].is_string(), + "the delete names the version it was made on" + ); + assert!(!server.doc(doc_id).unwrap().2); + assert_eq!(snapshot(&store, doc_id).await.content, json!({"n": 2})); + + store.delete_document(doc_id).await.unwrap(); + engine.notify_outbox(); + eventually("the repeated delete lands", || async { + server.doc(doc_id).is_some_and(|(_, _, deleted)| deleted) && outbox_rows(&store).await == 0 + }) + .await; + assert!(!snapshot(&store, doc_id).await.exists); + engine.stop().await; +} + +#[tokio::test] +async fn a_skewed_clock_joins_on_the_second_try_without_an_error() { + let server = ScriptedServer::start(ME).await; + server.clock_ahead_by(3600); + let (_dir, path) = seeded_db(ME, true).await; + let (events_tx, mut events) = mpsc::unbounded_channel(); + let engine = Engine::start(&path, config(&server.url, credentials("k1")), events_tx) + .await + .unwrap(); + wait_for(&mut events, "SyncCompleted", |event| { + assert!( + !matches!(event, EngineEvent::Lifecycle(Lifecycle::SyncError { code, .. }) if code == "clock_skew"), + "a clock the server corrected is not an error" + ); + *event == EngineEvent::Lifecycle(Lifecycle::SyncCompleted) + }) + .await; + let joins: Vec<i64> = server + .frames() + .into_iter() + .filter(|frame| frame.event == "phx_join") + .map(|frame| frame.payload["timestamp"].as_i64().unwrap()) + .collect(); + assert_eq!(joins.len(), 2, "one skewed join, one re-signed"); + assert!( + (joins[1] - joins[0] - 3600).abs() <= 5, + "the second join is signed with the server's clock: {joins:?}" + ); + assert_eq!(server.stats.upgrades(), 1, "on the same socket"); + + server.drop_connections(); + let join_times = || { + server + .frames() + .into_iter() + .filter(|frame| frame.event == "phx_join") + .map(|frame| frame.payload["timestamp"].as_i64().unwrap()) + .collect::<Vec<i64>>() + }; + eventually("the reconnect joins", || async { + jump(Duration::from_millis(2100)).await; + join_times().len() >= 3 + }) + .await; + wait_for(&mut events, "SyncCompleted after the reconnect", |event| { + *event == EngineEvent::Lifecycle(Lifecycle::SyncCompleted) + }) + .await; + let joins = join_times(); + assert_eq!(joins.len(), 3, "the reconnect joins first time: {joins:?}"); + assert!( + (joins[2] - joins[0] - 3600).abs() <= 5, + "the learnt offset signs the next join: {joins:?}" + ); + engine.stop().await; +} + +#[tokio::test] +async fn a_one_off_divergent_reply_is_corrected_by_one_more_upload() { + let server = ScriptedServer::start(ME).await; + let (_dir, path) = seeded_db(ME, true).await; + let (engine, _events) = live_engine(&server, &path).await; + let store = engine.store(); + let doc_id = store.create_document(None, json!({"n": 1})).await.unwrap(); + engine.notify_outbox(); + eventually("the create settles", || async { + in_sync(&store, &server, doc_id).await + }) + .await; + + server.stamp_updates(1); + store + .update_document(doc_id, json!({"n": 2})) + .await + .unwrap(); + engine.notify_outbox(); + eventually("the correction settles", || async { + server.uploads_for(doc_id).len() == 3 && in_sync(&store, &server, doc_id).await + }) + .await; + + assert_eq!(server_content(&server, doc_id), Some(json!({"n": 2}))); + assert_eq!(snapshot(&store, doc_id).await.content, json!({"n": 2})); + let uploads = server.uploads_for(doc_id); + assert_eq!(uploads.len(), 3, "create, the stamped edit, one correction"); + assert_eq!( + uploads[2]["payload"], + json!([{"op": "remove", "path": "/stamped"}]) + ); + engine.stop().await; +} + +#[tokio::test] +async fn a_persistent_divergent_reply_parks_the_document_after_a_bounded_number_of_uploads() { + let server = ScriptedServer::start(ME).await; + let (_dir, path) = seeded_db(ME, true).await; + let (engine, mut events) = live_engine(&server, &path).await; + let store = engine.store(); + let doc_id = store.create_document(None, json!({"n": 1})).await.unwrap(); + engine.notify_outbox(); + eventually("the create settles", || async { + in_sync(&store, &server, doc_id).await + }) + .await; + + server.stamp_updates(u32::MAX); + store + .update_document(doc_id, json!({"n": 2})) + .await + .unwrap(); + engine.notify_outbox(); + wait_for(&mut events, "the park", |event| { + matches!( + event, + EngineEvent::Doc(DocNotice { doc_id: noticed, event: DocEvent::SyncError { code }, .. }) + if *noticed == doc_id && code == "diverged" + ) + }) + .await; + let bound = 1 + MAX_DIVERGENT_REPLIES as usize + 1; + assert_eq!(server.uploads_for(doc_id).len(), bound); + assert_eq!( + count( + &store, + "SELECT COUNT(*) FROM outbox WHERE parked_error = 'diverged'" + ) + .await, + 1 + ); + assert_eq!(snapshot(&store, doc_id).await.content, json!({"n": 2})); + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!( + server.uploads_for(doc_id).len(), + bound, + "no uploads after the park" + ); + engine.stop().await; +} + +#[tokio::test] +async fn a_delete_made_while_an_upload_is_refused_still_reaches_the_server() { + let server = ScriptedServer::start(ME).await; + let (_dir, path) = seeded_db(ME, true).await; + let (engine, _events) = live_engine(&server, &path).await; + let store = engine.store(); + let doc_id = store.create_document(None, json!({"n": 1})).await.unwrap(); + engine.notify_outbox(); + eventually("the create settles", || async { + in_sync(&store, &server, doc_id).await + }) + .await; + + server.hold_uploads(); + server.reject_next_upload("validation"); + store + .update_document(doc_id, json!({"n": 2})) + .await + .unwrap(); + engine.notify_outbox(); + eventually("the edit is held", || async { + server.uploads_for(doc_id).len() == 2 + }) + .await; + store.delete_document(doc_id).await.unwrap(); + server.release_held(); + eventually("the delete reaches the server", || async { + server.doc(doc_id).is_some_and(|(_, _, deleted)| deleted) && outbox_rows(&store).await == 0 + }) + .await; + engine.stop().await; +} + +#[tokio::test] +async fn a_trimmed_cursor_holds_uploads_until_the_resync_and_keeps_no_copy() { + let server = ScriptedServer::start(ME).await; + let doc_id = Uuid::from_u128(0xD1); + server.put_doc(doc_id, json!({"a": 1, "b": 1})); + let (_dir, path) = seeded_db(ME, true).await; + let (engine, _events) = live_engine(&server, &path).await; + engine.stop().await; + + // Months offline: an edit here, another device's edit there, and the log trimmed past this + // data dir's nonzero cursor. + let store = Store::open(&path).await.unwrap(); + store + .update_document(doc_id, json!({"a": 2, "b": 1})) + .await + .unwrap(); + store.close().await; + server.put_doc(doc_id, json!({"a": 1, "b": 2})); + server.trim_log_through(server.head()); + server.hold_changes(); + let asked = |server: &ScriptedServer| { + server + .frames() + .iter() + .filter(|frame| frame.event == "get_changes_since") + .count() + }; + let asked_before = asked(&server); + + let (events_tx, mut events) = mpsc::unbounded_channel(); + let engine = Engine::start(&path, config(&server.url, credentials("k1")), events_tx) + .await + .unwrap(); + let store = engine.store(); + eventually("own asks for changes from its old cursor", || async { + asked(&server) > asked_before + }) + .await; + // Well past the upload pump's quiet period. + tokio::time::sleep(Duration::from_millis(1500)).await; + assert!( + server.uploads_for(doc_id).is_empty(), + "nothing is sent before own's cursor is answered" + ); + + server.release_held_changes(); + wait_for(&mut events, "SyncCompleted", |event| { + *event == EngineEvent::Lifecycle(Lifecycle::SyncCompleted) + }) + .await; + eventually("the offline edit is uploaded", || async { + outbox_rows(&store).await == 0 + }) + .await; + assert_eq!( + server_content(&server, doc_id), + Some(json!({"a": 2, "b": 2})) + ); + assert_eq!(recovered_rows(&store, doc_id).await, 0, "no kept copy"); + engine.stop().await; +} + +/// Syncs `{"n": 1}`, optionally edits it locally to `local_edit`, deletes it, and has the server +/// refuse the delete. Returns the kept copies' reason and content. +async fn refuse_a_delete(local_edit: Option<Value>) -> Vec<(String, Value)> { + let server = ScriptedServer::start(ME).await; + let (_dir, path) = seeded_db(ME, true).await; + let (engine, mut events) = live_engine(&server, &path).await; + let store = engine.store(); + let doc_id = store.create_document(None, json!({"n": 1})).await.unwrap(); + engine.notify_outbox(); + eventually("synced", || async { outbox_rows(&store).await == 0 }).await; + server.reject_deletes("forbidden"); + let edited = local_edit.is_some(); + if let Some(edit) = local_edit { + store.update_document(doc_id, edit).await.unwrap(); + } + store.delete_document(doc_id).await.unwrap(); + engine.notify_outbox(); + let refused = |event: &EngineEvent| { + matches!( + event, + EngineEvent::Doc(DocNotice { doc_id: noticed, event: DocEvent::SyncError { code }, .. }) + if *noticed == doc_id && code == "forbidden" + ) + }; + let EngineEvent::Doc(refusal) = wait_for(&mut events, "the refusal", refused).await else { + unreachable!() + }; + assert_eq!(refusal.kept.is_some(), edited, "a copy exactly when edited"); + if let Some(kept) = &refusal.kept { + assert_eq!(kept.reason, "delete_refused"); + } + let uploads_then = server.uploads_for(doc_id).len(); + tokio::time::sleep(Duration::from_millis(500)).await; + let mut refusals = 0; + while let Ok(event) = events.try_recv() { + refusals += usize::from(refused(&event)); + } + let uploads = server.uploads_for(doc_id); + let deletes = uploads + .iter() + .filter(|upload| upload["kind"] == "delete") + .count(); + let after = snapshot(&store, doc_id).await; + let outbox = outbox_rows(&store).await; + let kept: Vec<(String, Value)> = store + .list_recovered() + .await + .unwrap() + .into_iter() + .filter(|copy| copy.doc_id == doc_id) + .map(|copy| (copy.reason, copy.content)) + .collect(); + engine.stop().await; + assert_eq!(deletes, 1, "the refused delete is sent once"); + assert_eq!( + uploads.len(), + uploads_then, + "nothing is sent after the refusal" + ); + assert_eq!(refusals, 0, "no second SyncError after the first"); + assert!(after.exists && !after.soft_deleted, "visible again"); + assert_eq!(after.content, json!({"n": 1}), "the server's version"); + assert_eq!(outbox, 0); + kept +} + +#[tokio::test] +async fn a_delete_the_server_refuses_brings_the_server_version_back() { + assert_eq!(refuse_a_delete(None).await, vec![], "no kept copy"); +} + +#[tokio::test] +async fn a_refused_delete_after_a_local_edit_keeps_the_edit_as_a_copy() { + assert_eq!( + refuse_a_delete(Some(json!({"n": 2}))).await, + vec![("delete_refused".to_string(), json!({"n": 2}))] + ); +} diff --git a/replicant-client/src/driver/engine/event_tests.rs b/replicant-client/src/driver/engine/event_tests.rs index ac1e4f8..92caeda 100644 --- a/replicant-client/src/driver/engine/event_tests.rs +++ b/replicant-client/src/driver/engine/event_tests.rs @@ -50,7 +50,7 @@ async fn own_write_is_emitted_once_as_local() { let (engine, mut events) = offline_engine(&path).await; let doc_id = engine .store() - .create_document(ME, None, json!({"n": 1})) + .create_document(None, json!({"n": 1})) .await .unwrap(); engine.notify_outbox(); @@ -72,7 +72,7 @@ async fn other_process_edit_is_emitted_within_a_tick() { let (daw, _daw_events) = offline_engine(&path).await; let doc_id = daw .store() - .create_document(ME, None, json!({"n": 1})) + .create_document(None, json!({"n": 1})) .await .unwrap(); // No notify reaches the app: only its change-log tick can see the other process's write. diff --git a/replicant-client/src/driver/engine/tests.rs b/replicant-client/src/driver/engine/tests.rs index 9ed3917..5079d25 100644 --- a/replicant-client/src/driver/engine/tests.rs +++ b/replicant-client/src/driver/engine/tests.rs @@ -8,16 +8,17 @@ use tokio::time::timeout; use uuid::Uuid; use super::owner_support::{connection, harness, is_live, Harness}; -use super::{retry_migrate_once, twice, Command, Engine, EngineError, Queued}; +use super::{retry_open, twice, Command, Engine, EngineError, Queued, LOG_TICK}; use crate::driver::test_server::{Mode, ScriptedServer}; use crate::driver::test_support::{ - config, credentials, eventually, jump, seeded_db, SwitchableCredentials, WAIT, + config, credentials, eventually, jump, no_credentials, seeded_db, SwitchableCredentials, WAIT, }; +use crate::engine::list_merge::{ListMergePolicy, PathPattern}; use crate::engine::machine::{ ConnectionView, HaltReason, Input, Lifecycle, SettleOutcome, TimerId, }; -use crate::store::test_support::{count, exec, ME}; -use crate::store::{Store, StoreError}; +use crate::store::test_support::{count, exec, v012_db, v1_doc, v1_queue_row, v1_user, ME}; +use crate::store::StoreError; use crate::transport::socket::SocketEvent; use crate::transport::wire::user_agent; @@ -42,13 +43,13 @@ async fn effects_run_in_order_and_answers_are_fifo() { let first = h .controls .store - .create_document(ME, None, json!({"n": 1})) + .create_document(None, json!({"n": 1})) .await .unwrap(); let second = h .controls .store - .create_document(ME, None, json!({"n": 2})) + .create_document(None, json!({"n": 2})) .await .unwrap(); h.owner @@ -107,7 +108,7 @@ async fn joined_is_fed_only_after_the_identity_check() { let doc_id = h .controls .store - .create_document(previous, None, json!({"n": 1})) + .create_document(None, json!({"n": 1})) .await .unwrap(); h.live().await; @@ -187,17 +188,19 @@ async fn halted_auth_invalid_does_not_redial_with_the_same_credentials() { connection(o) == ConnectionView::Halted(HaltReason::AuthInvalid) }) .await; - jump(Duration::from_secs(301)).await; - h.turns(3).await; + for _ in 0..5 { + jump(Duration::from_millis(3100)).await; + h.turns(3).await; + } assert_eq!( server.stats.upgrades(), 1, - "the 5 min check must not redial with credentials the server rejected" + "the 3 s check must not redial with credentials the server rejected" ); assert!(h.owner.timers.is_scheduled(&TimerId::HaltRetry)); keys.set("k2"); server.accept_joins(); - jump(Duration::from_secs(301)).await; + jump(Duration::from_millis(3100)).await; h.turn_until("live with new credentials", is_live).await; assert_eq!(server.join_keys(), vec!["k1".to_string(), "k2".to_string()]); } @@ -254,6 +257,138 @@ async fn credentials_changed_while_a_join_is_outstanding_rejects_the_signer_not_ ); } +#[tokio::test] +async fn sign_out_while_live_halts_and_never_joins_with_the_old_credentials() { + let server = ScriptedServer::start(ME).await; + let keys = SwitchableCredentials::new("k1"); + let mut h = harness(&server.url, keys.loader(), ME, true).await; + h.live().await; + keys.sign_out(); + assert!(h + .controls + .commands + .try_send(Command::CredentialsChanged) + .is_ok()); + h.turn_until("halted as not enrolled", |o| { + connection(o) == ConnectionView::Halted(HaltReason::NotEnrolled) + }) + .await; + jump(Duration::from_secs(2)).await; + h.turn_until("the dial cooldown has passed", |o| { + !o.timers.is_scheduled(&TimerId::DialCooldown) + }) + .await; + assert!(h.controls.commands.try_send(Command::Reconnect).is_ok()); + h.turn_until("one more dial, halted before its join", |o| { + server.stats.upgrades() == 2 + && connection(o) == ConnectionView::Halted(HaltReason::NotEnrolled) + }) + .await; + assert_eq!( + server.join_keys(), + vec!["k1".to_string()], + "nothing joined after the sign-out" + ); + keys.set("k2"); + assert!(h + .controls + .commands + .try_send(Command::CredentialsChanged) + .is_ok()); + h.turn_until("live with the new credentials", is_live).await; + assert_eq!(server.join_keys(), vec!["k1".to_string(), "k2".to_string()]); +} + +#[tokio::test] +async fn a_dial_after_the_stored_credentials_are_gone_halts_without_joining() { + let server = ScriptedServer::start(ME).await; + let keys = SwitchableCredentials::new("k1"); + let mut h = harness(&server.url, keys.loader(), ME, true).await; + h.live().await; + // Cleared by another process: this engine is never told. + keys.sign_out(); + server.drop_connections(); + h.turn_until("disconnected", |o| { + connection(o) == ConnectionView::Disconnected + }) + .await; + jump(Duration::from_millis(2100)).await; + h.turn_until("halted at the next dial", |o| { + connection(o) == ConnectionView::Halted(HaltReason::NotEnrolled) + }) + .await; + assert_eq!(server.join_keys(), vec!["k1".to_string()]); + assert_eq!( + server.stats.upgrades(), + 2, + "the dial happened, the join did not" + ); +} + +#[tokio::test] +async fn unreadable_credentials_are_retried_and_never_read_as_a_sign_out() { + let server = ScriptedServer::start(ME).await; + let keys = SwitchableCredentials::new("k1"); + let mut h = harness(&server.url, keys.loader(), ME, true).await; + h.live().await; + keys.set_unreadable(true); + server.drop_connections(); + jump(Duration::from_millis(2100)).await; + h.turn_until("a dial abandoned before its join, backing off", |o| { + server.stats.upgrades() == 2 && connection(o) == ConnectionView::Disconnected + }) + .await; + assert_eq!(server.join_keys(), vec!["k1".to_string()]); + keys.set_unreadable(false); + jump(Duration::from_secs(10)).await; + h.turn_until("live again with k1", is_live).await; + assert_eq!(server.join_keys(), vec!["k1".to_string(), "k1".to_string()]); +} + +#[tokio::test] +async fn a_sign_out_in_another_process_ends_a_live_connection_within_a_second() { + let server = ScriptedServer::start(ME).await; + let keys = SwitchableCredentials::new("k1"); + let mut h = harness(&server.url, keys.loader(), ME, true).await; + h.live().await; + // This engine is never told. The tick count at sign-out is unknown, so the halt must land + // within a second of ticks. + keys.sign_out(); + for _ in 0..(1000 / LOG_TICK.as_millis()) { + jump(LOG_TICK).await; + h.turns(2).await; + } + assert_eq!( + connection(&h.owner), + ConnectionView::Halted(HaltReason::NotEnrolled) + ); +} + +#[tokio::test] +async fn a_sign_in_in_another_process_is_picked_up_within_three_seconds() { + let server = ScriptedServer::start(ME).await; + let keys = SwitchableCredentials::new("k1"); + keys.sign_out(); + let mut h = harness(&server.url, keys.loader(), ME, true).await; + h.start().await; + assert_eq!( + connection(&h.owner), + ConnectionView::Halted(HaltReason::NotEnrolled) + ); + // Stored by another process: this engine is never told. + keys.set("k1"); + for _ in 0..(3250 / LOG_TICK.as_millis()) { + jump(LOG_TICK).await; + h.turns(2).await; + } + assert_ne!( + connection(&h.owner), + ConnectionView::Halted(HaltReason::NotEnrolled) + ); + h.turn_until("live", is_live).await; + assert_eq!(server.join_keys(), vec!["k1".to_string()]); +} + #[tokio::test] async fn nothing_but_the_join_is_sent_while_connecting() { let server = ScriptedServer::start(ME).await; @@ -264,7 +399,7 @@ async fn nothing_but_the_join_is_sent_while_connecting() { .await; h.controls .store - .create_document(ME, None, json!({"n": 1})) + .create_document(None, json!({"n": 1})) .await .unwrap(); h.controls.outbox.notify_one(); @@ -325,12 +460,15 @@ async fn stale_settle_after_reconnect_is_dropped() { let doc_id = h .controls .store - .create_document(ME, None, json!({"n": 1})) + .create_document(None, json!({"n": 1})) .await .unwrap(); let settled = Input::Settled { doc_id, - outcome: SettleOutcome::Done { rows_remain: true }, + outcome: SettleOutcome::Done { + rows_remain: true, + diverged: false, + }, }; h.owner.queue.push_back(Queued { epoch: Some(stale_gen), @@ -435,28 +573,43 @@ async fn twice_retries_a_failed_feed_effect_once_then_succeeds() { ); } -#[tokio::test] -async fn migrate_error_is_retried_once() { +#[tokio::test(start_paused = true)] +async fn an_open_race_is_retried_until_the_fourth_attempt() { let attempts = Cell::new(0); - let opened = retry_migrate_once(|| { + let opened = retry_open(|| { attempts.set(attempts.get() + 1); let attempt = attempts.get(); async move { - if attempt == 1 { - Err(StoreError::Migrate(MigrateError::VersionMissing(13))) + if attempt < 4 { + Err(StoreError::Migrate(MigrateError::Dirty(13))) } else { Ok(attempt) } } }) .await; - assert_eq!(opened.unwrap(), 2); + assert_eq!(opened.unwrap(), 4); + let failing: Result<u32, StoreError> = + retry_open(|| async { Err(StoreError::Migrate(MigrateError::Dirty(13))) }).await; + assert!(failing.is_err(), "four attempts, then the error"); +} + +#[tokio::test(start_paused = true)] +async fn a_newer_schema_is_not_retried() { + let attempts = Cell::new(0); + let opened: Result<u32, StoreError> = retry_open(|| { + attempts.set(attempts.get() + 1); + async { Err(StoreError::Migrate(MigrateError::VersionMissing(99))) } + }) + .await; + assert!(opened.unwrap_err().is_newer_schema()); + assert_eq!(attempts.get(), 1); } #[tokio::test] async fn other_open_errors_are_not_retried() { let attempts = Cell::new(0); - let opened: Result<u32, StoreError> = retry_migrate_once(|| { + let opened: Result<u32, StoreError> = retry_open(|| { attempts.set(attempts.get() + 1); async { Err(StoreError::NoUserConfig) } }) @@ -466,21 +619,82 @@ async fn other_open_errors_are_not_retried() { } #[tokio::test] -async fn start_without_a_user_config_row_fails() { +async fn a_busy_open_is_retried() { + use sqlx::sqlite::SqliteConnectOptions; + use sqlx::{ConnectOptions, Connection}; + + let dir = tempfile::tempdir().unwrap(); + let options = SqliteConnectOptions::new() + .filename(dir.path().join("locked.sqlite3")) + .create_if_missing(true) + .busy_timeout(Duration::from_millis(1)); + let mut lock_holder = options.connect().await.unwrap(); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut lock_holder) + .await + .unwrap(); + let lock_holder = tokio::sync::Mutex::new(Some(lock_holder)); + let attempts = Cell::new(0); + let busy_errors = Cell::new(0); + let opened = retry_open(|| { + attempts.set(attempts.get() + 1); + let (lock_holder, options, busy_errors) = (&lock_holder, &options, &busy_errors); + let release_lock = attempts.get() == 2; + async move { + if release_lock { + let mut released = lock_holder.lock().await.take().unwrap(); + sqlx::query("ROLLBACK").execute(&mut released).await?; + } + let mut contender = options.connect().await?; + let locked = sqlx::query("BEGIN IMMEDIATE").execute(&mut contender).await; + contender.close().await?; + locked.map(|_| ()).map_err(|error| { + let error = StoreError::from(error); + assert!(error.is_busy(), "{error}"); + busy_errors.set(busy_errors.get() + 1); + error + }) + } + }) + .await; + opened.unwrap(); + assert_eq!(attempts.get(), 2); + assert_eq!(busy_errors.get(), 1); +} + +#[tokio::test] +async fn start_creates_the_user_config_row() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("replicant.sqlite3"); + let (events_tx, _events) = mpsc::unbounded_channel(); + let engine = Engine::start( + &path, + config("ws://127.0.0.1:9", no_credentials()), + events_tx, + ) + .await + .unwrap(); + engine.store().user_id().await.unwrap(); + assert_eq!( + count(&engine.store(), "SELECT COUNT(*) FROM user_config").await, + 1 + ); + engine.stop().await; +} + +#[tokio::test] +async fn a_bad_server_url_is_refused_before_the_database_opens() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("replicant.sqlite3"); - Store::open(&path).await.unwrap().close().await; let (events_tx, _events) = mpsc::unbounded_channel(); let started = Engine::start( &path, - config("ws://127.0.0.1:9", credentials("k1")), + config("wss://sync.example.com/?vsn=1", no_credentials()), events_tx, ) .await; - assert!(matches!( - started, - Err(EngineError::Store(StoreError::NoUserConfig)) - )); + assert!(matches!(started, Err(EngineError::Config(_)))); + assert!(!path.exists(), "nothing opened, nothing migrated"); } #[tokio::test] @@ -513,7 +727,7 @@ async fn a_new_users_empty_snapshot_still_unlocks_uploads() { let doc_id = h .controls .store - .create_document(ME, None, json!({"n": 1})) + .create_document(None, json!({"n": 1})) .await .unwrap(); h.controls.outbox.notify_one(); @@ -532,7 +746,7 @@ async fn upload_is_marked_sent_before_it_is_sent() { let doc_id = h .controls .store - .create_document(ME, None, json!({"n": 1})) + .create_document(None, json!({"n": 1})) .await .unwrap(); h.controls.outbox.notify_one(); @@ -564,7 +778,7 @@ async fn live_with_unsent_doc(server: &ScriptedServer) -> (Harness, Uuid) { let doc_id = h .controls .store - .create_document(ME, None, json!({"n": 1})) + .create_document(None, json!({"n": 1})) .await .unwrap(); (h, doc_id) @@ -632,3 +846,266 @@ async fn failed_mark_sent_backs_the_doc_off_at_once() { }) .await; } + +#[tokio::test] +async fn start_refuses_a_full_list_merge_policy() { + let (_dir, path) = seeded_db(ME, true).await; + let mut refused = config("ws://127.0.0.1:9", credentials("k1")); + refused + .list_merge + .rules + .push((PathPattern("/pitches".into()), ListMergePolicy::Full)); + let (events_tx, _events) = mpsc::unbounded_channel(); + match Engine::start(&path, refused, events_tx).await { + Err(EngineError::Config(message)) => { + assert_eq!(message, "Full list merge is not supported yet") + } + other => panic!("expected a config error, got {:?}", other.map(|_| ())), + } +} + +#[tokio::test] +async fn two_engines_starting_on_one_v1_database_both_start_and_migrate_it_once() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + let doc_id = Uuid::from_u128(0xD1).to_string(); + v1_doc(&pool, &doc_id, Some(ME), json!({"n": 1}), "pending", None).await; + v1_queue_row(&pool, &doc_id, "update", Some(json!({"n": 0}))).await; + pool.close().await; + let (app_events, _app) = mpsc::unbounded_channel(); + let (daw_events, _daw) = mpsc::unbounded_channel(); + let (app, daw) = tokio::join!( + Engine::start( + &path, + config("ws://127.0.0.1:9", no_credentials()), + app_events + ), + Engine::start( + &path, + config("ws://127.0.0.1:9", no_credentials()), + daw_events + ), + ); + let (app, daw) = (app.unwrap(), daw.unwrap()); + assert_eq!( + count(&app.store(), "SELECT COUNT(*) FROM outbox").await, + 1, + "one marker, not one per engine" + ); + app.stop().await; + daw.stop().await; +} + +#[tokio::test] +async fn a_re_sign_in_after_auth_invalid_in_another_process_is_picked_up_within_three_seconds() { + let server = ScriptedServer::start(ME).await; + server.reject_join("auth_invalid"); + let keys = SwitchableCredentials::new("k1"); + let mut h = harness(&server.url, keys.loader(), ME, true).await; + h.start().await; + h.turn_until("halted", |o| { + connection(o) == ConnectionView::Halted(HaltReason::AuthInvalid) + }) + .await; + // The user signs out and in again in another process (Studio): this engine is never told. + keys.set("k2"); + server.accept_joins(); + for _ in 0..(3250 / LOG_TICK.as_millis()) { + jump(LOG_TICK).await; + h.turns(2).await; + } + assert_ne!( + connection(&h.owner), + ConnectionView::Halted(HaltReason::AuthInvalid), + "a cross-process re-sign-in after auth_invalid is not picked up within 3 s" + ); +} + +#[tokio::test] +async fn a_sign_out_while_halted_auth_invalid_reports_not_enrolled() { + let server = ScriptedServer::start(ME).await; + server.reject_join("auth_invalid"); + let keys = SwitchableCredentials::new("k1"); + let mut h = harness(&server.url, keys.loader(), ME, true).await; + h.start().await; + h.turn_until("halted", |o| { + connection(o) == ConnectionView::Halted(HaltReason::AuthInvalid) + }) + .await; + // replicant_clear_credentials in this process. + keys.sign_out(); + assert!(h + .controls + .commands + .try_send(Command::CredentialsChanged) + .is_ok()); + h.turns(3).await; + assert_eq!( + connection(&h.owner), + ConnectionView::Halted(HaltReason::NotEnrolled), + "signed out, but the state still says the credentials were refused" + ); +} + +#[tokio::test] +async fn a_sign_out_in_another_process_while_halted_auth_invalid_reports_not_enrolled_within_three_seconds( +) { + let server = ScriptedServer::start(ME).await; + server.reject_join("auth_invalid"); + let keys = SwitchableCredentials::new("k1"); + let mut h = harness(&server.url, keys.loader(), ME, true).await; + h.start().await; + h.turn_until("halted", |o| { + connection(o) == ConnectionView::Halted(HaltReason::AuthInvalid) + }) + .await; + keys.sign_out(); + jump(Duration::from_millis(3100)).await; + h.turns(3).await; + assert_eq!( + connection(&h.owner), + ConnectionView::Halted(HaltReason::NotEnrolled) + ); +} + +async fn halted_account_disabled(server: &ScriptedServer, keys: &SwitchableCredentials) -> Harness { + server.reject_join("account_disabled"); + let mut h = harness(&server.url, keys.loader(), ME, true).await; + h.start().await; + h.turn_until("halted", |o| { + connection(o) == ConnectionView::Halted(HaltReason::AccountDisabled) + }) + .await; + h +} + +#[tokio::test] +async fn another_account_signed_in_elsewhere_after_account_disabled_is_dialled_within_three_seconds( +) { + let server = ScriptedServer::start(ME).await; + let keys = SwitchableCredentials::new("k1"); + let mut h = halted_account_disabled(&server, &keys).await; + keys.set("k2"); + server.accept_joins(); + jump(Duration::from_millis(3100)).await; + h.turn_until("live with the other account", is_live).await; + assert_eq!(server.join_keys(), vec!["k1".to_string(), "k2".to_string()]); +} + +#[tokio::test] +async fn a_disabled_account_is_never_redialled_by_the_recheck() { + let server = ScriptedServer::start(ME).await; + let keys = SwitchableCredentials::new("k1"); + let mut h = halted_account_disabled(&server, &keys).await; + for _ in 0..10 { + jump(Duration::from_millis(3100)).await; + h.turns(3).await; + } + assert_eq!(server.stats.upgrades(), 1); + assert_eq!( + connection(&h.owner), + ConnectionView::Halted(HaltReason::AccountDisabled) + ); + assert!(h.owner.timers.is_scheduled(&TimerId::HaltRetry)); +} + +#[tokio::test] +async fn a_sign_out_while_halted_account_disabled_reports_not_enrolled() { + let server = ScriptedServer::start(ME).await; + let keys = SwitchableCredentials::new("k1"); + let mut h = halted_account_disabled(&server, &keys).await; + keys.sign_out(); + jump(Duration::from_millis(3100)).await; + h.turns(3).await; + assert_eq!( + connection(&h.owner), + ConnectionView::Halted(HaltReason::NotEnrolled) + ); +} + +#[tokio::test] +async fn a_re_enabled_account_signed_in_again_elsewhere_with_the_same_keys_is_dialled_within_three_seconds( +) { + let server = ScriptedServer::start(ME).await; + let keys = SwitchableCredentials::new("k1"); + let mut h = halted_account_disabled(&server, &keys).await; + keys.sign_out(); + jump(Duration::from_millis(3100)).await; + h.turns(3).await; + assert_eq!( + connection(&h.owner), + ConnectionView::Halted(HaltReason::NotEnrolled) + ); + server.accept_joins(); + keys.set("k1"); + jump(Duration::from_millis(3100)).await; + h.turn_until("live with the same keys", is_live).await; + assert_eq!(server.join_keys(), vec!["k1".to_string(), "k1".to_string()]); +} + +#[tokio::test] +async fn a_new_secret_for_the_same_key_stored_elsewhere_after_account_disabled_is_dialled() { + let server = ScriptedServer::start(ME).await; + let keys = SwitchableCredentials::new("k1"); + let mut h = halted_account_disabled(&server, &keys).await; + keys.set_with_secret("k1", "rps_rotated"); + server.accept_joins(); + jump(Duration::from_millis(3100)).await; + h.turn_until("live with the new secret", is_live).await; + assert_eq!(server.stats.upgrades(), 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_first_launches_on_one_v1_database_write_exactly_one_backup() { + for _ in 0..5 { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + let doc_id = Uuid::from_u128(0xD1).to_string(); + v1_doc(&pool, &doc_id, Some(ME), json!({"n": 1}), "pending", None).await; + v1_queue_row(&pool, &doc_id, "update", Some(json!({"n": 0}))).await; + pool.close().await; + let (tx, _rx) = mpsc::unbounded_channel(); + let start = || { + Engine::start( + &path, + config("ws://127.0.0.1:9", no_credentials()), + tx.clone(), + ) + }; + let (a, b, c, d) = tokio::join!(start(), start(), start(), start()); + let engines: Vec<Engine> = [a, b, c, d].into_iter().map(Result::unwrap).collect(); + let prefix = format!("{}.v1-backup", path.file_name().unwrap().to_string_lossy()); + let backups: Vec<String> = std::fs::read_dir(path.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|name| name == &prefix || name.starts_with(&format!("{prefix}-"))) + .collect(); + assert_eq!(backups, vec![prefix]); + for engine in engines { + engine.stop().await; + } + } +} + +#[tokio::test] +async fn a_sign_out_seen_at_the_join_lets_the_disabled_keys_be_dialled_once_stored_again() { + let server = ScriptedServer::start(ME).await; + let keys = SwitchableCredentials::new("k1"); + let mut h = halted_account_disabled(&server, &keys).await; + keys.set("k2"); + jump(Duration::from_millis(3100)).await; + h.turn_until("dialling with k2", |o| { + connection(o) == ConnectionView::Connecting + }) + .await; + keys.sign_out(); + h.turn_until("signed out at the join", |o| { + connection(o) == ConnectionView::Halted(HaltReason::NotEnrolled) + }) + .await; + server.accept_joins(); + keys.set("k1"); + jump(Duration::from_millis(3100)).await; + h.turn_until("live with the re-enabled keys", is_live).await; + assert_eq!(server.join_keys(), vec!["k1".to_string(), "k1".to_string()]); +} diff --git a/replicant-client/src/driver/mod.rs b/replicant-client/src/driver/mod.rs index 5815617..3f08215 100644 --- a/replicant-client/src/driver/mod.rs +++ b/replicant-client/src/driver/mod.rs @@ -2,7 +2,7 @@ pub mod engine; #[cfg(test)] -mod test_server; +pub(crate) mod test_server; #[cfg(test)] mod test_support; pub mod timers; diff --git a/replicant-client/src/driver/test_server.rs b/replicant-client/src/driver/test_server.rs index 47f8145..0be8b44 100644 --- a/replicant-client/src/driver/test_server.rs +++ b/replicant-client/src/driver/test_server.rs @@ -102,6 +102,7 @@ struct Model { mode: Mode, user_id: Uuid, join_error: Option<String>, + clock_ahead: Option<i64>, seq: i64, docs: HashMap<Uuid, ServerDoc>, log: Vec<LogEntry>, @@ -109,9 +110,13 @@ struct Model { replies: HashMap<(Uuid, Option<String>), Value>, hold_uploads: bool, held: Vec<Held>, + hold_changes: bool, + held_changes: Vec<Held>, lose_next_upload: bool, drop_after_next_upload: bool, reject_next_upload: Option<String>, + reject_deletes: Option<String>, + stamp_updates: u32, drop_next_push: bool, frames: Vec<Frame>, user_agents: Vec<Option<String>>, @@ -158,6 +163,12 @@ impl ScriptedServer { self.model().join_error = None; } + /// Joins are checked against a server clock `secs` ahead of this machine's, with the real + /// server's 300 s window. + pub fn clock_ahead_by(&self, secs: i64) { + self.model().clock_ahead = Some(secs); + } + pub fn lose_next_upload(&self) { self.model().lose_next_upload = true; } @@ -187,10 +198,38 @@ impl ScriptedServer { } } + /// Holds every `get_changes_since` until `release_held_changes`. + pub fn hold_changes(&self) { + self.model().hold_changes = true; + } + + /// Stops holding and answers every held `get_changes_since` from the current log. + pub fn release_held_changes(&self) { + let mut model = self.model(); + model.hold_changes = false; + for held in std::mem::take(&mut model.held_changes) { + let (status, response) = model.changes_since(&held.payload); + let frame = json!([held.join_ref, held.reference, "sync:v2", "phx_reply", + {"status": status, "response": response}]) + .to_string(); + let _ = model.outbound[held.connection].send(frame); + } + } + + pub fn reject_deletes(&self, code: &str) { + self.model().reject_deletes = Some(code.to_string()); + } + pub fn reject_next_upload(&self, code: &str) { self.model().reject_next_upload = Some(code.to_string()); } + /// The next `times` updates store `"stamped": true` on top of the patched content + /// (`u32::MAX`: every update), so the reply differs from what the client sent. + pub fn stamp_updates(&self, times: u32) { + self.model().stamp_updates = times; + } + pub fn drop_next_push(&self) { self.model().drop_next_push = true; } @@ -286,6 +325,7 @@ impl Model { mode: Mode::Normal, user_id, join_error: None, + clock_ahead: None, seq: 0, docs: HashMap::new(), log: Vec::new(), @@ -293,9 +333,13 @@ impl Model { replies: HashMap::new(), hold_uploads: false, held: Vec::new(), + hold_changes: false, + held_changes: Vec::new(), lose_next_upload: false, drop_after_next_upload: false, reject_next_upload: None, + reject_deletes: None, + stamp_updates: 0, drop_next_push: false, frames: Vec::new(), user_agents: Vec::new(), @@ -467,6 +511,11 @@ impl Model { if let Some(code) = self.reject_next_upload.take() { return error(&code); } + if payload["kind"] == "delete" { + if let Some(code) = self.reject_deletes.clone() { + return error(&code); + } + } let kind = payload["kind"].as_str().unwrap_or_default().to_string(); let body = payload["payload"].clone(); match (kind.as_str(), self.docs.get_mut(&doc_id)) { @@ -492,14 +541,7 @@ impl Model { ("update", Some(doc)) if !doc.deleted => { let current_hash = content_hash(&jsonb(&doc.content)); if base_hash.as_deref() != Some(current_hash.as_str()) { - return UploadResult { - reply: Some(( - "error", - json!({"code": "hash_mismatch", "is_fatal": false, - "current_hash": current_hash, "current_seq": doc.seq}), - )), - close: false, - }; + return hash_mismatch(current_hash, doc.seq); } let Ok(patch) = serde_json::from_value::<json_patch::Patch>(body) else { return error("validation"); @@ -510,9 +552,20 @@ impl Model { if json_patch::patch(&mut rounded, &patch).is_err() { return error("validation"); } + if self.stamp_updates > 0 { + rounded["stamped"] = json!(true); + if self.stamp_updates != u32::MAX { + self.stamp_updates -= 1; + } + } doc.content = rounded; } - ("delete", Some(doc)) if !doc.deleted => {} + ("delete", Some(doc)) if !doc.deleted => { + let current_hash = content_hash(&jsonb(&doc.content)); + if base_hash.as_ref().is_some_and(|base| *base != current_hash) { + return hash_mismatch(current_hash, doc.seq); + } + } _ => return error("not_found"), } let lost = std::mem::take(&mut self.lose_next_upload); @@ -574,6 +627,17 @@ fn uuid_field(payload: &Value, name: &str) -> Option<Uuid> { payload.get(name)?.as_str()?.parse().ok() } +fn hash_mismatch(current_hash: String, current_seq: i64) -> UploadResult { + UploadResult { + reply: Some(( + "error", + json!({"code": "hash_mismatch", "is_fatal": false, + "current_hash": current_hash, "current_seq": current_seq}), + )), + close: false, + } +} + async fn accept_loop( listener: TcpListener, model: Arc<Mutex<Model>>, @@ -680,16 +744,33 @@ fn handle(model: &Mutex<Model>, index: usize, text: &str) -> Outcome { let mut outcome = Outcome::default(); match event.as_str() { "heartbeat" => outcome.replies.push(reply("ok", json!({}))), - "phx_join" => match model.join_error.clone() { - Some(code) => outcome - .replies - .push(reply("error", json!({"code": code, "is_fatal": true}))), - None => { - model.joined.insert(index); - let joined = json!({"user_id": model.user_id, "protocol_version": 2}); - outcome.replies.push(reply("ok", joined)); + "phx_join" => { + let skewed = model.clock_ahead.and_then(|ahead| { + let server_time = crate::store::now_unix() + ahead; + let signed = payload["timestamp"].as_i64().unwrap_or_default(); + ((signed - server_time).abs() > 300).then_some(server_time) + }); + match (skewed, model.join_error.clone()) { + (Some(server_time), _) => outcome.replies.push(reply( + "error", + json!({"code": "clock_skew", "is_fatal": false, "server_time": server_time}), + )), + (None, Some(code)) => outcome + .replies + .push(reply("error", json!({"code": code, "is_fatal": true}))), + (None, None) => { + model.joined.insert(index); + let joined = json!({"user_id": model.user_id, "protocol_version": 2}); + outcome.replies.push(reply("ok", joined)); + } } - }, + } + "get_changes_since" if model.hold_changes => model.held_changes.push(Held { + connection: index, + join_ref: join_ref.clone(), + reference: reference.clone(), + payload, + }), "get_changes_since" => { let (status, response) = model.changes_since(&payload); outcome.replies.push(reply(status, response)); @@ -988,6 +1069,49 @@ mod tests { assert_eq!(server.uploads_for(doc_id).len(), 4); } + #[tokio::test] + async fn delete_on_a_stale_base_is_a_hash_mismatch() { + let server = ScriptedServer::start(ME).await; + let mut connection = joined(&server, "k1").await; + let doc_id = Uuid::from_u128(0xD1); + server.put_doc(doc_id, json!({"n": 1})); + let _push = received(&mut connection).await; + let stale = upload( + 0x71, + doc_id, + UploadKind::Delete, + Some("stale".into()), + Value::Null, + ); + let Received::Input(Input::Reply { + req: 2, + result: Err(error), + }) = reply(&mut connection, 2, stale).await + else { + panic!("expected hash_mismatch"); + }; + assert_eq!( + (error.code.as_str(), error.current_seq), + ("hash_mismatch", Some(1)) + ); + assert!(!server.doc(doc_id).unwrap().2, "nothing was deleted"); + let current = upload( + 0x72, + doc_id, + UploadKind::Delete, + error.current_hash, + Value::Null, + ); + let Received::Input(Input::Reply { + req: 3, + result: Ok(Response::Uploaded(_)), + }) = reply(&mut connection, 3, current).await + else { + panic!("expected the delete on the current base to land"); + }; + assert!(server.doc(doc_id).unwrap().2); + } + #[tokio::test] async fn update_patches_the_rounded_stored_content() { let server = ScriptedServer::start(ME).await; diff --git a/replicant-client/src/driver/test_support.rs b/replicant-client/src/driver/test_support.rs index 0b2a848..c4e70fb 100644 --- a/replicant-client/src/driver/test_support.rs +++ b/replicant-client/src/driver/test_support.rs @@ -2,7 +2,9 @@ //! real sockets with long timers. use std::future::Future; +use std::io; use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -11,6 +13,7 @@ use tokio::sync::mpsc; use uuid::Uuid; use super::engine::{CredentialLoader, EngineConfig, EngineEvent}; +use crate::engine::list_merge::ListMergeConfig; use crate::store::test_support::seed_user; use crate::store::Store; use crate::transport::wire::JoinAuth; @@ -29,50 +32,84 @@ pub(crate) async fn seeded_db(user_id: Uuid, adopted: bool) -> (TempDir, PathBuf } fn auth(api_key: &str) -> JoinAuth { + auth_with_secret(api_key, "rps_test") +} + +fn auth_with_secret(api_key: &str, api_secret: &str) -> JoinAuth { JoinAuth { email: "a@b.c".into(), api_key: api_key.into(), - api_secret: "rps_test".into(), + api_secret: api_secret.into(), } } pub(crate) fn credentials(api_key: &str) -> CredentialLoader { let api_key = api_key.to_string(); - Arc::new(move || Some(auth(&api_key))) + Arc::new(move || Ok(Some(auth(&api_key)))) } /// No stored credentials: the engine halts as not enrolled and never touches the network. pub(crate) fn no_credentials() -> CredentialLoader { - Arc::new(|| None) + Arc::new(|| Ok(None)) } -/// Credentials a test can change while the engine runs. +/// Credentials a test can change, remove or make unreadable while the engine runs. #[derive(Clone)] -pub(crate) struct SwitchableCredentials(Arc<Mutex<String>>); +pub(crate) struct SwitchableCredentials { + stored: Arc<Mutex<Option<(String, String)>>>, + unreadable: Arc<AtomicBool>, +} impl SwitchableCredentials { pub fn new(api_key: &str) -> Self { - Self(Arc::new(Mutex::new(api_key.to_string()))) + Self { + stored: Arc::new(Mutex::new(Some((api_key.into(), "rps_test".into())))), + unreadable: Arc::new(AtomicBool::new(false)), + } } pub fn set(&self, api_key: &str) { - *self.0.lock().unwrap() = api_key.to_string(); + self.set_with_secret(api_key, "rps_test"); + } + + pub fn set_with_secret(&self, api_key: &str, api_secret: &str) { + *self.stored.lock().unwrap() = Some((api_key.into(), api_secret.into())); + } + + /// As if the stored credentials were cleared, here or by another process. + pub fn sign_out(&self) { + *self.stored.lock().unwrap() = None; + } + + /// As if the file could not be read right now (e.g. locked): every read fails until `false`. + pub fn set_unreadable(&self, unreadable: bool) { + self.unreadable.store(unreadable, Ordering::SeqCst); } pub fn loader(&self) -> CredentialLoader { - let api_key = self.0.clone(); - Arc::new(move || Some(auth(&api_key.lock().unwrap()))) + let (stored, unreadable) = (self.stored.clone(), self.unreadable.clone()); + Arc::new(move || { + if unreadable.load(Ordering::SeqCst) { + return Err(io::Error::other("file locked")); + } + Ok(stored + .lock() + .unwrap() + .as_ref() + .map(|(key, secret)| auth_with_secret(key, secret))) + }) } } pub(crate) fn config(server_url: &str, credentials: CredentialLoader) -> EngineConfig { EngineConfig { server_url: server_url.to_string(), - client_id: Uuid::from_u128(0xC11E), host_app: "Test Host".into(), host_version: "1.0".into(), credentials, jitter_seed: 7, + list_merge: ListMergeConfig::default(), + title_pointer: Some("/title".into()), } } diff --git a/replicant-client/src/engine/doc.rs b/replicant-client/src/engine/doc.rs index a380563..8f5bd85 100644 --- a/replicant-client/src/engine/doc.rs +++ b/replicant-client/src/engine/doc.rs @@ -1,9 +1,10 @@ -use json_patch::{AddOperation, PatchOperation}; +use json_patch::PatchOperation; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; use super::hash::content_hash; +use super::list_merge::{ListMergeConfig, ListMergePolicy}; use super::types::{Change, ChangeKind, DocEnvelope, Scope, Seq}; pub use super::doc_upload::{ @@ -64,6 +65,9 @@ pub enum RecoverReason { Conflict, BecamePublication, CreateRejected, + DeleteSuperseded, + DeleteRefused, + DeletePublication, } #[derive(Debug, Clone, PartialEq)] @@ -76,6 +80,10 @@ pub enum DocEvent { SyncError { code: String, }, + /// Another device changed the document after this data dir deleted it: the delete was + /// dropped and the newer version kept; local edits made before the delete are set aside as + /// a kept copy. + DeleteSuperseded, } /// A write the driver performs inside the same transaction that loaded the snapshot. @@ -97,6 +105,8 @@ pub enum DocOp { error: String, }, HardDelete, + /// Clears a local soft delete: the document is visible again. + Undelete, RecordTombstone(Seq), SetMembership(Membership), Recover { @@ -165,6 +175,7 @@ impl DocSnapshot { s.shadow = None; s.soft_deleted = false; } + DocOp::Undelete => s.soft_deleted = false, DocOp::RecordTombstone(seq) => s.tombstone_seq = Some(*seq), DocOp::SetMembership(mm) => { match s.memberships.iter_mut().find(|x| x.scope == mm.scope) { @@ -241,6 +252,7 @@ fn apply_upsert( doc: &DocEnvelope, seq: Seq, local_base_known: bool, + lists: &ListMergeConfig, ) -> Vec<DocOp> { let new_shadow = Shadow { content: doc.content.clone(), @@ -254,6 +266,33 @@ fn apply_upsert( let pending = snap.exists && !snap.rows.is_empty(); if pending && doc.read_only { + if delete_pending(snap) { + // A publication cannot be deleted through sync: it stays visible and read-only, + // like every subscriber's copy, and the pending delete is dropped. Local content + // is kept only if the publication replaced it and the user had edited it. + let local = content_hash(&snap.content); + let edited = content_hash(&doc.content) != local + && snap + .shadow + .as_ref() + .is_none_or(|shadow| content_hash(&shadow.content) != local); + if edited { + ops.push(DocOp::Recover { + content: snap.content.clone(), + reason: RecoverReason::DeletePublication, + }); + } + ops.push(DocOp::DropAllRows); + ops.push(DocOp::Undelete); + ops.push(DocOp::SetShadow(new_shadow)); + ops.push(DocOp::SetContent(doc.content.clone())); + if edited { + ops.push(DocOp::Emit(DocEvent::SyncError { + code: "became_publication".into(), + })); + } + return ops; + } ops.push(DocOp::Recover { content: snap.content.clone(), reason: RecoverReason::BecamePublication, @@ -271,8 +310,29 @@ fn apply_upsert( ops.push(DocOp::SetContent(doc.content.clone())); return ops; } - // The pending delete is unconditional and will win; leave content and rows untouched. + // A delete made on an older version must not destroy another device's newer edit. An + // envelope holding the version it was made on (the shadow's, or our own last upload's) + // leaves it pending, to go out on the new base. With no shadow the version it was made on + // is unknown, so any content other than the local content supersedes it, and the local + // content is kept whenever it differs. if delete_pending(snap) { + let incoming = content_hash(&doc.content); + let local = content_hash(&snap.content); + let base = snap.shadow.as_ref().map(|old| content_hash(&old.content)); + if incoming != local && base.as_ref() != Some(&incoming) { + if base.as_ref() != Some(&local) { + ops.push(DocOp::Recover { + content: snap.content.clone(), + reason: RecoverReason::DeleteSuperseded, + }); + } + ops.push(DocOp::DropAllRows); + ops.push(DocOp::Undelete); + ops.push(DocOp::SetShadow(new_shadow)); + ops.push(DocOp::SetContent(doc.content.clone())); + ops.push(DocOp::Emit(DocEvent::DeleteSuperseded)); + return ops; + } ops.push(DocOp::SetShadow(new_shadow)); return ops; } @@ -284,7 +344,7 @@ fn apply_upsert( return ops; } let rebased = match &snap.shadow { - Some(old) if local_base_known => rebase(&old.content, &doc.content, &snap.content), + Some(old) if local_base_known => rebase(&old.content, &doc.content, &snap.content, lists), _ => Rebased::Conflict, }; match rebased { @@ -313,7 +373,12 @@ fn apply_upsert( ops } -pub fn apply_change(snap: &DocSnapshot, change: &Change, me: Uuid) -> Vec<DocOp> { +pub fn apply_change( + snap: &DocSnapshot, + change: &Change, + me: Uuid, + lists: &ListMergeConfig, +) -> Vec<DocOp> { let mut ops = Vec::new(); let membership_applies = change.seq > membership_seq(snap, &change.scope); if membership_applies { @@ -368,7 +433,7 @@ pub fn apply_change(snap: &DocSnapshot, change: &Change, me: Uuid) -> Vec<DocOp> if doc.seq != change.seq { ops.push(DocOp::DeleteRows(covered)); let acked = snap.project(&ops); - ops.extend(apply_upsert(&acked, doc, doc.seq, false)); + ops.extend(apply_upsert(&acked, doc, doc.seq, false, lists)); return with_settle_invariant(snap, ops, me); } ops.push(DocOp::SetMeta { @@ -389,7 +454,13 @@ pub fn apply_change(snap: &DocSnapshot, change: &Change, me: Uuid) -> Vec<DocOp> if is_member(&ops) { if let Some(doc) = &change.doc { // Pages carry the current envelope; the shadow holds that version, not the change's. - ops.extend(apply_upsert(snap, doc, doc.seq.max(change.seq), true)); + ops.extend(apply_upsert( + snap, + doc, + doc.seq.max(change.seq), + true, + lists, + )); } } } @@ -422,11 +493,16 @@ pub fn apply_server_deleted(snap: &DocSnapshot, seq: Seq) -> Vec<DocOp> { apply_delete(snap, seq.max(snap.server_seq()), false) } -pub fn apply_server_copy(snap: &DocSnapshot, doc: &DocEnvelope, _me: Uuid) -> Vec<DocOp> { +pub fn apply_server_copy( + snap: &DocSnapshot, + doc: &DocEnvelope, + _me: Uuid, + lists: &ListMergeConfig, +) -> Vec<DocOp> { if doc.seq <= snap.server_seq() { return Vec::new(); } - apply_upsert(snap, doc, doc.seq, true) + apply_upsert(snap, doc, doc.seq, true, lists) } /// `get_document` said the document does not exist on the server: not_found means the server @@ -469,6 +545,7 @@ pub fn apply_snapshot_doc( scope: &str, doc: &DocEnvelope, me: Uuid, + lists: &ListMergeConfig, ) -> Vec<DocOp> { let mut ops = Vec::new(); if doc.seq > membership_seq(snap, scope) { @@ -491,7 +568,7 @@ pub fn apply_snapshot_doc( // sent, unacknowledged upload may already be applied there, so its rows conflict. let migrated_v1_base = snap.shadow.as_ref().is_some_and(|s| s.seq == 0); let base_known = migrated_v1_base || snap.unacked_upload.is_none(); - ops.extend(apply_upsert(snap, doc, doc.seq, base_known)); + ops.extend(apply_upsert(snap, doc, doc.seq, base_known, lists)); } with_settle_invariant(snap, ops, me) } @@ -520,34 +597,40 @@ pub enum Rebased { } /// Replays the local change (`old_base` → `local`) onto `new_base` field by field. A local -/// operation collides with a server one when their paths are equal or one contains the other -/// and the two sides end with different values there; the collision is recorded at the shorter -/// path. `json_patch::diff` compares arrays position by position, so an element path is a field -/// like any other; local appends never collide and are re-appended after the server's. -pub fn rebase(old_base: &Value, new_base: &Value, local: &Value) -> Rebased { +/// operation collides with a server one when their `collision_path`s are equal or one contains +/// the other and the two sides end with different values there; the collision is recorded at +/// the shorter path. A list that `lists` makes one value collides whole: the server's list is +/// kept and the local one set aside. +pub fn rebase( + old_base: &Value, + new_base: &Value, + local: &Value, + lists: &ListMergeConfig, +) -> Rebased { let local_ops = json_patch::diff(old_base, local).0; + let lift = |path: &str| collision_path(old_base, local, new_base, path, lists); let their_paths: Vec<String> = json_patch::diff(old_base, new_base) .0 .iter() - .map(|op| op_path(op).to_string()) + .map(|op| lift(op_path(op))) .collect(); let mut contested: Vec<String> = Vec::new(); - for op in local_ops.iter().filter(|op| !is_append(old_base, op)) { - let path = op_path(op); + for op in &local_ops { + let path = lift(op_path(op)); let shortest = their_paths .iter() - .filter(|theirs| contains(theirs, path) || contains(path, theirs)) + .filter(|theirs| contains(theirs, &path) || contains(&path, theirs)) .map(|theirs| { if theirs.len() < path.len() { - theirs.as_str() + theirs.clone() } else { - path + path.clone() } }) .min_by_key(|p| p.len()); if let Some(p) = shortest { - if !contested.iter().any(|c| c == p) { - contested.push(p.to_string()); + if !contested.contains(&p) { + contested.push(p); } } } @@ -557,6 +640,9 @@ pub fn rebase(old_base: &Value, new_base: &Value, local: &Value) -> Rebased { .cloned() .collect(); if outermost.iter().any(|p| p.is_empty()) { + if local == new_base { + return Rebased::Clean(new_base.clone()); + } return Rebased::Conflict; } let conflicts: Vec<FieldConflict> = outermost @@ -568,20 +654,10 @@ pub fn rebase(old_base: &Value, new_base: &Value, local: &Value) -> Rebased { local_removed: local.pointer(p).is_none(), }) .collect(); - let mut kept = Vec::new(); - let mut appends = Vec::new(); - for op in local_ops { - if is_append(old_base, &op) { - // `<parent>/-` on an object would add a key named "-". - if !matches!(new_base.pointer(append_parent(&op)), Some(Value::Array(_))) { - return Rebased::Conflict; - } - appends.push(to_end(op)); - } else if !outermost.iter().any(|p| contains(p, op_path(&op))) { - kept.push(op); - } - } - kept.extend(appends); + let kept: Vec<PatchOperation> = local_ops + .into_iter() + .filter(|op| !outermost.iter().any(|p| contains(p, op_path(op)))) + .collect(); let mut out = new_base.clone(); if json_patch::patch(&mut out, &kept).is_err() { return Rebased::Conflict; @@ -615,37 +691,109 @@ fn contains(ancestor: &str, path: &str) -> bool { && path.as_bytes()[ancestor.len()] == b'/') } -/// An `add` at or past the end of an array the old base already had. -fn is_append(old_base: &Value, op: &PatchOperation) -> bool { - let PatchOperation::Add(add) = op else { - return false; - }; - let Some((parent, index)) = add.path.rsplit_once('/') else { - return false; - }; - match (old_base.pointer(parent), index.parse::<usize>()) { - (Some(Value::Array(items)), Ok(index)) => index >= items.len(), - _ => false, +/// How one side left a list the old base had. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ListShape { + SameLength, + /// The old list is a prefix: elements were only added at the end. + Appended, + /// An insert or a removal, or the value is gone or no longer a list. + Moved, +} + +fn list_shape(old: &[Value], side: Option<&Value>) -> ListShape { + match side { + Some(Value::Array(now)) if now.len() == old.len() => ListShape::SameLength, + Some(Value::Array(now)) if now.len() > old.len() && now[..old.len()] == old[..] => { + ListShape::Appended + } + _ => ListShape::Moved, } } -fn append_parent(op: &PatchOperation) -> &str { - op_path(op) - .rsplit_once('/') - .map_or("", |(parent, _)| parent) +/// The indices where `side` differs from `old` (same length as `old`). +fn changed_indices(old: &[Value], side: &[Value]) -> std::collections::HashSet<usize> { + old.iter() + .zip(side) + .enumerate() + .filter_map(|(i, (was, now))| (was != now).then_some(i)) + .collect() } -fn to_end(op: PatchOperation) -> PatchOperation { - let path = format!("{}/-", append_parent(&op)); - match op { - PatchOperation::Add(add) => PatchOperation::Add(AddOperation { - path, - value: add.value, - }), - other => other, +/// Whether any two elements of `values` are equal (deep equality for objects/arrays). +fn has_repeated_values(values: &[Value]) -> bool { + values + .iter() + .enumerate() + .any(|(i, v)| values[i + 1..].contains(v)) +} + +/// Whether the list at `path` collides as one value: always under `Atomic`; under `Append` +/// once positions stop lining up, or when the base list has a repeated value and both sides +/// changed it — with a repeat, "which twin" is ambiguous, so a disjoint-looking edit may +/// really be retargeted at the other side's twin. A list changed on only one side, or a +/// same-length edit on both sides at disjoint indices with no repeat, merges by position. +fn merges_as_one_value( + old_base: &Value, + local: &Value, + new_base: &Value, + path: &str, + lists: &ListMergeConfig, +) -> bool { + let Some(Value::Array(old)) = old_base.pointer(path) else { + return false; + }; + match lists.policy_for(path) { + ListMergePolicy::Atomic | ListMergePolicy::Full => true, + ListMergePolicy::Append => { + let old_value = old_base.pointer(path); + let local_value = local.pointer(path); + let their_value = new_base.pointer(path); + let both_changed = local_value != old_value && their_value != old_value; + if both_changed && local_value != their_value && has_repeated_values(old) { + return true; + } + match (list_shape(old, local_value), list_shape(old, their_value)) { + (ListShape::SameLength, ListShape::SameLength) => { + let (Some(Value::Array(local)), Some(Value::Array(theirs))) = + (local_value, their_value) + else { + unreachable!("ListShape::SameLength implies an array of the same length") + }; + !changed_indices(old, local).is_disjoint(&changed_indices(old, theirs)) + && local != theirs + } + (ListShape::SameLength, ListShape::Appended) + | (ListShape::Appended, ListShape::SameLength) => false, + _ => true, + } + } } } +/// The path a change at `path` collides at: the outermost list holding it that collides as +/// one value, else `path` itself. +fn collision_path( + old_base: &Value, + local: &Value, + new_base: &Value, + path: &str, + lists: &ListMergeConfig, +) -> String { + let mut prefix = String::new(); + if merges_as_one_value(old_base, local, new_base, &prefix, lists) { + return prefix; + } + for token in path.split('/').skip(1) { + prefix.push('/'); + prefix.push_str(token); + if merges_as_one_value(old_base, local, new_base, &prefix, lists) { + return prefix; + } + } + path.to_string() +} + #[cfg(test)] pub(crate) mod fixtures { use super::*; @@ -655,6 +803,12 @@ pub(crate) mod fixtures { pub const OTHER: Uuid = Uuid::from_u128(0xB); pub const DOC: Uuid = Uuid::from_u128(0xD0C); + /// The default list merge configuration: `Append`, no rules. + pub static APPEND: ListMergeConfig = ListMergeConfig { + default: ListMergePolicy::Append, + rules: Vec::new(), + }; + pub fn m(n: u128) -> Uuid { Uuid::from_u128(n) } @@ -880,7 +1034,7 @@ mod apply_change_tests { #[test] fn stale_change_is_skipped_for_content() { let s = synced(sample(), 10); - let ops = apply_change(&s, &upsert("own", json!({"old": true}), 8), ME); + let ops = apply_change(&s, &upsert("own", json!({"old": true}), 8), ME, &APPEND); assert!(!ops .iter() .any(|o| matches!(o, DocOp::SetContent(_) | DocOp::SetShadow(_)))); @@ -890,7 +1044,12 @@ mod apply_change_tests { fn upsert_without_rows_replaces_content_and_shadow() { let s = synced(sample(), 1); let new = json!({"title": "B"}); - let after = s.project(&apply_change(&s, &upsert("own", new.clone(), 2), ME)); + let after = s.project(&apply_change( + &s, + &upsert("own", new.clone(), 2), + ME, + &APPEND, + )); assert_eq!(after.content, new); assert_eq!(after.shadow.unwrap().seq, 2); } @@ -901,13 +1060,13 @@ mod apply_change_tests { // A page row for seq 2 carrying the document's current envelope (seq 5). let mut first = upsert("own", json!({}), 2); first.doc = Some(env(json!({"a": 3}), 5)); - let after = s.project(&apply_change(&s, &first, ME)); + let after = s.project(&apply_change(&s, &first, ME, &APPEND)); assert_eq!(after.shadow.as_ref().unwrap().seq, 5); assert_eq!(after.content, json!({"a": 3})); let mut second = upsert("own", json!({}), 4); second.doc = Some(env(json!({"a": 3}), 5)); - let ops = apply_change(&after, &second, ME); + let ops = apply_change(&after, &second, ME, &APPEND); assert!(!ops .iter() .any(|op| matches!(op, DocOp::SetShadow(_) | DocOp::SetContent(_)))); @@ -919,6 +1078,7 @@ mod apply_change_tests { &empty(), &upsert("collection:curated", sample(), 3), ME, + &APPEND, )); assert!(after.exists); assert_eq!(after.content, sample()); @@ -937,6 +1097,7 @@ mod apply_change_tests { &s, &upsert("own", json!({"a": 1, "theirs": true}), 2), ME, + &APPEND, )); assert_eq!(after.content, json!({"a": 1, "mine": true, "theirs": true})); assert_eq!( @@ -951,7 +1112,7 @@ mod apply_change_tests { let mut s = synced(json!({"a": {"x": 1}}), 1); s.content = json!({"a": {"x": 2}}); s.rows = vec![row(5, RowKind::Update)]; - let ops = apply_change(&s, &upsert("own", json!({}), 2), ME); + let ops = apply_change(&s, &upsert("own", json!({}), 2), ME, &APPEND); assert!(ops.contains(&DocOp::RecoverFields { content: json!({"a": {"x": 2}}), fields: vec![FieldConflict { @@ -978,7 +1139,12 @@ mod apply_change_tests { let mut s = synced(json!({"s": "old", "k": 1}), 1); s.content = json!({"s": "mine", "k": 2}); s.rows = vec![row(5, RowKind::Update)]; - let ops = apply_change(&s, &upsert("own", json!({"s": "theirs", "k": 1}), 2), ME); + let ops = apply_change( + &s, + &upsert("own", json!({"s": "theirs", "k": 1}), 2), + ME, + &APPEND, + ); let after = s.project(&ops); assert_eq!(after.content, json!({"s": "theirs", "k": 2})); assert_eq!( @@ -989,22 +1155,157 @@ mod apply_change_tests { } #[test] - fn upsert_conflict_on_soft_deleted_keeps_delete_pending() { + fn an_upsert_that_changed_a_pending_delete_supersedes_it() { let mut s = synced(json!({"a": {"x": 1}}), 1); s.content = json!({"a": {"x": 2}}); s.soft_deleted = true; s.rows = vec![row(5, RowKind::Update), row(6, RowKind::Delete)]; - let ops = apply_change(&s, &upsert("own", json!({}), 2), ME); - assert!(!ops.iter().any(|o| matches!( - o, - DocOp::Recover { .. } | DocOp::SetContent(_) | DocOp::Emit(_) - ))); + let ops = apply_change(&s, &upsert("own", json!({"a": {"x": 3}}), 2), ME, &APPEND); + assert!(ops.contains(&DocOp::Emit(DocEvent::DeleteSuperseded))); + assert!(ops.contains(&DocOp::Recover { + content: json!({"a": {"x": 2}}), + reason: RecoverReason::DeleteSuperseded + })); + assert!(!ops + .iter() + .any(|op| matches!(op, DocOp::RecoverFields { .. }))); let after = s.project(&ops); - assert_eq!(after.rows, s.rows); - assert_eq!(after.content, s.content); + assert!(after.exists && !after.soft_deleted); + assert!(after.rows.is_empty()); + assert_eq!(after.content, json!({"a": {"x": 3}})); assert_eq!(after.shadow.unwrap().seq, 2); } + #[test] + fn an_upsert_holding_the_deleted_version_keeps_the_delete() { + let mut s = synced(json!({"a": 1}), 1); + s.soft_deleted = true; + s.rows = vec![row(6, RowKind::Delete)]; + let ops = apply_change(&s, &upsert("own", json!({"a": 1}), 2), ME, &APPEND); + assert!(!ops + .iter() + .any(|op| matches!(op, DocOp::Emit(_) | DocOp::Undelete))); + let after = s.project(&ops); + assert!(after.soft_deleted); + assert_eq!(after.rows, s.rows); + assert_eq!( + after.shadow.unwrap().seq, + 2, + "the delete goes out on the new base" + ); + } + + #[test] + fn an_upsert_holding_our_own_last_edit_keeps_the_delete() { + // Our update landed but its reply was lost; then the user deleted the document. + let mut s = synced(json!({"a": 1}), 1); + s.content = json!({"a": 2}); + s.soft_deleted = true; + s.rows = vec![row(5, RowKind::Update), row(6, RowKind::Delete)]; + let ops = apply_change(&s, &upsert("own", json!({"a": 2}), 2), ME, &APPEND); + assert!(!ops + .iter() + .any(|op| matches!(op, DocOp::Emit(_) | DocOp::Undelete))); + let after = s.project(&ops); + assert!(after.soft_deleted); + assert_eq!(after.rows, s.rows); + assert_eq!(after.shadow.unwrap().content, json!({"a": 2})); + } + + #[test] + fn a_server_copy_that_changed_a_pending_delete_supersedes_it() { + let mut s = synced(json!({"a": 1}), 1); + s.soft_deleted = true; + s.rows = vec![row(6, RowKind::Delete)]; + let ops = apply_server_copy(&s, &env(json!({"a": 3}), 4), ME, &APPEND); + assert!(ops.contains(&DocOp::Emit(DocEvent::DeleteSuperseded))); + assert!( + !ops.iter().any(|op| matches!(op, DocOp::Recover { .. })), + "an unedited document has nothing to keep" + ); + let after = s.project(&ops); + assert!(!after.soft_deleted && after.rows.is_empty()); + assert_eq!(after.content, json!({"a": 3})); + } + + #[test] + fn a_pending_delete_without_a_shadow_is_superseded_by_other_content() { + let mut s = synced(json!({"a": 1}), 0); + s.shadow = None; + s.soft_deleted = true; + s.rows = vec![row(1, RowKind::Create), row(2, RowKind::Delete)]; + let ops = apply_change(&s, &upsert("own", json!({"a": 9}), 3), ME, &APPEND); + assert!(ops.contains(&DocOp::Emit(DocEvent::DeleteSuperseded))); + assert!( + ops.contains(&DocOp::Recover { + content: json!({"a": 1}), + reason: RecoverReason::DeleteSuperseded + }), + "with no shadow, any local content other than the incoming is kept" + ); + let after = s.project(&ops); + assert!(after.exists && !after.soft_deleted); + assert!(after.rows.is_empty()); + assert_eq!(after.content, json!({"a": 9})); + assert_eq!(after.shadow.unwrap().seq, 3); + } + + #[test] + fn a_delete_without_a_shadow_is_superseded_by_a_version_this_device_never_saw() { + // Create sent, reply lost; another device edited it; this device deleted it offline + // and returns via a full resync. + let mut s = synced(json!({"a": 1}), 0); + s.shadow = None; + s.soft_deleted = true; + s.rows = vec![row(1, RowKind::Create), row(2, RowKind::Delete)]; + s.unacked_upload = Some(m(1)); + let other_device = env(json!({"a": 9}), 5); + let ops = apply_snapshot_doc(&s, "own", &other_device, ME, &APPEND); + assert!(ops.contains(&DocOp::Emit(DocEvent::DeleteSuperseded))); + assert!(ops.contains(&DocOp::Recover { + content: json!({"a": 1}), + reason: RecoverReason::DeleteSuperseded + })); + let after = s.project(&ops); + assert!(after.exists && !after.soft_deleted); + assert_eq!(after.content, json!({"a": 9})); + assert_eq!( + build_upload(&after, ME), + BuildResult::Nothing, + "no delete goes out on the other device's version" + ); + } + + #[test] + fn a_delete_without_a_shadow_stays_pending_when_the_server_holds_our_content() { + // Our create landed but its reply was lost; the envelope is that create. + let mut s = synced(json!({"a": 1}), 0); + s.shadow = None; + s.soft_deleted = true; + s.rows = vec![row(1, RowKind::Create), row(2, RowKind::Delete)]; + s.unacked_upload = Some(m(1)); + let ours = env(json!({"a": 1}), 5); + let ops = apply_snapshot_doc(&s, "own", &ours, ME, &APPEND); + assert!(!ops + .iter() + .any(|op| matches!(op, DocOp::Emit(_) | DocOp::Undelete | DocOp::Recover { .. }))); + let after = s.project(&ops); + assert!(after.soft_deleted); + assert_eq!(after.rows, s.rows); + assert_eq!(after.shadow.as_ref().unwrap().seq, 5); + match build_upload(&after, ME) { + BuildResult::Send { upload, .. } => { + assert_eq!(upload.kind, crate::engine::types::UploadKind::Delete); + assert_eq!( + upload.base_hash, + Some(ours.hash), + "the delete goes out on our version" + ); + } + other => panic!("expected the delete, got {other:?}"), + } + } + #[test] fn null_shadow_with_pending_rows_recovers_exact_local_content() { // No known base (a v2 create whose reply was lost, or a migrated v1 doc with no @@ -1013,7 +1314,12 @@ mod apply_change_tests { let mut s = synced(json!({"items": ["a"]}), 0); s.shadow = None; s.rows = vec![row(1, RowKind::Create)]; - let ops = apply_change(&s, &upsert("own", json!({"items": ["a", "x"]}), 4), ME); + let ops = apply_change( + &s, + &upsert("own", json!({"items": ["a", "x"]}), 4), + ME, + &APPEND, + ); assert!(ops.contains(&DocOp::Recover { content: json!({"items": ["a"]}), reason: RecoverReason::Conflict, @@ -1029,7 +1335,7 @@ mod apply_change_tests { fn membership_guard_is_independent_of_content_guard() { // Doc already at server_seq 20; lagging scope Y delivers upsert@15. let s = synced(sample(), 20); - let ops = apply_change(&s, &upsert("collection:y", sample(), 15), ME); + let ops = apply_change(&s, &upsert("collection:y", sample(), 15), ME, &APPEND); assert!(ops.contains(&DocOp::SetMembership(Membership { scope: "collection:y".into(), member: true, @@ -1046,7 +1352,7 @@ mod apply_change_tests { member: false, seq: 20, }); - let ops = apply_change(&s, &upsert("collection:y", sample(), 15), ME); + let ops = apply_change(&s, &upsert("collection:y", sample(), 15), ME, &APPEND); assert!(!ops.iter().any(|o| matches!(o, DocOp::SetMembership(_)))); } @@ -1062,6 +1368,7 @@ mod apply_change_tests { &s, &kind_change("collection:curated", ChangeKind::Leave, 4), ME, + &APPEND, )); assert!(!after.exists); } @@ -1078,12 +1385,14 @@ mod apply_change_tests { &s, &kind_change("collection:curated", ChangeKind::Leave, 4), ME, + &APPEND, ); let after_leave = s.project(&leave_ops); let after = after_leave.project(&apply_change( &after_leave, &upsert("collection:curated", sample(), 3), ME, + &APPEND, )); assert!(!after.exists); } @@ -1100,6 +1409,7 @@ mod apply_change_tests { &s, &kind_change("collection:x", ChangeKind::Leave, 4), ME, + &APPEND, )); assert!(after.exists); } @@ -1111,7 +1421,7 @@ mod apply_change_tests { s.rows = vec![row(5, RowKind::Update)]; let mut c = upsert("collection:curated", json!({"a": 1}), 2); c.doc.as_mut().unwrap().read_only = true; - let ops = apply_change(&s, &c, ME); + let ops = apply_change(&s, &c, ME, &APPEND); assert!(ops.contains(&DocOp::Recover { content: json!({"a": 2}), reason: RecoverReason::BecamePublication @@ -1122,11 +1432,144 @@ mod apply_change_tests { assert!(s.project(&ops).rows.is_empty()); } + #[test] + fn becoming_a_publication_over_an_unedited_pending_delete_unhides_it_without_a_kept_copy() { + let mut s = synced(json!({"a": 1}), 1); + s.soft_deleted = true; + s.rows = vec![row(5, RowKind::Delete)]; + let mut c = upsert("collection:curated", json!({"a": 2}), 2); + c.doc.as_mut().unwrap().read_only = true; + let ops = apply_change(&s, &c, ME, &APPEND); + assert!( + !ops.iter() + .any(|op| matches!(op, DocOp::Recover { .. } | DocOp::Emit(_))), + "unedited: nothing of the user's to keep, nothing to report" + ); + let after = s.project(&ops); + assert!(after.exists && !after.soft_deleted && after.read_only); + assert!(after.rows.is_empty()); + assert_eq!(after.content, json!({"a": 2})); + } + + #[test] + fn becoming_a_publication_over_an_edited_pending_delete_keeps_the_edit() { + let mut s = synced(json!({"a": 1}), 1); + s.content = json!({"a": 9}); + s.soft_deleted = true; + s.rows = vec![row(4, RowKind::Update), row(5, RowKind::Delete)]; + let mut c = upsert("collection:curated", json!({"a": 2}), 2); + c.doc.as_mut().unwrap().read_only = true; + let ops = apply_change(&s, &c, ME, &APPEND); + assert!(ops.contains(&DocOp::Recover { + content: json!({"a": 9}), + reason: RecoverReason::DeletePublication + })); + assert!(ops.contains(&DocOp::Emit(DocEvent::SyncError { + code: "became_publication".into() + }))); + let after = s.project(&ops); + assert!(after.exists && !after.soft_deleted && after.read_only); + assert!(after.rows.is_empty()); + assert_eq!(after.content, json!({"a": 2})); + } + + fn published(content: serde_json::Value, seq: i64) -> Change { + let mut c = upsert("collection:curated", content, seq); + c.doc.as_mut().unwrap().read_only = true; + c + } + + #[test] + fn a_publication_equal_to_the_local_edit_keeps_no_copy() { + let mut s = synced(json!({"a": 1}), 1); + s.content = json!({"a": 9}); + s.soft_deleted = true; + s.rows = vec![row(4, RowKind::Update), row(5, RowKind::Delete)]; + s.unacked_upload = Some(m(4)); + let ops = apply_change(&s, &published(json!({"a": 9}), 3), ME, &APPEND); + assert!( + !ops.iter() + .any(|op| matches!(op, DocOp::Recover { .. } | DocOp::Emit(_))), + "{ops:?}" + ); + let after = s.project(&ops); + assert!(after.exists && !after.soft_deleted && after.read_only); + assert!(after.rows.is_empty()); + } + + #[test] + fn a_parked_delete_of_a_never_shadowed_document_is_undone_by_a_publication() { + let mut s = synced(json!({"a": 1}), 1); + s.shadow = None; + s.soft_deleted = true; + let mut create = row(4, RowKind::Create); + create.parked = true; + let mut delete = row(5, RowKind::Delete); + delete.parked = true; + s.rows = vec![create, delete]; + let ops = apply_change(&s, &published(json!({"a": 1}), 3), ME, &APPEND); + let after = s.project(&ops); + assert!(after.exists && !after.soft_deleted && after.read_only && after.rows.is_empty()); + assert!(!ops + .iter() + .any(|op| matches!(op, DocOp::Recover { .. } | DocOp::Emit(_)))); + let ops = apply_change(&s, &published(json!({"a": 2}), 3), ME, &APPEND); + assert!(ops.contains(&DocOp::Recover { + content: json!({"a": 1}), + reason: RecoverReason::DeletePublication + })); + assert_eq!( + ops.iter().filter(|op| matches!(op, DocOp::Emit(_))).count(), + 1 + ); + } + + #[test] + fn the_echo_of_our_own_update_as_a_publication_unhides_a_document_deleted_after() { + let mut s = synced(json!({"a": 1}), 1); + s.content = json!({"a": 9}); + s.soft_deleted = true; + s.rows = vec![row(4, RowKind::Update), row(5, RowKind::Delete)]; + s.unacked_upload = Some(m(4)); + let mut c = published(json!({"a": 9}), 3); + c.seq = 2; + c.scope = "own".into(); + c.upload_id = Some(m(4)); + let ops = apply_change(&s, &c, ME, &APPEND); + let after = s.project(&ops); + assert!( + after.exists && !after.soft_deleted && after.read_only && after.rows.is_empty(), + "{ops:?}" + ); + } + + #[test] + fn a_server_copy_that_is_a_publication_over_a_never_shadowed_delete_keeps_the_local_content() { + let mut s = synced(json!({"a": 1}), 1); + s.shadow = None; + s.soft_deleted = true; + s.rows = vec![row(5, RowKind::Delete)]; + let mut e = env(json!({"a": 2}), 3); + e.read_only = true; + let ops = apply_server_copy(&s, &e, ME, &APPEND); + let after = s.project(&ops); + assert!(after.exists && !after.soft_deleted && after.read_only && after.rows.is_empty()); + assert!(ops.contains(&DocOp::Recover { + content: json!({"a": 1}), + reason: RecoverReason::DeletePublication + })); + } + #[test] fn server_copy_applies_with_content_guard() { let s = synced(sample(), 5); - assert!(apply_server_copy(&s, &env(json!({"x": 1}), 4), ME).is_empty()); - let after = s.project(&apply_server_copy(&s, &env(json!({"x": 1}), 6), ME)); + assert!(apply_server_copy(&s, &env(json!({"x": 1}), 4), ME, &APPEND).is_empty()); + let after = s.project(&apply_server_copy( + &s, + &env(json!({"x": 1}), 6), + ME, + &APPEND, + )); assert_eq!(after.content, json!({"x": 1})); } @@ -1135,7 +1578,7 @@ mod apply_change_tests { let mut s = synced(json!({"items": ["a"]}), 0); s.shadow = None; s.rows = vec![row(1, RowKind::Create)]; - let ops = apply_server_copy(&s, &env(json!({"items": ["a", "x"]}), 4), ME); + let ops = apply_server_copy(&s, &env(json!({"items": ["a", "x"]}), 4), ME, &APPEND); assert!(ops.contains(&DocOp::Recover { content: json!({"items": ["a"]}), reason: RecoverReason::Conflict, @@ -1152,7 +1595,7 @@ mod apply_change_tests { s.content = json!({"s": "mine", "k": 2}); s.rows = vec![row(5, RowKind::Update)]; s.unacked_upload = Some(m(5)); - let ops = apply_server_copy(&s, &env(json!({"s": "theirs", "k": 1}), 2), ME); + let ops = apply_server_copy(&s, &env(json!({"s": "theirs", "k": 1}), 2), ME, &APPEND); assert!(ops.contains(&DocOp::RecoverFields { content: json!({"s": "mine", "k": 2}), fields: vec![FieldConflict { @@ -1204,7 +1647,7 @@ mod echo_and_delete_tests { s.content = json!({"items": ["x", "y"]}); s.rows = vec![row(1, RowKind::Update), row(2, RowKind::Update)]; let echo = with_upload(upsert("own", json!({"items": ["x"]}), 2), m(1)); - let after = s.project(&apply_change(&s, &echo, ME)); + let after = s.project(&apply_change(&s, &echo, ME, &APPEND)); assert_eq!( after.content, json!({"items": ["x", "y"]}), @@ -1257,7 +1700,7 @@ mod echo_and_delete_tests { s.rows = vec![row(1, RowKind::Update), row(2, RowKind::Update)]; let envelope = json!({"a": 1, "mine": true, "theirs": true}); - let ops = apply_change(&s, &page_echo(envelope.clone()), ME); + let ops = apply_change(&s, &page_echo(envelope.clone()), ME, &APPEND); let after = s.project(&ops); assert_adopted(&after, envelope); @@ -1272,7 +1715,7 @@ mod echo_and_delete_tests { s.rows = vec![row(1, RowKind::Update)]; let envelope = json!({"items": ["x", "z"]}); - let ops = apply_change(&s, &page_echo(envelope.clone()), ME); + let ops = apply_change(&s, &page_echo(envelope.clone()), ME, &APPEND); assert_adopted(&s.project(&ops), envelope); assert_no_conflict_or_marker(&ops); @@ -1286,7 +1729,7 @@ mod echo_and_delete_tests { s.rows = vec![row(1, RowKind::Update), row(2, RowKind::Update)]; let envelope = json!({"items": ["x", "z"]}); - let ops = apply_change(&s, &page_echo(envelope.clone()), ME); + let ops = apply_change(&s, &page_echo(envelope.clone()), ME, &APPEND); assert_adopted(&s.project(&ops), envelope); assert_conflict_recovers(&ops, json!({"items": ["x", "y"]})); @@ -1300,7 +1743,7 @@ mod echo_and_delete_tests { s.rows = vec![row(1, RowKind::Update)]; let envelope = json!({"a": 1, "b": 2}); - let ops = apply_change(&s, &page_echo(envelope.clone()), ME); + let ops = apply_change(&s, &page_echo(envelope.clone()), ME, &APPEND); assert_adopted(&s.project(&ops), envelope); assert_no_conflict_or_marker(&ops); @@ -1314,7 +1757,7 @@ mod echo_and_delete_tests { s.rows = vec![row(1, RowKind::Update), row(2, RowKind::Update)]; let envelope = json!({"a": 1, "b": 2}); - let ops = apply_change(&s, &page_echo(envelope.clone()), ME); + let ops = apply_change(&s, &page_echo(envelope.clone()), ME, &APPEND); assert_adopted(&s.project(&ops), envelope); assert_conflict_recovers(&ops, json!({"a": 1, "c": 3})); @@ -1328,7 +1771,7 @@ mod echo_and_delete_tests { let mut page_echo = with_upload(upsert("own", json!({}), 2), m(1)); page_echo.doc = Some(env(json!({"n": 2, "other": 1}), 5)); - let ops = apply_change(&s, &page_echo, ME); + let ops = apply_change(&s, &page_echo, ME, &APPEND); let after = s.project(&ops); assert!(after.rows.is_empty()); @@ -1345,6 +1788,7 @@ mod echo_and_delete_tests { &s, &with_upload(upsert("own", json!({"n": 2}), 2), m(1)), ME, + &APPEND, ); let after = s.project(&ops); assert!(after.rows.is_empty()); @@ -1362,6 +1806,7 @@ mod echo_and_delete_tests { &s, &with_upload(upsert("own", sample(), 3), m(1)), ME, + &APPEND, )); assert_eq!(after.shadow.unwrap().seq, 3); assert!(after.rows.is_empty()); @@ -1375,6 +1820,7 @@ mod echo_and_delete_tests { &s, &with_upload(upsert("own", json!({"n": 2}), 2), m(1)), ME, + &APPEND, ); assert!(!ops .iter() @@ -1390,6 +1836,7 @@ mod echo_and_delete_tests { &s, &with_upload(kind_change("own", ChangeKind::Delete, 2), m(1)), ME, + &APPEND, ); let after = s.project(&ops); assert!(!after.exists); @@ -1404,7 +1851,7 @@ mod echo_and_delete_tests { let mut s = synced(sample(), 1); s.soft_deleted = true; s.rows = vec![row(1, RowKind::Update), row(2, RowKind::Delete)]; - let ops = apply_change(&s, &kind_change("own", ChangeKind::Delete, 2), ME); + let ops = apply_change(&s, &kind_change("own", ChangeKind::Delete, 2), ME, &APPEND); assert!(!ops .iter() .any(|o| matches!(o, DocOp::Recover { .. } | DocOp::Emit(_)))); @@ -1416,7 +1863,7 @@ mod echo_and_delete_tests { let mut s = synced(json!({"a": 1}), 1); s.content = json!({"a": 2}); s.rows = vec![row(1, RowKind::Update)]; - let ops = apply_change(&s, &kind_change("own", ChangeKind::Delete, 2), ME); + let ops = apply_change(&s, &kind_change("own", ChangeKind::Delete, 2), ME, &APPEND); assert!(ops.contains(&DocOp::Recover { content: json!({"a": 2}), reason: RecoverReason::DeleteWins @@ -1434,6 +1881,7 @@ mod echo_and_delete_tests { &s, &kind_change("own", ChangeKind::Delete, 2), ME, + &APPEND, )); assert!(!after.exists); assert_eq!(after.tombstone_seq, Some(2)); @@ -1445,7 +1893,7 @@ mod echo_and_delete_tests { tombstone_seq: Some(20), ..empty() }; - let ops = apply_change(&s, &upsert("collection:y", sample(), 15), ME); + let ops = apply_change(&s, &upsert("collection:y", sample(), 15), ME, &APPEND); assert!(!s.project(&ops).exists); } @@ -1485,13 +1933,15 @@ mod echo_and_delete_tests { #[should_panic(expected = "upsert without doc")] fn upsert_without_doc_is_a_bug() { let s = synced(sample(), 1); - apply_change(&s, &kind_change("own", ChangeKind::Upsert, 2), ME); + apply_change(&s, &kind_change("own", ChangeKind::Upsert, 2), ME, &APPEND); } } #[cfg(test)] mod rebase_tests { + use super::fixtures::APPEND; use super::*; + use crate::engine::list_merge::{ListMergePolicy, PathPattern}; use serde_json::json; #[test] @@ -1500,23 +1950,36 @@ mod rebase_tests { &json!({"a": 1}), &json!({"a": 1, "b": 2}), &json!({"a": 1, "c": 3}), + &APPEND, ); assert_eq!(r, Rebased::Clean(json!({"a": 1, "b": 2, "c": 3}))); } #[test] - fn append_to_an_array_the_server_made_an_object_is_a_conflict() { + fn append_to_an_array_the_server_made_an_object_collides_at_the_array() { let r = rebase( &json!({"items": ["a"]}), &json!({"items": {"k": 1}}), &json!({"items": ["a", "b"]}), + &APPEND, + ); + assert_eq!( + r, + Rebased::Fields { + content: json!({"items": {"k": 1}}), + conflicts: vec![kept("/items", json!(["a", "b"]))] + } ); - assert_eq!(r, Rebased::Conflict); } #[test] fn no_local_change_takes_new_base() { - let r = rebase(&json!({"a": 1}), &json!({"a": 2}), &json!({"a": 1})); + let r = rebase( + &json!({"a": 1}), + &json!({"a": 2}), + &json!({"a": 1}), + &APPEND, + ); assert_eq!(r, Rebased::Clean(json!({"a": 2}))); } @@ -1534,6 +1997,7 @@ mod rebase_tests { &json!({"a": {"x": 1}, "k": 1}), &json!({"k": 1}), &json!({"a": {"x": 2}, "k": 2}), + &APPEND, ); assert_eq!( r, @@ -1550,6 +2014,7 @@ mod rebase_tests { &json!({"s": "old", "k": 1}), &json!({"s": "theirs", "k": 1}), &json!({"s": "mine", "k": 2}), + &APPEND, ); assert_eq!( r, @@ -1562,7 +2027,12 @@ mod rebase_tests { #[test] fn same_value_on_both_sides_is_not_a_conflict() { - let r = rebase(&json!({"s": "old"}), &json!({"s": "x"}), &json!({"s": "x"})); + let r = rebase( + &json!({"s": "old"}), + &json!({"s": "x"}), + &json!({"s": "x"}), + &APPEND, + ); assert_eq!(r, Rebased::Clean(json!({"s": "x"}))); } @@ -1572,6 +2042,7 @@ mod rebase_tests { &json!({"s": "old", "k": 1}), &json!({"s": "theirs", "k": 1}), &json!({"k": 1}), + &APPEND, ); assert_eq!( r, @@ -1592,6 +2063,7 @@ mod rebase_tests { &json!({"a": {"x": 1, "y": 1}}), &json!({"a": {"x": 2, "y": 1}}), &json!({"a": "flat"}), + &APPEND, ); assert_eq!( r, @@ -1603,27 +2075,35 @@ mod rebase_tests { } #[test] - fn appends_to_one_array_from_both_sides_merge() { + fn appends_to_one_array_from_both_sides_collide_at_the_array() { let r = rebase( &json!({"items": ["a"]}), &json!({"items": ["a", "t"]}), &json!({"items": ["a", "m1", "m2"]}), + &APPEND, + ); + assert_eq!( + r, + Rebased::Fields { + content: json!({"items": ["a", "t"]}), + conflicts: vec![kept("/items", json!(["a", "m1", "m2"]))] + } ); - assert_eq!(r, Rebased::Clean(json!({"items": ["a", "t", "m1", "m2"]}))); } #[test] - fn the_same_array_element_changed_on_both_sides_is_a_field_conflict() { + fn overlapping_array_elements_changed_on_both_sides_collide_at_the_array() { let r = rebase( &json!({"items": ["a", "b", "c"]}), &json!({"items": ["a", "B", "c"]}), &json!({"items": ["a", "b2", "C"]}), + &APPEND, ); assert_eq!( r, Rebased::Fields { - content: json!({"items": ["a", "B", "C"]}), - conflicts: vec![kept("/items/1", json!("b2"))] + content: json!({"items": ["a", "B", "c"]}), + conflicts: vec![kept("/items", json!(["a", "b2", "C"]))] } ); } @@ -1643,53 +2123,518 @@ mod rebase_tests { ]) ); } -} -#[cfg(test)] -mod property_tests { - //! One writable document against a model server that applies updates and deletes - //! (deduplicated by `(upload_id, base_hash)`) and that another device edits or deletes. - //! Changes reach the client as pushes (envelope at the change's seq) or catch-up pages - //! (current envelope). A lost reply is either a reconnect (every committed change is - //! delivered before the next build) or silent (the client learns of it only through - //! `hash_mismatch`). Like the machine, the client applies a server copy only once it has - //! been delivered every change up to the copy's seq; otherwise it catches up and rebuilds. - use std::collections::HashMap; + fn pitches(list: Value) -> Value { + json!({ "pitches": list }) + } - use super::change_fixtures::env; - use super::fixtures::*; - use super::*; - use crate::engine::backoff::Jitter; - use crate::engine::types::{ServerError, Upload, UploadKind}; - use serde_json::json; + fn scale() -> Value { + pitches(json!([0, 200, 400, 500, 700, 900])) + } - const SEEDS: u64 = 500; - const STEPS: usize = 80; - const MAX_LOCAL_EDITS: u32 = 12; - const MAX_OTHER_EDITS: u32 = 8; + /// The server's list is kept and the whole local list is set aside. + fn list_kept_aside(theirs: Value, mine: Value) -> Rebased { + Rebased::Fields { + conflicts: vec![kept("/pitches", mine["pitches"].clone())], + content: theirs, + } + } - #[derive(Debug, Clone, Copy)] - struct Committed { - seq: Seq, - upload_id: Option<Uuid>, - deleted: bool, + fn atomic() -> ListMergeConfig { + ListMergeConfig { + default: ListMergePolicy::Atomic, + rules: Vec::new(), + } } - /// How often each rarely reached branch ran. - #[derive(Debug, Default)] - struct Hits { - /// Rebase conflicts hit by a change-feed delivery (push or catch-up page). - rebase_conflicts_delivery: u32, - /// Rebase conflicts hit while settling a fetched server copy. - rebase_conflicts_server_copy: u32, + #[test] + fn retunes_of_different_degrees_merge() { + let mine = pitches(json!([0, 200, 386, 500, 700, 900])); + let theirs = pitches(json!([0, 200, 400, 500, 700, 884])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &APPEND), + Rebased::Clean(pitches(json!([0, 200, 386, 500, 700, 884]))) + ); + } + + #[test] + fn removals_on_both_sides_keep_the_servers_list() { + let mine = pitches(json!([0, 200, 500, 700, 900])); + let theirs = pitches(json!([0, 200, 400, 500, 700])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &APPEND), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn an_insert_against_a_retune_keeps_the_servers_list() { + let mine = pitches(json!([0, 200, 400, 450, 500, 700, 900])); + let theirs = pitches(json!([0, 200, 400, 500, 702, 900])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &APPEND), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn inserts_at_one_spot_on_both_sides_keep_the_servers_list() { + let mine = pitches(json!([0, 200, 400, 450, 500, 700, 900])); + let theirs = pitches(json!([0, 200, 400, 460, 500, 700, 900])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &APPEND), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn a_removal_against_a_retune_of_that_degree_keeps_the_servers_list() { + let mine = pitches(json!([0, 200, 400, 700, 900])); + let theirs = pitches(json!([0, 200, 400, 498, 700, 900])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &APPEND), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn a_removal_of_a_repeated_value_against_a_retune_of_its_twin_keeps_the_servers_list() { + let old = pitches(json!([0, 200, 200, 500])); + let mine = pitches(json!([0, 200, 500])); + let theirs = pitches(json!([0, 204, 200, 500])); + assert_eq!( + rebase(&old, &theirs, &mine, &APPEND), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn a_local_append_merges_with_a_server_retune() { + let mine = pitches(json!([0, 200, 400, 500, 700, 900, 1000])); + let theirs = pitches(json!([0, 200, 386, 500, 700, 900])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &APPEND), + Rebased::Clean(pitches(json!([0, 200, 386, 500, 700, 900, 1000]))) + ); + } + + #[test] + fn a_server_append_merges_with_a_local_retune() { + let mine = pitches(json!([0, 200, 386, 500, 700, 900])); + let theirs = pitches(json!([0, 200, 400, 500, 700, 900, 1000])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &APPEND), + Rebased::Clean(pitches(json!([0, 200, 386, 500, 700, 900, 1000]))) + ); + } + + #[test] + fn appends_on_both_sides_keep_the_servers_list() { + let mine = pitches(json!([0, 200, 400, 500, 700, 900, 1000])); + let theirs = pitches(json!([0, 200, 400, 500, 700, 900, 1100])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &APPEND), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn an_append_against_a_removal_keeps_the_servers_list() { + let mine = pitches(json!([0, 200, 400, 500, 700, 900, 1000])); + let theirs = pitches(json!([0, 200, 400, 500, 700])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &APPEND), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn the_same_append_on_both_sides_is_one_element() { + let both = pitches(json!([0, 200, 400, 500, 700, 900, 1000])); + assert_eq!( + rebase(&scale(), &both, &both, &APPEND), + Rebased::Clean(both.clone()) + ); + } + + fn two_tunings() -> Value { + json!({"tunings": [ + {"name": "A", "pitches": [0, 100]}, + {"name": "B", "pitches": [0, 200]} + ]}) + } + + /// Inserts 50 into the first tuning's pitches. + fn inner_insert() -> Value { + json!({"tunings": [ + {"name": "A", "pitches": [0, 50, 100]}, + {"name": "B", "pitches": [0, 200]} + ]}) + } + + #[test] + fn a_resized_inner_list_merges_with_edits_at_a_different_index_in_the_outer_list() { + let renamed = json!({"tunings": [ + {"name": "A", "pitches": [0, 100]}, + {"name": "B2", "pitches": [0, 200]} + ]}); + assert_eq!( + rebase(&two_tunings(), &renamed, &inner_insert(), &APPEND), + Rebased::Clean(json!({"tunings": [ + {"name": "A", "pitches": [0, 50, 100]}, + {"name": "B2", "pitches": [0, 200]} + ]})) + ); + } + + #[test] + fn a_resized_inner_list_collides_with_an_edit_at_the_same_index_in_the_outer_list() { + // Both sides touch tuning A (outer index 0), so the outer /tunings list collides whole + // even though the edits (an insert into A's pitches, a retune of A's pitches) would + // have merged if they had landed on different tunings. + let retuned = json!({"tunings": [ + {"name": "A", "pitches": [0, 101]}, + {"name": "B", "pitches": [0, 200]} + ]}); + assert_eq!( + rebase(&two_tunings(), &retuned, &inner_insert(), &APPEND), + Rebased::Fields { + content: retuned.clone(), + conflicts: vec![kept("/tunings", inner_insert()["tunings"].clone())] + } + ); + } + + #[test] + fn a_resized_outer_list_collides_with_any_edit_inside_it() { + let removed = json!({"tunings": [{"name": "A", "pitches": [0, 100]}]}); + assert_eq!( + rebase(&two_tunings(), &removed, &inner_insert(), &APPEND), + Rebased::Fields { + content: removed.clone(), + conflicts: vec![kept("/tunings", inner_insert()["tunings"].clone())] + } + ); + } + + #[test] + fn retunes_of_different_degrees_conflict_when_the_list_is_atomic() { + let mine = pitches(json!([0, 200, 386, 500, 700, 900])); + let theirs = pitches(json!([0, 200, 400, 500, 700, 884])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &atomic()), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn a_full_policy_that_bypassed_start_rebases_like_atomic() { + let full = ListMergeConfig { + default: ListMergePolicy::Full, + rules: Vec::new(), + }; + let mine = pitches(json!([0, 200, 386, 500, 700, 900])); + let theirs = pitches(json!([0, 200, 400, 500, 700, 884])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &full), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn an_atomic_list_changed_on_one_side_applies() { + let old = json!({"name": "12-TET", "pitches": [0, 200, 400]}); + let mine = json!({"name": "12-TET", "pitches": [0, 200, 386, 1000]}); + let theirs = json!({"name": "Just", "pitches": [0, 200, 400]}); + assert_eq!( + rebase(&old, &theirs, &mine, &atomic()), + Rebased::Clean(json!({"name": "Just", "pitches": [0, 200, 386, 1000]})) + ); + } + + // Tunings keyed by name (an object, not a list) so the two edits below land in the same + // pitches list without the outer container itself being a list rule (b) also governs. + fn tunings_by_name() -> Value { + json!({"tunings": { + "A": {"pitches": [0, 100]}, + "B": {"pitches": [0, 200]} + }}) + } + + #[test] + fn rules_choose_the_policy_per_list() { + let mine = json!({"tunings": { + "A": {"pitches": [0, 101]}, + "B": {"pitches": [0, 200]} + }}); + let theirs = json!({"tunings": { + "A": {"pitches": [1, 100]}, + "B": {"pitches": [0, 200]} + }}); + assert_eq!( + rebase(&tunings_by_name(), &theirs, &mine, &APPEND), + Rebased::Clean(json!({"tunings": { + "A": {"pitches": [1, 101]}, + "B": {"pitches": [0, 200]} + }})) + ); + let atomic_pitches = ListMergeConfig { + default: ListMergePolicy::Append, + rules: vec![( + PathPattern("/tunings/*/pitches".into()), + ListMergePolicy::Atomic, + )], + }; + assert_eq!( + rebase(&tunings_by_name(), &theirs, &mine, &atomic_pitches), + Rebased::Fields { + content: theirs.clone(), + conflicts: vec![kept("/tunings/A/pitches", json!([0, 101]))] + } + ); + } + + #[test] + fn a_removal_on_each_side_with_a_shared_append_collides_at_the_array() { + // Mine removes 400 and appends 1200; theirs removes 700 and appends 1200. The two + // removals shift the tail by one either way, so the shifted diffs land on overlapping + // indices; a plain positional merge would silently undo theirs' removal of 700. + let mine = pitches(json!([0, 200, 500, 700, 900, 1200])); + let theirs = pitches(json!([0, 200, 400, 500, 900, 1200])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &APPEND), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn a_removal_on_each_side_with_a_shared_middle_insert_collides_at_the_array() { + // Same shape as above, but the shared new value (800) lands in the middle of each list + // rather than at the tail: mine removes 400 and inserts 800; theirs removes 700 and + // inserts 800. + let mine = pitches(json!([0, 200, 500, 700, 800, 900])); + let theirs = pitches(json!([0, 200, 800, 400, 500, 900])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &APPEND), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn a_reorder_on_one_side_collides_with_an_edit_the_reorder_touches() { + // Mine swaps the two tunings; theirs edits a field inside the first one. The swap + // touches both indices, so it overlaps theirs' edit at index 0. + fn tuning(name: &str, kind: &str, pitches: Value) -> Value { + json!({"name": name, "kind": kind, "pitches": pitches}) + } + let old = json!({"tunings": [ + tuning("A", "x", json!([0, 100])), + tuning("B", "x", json!([0, 200])) + ]}); + let mine = json!({"tunings": [ + tuning("B", "x", json!([0, 200])), + tuning("A", "x", json!([0, 100])) + ]}); + let theirs = json!({"tunings": [ + tuning("A", "y", json!([0, 100])), + tuning("B", "x", json!([0, 200])) + ]}); + assert_eq!( + rebase(&old, &theirs, &mine, &APPEND), + Rebased::Fields { + content: theirs.clone(), + conflicts: vec![kept("/tunings", mine["tunings"].clone())] + } + ); + } + + #[test] + fn a_reorder_and_a_disjoint_retune_merge_by_position() { + // Mine swaps indices 0 and 1; theirs retunes index 3. The changed-index sets ({0,1} vs + // {3}) are disjoint, so rule (b) leaves them to merge by position: nothing is lost, but + // "swap" is really just two replacements at stable positions, so the result carries + // both edits rather than an actually-reordered list. + let old = pitches(json!([0, 200, 400, 600])); + let mine = pitches(json!([200, 0, 400, 600])); + let theirs = pitches(json!([0, 200, 400, 650])); + assert_eq!( + rebase(&old, &theirs, &mine, &APPEND), + Rebased::Clean(pitches(json!([200, 0, 400, 650]))) + ); + } + + #[test] + fn both_sides_retuning_the_same_degree_collides_at_the_array() { + let mine = pitches(json!([0, 200, 386, 500, 700, 900])); + let theirs = pitches(json!([0, 200, 395, 500, 700, 900])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &APPEND), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn an_atomic_outer_list_overrides_an_inner_append_rule() { + let lists = ListMergeConfig { + default: ListMergePolicy::Append, + rules: vec![(PathPattern("/tunings".into()), ListMergePolicy::Atomic)], + }; + let mine = json!({"tunings": [ + {"name": "A", "pitches": [0, 101]}, + {"name": "B", "pitches": [0, 200]} + ]}); + let theirs = json!({"tunings": [ + {"name": "A", "pitches": [0, 100]}, + {"name": "B", "pitches": [0, 201]} + ]}); + assert_eq!( + rebase(&two_tunings(), &theirs, &mine, &lists), + Rebased::Fields { + content: theirs.clone(), + conflicts: vec![kept("/tunings", mine["tunings"].clone())] + } + ); + } + + #[test] + fn an_append_on_an_empty_list_applies_but_both_sides_appending_conflicts() { + let empty = pitches(json!([])); + let mine = pitches(json!([100])); + assert_eq!( + rebase(&empty, &empty, &mine, &APPEND), + Rebased::Clean(mine.clone()) + ); + let theirs = pitches(json!([200])); + assert_eq!( + rebase(&empty, &theirs, &mine, &APPEND), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn both_sides_removing_the_same_element_from_a_root_list_is_clean() { + let old = json!([0, 200, 400, 500]); + let mine = json!([0, 400, 500]); + let theirs = json!([0, 400, 500]); + assert_eq!( + rebase(&old, &theirs, &mine, &APPEND), + Rebased::Clean(theirs) + ); + } + + #[test] + fn removing_one_twin_while_retuning_the_other_collides_at_the_array() { + // With a repeated value, "which twin" is ambiguous: the disjoint index sets here would + // otherwise merge, silently retargeting the removal at the wrong twin. + let old = pitches(json!([0, 200, 200, 500])); + let mine = pitches(json!([0, 200, 500, 800])); + let theirs = pitches(json!([0, 204, 200, 500])); + assert_eq!( + rebase(&old, &theirs, &mine, &APPEND), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn an_append_against_a_disjoint_retune_of_a_repeated_value_collides_at_the_array() { + let old = pitches(json!([0, 200, 200, 500])); + let mine = pitches(json!([0, 200, 200, 500, 800])); + let theirs = pitches(json!([0, 200, 200, 501])); + assert_eq!( + rebase(&old, &theirs, &mine, &APPEND), + list_kept_aside(theirs, mine) + ); + } + + #[test] + fn a_disjoint_retune_with_no_repeated_values_still_merges() { + let mine = pitches(json!([0, 200, 386, 500, 700, 900])); + let theirs = pitches(json!([0, 200, 400, 500, 700, 884])); + assert_eq!( + rebase(&scale(), &theirs, &mine, &APPEND), + Rebased::Clean(pitches(json!([0, 200, 386, 500, 700, 884]))) + ); + } + + #[test] + fn both_sides_removing_the_same_element_from_a_repeated_value_list_is_clean() { + let old = pitches(json!([0, 200, 200, 500])); + let mine = pitches(json!([0, 200, 500])); + let theirs = pitches(json!([0, 200, 500])); + assert_eq!( + rebase(&old, &theirs, &mine, &APPEND), + Rebased::Clean(theirs) + ); + } +} + +#[cfg(test)] +mod property_tests { + //! One writable document against a model server that applies updates and deletes + //! (deduplicated by `(upload_id, base_hash)`) and that another device edits or deletes. + //! Both sides edit `pitches` with random inserts, removes, retunes, moves and appends; the + //! client also appends to `items` and both write the scalar `shared`. Every seed runs under + //! the `Append` and the `Atomic` list policy. Changes reach the client as pushes (envelope + //! at the change's seq) or catch-up pages (current envelope). A lost reply is either a + //! reconnect (every committed change is delivered before the next build) or silent (the + //! client learns of it only through `hash_mismatch`). Like the machine, the client applies + //! a server copy only once it has been delivered every change up to the copy's seq; + //! otherwise it catches up and rebuilds. Elements are unique across the run, so the + //! repeated-values list rule is covered by unit tests, not by this model. + use std::collections::HashMap; + + use super::change_fixtures::env; + use super::fixtures::*; + use super::*; + use crate::engine::backoff::Jitter; + use crate::engine::doc_upload::reply_diverged; + use crate::engine::types::{ServerError, Upload, UploadKind}; + use serde_json::json; + + const SEEDS: u64 = 500; + const STEPS: usize = 80; + const MAX_LOCAL_EDITS: u32 = 12; + const MAX_OTHER_EDITS: u32 = 8; + + static ATOMIC: ListMergeConfig = ListMergeConfig { + default: ListMergePolicy::Atomic, + rules: Vec::new(), + }; + + #[derive(Debug, Clone, Copy)] + struct Committed { + seq: Seq, + upload_id: Option<Uuid>, + deleted: bool, + } + + /// How often each rarely reached branch ran. + #[derive(Debug, Default)] + struct Hits { + /// Rebase conflicts hit by a change-feed delivery (push or catch-up page). + rebase_conflicts_delivery: u32, + /// Rebase conflicts hit while settling a fetched server copy. + rebase_conflicts_server_copy: u32, server_copy_fetches: u32, server_copy_waits: u32, + /// Deletes with no shadow that fetched the server copy before going out. + delete_copy_fetches: u32, mismatch_retries: u32, delete_wins: u32, local_deletes_settled: u32, equal_content_adopts: u32, never_sent_rebases: u32, field_conflicts: u32, + /// Rebases where both sides changed `pitches` at the same length and it merged. + list_merges: u32, + /// Rebases where one side only appended to `pitches` and it merged. + append_merges: u32, + /// Rebases that kept the server's `pitches` and set the local list aside. + list_conflicts: u32, + /// Pending deletes a newer other-device version superseded. + deletes_superseded: u32, + /// Superseded deletes that kept the user's earlier edits as a copy. + deletes_superseded_kept: u32, } impl Hits { @@ -1698,12 +2643,18 @@ mod property_tests { self.rebase_conflicts_server_copy += other.rebase_conflicts_server_copy; self.server_copy_fetches += other.server_copy_fetches; self.server_copy_waits += other.server_copy_waits; + self.delete_copy_fetches += other.delete_copy_fetches; self.mismatch_retries += other.mismatch_retries; self.delete_wins += other.delete_wins; self.local_deletes_settled += other.local_deletes_settled; self.equal_content_adopts += other.equal_content_adopts; self.never_sent_rebases += other.never_sent_rebases; self.field_conflicts += other.field_conflicts; + self.list_merges += other.list_merges; + self.append_merges += other.append_merges; + self.list_conflicts += other.list_conflicts; + self.deletes_superseded += other.deletes_superseded; + self.deletes_superseded_kept += other.deletes_superseded_kept; } } @@ -1721,6 +2672,8 @@ mod property_tests { /// needed to tell whether this write's value actually won a race against a local upload /// that landed later in true time (see `check_shared_key_preserved`). other_shared_writes: Vec<(u64, Seq, Option<String>)>, + /// (base hash named, hash of the content deleted over) of every client delete committed. + deletes: Vec<(Option<String>, String)>, } impl Server { @@ -1732,6 +2685,7 @@ mod property_tests { other_edits: 0, deleted: false, other_shared_writes: Vec::new(), + deletes: Vec::new(), } } @@ -1768,6 +2722,12 @@ mod property_tests { self.other_edits += 1; let mut next = self.current().clone(); next["theirs"] = json!(self.other_edits); + if rng.next_unit() < 0.5 { + edit_pitches(&mut next, rng, "q", self.other_edits); + if rng.next_unit() < 0.5 { + edit_pitches(&mut next, rng, "r", self.other_edits); + } + } let shared_write = if rng.next_unit() < 0.5 { let value = format!("theirs{}", self.other_edits); next["shared"] = json!(value); @@ -1796,6 +2756,13 @@ mod property_tests { self.commit(self.current().clone(), None, true); } + fn mismatch(&self, current_hash: String) -> ServerError { + let mut error = ServerError::new("hash_mismatch"); + error.current_hash = Some(current_hash); + error.current_seq = Some(self.seq()); + error + } + fn upload(&mut self, upload: &Upload) -> Result<DocEnvelope, ServerError> { let key = (upload.upload_id, upload.base_hash.clone()); if let Some(seq) = self.stored.get(&key) { @@ -1806,16 +2773,22 @@ mod property_tests { } let seq = match upload.kind { UploadKind::Delete => { + let current_hash = content_hash(self.current()); + if upload + .base_hash + .as_ref() + .is_some_and(|base| *base != current_hash) + { + return Err(self.mismatch(current_hash)); + } + self.deletes.push((upload.base_hash.clone(), current_hash)); self.deleted = true; self.commit(self.current().clone(), Some(upload.upload_id), true) } UploadKind::Update => { let current_hash = content_hash(self.current()); if upload.base_hash.as_deref() != Some(current_hash.as_str()) { - let mut error = ServerError::new("hash_mismatch"); - error.current_hash = Some(current_hash); - error.current_seq = Some(self.seq()); - return Err(error); + return Err(self.mismatch(current_hash)); } let patch: json_patch::Patch = serde_json::from_value(upload.payload.clone()).unwrap(); @@ -1845,6 +2818,8 @@ mod property_tests { deleted_locally: bool, recovered: Vec<Value>, hits: Hits, + /// List merge configuration passed to every apply and rebase call. + lists: &'static ListMergeConfig, /// (order, value) of every local write to `shared`, in the order they happened — /// recorded in `edit`, the moment the write is made, whether or not it ever reaches an /// upload (a rebase can silently drop a pending edit before it is ever built). Used to @@ -1865,7 +2840,7 @@ mod property_tests { } impl Client { - fn new(content: Value) -> Client { + fn new(content: Value, lists: &'static ListMergeConfig) -> Client { Client { snap: synced(content, 1), next_row: 0, @@ -1879,6 +2854,7 @@ mod property_tests { deleted_locally: false, recovered: Vec::new(), hits: Hits::default(), + lists, local_shared_writes: Vec::new(), landed_shared: None, field_recovered: Vec::new(), @@ -1890,8 +2866,8 @@ mod property_tests { /// `Create` row. Fuzzes the no-shadow branch of `apply_snapshot_doc` and of /// `apply_change`'s non-echo arm, plus its echo arm on the seeds where `server.changes` /// carries a matching entry. - fn new_lost_create(content: Value) -> Client { - let mut c = Client::new(content); + fn new_lost_create(content: Value, lists: &'static ListMergeConfig) -> Client { + let mut c = Client::new(content, lists); c.snap.shadow = None; c.cursor = 0; c.next_row = 1; @@ -1904,6 +2880,15 @@ mod property_tests { c } + fn count_pitch_merge(&mut self, merge: Option<PitchMerge>) { + match merge { + Some(PitchMerge::Positional) => self.hits.list_merges += 1, + Some(PitchMerge::Appended) => self.hits.append_merges += 1, + Some(PitchMerge::WholeList) => self.hits.list_conflicts += 1, + None => {} + } + } + fn push_row(&mut self, kind: RowKind) { self.next_row += 1; self.snap.rows.push(OutboxRow { @@ -1916,6 +2901,32 @@ mod property_tests { /// Applies ops the way the store does: markers get fresh increasing ids. fn apply(&mut self, ops: &[DocOp]) { let seq_before = self.snap.server_seq(); + let superseded = ops.contains(&DocOp::Emit(DocEvent::DeleteSuperseded)); + let edited_before_delete = superseded + && self.snap.shadow.as_ref().map(|s| content_hash(&s.content)) + != Some(content_hash(&self.snap.content)); + if superseded { + let kept: Vec<&Value> = ops + .iter() + .filter_map(|op| match op { + DocOp::Recover { + content, + reason: RecoverReason::DeleteSuperseded, + } => Some(content), + _ => None, + }) + .collect(); + if edited_before_delete { + assert_eq!( + kept, + vec![&self.snap.content], + "a superseded delete keeps the edits made before it" + ); + self.hits.deletes_superseded_kept += 1; + } else { + assert!(kept.is_empty(), "an unedited superseded delete kept a copy"); + } + } for op in ops { match op { DocOp::InsertMarker(kind) => self.push_row(*kind), @@ -1928,6 +2939,7 @@ mod property_tests { DocOp::RecoverFields { fields, .. } => { self.field_recovered.extend(fields.iter().cloned()); } + DocOp::Emit(DocEvent::DeleteSuperseded) => self.hits.deletes_superseded += 1, other => self.snap = self.snap.project(std::slice::from_ref(other)), } } @@ -1949,17 +2961,20 @@ mod property_tests { } self.local_edits += 1; let n = self.local_edits; - if rng.next_unit() < 0.3 { + let roll = rng.next_unit(); + if roll < 0.3 { let value = format!("mine{n}"); self.snap.content["shared"] = json!(value); *order += 1; self.local_shared_writes.push((*order, value)); - } else { + } else if roll < 0.6 { self.snap.content["items"] .as_array_mut() .expect("items array") .push(token(n)); self.tokens.push(n); + } else { + edit_pitches(&mut self.snap.content, rng, "p", n); } self.push_row(RowKind::Update); } @@ -2085,7 +3100,7 @@ mod property_tests { && doc .as_ref() .is_some_and(|doc| self.equal_content_adoptable(doc, committed.seq)); - let ops = apply_change(&self.snap, &change, ME); + let ops = apply_change(&self.snap, &change, ME, self.lists); self.check_adopted(adoptable, &ops); self.count_rebase_conflict(rebase_possible && !is_echo_for_us, &ops, false); if rebase_possible @@ -2102,6 +3117,15 @@ mod property_tests { &doc.content, &ops, ); + let merge = check_pitches( + "deliver", + self, + shadow_content, + &local_content_before, + &doc.content, + &ops, + ); + self.count_pitch_merge(merge); } } self.apply(&ops); @@ -2162,14 +3186,31 @@ mod property_tests { && self.snap.unacked_upload.is_none() && self.snap.shadow.as_ref().is_some_and(|shadow| { matches!( - rebase(&shadow.content, &doc.content, &self.snap.content), + rebase( + &shadow.content, + &doc.content, + &self.snap.content, + self.lists + ), Rebased::Clean(_) | Rebased::Fields { .. } ) }); let pre_content = self.snap.content.clone(); - let ops = apply_snapshot_doc(&self.snap, "own", &doc, ME); + let old_shadow_content = self.snap.shadow.as_ref().map(|s| s.content.clone()); + let ops = apply_snapshot_doc(&self.snap, "own", &doc, ME, self.lists); self.check_adopted(adoptable, &ops); if never_sent_rebase { + if let Some(shadow_content) = &old_shadow_content { + let merge = check_pitches( + "page", + self, + shadow_content, + &pre_content, + &doc.content, + &ops, + ); + self.count_pitch_merge(merge); + } assert!( !ops.iter().any(|op| matches!(op, DocOp::Recover { .. })), "never-sent edits were recovered instead of rebased" @@ -2215,6 +3256,13 @@ mod property_tests { } match build_upload(&self.snap, ME) { BuildResult::Send { upload, inflight } => { + if upload.kind == UploadKind::Delete { + assert_eq!( + upload.base_hash, + self.snap.shadow.as_ref().map(|s| s.hash.clone()), + "a delete names the shadow's version whenever there is one" + ); + } self.snap.unacked_upload = self.snap.unacked_upload.max(Some(upload.upload_id)); // Captured now, not from `snap.content` on the reply: content can move on // (a later local edit) before the reply for this exact upload comes back. @@ -2230,17 +3278,88 @@ mod property_tests { } self.in_flight = Some((inflight, reply)); } - BuildResult::SettleLocally(ops) => self.apply(&ops), - BuildResult::Nothing => {} - BuildResult::NeedsServerCopy => panic!("the model document always has a shadow"), + // Like the machine's `forget_failures`: a document with nothing to send starts over. + BuildResult::SettleLocally(ops) => { + self.mismatches = 0; + self.apply(&ops); + } + BuildResult::Nothing => self.mismatches = 0, + BuildResult::NeedsServerCopy => { + assert!( + self.snap.shadow.is_none() && ends_in_delete, + "only a delete with no shadow asks for the server copy in the model" + ); + self.hits.delete_copy_fetches += 1; + self.fetch_server_copy(server, rng); + } + } + } + + fn fetch_server_copy(&mut self, server: &Server, rng: &mut Jitter) { + self.hits.server_copy_fetches += 1; + if server.deleted { + let ops = apply_server_deleted(&self.snap, server.seq()); + self.apply(&ops); + return; + } + let doc = server.envelope(server.seq()); + if doc.seq > self.cursor { + // The copy may hold an upload of ours whose reply was lost; + // catching up lets its echo settle our rows first, then we rebuild. + self.hits.server_copy_waits += 1; + self.catch_up(server, rng); + return; + } + let pending_delete_before = delete_pending(&self.snap); + let rebase_possible = self.snap.shadow.is_some() && !self.snap.rows.is_empty(); + let before_server_seq = self.snap.server_seq(); + let old_shadow_content = self.snap.shadow.as_ref().map(|s| s.content.clone()); + let local_content_before = self.snap.content.clone(); + let ops = apply_server_copy(&self.snap, &doc, ME, self.lists); + self.count_rebase_conflict(rebase_possible, &ops, true); + if rebase_possible && !pending_delete_before && doc.seq > before_server_seq { + if let Some(shadow_content) = &old_shadow_content { + assert_shared_collision_kept_aside( + "server copy", + self, + shadow_content, + &local_content_before, + &doc.content, + &ops, + ); + let merge = check_pitches( + "server copy", + self, + shadow_content, + &local_content_before, + &doc.content, + &ops, + ); + self.count_pitch_merge(merge); + } } + self.apply(&ops); + // apply_server_copy's content guard gates on the envelope's own seq. + assert_other_device_edit_preserved( + before_server_seq, + doc.seq, + pending_delete_before, + &doc, + self, + &ops, + "server copy", + ); } fn reply(&mut self, server: &Server, rng: &mut Jitter) { let Some((inflight, reply)) = self.in_flight.take() else { return; }; - match settle(&self.snap, &inflight, &reply, ME, self.mismatches) { + assert!( + !reply_diverged(&self.snap, &inflight, &reply), + "the model server never transforms content, so a divergent reply is false" + ); + match settle(&self.snap, &inflight, &reply, ME, self.mismatches, 0) { SettleResult::Ops(ops) => { if inflight.kind == UploadKind::Delete { self.hits.local_deletes_settled += 1; @@ -2249,51 +3368,8 @@ mod property_tests { self.apply(&ops); } SettleResult::FetchServerCopy => { - self.hits.server_copy_fetches += 1; self.mismatches += 1; - if server.deleted { - let ops = apply_server_deleted(&self.snap, server.seq()); - self.apply(&ops); - return; - } - let doc = server.envelope(server.seq()); - if doc.seq > self.cursor { - // The copy may hold an upload of ours whose reply was lost; - // catching up lets its echo settle our rows first, then we rebuild. - self.hits.server_copy_waits += 1; - self.catch_up(server, rng); - return; - } - let pending_delete_before = delete_pending(&self.snap); - let rebase_possible = self.snap.shadow.is_some() && !self.snap.rows.is_empty(); - let before_server_seq = self.snap.server_seq(); - let old_shadow_content = self.snap.shadow.as_ref().map(|s| s.content.clone()); - let local_content_before = self.snap.content.clone(); - let ops = apply_server_copy(&self.snap, &doc, ME); - self.count_rebase_conflict(rebase_possible, &ops, true); - if rebase_possible && !pending_delete_before && doc.seq > before_server_seq { - if let Some(shadow_content) = &old_shadow_content { - assert_shared_collision_kept_aside( - "server copy", - self, - shadow_content, - &local_content_before, - &doc.content, - &ops, - ); - } - } - self.apply(&ops); - // apply_server_copy's content guard gates on the envelope's own seq. - assert_other_device_edit_preserved( - before_server_seq, - doc.seq, - pending_delete_before, - &doc, - self, - &ops, - "server copy", - ); + self.fetch_server_copy(server, rng); } SettleResult::Retry { mismatch, .. } => { if mismatch { @@ -2321,6 +3397,163 @@ mod property_tests { json!(format!("t{n}")) } + /// One random retune (replace), remove, insert, move or append on `pitches`. A new element + /// is `{side}{n}`, unique across the run; a move keeps its element. + fn edit_pitches(content: &mut Value, rng: &mut Jitter, side: &str, n: u32) { + let pitches = content["pitches"].as_array_mut().expect("pitches array"); + let element = json!(format!("{side}{n}")); + let len = pitches.len(); + match (rng.next_unit() * 5.0) as u32 { + 0 if len > 0 => { + let at = index_below(rng, len); + pitches[at] = element; + } + 1 if len > 0 => { + let at = index_below(rng, len); + pitches.remove(at); + } + 2 => { + let at = index_below(rng, len + 1); + pitches.insert(at, element); + } + 3 if len >= 2 => { + let from = index_below(rng, len); + let moved = pitches.remove(from); + let to = index_below(rng, len); + pitches.insert(to, moved); + } + _ => pitches.push(element), + } + } + + fn index_below(rng: &mut Jitter, len: usize) -> usize { + ((rng.next_unit() * len as f64) as usize).min(len - 1) + } + + #[derive(Debug, Clone, Copy, PartialEq)] + enum PitchMerge { + /// Both sides kept the length; merged position by position. + Positional, + /// One side only appended, the other kept the length. + Appended, + /// The server's list was kept and the local list set aside. + WholeList, + } + + fn pitches(content: &Value) -> Vec<Value> { + content["pitches"].as_array().cloned().unwrap_or_default() + } + + fn only_appends(old: &[Value], list: &[Value]) -> bool { + list.len() > old.len() && list[..old.len()] == old[..] + } + + /// At a rebase, `pitches` is either set aside whole (the server's list taken) or merged with + /// nothing lost, duplicated or revived. The order of a pair is kept when at least one side + /// holds both elements and no side holding both disagrees; a pair is skipped when either + /// side holds exactly one of the two. Nothing comes from neither side. An atomic list both sides changed to + /// different lists is always set aside whole. + fn check_pitches( + what: &str, + client: &Client, + old_shadow: &Value, + local_before: &Value, + incoming: &Value, + ops: &[DocOp], + ) -> Option<PitchMerge> { + if ops.iter().any(|op| matches!(op, DocOp::Recover { .. })) { + return None; + } + let old = pitches(old_shadow); + let mine = pitches(local_before); + let theirs = pitches(incoming); + let result = pitches(&client.snap.project(ops).content); + let fields: Vec<&FieldConflict> = ops + .iter() + .filter_map(|op| match op { + DocOp::RecoverFields { fields, .. } => Some(fields), + _ => None, + }) + .flatten() + .collect(); + let both_changed = mine != old && theirs != old && mine != theirs; + if both_changed && client.lists.policy_for("/pitches") == ListMergePolicy::Atomic { + assert!( + fields.iter().any(|f| f.path == "/pitches"), + "{what}: an atomic list both sides changed is set aside whole" + ); + } + if let Some(whole) = fields.iter().find(|f| f.path == "/pitches") { + assert_eq!( + result, theirs, + "{what}: a list set aside takes the server's list" + ); + assert_eq!( + whole.local_value, + Value::Array(mine.clone()), + "{what}: the whole local list is kept aside" + ); + return Some(PitchMerge::WholeList); + } + assert!( + !fields.iter().any(|f| f.path.starts_with("/pitches/")), + "{what}: a list conflict is whole, never per element" + ); + for element in mine.iter().chain(&theirs) { + let copies = result.iter().filter(|e| *e == element).count(); + assert!(copies <= 1, "{what}: {element} duplicated: {result:?}"); + let wanted = + !old.contains(element) || (mine.contains(element) && theirs.contains(element)); + assert_eq!( + copies, + usize::from(wanted), + "{what}: {element} lost or revived \ + (old {old:?}, mine {mine:?}, theirs {theirs:?}, result {result:?})" + ); + } + for element in &result { + assert!( + mine.contains(element) || theirs.contains(element), + "{what}: {element} came from neither side" + ); + } + for (at, first) in result.iter().enumerate() { + for second in &result[at + 1..] { + // Per side: None if it holds exactly one of the pair, else whether it holds both + // (and, if so, whether `first` comes before `second`). + let sides = [&mine, &theirs].map(|side| { + let first_at = side.iter().position(|e| e == first); + let second_at = side.iter().position(|e| e == second); + match (first_at, second_at) { + (Some(a), Some(b)) => Some(Some(a < b)), + (None, None) => Some(None), + _ => None, + } + }); + let [Some(in_mine), Some(in_theirs)] = sides else { + continue; + }; + let kept_before = match (in_mine, in_theirs) { + (Some(a), Some(b)) if a != b => continue, + (Some(a), _) | (_, Some(a)) => a, + (None, None) => continue, + }; + assert!( + kept_before, + "{what}: {first} and {second} are out of order \ + (mine {mine:?}, theirs {theirs:?}, result {result:?})" + ); + } + } + if !both_changed { + None + } else if only_appends(&old, &mine) || only_appends(&old, &theirs) { + Some(PitchMerge::Appended) + } else { + Some(PitchMerge::Positional) + } + } + /// Whether a JSON Patch upload payload includes an operation on `/shared`. fn upload_touches_shared(payload: &Value) -> bool { payload.as_array().is_some_and(|ops| { @@ -2496,6 +3729,13 @@ mod property_tests { "seed {seed}: t{n} applied {copies} times: {items:?}" ); } + let list = pitches(&client.snap.content); + for (at, element) in list.iter().enumerate() { + assert!( + !list[at + 1..].contains(element), + "seed {seed}: {element} twice in pitches: {list:?}" + ); + } if client.snap.rows.is_empty() { let shadow = client.snap.shadow.as_ref().expect("shadow"); assert_eq!( @@ -2528,19 +3768,24 @@ mod property_tests { panic!("seed {seed}: rows never settled: {:?}", client.snap.rows); } - #[test] - fn random_echo_rebase_and_page_sequences_lose_and_duplicate_nothing() { + /// Runs every seed with `lists`; returns the hits after asserting the ones every policy + /// must reach. + fn run_model(lists: &'static ListMergeConfig) -> Hits { let mut totals = Hits::default(); for seed in 1..=SEEDS { let mut rng = Jitter::new(seed * 104_729); - let start = json!({"items": [], "theirs": 0}); + let start = json!({ + "items": [], + "theirs": 0, + "pitches": ["b0", "b1", "b2", "b3", "b4", "b5"] + }); let mut server = Server::new(start.clone()); // Half the seeds start as a lost-create client (no shadow, one pending Create row). let lost_create = seed % 2 == 0; let mut client = if lost_create { - Client::new_lost_create(start) + Client::new_lost_create(start, lists) } else { - Client::new(start) + Client::new(start, lists) }; if lost_create && seed % 4 == 0 { // The create landed as the doc's starting content but our reply was lost; @@ -2581,6 +3826,12 @@ mod property_tests { check_invariants(seed, &client); } drain(seed, &mut server, &mut client, &mut rng); + for (base, deleted_over) in &server.deletes { + assert!( + base.as_ref() == Some(deleted_over), + "seed {seed}: a delete destroyed a version the client had not seen" + ); + } if server.deleted { assert!( @@ -2626,12 +3877,33 @@ mod property_tests { assert!(totals.rebase_conflicts_delivery > 0, "{totals:?}"); assert!(totals.server_copy_fetches > 0, "{totals:?}"); assert!(totals.server_copy_waits > 0, "{totals:?}"); + assert!(totals.delete_copy_fetches > 0, "{totals:?}"); assert!(totals.mismatch_retries > 0, "{totals:?}"); assert!(totals.delete_wins > 0, "{totals:?}"); assert!(totals.local_deletes_settled > 0, "{totals:?}"); assert!(totals.equal_content_adopts > 0, "{totals:?}"); assert!(totals.never_sent_rebases > 0, "{totals:?}"); assert!(totals.field_conflicts > 0, "{totals:?}"); + assert!(totals.list_conflicts > 0, "{totals:?}"); + assert!(totals.deletes_superseded > 0, "{totals:?}"); + totals + } + + #[test] + fn random_echo_rebase_and_page_sequences_lose_and_duplicate_nothing() { + let totals = run_model(&APPEND); + assert!(totals.list_merges > 0, "{totals:?}"); + assert!(totals.append_merges > 0, "{totals:?}"); + } + + #[test] + fn random_sequences_with_atomic_lists_lose_and_duplicate_nothing() { + let totals = run_model(&ATOMIC); + assert_eq!( + (totals.list_merges, totals.append_merges), + (0, 0), + "an atomic list both sides changed never merges: {totals:?}" + ); } } @@ -2712,7 +3984,7 @@ mod snapshot_doc_tests { #[test] fn snapshot_doc_with_pending_rows_recovers_exact_local_content() { let (s, doc) = pending_append(); - let ops = apply_snapshot_doc(&s, "own", &doc, ME); + let ops = apply_snapshot_doc(&s, "own", &doc, ME, &APPEND); assert!(ops.contains(&DocOp::Recover { content: json!({"items": ["a", "b"]}), reason: RecoverReason::Conflict, @@ -2732,7 +4004,7 @@ mod snapshot_doc_tests { s.content = json!({"items": ["a", "b"]}); s.rows = vec![row(1, RowKind::Update)]; let doc = env(json!({"items": ["a"], "theirs": 1}), 5); - let ops = apply_snapshot_doc(&s, "own", &doc, ME); + let ops = apply_snapshot_doc(&s, "own", &doc, ME, &APPEND); assert!(!ops .iter() .any(|op| matches!(op, DocOp::Recover { .. } | DocOp::Emit(_)))); @@ -2767,7 +4039,7 @@ mod snapshot_doc_tests { fn snapshot_doc_without_pending_rows_is_adopted_as_is() { let s = synced(json!({"items": ["a"]}), 1); let doc = env(json!({"items": ["x"]}), 5); - let ops = apply_snapshot_doc(&s, "own", &doc, ME); + let ops = apply_snapshot_doc(&s, "own", &doc, ME, &APPEND); assert!(!ops .iter() .any(|op| matches!(op, DocOp::Recover { .. } | DocOp::Emit(_)))); @@ -2777,12 +4049,22 @@ mod snapshot_doc_tests { } #[test] - fn snapshot_doc_with_pending_delete_keeps_the_delete() { + fn snapshot_doc_that_changed_a_pending_delete_supersedes_it() { let (mut s, doc) = pending_append(); + s.soft_deleted = true; s.rows.push(row(2, RowKind::Delete)); - let after = s.project(&apply_snapshot_doc(&s, "own", &doc, ME)); - assert_eq!(after.rows.len(), 2); - assert_eq!(after.content, json!({"items": ["a", "b"]})); + let ops = apply_snapshot_doc(&s, "own", &doc, ME, &APPEND); + assert!(ops.contains(&DocOp::Emit(DocEvent::DeleteSuperseded))); + assert!( + ops.contains(&DocOp::Recover { + content: json!({"items": ["a", "b"]}), + reason: RecoverReason::DeleteSuperseded + }), + "the local edit made before the delete is kept" + ); + let after = s.project(&ops); + assert!(after.rows.is_empty() && !after.soft_deleted); + assert_eq!(after.content, json!({"items": ["a", "b"], "theirs": 1})); assert_eq!(after.shadow.unwrap().seq, 5); } @@ -2793,7 +4075,7 @@ mod snapshot_doc_tests { s.content = json!({"items": ["a", "b"]}); s.rows = vec![row(1, RowKind::Update)]; let doc = env(json!({"items": ["a"], "theirs": 1}), 5); - let ops = apply_snapshot_doc(&s, "own", &doc, ME); + let ops = apply_snapshot_doc(&s, "own", &doc, ME, &APPEND); assert!(!ops .iter() .any(|op| matches!(op, DocOp::Recover { .. } | DocOp::Emit(_)))); @@ -2811,7 +4093,7 @@ mod snapshot_doc_tests { s.shadow = None; s.rows = vec![row(1, RowKind::Create)]; let doc = env(json!({"items": ["a", "x"]}), 4); - let ops = apply_snapshot_doc(&s, "own", &doc, ME); + let ops = apply_snapshot_doc(&s, "own", &doc, ME, &APPEND); assert!(ops.contains(&DocOp::Recover { content: json!({"items": ["a"]}), reason: RecoverReason::Conflict, @@ -2828,7 +4110,7 @@ mod snapshot_doc_tests { fn snapshot_doc_is_skipped_by_the_content_guard() { let (s, _) = pending_append(); let older = env(json!({"items": []}), 1); - let ops = apply_snapshot_doc(&s, "own", &older, ME); + let ops = apply_snapshot_doc(&s, "own", &older, ME, &APPEND); assert!(!ops.iter().any(|op| matches!( op, DocOp::SetShadow(_) | DocOp::SetContent(_) | DocOp::Recover { .. } @@ -2870,7 +4152,7 @@ mod equal_content_tests { let s = lost_create(json!({"items": ["a"]}), vec![row(1, RowKind::Create)]); let mut change = upsert("own", json!({"items": ["a"]}), 4); change.doc.as_mut().unwrap().hash = "server-hash".into(); - let ops = apply_change(&s, &change, ME); + let ops = apply_change(&s, &change, ME, &APPEND); assert_adopted(&s, &ops, 4); assert_eq!( s.project(&ops).shadow.unwrap().hash, @@ -2885,7 +4167,7 @@ mod equal_content_tests { json!({"items": ["a"]}), vec![row(1, RowKind::Create), row(2, RowKind::Update)], ); - let ops = apply_snapshot_doc(&s, "own", &env(json!({"items": ["a"]}), 4), ME); + let ops = apply_snapshot_doc(&s, "own", &env(json!({"items": ["a"]}), 4), ME, &APPEND); assert_adopted(&s, &ops, 4); assert!(!ops.iter().any(|op| matches!(op, DocOp::InsertMarker(_)))); } @@ -2893,7 +4175,7 @@ mod equal_content_tests { #[test] fn no_shadow_server_copy_with_equal_content_adopts_and_settles() { let s = lost_create(json!({"n": 1}), vec![row(1, RowKind::Create)]); - let ops = apply_server_copy(&s, &env(json!({"n": 1}), 2), ME); + let ops = apply_server_copy(&s, &env(json!({"n": 1}), 2), ME, &APPEND); assert_adopted(&s, &ops, 2); } @@ -2904,7 +4186,7 @@ mod equal_content_tests { json!({"items": ["a", "b"]}), vec![row(1, RowKind::Create), row(2, RowKind::Update)], ); - let ops = apply_change(&s, &upsert("own", json!({"items": ["a"]}), 4), ME); + let ops = apply_change(&s, &upsert("own", json!({"items": ["a"]}), 4), ME, &APPEND); assert!(ops.contains(&DocOp::Recover { content: json!({"items": ["a", "b"]}), reason: RecoverReason::Conflict, diff --git a/replicant-client/src/engine/doc_upload.rs b/replicant-client/src/engine/doc_upload.rs index 7d91fc5..bd372b6 100644 --- a/replicant-client/src/engine/doc_upload.rs +++ b/replicant-client/src/engine/doc_upload.rs @@ -5,17 +5,21 @@ use super::doc::{ conflict_ops, delete_pending, with_settle_invariant, DocEvent, DocOp, DocSnapshot, RecoverReason, RowKind, Shadow, }; +use super::hash::content_hash; use super::types::{DocEnvelope, Seq, ServerError, Upload, UploadKind}; pub const MAX_MISMATCH_ATTEMPTS: u32 = 3; +/// Consecutive replies whose content differs from what was sent before the document is parked. +pub const MAX_DIVERGENT_REPLIES: u32 = 3; + #[derive(Debug, Clone, PartialEq)] pub struct InFlight { pub upload_id: Uuid, pub covered: Vec<Uuid>, pub kind: UploadKind, pub base_hash: Option<String>, - /// In memory only; becomes the shadow on success. + /// In memory only; compared with the reply's content to log a divergence. pub sent_content: Value, } @@ -28,12 +32,18 @@ pub enum BuildResult { } pub fn build_upload(snap: &DocSnapshot, me: Uuid) -> BuildResult { - if snap.rows.is_empty() || snap.rows.iter().any(|r| r.parked) { + let ends_in_delete = snap.rows.last().is_some_and(|r| r.kind == RowKind::Delete); + // A pending delete goes out even behind parked rows: nothing else can unpark a deleted + // document. A refused delete with no shadow is parked itself and waits like any other row. + let sendable_delete = snap + .rows + .last() + .is_some_and(|r| r.kind == RowKind::Delete && !r.parked); + if snap.rows.is_empty() || (!sendable_delete && snap.rows.iter().any(|r| r.parked)) { return BuildResult::Nothing; } let covered: Vec<Uuid> = snap.rows.iter().map(|r| r.mutation_id).collect(); let upload_id = *covered.last().expect("rows not empty"); - let ends_in_delete = snap.rows.last().is_some_and(|r| r.kind == RowKind::Delete); let has_create = snap.rows.iter().any(|r| r.kind == RowKind::Create); let send = |kind: UploadKind, @@ -57,7 +67,18 @@ pub fn build_upload(snap: &DocSnapshot, me: Uuid) -> BuildResult { }; match (&snap.shadow, ends_in_delete) { - (_, true) => send(UploadKind::Delete, None, Value::Null, Value::Null), + // The server may hold a version this device never saw (a sent row landed, or the + // document came from the server): the delete waits for that version to go out on. + // Only a create that was never sent is deleted unconditionally. + (None, true) if snap.unacked_upload.is_some() || !has_create => { + BuildResult::NeedsServerCopy + } + (shadow, true) => send( + UploadKind::Delete, + shadow.as_ref().map(|sh| sh.hash.clone()), + Value::Null, + Value::Null, + ), (None, false) if has_create => send( UploadKind::Create, None, @@ -85,6 +106,18 @@ pub fn build_upload(snap: &DocSnapshot, me: Uuid) -> BuildResult { } } +/// A successful update or create whose reply holds content other than what was sent. A push +/// may have applied it already, so the reply need not advance the shadow. +pub fn reply_diverged( + snap: &DocSnapshot, + inflight: &InFlight, + reply: &Result<DocEnvelope, ServerError>, +) -> bool { + matches!(reply, Ok(doc) if inflight.kind != UploadKind::Delete + && snap.exists + && content_hash(&doc.content) != content_hash(&inflight.sent_content)) +} + #[derive(Debug, Clone, PartialEq)] pub enum SettleResult { Ops(Vec<DocOp>), @@ -101,6 +134,7 @@ pub fn settle( reply: &Result<DocEnvelope, ServerError>, me: Uuid, mismatch_attempts: u32, + divergent_replies: u32, ) -> SettleResult { if !snap.exists && inflight.kind != UploadKind::Delete { return SettleResult::Ops(vec![DocOp::DeleteRows(inflight.covered.clone())]); @@ -115,27 +149,54 @@ pub fn settle( match reply { Ok(doc) if inflight.kind == UploadKind::Delete => delete_done(doc.seq), Ok(doc) => { - let mut ops = vec![DocOp::DeleteRows(inflight.covered.clone())]; - if doc.seq > snap.server_seq() { + let advances = doc.seq > snap.server_seq(); + let diverged = reply_diverged(snap, inflight, reply); + let park = + diverged && divergent_replies >= MAX_DIVERGENT_REPLIES && !delete_pending(snap); + let mut ops = if park { + // A push of this upload may have replaced the covered rows with a marker. + vec![DocOp::Park { + rows: snap.rows.iter().map(|r| r.mutation_id).collect(), + error: "diverged".into(), + }] + } else { + vec![DocOp::DeleteRows(inflight.covered.clone())] + }; + if advances { ops.push(DocOp::SetMeta { owner_id: doc.owner_id, read_only: doc.read_only, }); + // The server's copy, never our prediction of it: if they differ, the settle + // invariant queues an upload of the difference. ops.push(DocOp::SetShadow(Shadow { - content: inflight.sent_content.clone(), + content: doc.content.clone(), hash: doc.hash.clone(), seq: doc.seq, })); } + if park { + ops.push(DocOp::Emit(DocEvent::SyncError { + code: "diverged".into(), + })); + } SettleResult::Ops(with_settle_invariant(snap, ops, me)) } Err(e) => match e.code.as_str() { + // The server copy decides: it supersedes the delete, or the delete goes out on it. + "hash_mismatch" if inflight.kind == UploadKind::Delete => { + if mismatch_attempts >= MAX_MISMATCH_ATTEMPTS { + SettleResult::Retry { + after_ms: None, + mismatch: false, + } + } else { + SettleResult::FetchServerCopy + } + } "hash_mismatch" if mismatch_attempts >= MAX_MISMATCH_ATTEMPTS => { if delete_pending(snap) { - SettleResult::Ops(vec![ - DocOp::DropAllRows, - DocOp::InsertMarker(RowKind::Delete), - ]) + SettleResult::FetchServerCopy } else { SettleResult::Ops(conflict_ops(snap)) } @@ -174,6 +235,29 @@ pub fn settle( DocOp::DropAllRows, DocOp::HardDelete, ]), + // The server keeps its version: show it again, and keep local edits made before the + // delete aside. With no shadow there is no server version to show, so the rows park. + "validation" | "forbidden" | "too_large" + if inflight.kind == UploadKind::Delete && snap.shadow.is_some() => + { + let shadow = snap.shadow.as_ref().expect("guarded above"); + let mut ops = Vec::new(); + if content_hash(&snap.content) != content_hash(&shadow.content) { + ops.push(DocOp::Recover { + content: snap.content.clone(), + reason: RecoverReason::DeleteRefused, + }); + } + ops.extend([ + DocOp::DropAllRows, + DocOp::Undelete, + DocOp::SetContent(shadow.content.clone()), + DocOp::Emit(DocEvent::SyncError { + code: e.code.clone(), + }), + ]); + SettleResult::Ops(ops) + } "validation" | "forbidden" | "too_large" => SettleResult::Ops(vec![ DocOp::Park { rows: inflight.covered.clone(), @@ -236,15 +320,53 @@ mod upload_tests { } #[test] - fn migrated_update_then_delete_sends_delete() { + fn a_delete_without_a_shadow_after_a_sent_row_fetches_the_server_copy() { let s = DocSnapshot { shadow: None, soft_deleted: true, - rows: vec![row(1, RowKind::Update), row(2, RowKind::Delete)], + rows: vec![row(1, RowKind::Create), row(2, RowKind::Delete)], + unacked_upload: Some(m(1)), ..synced(sample(), 0) }; - let (u, _) = sent(build_upload(&s, ME)); + assert_eq!(build_upload(&s, ME), BuildResult::NeedsServerCopy); + } + + fn migrated_then_deleted() -> DocSnapshot { + DocSnapshot { + shadow: None, + soft_deleted: true, + rows: vec![row(1, RowKind::Update), row(2, RowKind::Delete)], + ..synced(sample(), 0) + } + } + + #[test] + fn a_migrated_update_then_delete_goes_out_on_the_server_copy() { + let s = migrated_then_deleted(); + assert_eq!(build_upload(&s, ME), BuildResult::NeedsServerCopy); + let server = env(sample(), 4); + let after = s.project(&apply_server_copy(&s, &server, ME, &APPEND)); + assert!(after.soft_deleted); + assert_eq!(after.rows, s.rows); + let (u, _) = sent(build_upload(&after, ME)); assert_eq!(u.kind, UploadKind::Delete); + assert_eq!(u.base_hash, Some(server.hash)); + } + + #[test] + fn a_migrated_delete_is_superseded_by_a_different_server_copy() { + let s = migrated_then_deleted(); + let theirs = json!({"theirs": true}); + let ops = apply_server_copy(&s, &env(theirs.clone(), 4), ME, &APPEND); + assert!(ops.contains(&DocOp::Emit(DocEvent::DeleteSuperseded))); + assert!(ops.contains(&DocOp::Recover { + content: sample(), + reason: RecoverReason::DeleteSuperseded + })); + let after = s.project(&ops); + assert!(after.exists && !after.soft_deleted); + assert!(after.rows.is_empty()); + assert_eq!(after.content, theirs); } #[test] @@ -266,13 +388,17 @@ mod upload_tests { } #[test] - fn delete_with_shadow_sends_unconditional_delete() { + fn delete_with_shadow_carries_the_shadow_hash() { let mut s = synced(sample(), 4); s.soft_deleted = true; s.rows = vec![row(1, RowKind::Update), row(2, RowKind::Delete)]; - let (u, _) = sent(build_upload(&s, ME)); + let (u, f) = sent(build_upload(&s, ME)); assert_eq!(u.kind, UploadKind::Delete); - assert_eq!(u.base_hash, None); + assert_eq!( + u.base_hash, + Some(crate::engine::hash::content_hash(&sample())) + ); + assert_eq!(f.base_hash, u.base_hash); } #[test] @@ -326,7 +452,7 @@ mod upload_tests { s.content = json!({"a": 3}); s.rows = vec![row(1, RowKind::Update), row(2, RowKind::Update)]; let f = inflight_update(vec![m(1)], json!({"a": 2})); - let SettleResult::Ops(ops) = settle(&s, &f, &Ok(env(json!({"a": 2}), 2)), ME, 0) else { + let SettleResult::Ops(ops) = settle(&s, &f, &Ok(env(json!({"a": 2}), 2)), ME, 0, 0) else { panic!() }; let after = s.project(&ops); @@ -338,29 +464,142 @@ mod upload_tests { } #[test] - fn success_shadow_uses_sent_content() { + fn success_shadow_takes_the_replys_content() { let mut s = synced(json!({"n": 1}), 1); s.content = json!({"n": 100}); s.rows = vec![row(1, RowKind::Update)]; let f = inflight_update(vec![m(1)], json!({"n": 100})); - // The reply's content and hash differ from what was sent; the shadow keeps the sent content. - let mut reply = env(json!({"n": 100.0}), 2); + let mut reply = env(json!({"n": 100}), 2); reply.hash = "server-hash".into(); - let SettleResult::Ops(ops) = settle(&s, &f, &Ok(reply), ME, 0) else { + let SettleResult::Ops(ops) = settle(&s, &f, &Ok(reply), ME, 0, 0) else { panic!() }; let after = s.project(&ops); assert_eq!(after.shadow.as_ref().unwrap().content, json!({"n": 100})); - assert_eq!(after.shadow.unwrap().hash, "server-hash"); + assert_eq!( + after.shadow.unwrap().hash, + "server-hash", + "the server's hash, verbatim" + ); assert!(!ops.iter().any(|o| matches!(o, DocOp::InsertMarker(_)))); } + #[test] + fn a_reply_that_differs_from_what_was_sent_queues_a_corrective_upload() { + let mut s = synced(json!({"n": 1}), 1); + s.content = json!({"n": 2}); + s.rows = vec![row(1, RowKind::Update)]; + let f = inflight_update(vec![m(1)], json!({"n": 2})); + let reply = env(json!({"n": 2, "added": true}), 2); + let SettleResult::Ops(ops) = settle(&s, &f, &Ok(reply), ME, 0, 0) else { + panic!() + }; + let after = s.project(&ops); + assert_eq!( + after.shadow.unwrap().content, + json!({"n": 2, "added": true}) + ); + assert_eq!( + after.content, + json!({"n": 2}), + "a reply never overwrites local content" + ); + assert_eq!(after.rows.len(), 1, "a marker uploads the difference"); + } + + fn divergent_settle(divergent_replies: u32) -> (DocSnapshot, Vec<DocOp>) { + let mut s = synced(json!({"n": 1}), 1); + s.content = json!({"n": 2}); + s.rows = vec![row(1, RowKind::Update)]; + let f = inflight_update(vec![m(1)], json!({"n": 2})); + let reply = env(json!({"n": 2, "added": true}), 2); + let SettleResult::Ops(ops) = settle(&s, &f, &Ok(reply), ME, 0, divergent_replies) else { + panic!() + }; + (s, ops) + } + + #[test] + fn a_divergent_reply_below_the_limit_queues_a_marker_without_parking() { + let (s, ops) = divergent_settle(MAX_DIVERGENT_REPLIES - 1); + assert!(!ops.iter().any(|o| matches!(o, DocOp::Park { .. }))); + assert!(!ops.iter().any(|o| matches!(o, DocOp::Emit(_)))); + assert_eq!(s.project(&ops).rows.len(), 1); + } + + #[test] + fn a_divergent_reply_at_the_limit_parks_the_rows_and_keeps_local_content() { + let (s, ops) = divergent_settle(MAX_DIVERGENT_REPLIES); + assert!(ops.contains(&DocOp::Park { + rows: vec![m(1)], + error: "diverged".into() + })); + assert!(ops.contains(&DocOp::Emit(DocEvent::SyncError { + code: "diverged".into() + }))); + let after = s.project(&ops); + assert_eq!(after.content, json!({"n": 2})); + assert_eq!( + after.shadow.unwrap().content, + json!({"n": 2, "added": true}), + "the reply's copy is still adopted" + ); + assert!(after.rows.iter().all(|r| r.parked)); + } + + #[test] + fn a_pending_delete_is_built_behind_parked_rows() { + let mut s = synced(json!({"n": 1}), 1); + s.soft_deleted = true; + let mut parked = row(1, RowKind::Update); + parked.parked = true; + s.rows = vec![parked, row(2, RowKind::Delete)]; + let (upload, inflight) = sent(build_upload(&s, ME)); + assert_eq!(upload.kind, UploadKind::Delete); + assert_eq!( + upload.base_hash, + s.shadow.as_ref().map(|sh| sh.hash.clone()) + ); + assert_eq!(inflight.covered, vec![m(1), m(2)]); + } + + #[test] + fn a_parked_delete_is_not_built_again() { + let mut s = synced(json!({"n": 1}), 1); + s.soft_deleted = true; + let mut parked_update = row(1, RowKind::Update); + parked_update.parked = true; + let mut parked_delete = row(2, RowKind::Delete); + parked_delete.parked = true; + s.rows = vec![parked_update, parked_delete]; + assert!(matches!(build_upload(&s, ME), BuildResult::Nothing)); + } + + #[test] + fn a_delete_made_during_the_last_divergent_upload_is_not_parked() { + let mut s = synced(json!({"n": 1}), 1); + s.content = json!({"n": 2}); + s.soft_deleted = true; + s.rows = vec![row(1, RowKind::Update), row(2, RowKind::Delete)]; + let f = inflight_update(vec![m(1)], json!({"n": 2})); + let reply = env(json!({"n": 2, "added": true}), 2); + let SettleResult::Ops(ops) = settle(&s, &f, &Ok(reply), ME, 0, MAX_DIVERGENT_REPLIES) + else { + panic!() + }; + assert!(!ops.iter().any(|o| matches!(o, DocOp::Park { .. }))); + let after = s.project(&ops); + assert!(after.rows.iter().all(|r| !r.parked)); + let (upload, _) = sent(build_upload(&after, ME)); + assert_eq!(upload.kind, UploadKind::Delete); + } + #[test] fn success_older_than_shadow_only_deletes_rows() { let mut s = synced(json!({"n": 5}), 9); s.rows = vec![row(1, RowKind::Update)]; let f = inflight_update(vec![m(1)], json!({"n": 4})); - let SettleResult::Ops(ops) = settle(&s, &f, &Ok(env(json!({"n": 4}), 7)), ME, 0) else { + let SettleResult::Ops(ops) = settle(&s, &f, &Ok(env(json!({"n": 4}), 7)), ME, 0, 0) else { panic!() }; assert!(!ops.iter().any(|o| matches!(o, DocOp::SetShadow(_)))); @@ -376,7 +615,7 @@ mod upload_tests { base_hash: None, ..inflight_update(vec![m(1)], Value::Null) }; - let SettleResult::Ops(ops) = settle(&s, &f, &Ok(env(Value::Null, 2)), ME, 0) else { + let SettleResult::Ops(ops) = settle(&s, &f, &Ok(env(Value::Null, 2)), ME, 0, 0) else { panic!() }; let after = s.project(&ops); @@ -394,7 +633,7 @@ mod upload_tests { base_hash: None, ..inflight_update(vec![m(1)], Value::Null) }; - let SettleResult::Ops(ops) = settle(&s, &f, &Err(ServerError::new("not_found")), ME, 0) + let SettleResult::Ops(ops) = settle(&s, &f, &Err(ServerError::new("not_found")), ME, 0, 0) else { panic!() }; @@ -419,7 +658,7 @@ mod upload_tests { }; let f = inflight_update(vec![m(1)], json!({"a": 2})); assert_eq!( - settle(&s, &f, &Err(mismatch("other")), ME, 0), + settle(&s, &f, &Err(mismatch("other")), ME, 0, 0), SettleResult::FetchServerCopy ); } @@ -433,7 +672,7 @@ mod upload_tests { let current = s.shadow.as_ref().unwrap().hash.clone(); let f = inflight_update(vec![m(1)], json!({"a": 2})); assert_eq!( - settle(&s, &f, &Err(mismatch(¤t)), ME, 0), + settle(&s, &f, &Err(mismatch(¤t)), ME, 0, 0), SettleResult::Retry { after_ms: None, mismatch: true @@ -447,7 +686,8 @@ mod upload_tests { s.content = json!({"a": 2}); s.rows = vec![row(1, RowKind::Update)]; let f = inflight_update(vec![m(1)], json!({"a": 2})); - let SettleResult::Ops(ops) = settle(&s, &f, &Err(mismatch("x")), ME, MAX_MISMATCH_ATTEMPTS) + let SettleResult::Ops(ops) = + settle(&s, &f, &Err(mismatch("x")), ME, MAX_MISMATCH_ATTEMPTS, 0) else { panic!() }; @@ -459,18 +699,62 @@ mod upload_tests { } #[test] - fn mismatch_conflict_on_soft_deleted_requeues_delete() { + fn mismatch_limit_with_a_pending_delete_fetches_the_server_copy() { let mut s = synced(json!({"a": 1}), 1); s.content = json!({"a": 2}); s.soft_deleted = true; s.rows = vec![row(1, RowKind::Update), row(2, RowKind::Delete)]; let f = inflight_update(vec![m(1)], json!({"a": 2})); assert_eq!( - settle(&s, &f, &Err(mismatch("x")), ME, MAX_MISMATCH_ATTEMPTS), - SettleResult::Ops(vec![ - DocOp::DropAllRows, - DocOp::InsertMarker(RowKind::Delete) - ]) + settle(&s, &f, &Err(mismatch("x")), ME, MAX_MISMATCH_ATTEMPTS, 0), + SettleResult::FetchServerCopy + ); + } + + fn inflight_delete(s: &DocSnapshot) -> InFlight { + InFlight { + kind: UploadKind::Delete, + base_hash: s.shadow.as_ref().map(|sh| sh.hash.clone()), + ..inflight_update(vec![m(1)], Value::Null) + } + } + + #[test] + fn a_stale_delete_fetches_the_server_copy() { + let mut s = synced(json!({"a": 1}), 1); + s.soft_deleted = true; + s.rows = vec![row(1, RowKind::Delete)]; + let f = inflight_delete(&s); + assert_eq!( + settle(&s, &f, &Err(mismatch("newer")), ME, 0, 0), + SettleResult::FetchServerCopy + ); + let current = s.shadow.as_ref().unwrap().hash.clone(); + assert_eq!( + settle(&s, &f, &Err(mismatch(¤t)), ME, 0, 0), + SettleResult::FetchServerCopy, + "a delete is never retried blind on the same base" + ); + } + + #[test] + fn a_delete_that_keeps_mismatching_backs_off() { + let mut s = synced(json!({"a": 1}), 1); + s.soft_deleted = true; + s.rows = vec![row(1, RowKind::Delete)]; + assert_eq!( + settle( + &s, + &inflight_delete(&s), + &Err(mismatch("newer")), + ME, + MAX_MISMATCH_ATTEMPTS, + 0 + ), + SettleResult::Retry { + after_ms: None, + mismatch: false + } ); } @@ -491,7 +775,7 @@ mod upload_tests { ..ServerError::new("exists") }; assert_eq!( - settle(&s, &f, &Err(err), ME, 0), + settle(&s, &f, &Err(err), ME, 0, 0), SettleResult::FetchServerCopy ); } @@ -512,7 +796,7 @@ mod upload_tests { existing_owner: Some(OTHER), ..ServerError::new("exists") }; - let SettleResult::Ops(ops) = settle(&s, &f, &Err(err), ME, 0) else { + let SettleResult::Ops(ops) = settle(&s, &f, &Err(err), ME, 0, 0) else { panic!() }; assert!(ops.contains(&DocOp::Recover { @@ -529,7 +813,7 @@ mod upload_tests { ..synced(sample(), 1) }; let f = inflight_update(vec![m(1)], sample()); - let SettleResult::Ops(ops) = settle(&s, &f, &Err(ServerError::new("validation")), ME, 0) + let SettleResult::Ops(ops) = settle(&s, &f, &Err(ServerError::new("validation")), ME, 0, 0) else { panic!() }; @@ -547,13 +831,111 @@ mod upload_tests { ); } + fn refused_delete_ops(s: &DocSnapshot, code: &str) -> Vec<DocOp> { + let (_, f) = sent(build_upload(s, ME)); + assert_eq!(f.kind, UploadKind::Delete); + let SettleResult::Ops(ops) = settle(s, &f, &Err(ServerError::new(code)), ME, 0, 0) else { + panic!() + }; + ops + } + + #[test] + fn a_refused_delete_after_edits_keeps_them_aside_and_shows_the_server_version() { + let mut s = synced(json!({"n": 1}), 1); + s.content = json!({"n": 2}); + s.soft_deleted = true; + let mut parked_update = row(1, RowKind::Update); + parked_update.parked = true; + s.rows = vec![parked_update, row(2, RowKind::Delete)]; + + let ops = refused_delete_ops(&s, "forbidden"); + + let recovered: Vec<_> = ops + .iter() + .filter(|o| matches!(o, DocOp::Recover { .. })) + .collect(); + assert_eq!( + recovered, + vec![&DocOp::Recover { + content: json!({"n": 2}), + reason: RecoverReason::DeleteRefused + }] + ); + let errors: Vec<_> = ops.iter().filter(|o| matches!(o, DocOp::Emit(_))).collect(); + assert_eq!( + errors, + vec![&DocOp::Emit(DocEvent::SyncError { + code: "forbidden".into() + })] + ); + let after = s.project(&ops); + assert!(after.exists); + assert!(!after.soft_deleted); + assert!(after.rows.is_empty()); + assert_eq!(after.content, json!({"n": 1})); + assert_eq!(after.shadow, s.shadow); + } + + #[test] + fn a_refused_unedited_delete_shows_the_server_version_without_a_copy() { + let mut s = synced(json!({"n": 1}), 1); + s.soft_deleted = true; + s.rows = vec![row(1, RowKind::Delete)]; + + let ops = refused_delete_ops(&s, "validation"); + + assert!(!ops.iter().any(|o| matches!(o, DocOp::Recover { .. }))); + assert_eq!( + ops.iter() + .filter(|o| matches!(o, DocOp::Emit(_))) + .collect::<Vec<_>>(), + vec![&DocOp::Emit(DocEvent::SyncError { + code: "validation".into() + })] + ); + let after = s.project(&ops); + assert!(after.exists); + assert!(!after.soft_deleted); + assert!(after.rows.is_empty()); + assert_eq!(after.content, json!({"n": 1})); + } + + #[test] + fn a_refused_delete_with_no_shadow_parks_its_rows() { + let s = DocSnapshot { + shadow: None, + soft_deleted: true, + rows: vec![row(1, RowKind::Create), row(2, RowKind::Delete)], + ..synced(sample(), 0) + }; + + let ops = refused_delete_ops(&s, "too_large"); + + assert_eq!( + ops, + vec![ + DocOp::Park { + rows: vec![m(1), m(2)], + error: "too_large".into() + }, + DocOp::Emit(DocEvent::SyncError { + code: "too_large".into() + }), + ] + ); + let after = s.project(&ops); + assert!(after.soft_deleted); + assert!(matches!(build_upload(&after, ME), BuildResult::Nothing)); + } + #[test] fn not_found_on_update_is_delete_wins() { let mut s = synced(json!({"a": 1}), 1); s.content = json!({"a": 2}); s.rows = vec![row(1, RowKind::Update)]; let f = inflight_update(vec![m(1)], json!({"a": 2})); - let SettleResult::Ops(ops) = settle(&s, &f, &Err(ServerError::new("not_found")), ME, 0) + let SettleResult::Ops(ops) = settle(&s, &f, &Err(ServerError::new("not_found")), ME, 0, 0) else { panic!() }; @@ -576,7 +958,7 @@ mod upload_tests { ..ServerError::new("internal") }; assert_eq!( - settle(&s, &f, &Err(err), ME, 0), + settle(&s, &f, &Err(err), ME, 0, 0), SettleResult::Retry { after_ms: Some(5000), mismatch: false @@ -589,7 +971,7 @@ mod upload_tests { let s = empty(); let f = inflight_update(vec![m(1)], sample()); assert_eq!( - settle(&s, &f, &Ok(env(sample(), 3)), ME, 0), + settle(&s, &f, &Ok(env(sample(), 3)), ME, 0, 0), SettleResult::Ops(vec![DocOp::DeleteRows(vec![m(1)])]) ); } diff --git a/replicant-client/src/engine/fuzz_tests.rs b/replicant-client/src/engine/fuzz_tests.rs index 17daf48..d322a95 100644 --- a/replicant-client/src/engine/fuzz_tests.rs +++ b/replicant-client/src/engine/fuzz_tests.rs @@ -78,6 +78,10 @@ fn next_input(rng: &mut Jitter, outstanding: &mut Vec<(u64, Request)>, socket_ge error: refused, }, Input::Reconnect, + Input::CredentialsChanged { + has_credentials: false, + }, + Input::CredentialsUnchanged, Input::OutboxChanged, Input::UnreadablePush { scope: Some("own".into()), diff --git a/replicant-client/src/engine/hash.rs b/replicant-client/src/engine/hash.rs index 506631e..3a23458 100644 --- a/replicant-client/src/engine/hash.rs +++ b/replicant-client/src/engine/hash.rs @@ -1,8 +1,9 @@ use serde_json::Value; +use sha2::{Digest, Sha256}; /// Canonical content hash shared with the server: compact key-sorted JSON, SHA-256, lowercase hex. pub fn content_hash(content: &Value) -> String { - replicant_core::patches::calculate_checksum(content) + format!("{:x}", Sha256::digest(content.to_string().as_bytes())) } /// Makes every integral float an integer (`1200.0` → `1200`, `-0.0` → `0`). The server's @@ -72,6 +73,14 @@ mod tests { assert_eq!(content_hash(&a), content_hash(&b)); } + #[test] + fn parsed_objects_are_key_sorted_so_preserve_order_must_stay_off() { + let a: Value = serde_json::from_str(r#"{"a":1,"b":2}"#).unwrap(); + let b: Value = serde_json::from_str(r#"{"b":2,"a":1}"#).unwrap(); + assert_eq!(content_hash(&a), content_hash(&b)); + assert_eq!(b.to_string(), r#"{"a":1,"b":2}"#); + } + fn canonical(mut value: Value) -> Value { canonicalise_numbers(&mut value); value diff --git a/replicant-client/src/engine/list_merge.rs b/replicant-client/src/engine/list_merge.rs new file mode 100644 index 0000000..972c353 --- /dev/null +++ b/replicant-client/src/engine/list_merge.rs @@ -0,0 +1,209 @@ +//! How `rebase` merges a list both sides changed, chosen per list path by the host. + +/// Merge policy for a list both sides changed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ListMergePolicy { + /// Any change from both sides collides at the list: the server's list is kept. + Atomic, + /// Element by element while positions line up on both sides (neither side resized the + /// list, or one side only appended and the other kept the length); otherwise as `Atomic`. + Append, + /// Content-aware merge. Not supported yet: `Engine::start` refuses it. + Full, +} + +/// A JSON Pointer whose `*` segments match any one object key or array index, e.g. +/// `/tunings/*/pitches`. Literal segments are written escaped (`~0`, `~1`), as in a pointer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathPattern(pub String); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListMergeConfig { + pub default: ListMergePolicy, + /// For a list, the matching rule with the most literal segments wins; a tie goes to the + /// rule listed first. No match: `default`. + pub rules: Vec<(PathPattern, ListMergePolicy)>, +} + +impl Default for ListMergeConfig { + fn default() -> Self { + ListMergeConfig { + default: ListMergePolicy::Append, + rules: Vec::new(), + } + } +} + +impl ListMergeConfig { + /// Refuses a policy the engine cannot apply and any malformed pattern. + pub fn validate(&self) -> Result<(), String> { + let mut policies = + std::iter::once(self.default).chain(self.rules.iter().map(|(_, policy)| *policy)); + if policies.any(|policy| policy == ListMergePolicy::Full) { + return Err("Full list merge is not supported yet".to_string()); + } + self.rules + .iter() + .try_for_each(|(pattern, _)| pattern.validate()) + } + + /// The policy for the list at `list_path`, a JSON Pointer. + pub fn policy_for(&self, list_path: &str) -> ListMergePolicy { + let mut best: Option<(usize, ListMergePolicy)> = None; + for (pattern, policy) in &self.rules { + let Some(literal) = pattern.literal_segments_matching(list_path) else { + continue; + }; + match best { + Some((most, _)) if most >= literal => {} + _ => best = Some((literal, *policy)), + } + } + best.map_or(self.default, |(_, policy)| policy) + } +} + +impl PathPattern { + fn segments(&self) -> impl Iterator<Item = &str> { + self.0.split('/').skip(1) + } + + fn validate(&self) -> Result<(), String> { + let pattern = &self.0; + if !pattern.is_empty() && !pattern.starts_with('/') { + return Err(format!( + "list merge pattern {pattern:?} must be empty or start with '/'" + )); + } + for segment in self.segments() { + if segment.contains('*') && segment != "*" { + return Err(format!( + "list merge pattern {pattern:?}: '*' must be a whole segment" + )); + } + if !escapes_are_valid(segment) { + return Err(format!( + "list merge pattern {pattern:?}: '~' must be followed by 0 or 1" + )); + } + } + Ok(()) + } + + /// How many literal segments match `path` when the whole pattern matches it. + fn literal_segments_matching(&self, path: &str) -> Option<usize> { + let pattern: Vec<&str> = self.segments().collect(); + let tokens: Vec<&str> = path.split('/').skip(1).collect(); + if pattern.len() != tokens.len() { + return None; + } + let mut literal = 0; + for (wanted, token) in pattern.iter().zip(&tokens) { + if *wanted == "*" { + continue; + } + if wanted != token { + return None; + } + literal += 1; + } + Some(literal) + } +} + +fn escapes_are_valid(segment: &str) -> bool { + let mut chars = segment.chars(); + while let Some(c) = chars.next() { + if c == '~' && !matches!(chars.next(), Some('0' | '1')) { + return false; + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use ListMergePolicy::{Append, Atomic, Full}; + + fn lists(default: ListMergePolicy, rules: &[(&str, ListMergePolicy)]) -> ListMergeConfig { + ListMergeConfig { + default, + rules: rules + .iter() + .map(|(pattern, policy)| (PathPattern(pattern.to_string()), *policy)) + .collect(), + } + } + + #[test] + fn the_default_is_append_with_no_rules() { + let config = ListMergeConfig::default(); + assert_eq!(config, lists(Append, &[])); + assert_eq!(config.policy_for("/pitches"), Append); + } + + #[test] + fn a_more_specific_rule_beats_a_wildcard_one_in_either_order() { + for rules in [ + [ + ("/tunings/*/pitches", Append), + ("/tunings/0/pitches", Atomic), + ], + [ + ("/tunings/0/pitches", Atomic), + ("/tunings/*/pitches", Append), + ], + ] { + let config = lists(Append, &rules); + assert_eq!(config.policy_for("/tunings/0/pitches"), Atomic); + assert_eq!(config.policy_for("/tunings/1/pitches"), Append); + } + } + + #[test] + fn a_tie_goes_to_the_rule_listed_first() { + assert_eq!( + lists(Append, &[("/*/0", Atomic), ("/a/*", Append)]).policy_for("/a/0"), + Atomic + ); + assert_eq!( + lists(Atomic, &[("/a/*", Append), ("/*/0", Atomic)]).policy_for("/a/0"), + Append + ); + } + + #[test] + fn a_wildcard_matches_exactly_one_key_or_index() { + let config = lists(Append, &[("/tunings/*", Atomic)]); + assert_eq!(config.policy_for("/tunings/0"), Atomic); + assert_eq!(config.policy_for("/tunings/names"), Atomic); + assert_eq!(config.policy_for("/tunings"), Append); + assert_eq!(config.policy_for("/tunings/0/pitches"), Append); + } + + #[test] + fn full_is_refused_as_the_default_or_in_a_rule() { + let refused = Err("Full list merge is not supported yet".to_string()); + assert_eq!(lists(Full, &[]).validate(), refused); + assert_eq!(lists(Append, &[("/pitches", Full)]).validate(), refused); + assert_eq!(lists(Atomic, &[("/pitches", Append)]).validate(), Ok(())); + } + + #[test] + fn malformed_patterns_are_refused() { + for bad in ["pitches", "/pit*ches", "/a/~2", "/a~"] { + assert!( + lists(Append, &[(bad, Atomic)]).validate().is_err(), + "{bad:?} should be refused" + ); + } + for good in ["", "/pitches", "/tunings/*/pitches", "/a~1b", "/*"] { + assert_eq!( + lists(Append, &[(good, Atomic)]).validate(), + Ok(()), + "{good:?}" + ); + } + } +} diff --git a/replicant-client/src/engine/machine.rs b/replicant-client/src/engine/machine.rs index 130ada3..656b7de 100644 --- a/replicant-client/src/engine/machine.rs +++ b/replicant-client/src/engine/machine.rs @@ -22,10 +22,28 @@ const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(30); const STABLE_AFTER: Duration = Duration::from_secs(60); const QUIET: Duration = Duration::from_millis(200); const QUIET_CAP: Duration = Duration::from_secs(1); -const HALT_RETRY: Duration = Duration::from_secs(300); +/// A sign-in in another process is noticed this soon (0.6 polled every 3 s). +const CREDENTIALS_RECHECK: Duration = Duration::from_secs(3); const MAX_CATCH_UP_FAILURES: u32 = 3; const UNREADABLE_PUSHES_BEFORE_ERROR: u32 = 3; +/// Halts that another process's sign-in can end: the stored credentials are read again every +/// `CREDENTIALS_RECHECK`, and a sign-out replaces the halt with `NotEnrolled`. +fn is_credential_halt(reason: &HaltReason) -> bool { + match reason { + HaltReason::NotEnrolled | HaltReason::AuthInvalid | HaltReason::AccountDisabled => true, + HaltReason::Other(code) => code == "identity_drift", + HaltReason::UpdateRequired => false, + } +} + +fn schedule_credentials_recheck(fx: &mut Vec<Effect>) { + fx.push(Effect::Schedule { + timer: TimerId::HaltRetry, + after: CREDENTIALS_RECHECK, + }); +} + /// Why the connection is halted and will not retry on its own schedule. #[derive(Debug, Clone, PartialEq)] pub enum HaltReason { @@ -144,6 +162,8 @@ pub enum BuildOutcome { pub enum SettleOutcome { Done { rows_remain: bool, + /// The reply held content other than what was sent. + diverged: bool, }, FetchServerCopy, Retry { @@ -161,6 +181,9 @@ pub enum Input { CredentialsChanged { has_credentials: bool, }, + /// A halted engine's credential check found nothing new to try: the refused credentials are + /// still stored, or the file was unreadable. + CredentialsUnchanged, OutboxChanged, Shutdown, /// `gen` echoes the `OpenSocket` it answers; events from older sockets are ignored. @@ -229,7 +252,7 @@ pub enum Effect { CloseSocket { gen: u64, }, - /// Answered with `Input::CredentialsChanged`. + /// Answered with `Input::CredentialsChanged` or `Input::CredentialsUnchanged`. CheckCredentials, Send { req: u64, @@ -274,6 +297,7 @@ pub enum Effect { inflight: InFlight, reply: Result<DocEnvelope, ServerError>, mismatch_attempts: u32, + divergent_replies: u32, }, /// Answered with `Input::ServerCopyApplied`. ApplyServerCopy { @@ -353,6 +377,7 @@ struct Session { settling: HashSet<Uuid>, doc_failures: HashMap<Uuid, u32>, mismatch_attempts: HashMap<Uuid, u32>, + divergent_replies: HashMap<Uuid, u32>, catch_up_failures: HashMap<Scope, u32>, unreadable_pushes: HashMap<Option<Scope>, u32>, /// Scopes that took an unreadable push while not `Live`; the push may be the one change @@ -366,12 +391,11 @@ struct Session { pump_scheduled: bool, pump_deferred: bool, pending_load_failures: u32, - /// `own`'s snapshot resync hasn't finished applying yet: a build now could be marked sent - /// and found unacknowledged when the snapshot page applies, wrongly forcing the conflict - /// path for a returning user's offline edits. Starts true (cursors aren't loaded yet, so it - /// isn't known whether `own` needs a snapshot at all); cleared once cursors show a nonzero - /// `own` cursor, or on `own`'s `SnapshotFinish`, or when `own` is dropped. Set true again - /// whenever `own` starts (or restarts, e.g. after `cursor_too_old`) a snapshot resync. + /// Uploads wait while this is set: a build now could be marked sent and then found + /// unacknowledged by an `own` snapshot page, forcing the conflict path for a returning + /// user's offline edits. Starts true on join; cleared when `own`'s changes request is + /// answered (the server accepted the cursor), on `own`'s `SnapshotFinish`, or when `own` is + /// dropped. A nonzero cursor alone never clears it: it may be past the trim. own_snapshot_resync_pending: bool, } @@ -385,7 +409,11 @@ impl Session { enum Conn { Idle, Disconnected, - Connecting { join_req: Option<u64> }, + /// `skew_retried`: this dial already re-sent its join after a `clock_skew`. + Connecting { + join_req: Option<u64>, + skew_retried: bool, + }, Connected(Session), Halted(HaltReason), Stopped, @@ -489,21 +517,28 @@ impl Core { if let Conn::Halted(reason) = &self.conn { if has_credentials { self.connect_now(fx); - } else if matches!(reason, HaltReason::NotEnrolled | HaltReason::AuthInvalid) { - fx.push(Effect::Schedule { - timer: TimerId::HaltRetry, - after: HALT_RETRY, - }); + } else if *reason == HaltReason::NotEnrolled { + schedule_credentials_recheck(fx); + } else if is_credential_halt(reason) { + self.halt(HaltReason::NotEnrolled, fx); } + } else if !has_credentials { + self.sign_out(fx); + } + } + Input::CredentialsUnchanged => { + if matches!(&self.conn, Conn::Halted(reason) if is_credential_halt(reason)) { + schedule_credentials_recheck(fx); } } Input::SocketOpened { gen } | Input::SocketClosed { gen } if gen != self.socket_gen => { } Input::SocketOpened { .. } => { - if let Conn::Connecting { join_req: None } = self.conn { + if let Conn::Connecting { join_req: None, .. } = self.conn { let req = self.alloc_req(); self.conn = Conn::Connecting { join_req: Some(req), + skew_retried: false, }; fx.push(Effect::Send { req, @@ -536,8 +571,12 @@ impl Core { } fn connect_now(&mut self, fx: &mut Vec<Effect>) { - self.conn = Conn::Connecting { join_req: None }; + self.conn = Conn::Connecting { + join_req: None, + skew_retried: false, + }; self.socket_gen += 1; + fx.push(Effect::Cancel(TimerId::HaltRetry)); fx.push(Effect::Emit(Lifecycle::ConnectionAttempted)); fx.push(Effect::OpenSocket { gen: self.socket_gen, @@ -615,15 +654,30 @@ impl Core { doc_id: None, fatal: true, })); - if matches!(reason, HaltReason::NotEnrolled | HaltReason::AuthInvalid) { - fx.push(Effect::Schedule { - timer: TimerId::HaltRetry, - after: HALT_RETRY, - }); + if is_credential_halt(&reason) { + schedule_credentials_recheck(fx); } self.conn = Conn::Halted(reason); } + /// The stored credentials are gone: nothing may join with the old ones, so the connection + /// ends and the engine halts until someone signs in (spec §9, local "no credentials"). + fn sign_out(&mut self, fx: &mut Vec<Effect>) { + match self.conn { + Conn::Connected(_) => self.catch_up_fatal(HaltReason::NotEnrolled, fx), + Conn::Connecting { .. } => { + fx.push(Effect::Cancel(TimerId::ConnectTimeout)); + self.close_socket(fx); + self.halt(HaltReason::NotEnrolled, fx); + } + Conn::Disconnected => { + fx.push(Effect::Cancel(TimerId::Reconnect)); + self.halt(HaltReason::NotEnrolled, fx); + } + Conn::Idle | Conn::Halted(_) | Conn::Stopped => {} + } + } + fn halt_reason_for(e: &ServerError) -> Option<HaltReason> { match e.code.as_str() { "update_required" => Some(HaltReason::UpdateRequired), @@ -657,7 +711,11 @@ impl Core { } fn on_reply(&mut self, req: u64, result: Result<Response, ServerError>, fx: &mut Vec<Effect>) { - if let Conn::Connecting { join_req: Some(j) } = self.conn { + if let Conn::Connecting { + join_req: Some(j), + skew_retried, + } = self.conn + { if j == req { match result { Ok(_) => self.on_joined(fx), @@ -667,6 +725,21 @@ impl Core { self.close_socket(fx); self.halt(reason, fx); } + // The driver signs the re-sent join with the server's clock. + None if e.code == "clock_skew" + && e.server_time.is_some() + && !skew_retried => + { + let req = self.alloc_req(); + self.conn = Conn::Connecting { + join_req: Some(req), + skew_retried: true, + }; + fx.push(Effect::Send { + req, + request: Request::Join, + }); + } None => { // The host can tell the user to fix the device clock. if e.code == "clock_skew" { @@ -705,7 +778,9 @@ impl Core { self.close_socket(fx); self.fail_connect(None, fx); } - (Conn::Halted(_), TimerId::HaltRetry) => fx.push(Effect::CheckCredentials), + (Conn::Halted(reason), TimerId::HaltRetry) if is_credential_halt(reason) => { + fx.push(Effect::CheckCredentials) + } (Conn::Connected(_), TimerId::StableReset) => self.attempt = 0, (Conn::Connected(s), TimerId::Heartbeat) => { let req = self.next_req; @@ -745,10 +820,6 @@ impl Core { let cursor = list.iter().find(|(n, _)| n == name).map_or(0, |(_, c)| *c); s.cursors.insert(name.clone(), cursor); s.applied.insert(name.clone(), cursor); - if name == SCOPE_OWN && cursor != 0 { - // No snapshot needed: catch-up resumes from this cursor via change pages. - s.own_snapshot_resync_pending = false; - } } s.phase = Some(Phase::CatchingUp); fx.push(Effect::Emit(Lifecycle::SyncStarted)); @@ -881,7 +952,7 @@ impl Core { }); } - /// A fatal server error ends the Connected period like `lose_connection`, then halts + /// A fatal error ends the Connected period like `lose_connection`, then halts /// instead of scheduling a reconnect. fn catch_up_fatal(&mut self, reason: HaltReason, fx: &mut Vec<Effect>) { fx.push(Effect::Emit(Lifecycle::ConnectionLost)); @@ -932,6 +1003,9 @@ impl Core { has_more, }) => { let Some(s) = self.session() else { return }; + if scope == SCOPE_OWN { + s.own_snapshot_resync_pending = false; + } s.cursors.insert(scope.clone(), next_cursor); s.scopes .insert(scope.clone(), ScopeSync::Applying { req, has_more }); @@ -1007,6 +1081,7 @@ impl Core { }; s.settling.insert(doc_id); let mismatch_attempts = *s.mismatch_attempts.get(&doc_id).unwrap_or(&0); + let divergent_replies = *s.divergent_replies.get(&doc_id).unwrap_or(&0); let reply = match result { Ok(Response::Uploaded(doc)) => Ok(doc), Ok(other) => Err(ServerError::new(&format!("unexpected_response:{other:?}"))), @@ -1017,6 +1092,7 @@ impl Core { inflight, reply, mismatch_attempts, + divergent_replies, }); self.refill(fx); } @@ -1358,6 +1434,7 @@ impl Core { fn forget_failures(&mut self, doc_id: Uuid) { if let Some(s) = self.session() { s.mismatch_attempts.remove(&doc_id); + s.divergent_replies.remove(&doc_id); s.doc_failures.remove(&doc_id); } } @@ -1476,8 +1553,19 @@ impl Core { s.settling.remove(&doc_id); } match outcome { - SettleOutcome::Done { rows_remain } => { + SettleOutcome::Done { + rows_remain, + diverged, + } => { + let divergent_replies = self + .session() + .map_or(0, |s| *s.divergent_replies.get(&doc_id).unwrap_or(&0)); self.forget_failures(doc_id); + if diverged { + if let Some(s) = self.session() { + s.divergent_replies.insert(doc_id, divergent_replies + 1); + } + } if rows_remain { self.try_build(doc_id, fx); } @@ -1757,7 +1845,7 @@ mod connection_tests { ); assert!(fx.contains(&Effect::Schedule { timer: TimerId::HaltRetry, - after: Duration::from_secs(300) + after: Duration::from_secs(3) })); assert!(emitted(&fx) .iter() @@ -1812,18 +1900,54 @@ mod connection_tests { assert!(opens(&fx)); } + fn skew() -> ServerError { + let mut skew = ServerError::new("clock_skew"); + skew.server_time = Some(1_767_225_600); + skew + } + #[test] - fn clock_skew_join_error_backs_off_and_reports_without_halting() { + fn a_clock_skew_join_error_rejoins_once_on_the_same_socket_without_reporting() { let mut c = core(); c.step(Input::Start { has_credentials: true, }); let (req, _) = sends(&c.step(opened(&c))).pop().unwrap(); - let mut skew = ServerError::new("clock_skew"); - skew.server_time = Some(1_767_225_600); let fx = c.step(Input::Reply { req, - result: Err(skew), + result: Err(skew()), + }); + assert_eq!(c.state().connection, ConnectionView::Connecting); + assert!(!closes(&fx), "the socket stays open"); + assert!( + emitted(&fx).is_empty(), + "a clock the server corrected is not an error" + ); + let (rejoin, request) = sends(&fx).pop().expect("the join is sent again"); + assert_eq!(request, Request::Join); + assert_ne!(rejoin, req); + c.step(Input::Reply { + req: rejoin, + result: Ok(Response::Joined), + }); + assert_eq!(c.state().connection, ConnectionView::Connected); + } + + #[test] + fn a_second_clock_skew_on_one_dial_backs_off_and_reports() { + let mut c = core(); + c.step(Input::Start { + has_credentials: true, + }); + let (req, _) = sends(&c.step(opened(&c))).pop().unwrap(); + let fx = c.step(Input::Reply { + req, + result: Err(skew()), + }); + let (rejoin, _) = sends(&fx).pop().unwrap(); + let fx = c.step(Input::Reply { + req: rejoin, + result: Err(skew()), }); assert_eq!(c.state().connection, ConnectionView::Disconnected); assert!(closes(&fx)); @@ -1845,6 +1969,22 @@ mod connection_tests { ); } + #[test] + fn clock_skew_without_server_time_backs_off_and_reports() { + let mut c = core(); + c.step(Input::Start { + has_credentials: true, + }); + let (req, _) = sends(&c.step(opened(&c))).pop().unwrap(); + let fx = c.step(Input::Reply { + req, + result: Err(ServerError::new("clock_skew")), + }); + assert_eq!(c.state().connection, ConnectionView::Disconnected); + assert!(closes(&fx)); + assert_eq!(emitted(&fx).len(), 1); + } + #[test] fn server_retry_after_overrides_backoff() { let mut c = core(); @@ -2150,6 +2290,207 @@ mod connection_tests { after: Duration::from_secs(1), })); } + + #[test] + fn sign_out_while_connected_ends_the_connection_and_halts_not_enrolled() { + let mut c = core(); + c.step(Input::Start { + has_credentials: true, + }); + open_and_join(&mut c); + assert_eq!(c.state().connection, ConnectionView::Connected); + let fx = c.step(Input::CredentialsChanged { + has_credentials: false, + }); + assert_eq!( + c.state().connection, + ConnectionView::Halted(HaltReason::NotEnrolled) + ); + assert!(closes(&fx)); + assert_eq!(emitted(&fx).first(), Some(&Lifecycle::ConnectionLost)); + assert!(fx.contains(&Effect::Schedule { + timer: TimerId::HaltRetry, + after: Duration::from_secs(3) + })); + } + + #[test] + fn sign_out_while_connecting_or_disconnected_halts_without_a_join() { + let mut connecting = core(); + connecting.step(Input::Start { + has_credentials: true, + }); + let fx = connecting.step(Input::CredentialsChanged { + has_credentials: false, + }); + assert!(closes(&fx)); + assert!(fx.contains(&Effect::Cancel(TimerId::ConnectTimeout))); + assert_eq!( + connecting.state().connection, + ConnectionView::Halted(HaltReason::NotEnrolled) + ); + assert!( + sends(&connecting.step(opened(&connecting))).is_empty(), + "a socket that opens late sends no join" + ); + + let mut disconnected = core(); + disconnected.step(Input::Start { + has_credentials: true, + }); + disconnected.step(closed(&disconnected)); + assert_eq!( + disconnected.state().connection, + ConnectionView::Disconnected + ); + let fx = disconnected.step(Input::CredentialsChanged { + has_credentials: false, + }); + assert!(fx.contains(&Effect::Cancel(TimerId::Reconnect))); + assert_eq!( + disconnected.state().connection, + ConnectionView::Halted(HaltReason::NotEnrolled) + ); + } + + #[test] + fn new_credentials_while_connected_change_nothing() { + let mut c = core(); + c.step(Input::Start { + has_credentials: true, + }); + open_and_join(&mut c); + let fx = c.step(Input::CredentialsChanged { + has_credentials: true, + }); + assert!(fx.is_empty(), "they sign the next join (contract b)"); + assert_eq!(c.state().connection, ConnectionView::Connected); + } + + #[test] + fn an_identity_drift_halt_is_checked_again_every_three_seconds() { + let mut c = core(); + c.step(Input::Start { + has_credentials: true, + }); + let (req, _) = sends(&c.step(opened(&c))).pop().expect("join sent"); + let mut drift = ServerError::new("identity_drift"); + drift.is_fatal = true; + let fx = c.step(Input::Reply { + req, + result: Err(drift), + }); + assert_eq!( + c.state().connection, + ConnectionView::Halted(HaltReason::Other("identity_drift".into())) + ); + assert!(fx.contains(&Effect::Schedule { + timer: TimerId::HaltRetry, + after: CREDENTIALS_RECHECK + })); + assert!(c + .step(Input::Timer(TimerId::HaltRetry)) + .contains(&Effect::CheckCredentials)); + assert!( + c.step(Input::CredentialsUnchanged) + .contains(&Effect::Schedule { + timer: TimerId::HaltRetry, + after: CREDENTIALS_RECHECK + }), + "a check that finds the drifted credentials schedules the next one" + ); + assert_eq!( + c.state().connection, + ConnectionView::Halted(HaltReason::Other("identity_drift".into())) + ); + } + + #[test] + fn a_sign_out_from_any_credential_halt_reports_not_enrolled_and_rechecks() { + for code in ["auth_invalid", "identity_drift", "account_disabled"] { + let mut c = core(); + c.step(Input::Start { + has_credentials: true, + }); + let (req, _) = sends(&c.step(opened(&c))).pop().expect("join sent"); + let mut refused = ServerError::new(code); + refused.is_fatal = true; + c.step(Input::Reply { + req, + result: Err(refused), + }); + assert!(matches!(c.state().connection, ConnectionView::Halted(_))); + let fx = c.step(Input::CredentialsChanged { + has_credentials: false, + }); + assert_eq!( + c.state().connection, + ConnectionView::Halted(HaltReason::NotEnrolled), + "{code}" + ); + assert!( + fx.contains(&Effect::Schedule { + timer: TimerId::HaltRetry, + after: CREDENTIALS_RECHECK + }), + "{code}" + ); + } + } + + #[test] + fn a_sign_out_while_halted_update_required_keeps_that_reason() { + let mut c = core(); + c.step(Input::Start { + has_credentials: true, + }); + let (req, _) = sends(&c.step(opened(&c))).pop().expect("join sent"); + let mut refused = ServerError::new("update_required"); + refused.is_fatal = true; + c.step(Input::Reply { + req, + result: Err(refused), + }); + c.step(Input::CredentialsChanged { + has_credentials: false, + }); + assert_eq!( + c.state().connection, + ConnectionView::Halted(HaltReason::UpdateRequired) + ); + } + + #[test] + fn an_account_disabled_halt_is_checked_again_every_three_seconds() { + let mut c = core(); + c.step(Input::Start { + has_credentials: true, + }); + let (req, _) = sends(&c.step(opened(&c))).pop().expect("join sent"); + let mut refused = ServerError::new("account_disabled"); + refused.is_fatal = true; + let fx = c.step(Input::Reply { + req, + result: Err(refused), + }); + assert!(fx.contains(&Effect::Schedule { + timer: TimerId::HaltRetry, + after: CREDENTIALS_RECHECK + })); + assert!(c + .step(Input::Timer(TimerId::HaltRetry)) + .contains(&Effect::CheckCredentials)); + assert!(c + .step(Input::CredentialsUnchanged) + .contains(&Effect::Schedule { + timer: TimerId::HaltRetry, + after: CREDENTIALS_RECHECK + })); + assert_eq!( + c.state().connection, + ConnectionView::Halted(HaltReason::AccountDisabled) + ); + } } #[cfg(test)] @@ -2981,8 +3322,8 @@ mod upload_orchestration_tests { Uuid::from_u128(0x1000 + n) } - /// Joined and past the snapshot-resync window: `own` has a nonzero cursor, so uploads are - /// never held for it. + /// Joined and past the resync hold: `own`'s changes request from a nonzero cursor has been + /// answered, so uploads are never held for it. fn connected(c: &mut Core) { c.step(Input::Start { has_credentials: true, @@ -2992,10 +3333,22 @@ mod upload_orchestration_tests { req, result: Ok(Response::Joined), }); - c.step(Input::Cursors(vec![ + let fx = c.step(Input::Cursors(vec![ ("own".into(), 1000), ("collection:curated".into(), 1), ])); + let (own_req, _) = sends(&fx) + .into_iter() + .find(|(_, r)| matches!(r, Request::GetChangesSince { scope, .. } if scope == "own")) + .expect("own catches up from its cursor"); + c.step(Input::Reply { + req: own_req, + result: Ok(Response::Changes { + changes: vec![], + next_cursor: 1000, + has_more: false, + }), + }); } fn prepared(d: Uuid) -> BuildOutcome { @@ -3216,7 +3569,10 @@ mod upload_orchestration_tests { connected(&mut c); let fx = c.step(Input::Settled { doc_id: doc(1), - outcome: SettleOutcome::Done { rows_remain: true }, + outcome: SettleOutcome::Done { + rows_remain: true, + diverged: false, + }, }); assert!(fx.contains(&Effect::BuildUpload { doc_id: doc(1) })); } @@ -3322,12 +3678,16 @@ mod upload_orchestration_tests { e, Effect::SettleUpload { mismatch_attempts: 2, + divergent_replies: 0, .. } ))); c.step(Input::Settled { doc_id: doc(1), - outcome: SettleOutcome::Done { rows_remain: false }, + outcome: SettleOutcome::Done { + rows_remain: false, + diverged: false, + }, }); c.step(Input::PendingDocs(vec![doc(1)])); let req = upload_req(&c.step(Input::UploadBuilt { @@ -3342,6 +3702,7 @@ mod upload_orchestration_tests { e, Effect::SettleUpload { mismatch_attempts: 0, + divergent_replies: 0, .. } ))); @@ -3417,7 +3778,10 @@ mod upload_orchestration_tests { assert!(!builds(&c.step(Input::PendingDocs(vec![doc(1)])))); c.step(Input::Settled { doc_id: doc(1), - outcome: SettleOutcome::Done { rows_remain: false }, + outcome: SettleOutcome::Done { + rows_remain: false, + diverged: false, + }, }); assert!(builds(&c.step(Input::PendingDocs(vec![doc(1)])))); } @@ -3440,7 +3804,10 @@ mod upload_orchestration_tests { assert!(!builds(&c.step(Input::PendingDocs(vec![doc(9)])))); let fx = c.step(Input::Settled { doc_id: doc(0), - outcome: SettleOutcome::Done { rows_remain: false }, + outcome: SettleOutcome::Done { + rows_remain: false, + diverged: false, + }, }); assert!(fx.contains(&Effect::LoadPending)); } @@ -3565,6 +3932,7 @@ mod upload_orchestration_tests { e, Effect::SettleUpload { mismatch_attempts: 0, + divergent_replies: 0, .. } ))); @@ -3629,6 +3997,104 @@ mod upload_orchestration_tests { ))); } + #[test] + fn a_nonzero_own_cursor_holds_builds_until_its_changes_reply() { + let mut c = core(); + c.step(Input::Start { + has_credentials: true, + }); + let (join_req, _) = sends(&c.step(opened(&c))).pop().unwrap(); + c.step(Input::Reply { + req: join_req, + result: Ok(Response::Joined), + }); + let fx = c.step(Input::Cursors(vec![ + ("own".into(), 5), + ("collection:curated".into(), 5), + ])); + let (own_req, _) = sends(&fx) + .into_iter() + .find(|(_, r)| matches!(r, Request::GetChangesSince { scope, .. } if scope == "own")) + .expect("own catches up from its cursor"); + assert!( + !c.step(Input::PendingDocs(vec![doc(1)])) + .contains(&Effect::BuildUpload { doc_id: doc(1) }), + "the cursor may be past the trim: wait for the server's answer" + ); + c.step(Input::Reply { + req: own_req, + result: Ok(Response::Changes { + changes: vec![], + next_cursor: 5, + has_more: false, + }), + }); + assert!(c + .step(Input::PendingDocs(vec![doc(1)])) + .contains(&Effect::BuildUpload { doc_id: doc(1) })); + } + + #[test] + fn a_trimmed_own_cursor_keeps_builds_held_through_the_snapshot() { + let mut c = core(); + c.step(Input::Start { + has_credentials: true, + }); + let (join_req, _) = sends(&c.step(opened(&c))).pop().unwrap(); + c.step(Input::Reply { + req: join_req, + result: Ok(Response::Joined), + }); + let fx = c.step(Input::Cursors(vec![ + ("own".into(), 5), + ("collection:curated".into(), 5), + ])); + let (own_req, _) = sends(&fx) + .into_iter() + .find(|(_, r)| matches!(r, Request::GetChangesSince { scope, .. } if scope == "own")) + .expect("own catches up from its cursor"); + assert!(!c + .step(Input::PendingDocs(vec![doc(1)])) + .contains(&Effect::BuildUpload { doc_id: doc(1) })); + let fx = c.step(Input::Reply { + req: own_req, + result: Err(ServerError::new("cursor_too_old")), + }); + let (snapshot_req, _) = sends(&fx) + .into_iter() + .find(|(_, r)| matches!(r, Request::GetSnapshot { scope, .. } if scope == "own")) + .expect("own resyncs by snapshot"); + assert!(!c + .step(Input::PendingDocs(vec![doc(1)])) + .contains(&Effect::BuildUpload { doc_id: doc(1) })); + c.step(Input::Reply { + req: snapshot_req, + result: Ok(Response::SnapshotPage { + docs: vec![], + snapshot_seq: 9, + next_page_token: None, + }), + }); + let fx = c.step(Input::Applied { + scope: "own".into(), + tag: ApplyTag::SnapshotPage(snapshot_req), + }); + let finish = fx + .iter() + .find_map(|e| match e { + Effect::FinishSnapshot { tag, .. } => Some(tag.clone()), + _ => None, + }) + .expect("finish snapshot"); + c.step(Input::Applied { + scope: "own".into(), + tag: finish, + }); + assert!(c + .step(Input::PendingDocs(vec![doc(1)])) + .contains(&Effect::BuildUpload { doc_id: doc(1) })); + } + #[test] fn builds_wait_while_own_is_resynced_by_snapshot() { let mut c = core(); @@ -4152,4 +4618,41 @@ mod server_copy_tests { "the pushed seq commits applied[own] to 6, releasing the doc without waiting for the round to finish" ); } + + #[test] + fn a_credential_recheck_left_over_from_an_earlier_halt_never_dials_after_update_required() { + let mut c = core(); + c.step(Input::Start { + has_credentials: true, + }); + let (req, _) = sends(&c.step(opened(&c))).pop().unwrap(); + c.step(Input::Reply { + req, + result: Err(ServerError { + is_fatal: true, + ..ServerError::new("auth_invalid") + }), + }); + // Signed in again in this process: the dial drops the pending recheck. + let fx = c.step(Input::CredentialsChanged { + has_credentials: true, + }); + assert!(opens(&fx)); + assert!(fx.contains(&Effect::Cancel(TimerId::HaltRetry))); + let (req, _) = sends(&c.step(opened(&c))).pop().unwrap(); + c.step(Input::Reply { + req, + result: Err(ServerError { + is_fatal: true, + ..ServerError::new("update_required") + }), + }); + let fx = c.step(Input::Timer(TimerId::HaltRetry)); + assert!(!fx.contains(&Effect::CheckCredentials)); + assert!(!opens(&fx)); + assert_eq!( + c.state().connection, + ConnectionView::Halted(HaltReason::UpdateRequired) + ); + } } diff --git a/replicant-client/src/engine/mod.rs b/replicant-client/src/engine/mod.rs index e8524f3..b3b8f1f 100644 --- a/replicant-client/src/engine/mod.rs +++ b/replicant-client/src/engine/mod.rs @@ -6,5 +6,8 @@ pub(crate) mod doc_upload; #[cfg(test)] mod fuzz_tests; pub mod hash; +pub mod list_merge; pub mod machine; +#[cfg(test)] +mod patch_fixture_tests; pub mod types; diff --git a/replicant-client/src/engine/patch_fixture_tests.rs b/replicant-client/src/engine/patch_fixture_tests.rs new file mode 100644 index 0000000..bd63bf6 --- /dev/null +++ b/replicant-client/src/engine/patch_fixture_tests.rs @@ -0,0 +1,238 @@ +//! Shared with replicant-server (`test/fixtures/json_patch_fixture.json`): every patch the +//! client sends is `json_patch::diff` output, and the server's `jsonpatch` must turn each `doc` +//! into the same `result`. Recorded with `RECORD_PATCH_FIXTURE=1`. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; + +use super::backoff::Jitter; + +const RECORD: &str = "RECORD_PATCH_FIXTURE"; + +#[derive(Debug, Serialize, Deserialize)] +struct Case { + name: String, + doc: Value, + patch: Value, + result: Value, +} + +fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/server_v2/json_patch_fixture.json") +} + +/// (name, doc, result): the patch is whatever `json_patch::diff` makes of the pair. The client +/// sends canonical content, so only canonical numbers appear in the cases below (and every +/// document is an object, so `diff` never emits a root replace). +fn inputs() -> Vec<(String, Value, Value)> { + let scale = |pitches: Value| json!({"name": "12-TET", "pitches": pitches}); + let base = scale(json!([0, 200, 400, 500, 700, 900])); + let mut cases = vec![ + ("replace a scalar", json!({"a": 1}), json!({"a": 2})), + ("add a key", json!({"a": 1}), json!({"a": 1, "b": [1, 2]})), + ("remove a key", json!({"a": 1, "b": 2}), json!({"a": 1})), + ( + "nested object", + json!({"a": {"b": {"c": 1}}}), + json!({"a": {"b": {"c": 2, "d": null}}}), + ), + ("type change", json!({"a": [1]}), json!({"a": {"k": true}})), + ( + "retune one degree", + base.clone(), + scale(json!([0, 200, 386, 500, 700, 900])), + ), + ( + "append", + base.clone(), + scale(json!([0, 200, 400, 500, 700, 900, 1000])), + ), + ( + "insert in the middle", + base.clone(), + scale(json!([0, 200, 400, 450, 500, 700, 900])), + ), + ( + "remove from the middle", + base.clone(), + scale(json!([0, 200, 500, 700, 900])), + ), + ( + "remove several from the end", + base.clone(), + scale(json!([0, 200])), + ), + ("empty the list", base.clone(), scale(json!([]))), + ( + "list of objects", + json!({"tunings": [{"n": "A", "p": [1, 2]}, {"n": "B", "p": [3]}]}), + json!({"tunings": [{"n": "A", "p": [1, 2, 5]}, {"n": "B2", "p": []}]}), + ), + ( + "keys needing escapes", + json!({"a/b": 1, "m~n": 2}), + json!({"a/b": 3, "m~n": 4, "x/~y": 5}), + ), + ("empty key", json!({"": 1}), json!({"": 2})), + ( + "unicode", + json!({"name": "Bohlen–Pierce"}), + json!({"name": "Bohlen–Pierce ✓", "ñ": "é"}), + ), + ( + "numbers", + json!({"cents": 1.5, "big": 9_007_199_254_740_993_i64, "neg": -3}), + json!({"cents": 386.3137, "big": 9_007_199_254_740_994_i64, "neg": -0.5}), + ), + ( + "exponent-form numbers", + json!({"a": 1e-5, "b": 1e20, "c": -1e19}), + json!({"a": 2e-5, "b": -1e20, "c": 1e19, "d": 1.5e-7}), + ), + ( + "keys that look like indices", + json!({"0": 1, "01": 2, "-": 3, "1abc": 4}), + json!({"0": 5, "01": 6, "-": 7, "1abc": 8, "2": 9}), + ), + ( + "index-like keys nested in a list", + json!({"l": [{"0": [1, 2], "-": 1}]}), + json!({"l": [{"0": [1, 3, 2], "-": null}]}), + ), + ] + .into_iter() + .map(|(name, doc, result)| (name.to_string(), doc, result)) + .collect::<Vec<_>>(); + let mut rng = Jitter::new(20_260_929); + for n in 0..40 { + let doc = random_object(&mut rng, 3); + let result = edited(&doc, &mut rng); + cases.push((format!("random {n}"), doc, result)); + } + cases +} + +fn index_below(rng: &mut Jitter, len: usize) -> usize { + ((rng.next_unit() * len as f64) as usize).min(len - 1) +} + +fn random_scalar(rng: &mut Jitter) -> Value { + match (rng.next_unit() * 5.0) as u32 { + 0 => Value::Null, + 1 => json!(rng.next_unit() < 0.5), + 2 => json!((rng.next_unit() * 2000.0) as i64 - 1000), + 3 => json!((rng.next_unit() * 1000.0).floor() + 0.5), + _ => json!(format!("s{}", (rng.next_unit() * 100.0) as u32)), + } +} + +fn random_value(rng: &mut Jitter, depth: u32) -> Value { + if depth == 0 { + return random_scalar(rng); + } + match (rng.next_unit() * 3.0) as u32 { + 0 => random_scalar(rng), + 1 => { + let len = (rng.next_unit() * 5.0) as usize; + Value::Array((0..len).map(|_| random_value(rng, depth - 1)).collect()) + } + _ => random_object(rng, depth - 1), + } +} + +fn random_object(rng: &mut Jitter, depth: u32) -> Value { + let mut fields = Map::new(); + for _ in 0..1 + (rng.next_unit() * 4.0) as usize { + let key = format!("k{}", (rng.next_unit() * 6.0) as u32); + fields.insert(key, random_value(rng, depth)); + } + Value::Object(fields) +} + +/// Edits `value` the way a user might: keys replaced, added or removed; list elements +/// inserted, removed or appended; scalars changed. +fn edited(value: &Value, rng: &mut Jitter) -> Value { + match value { + Value::Object(fields) => { + let mut out = Map::new(); + for (key, field) in fields { + match (rng.next_unit() * 6.0) as u32 { + 0 => {} + 1 => { + out.insert(key.clone(), random_value(rng, 2)); + } + _ => { + out.insert(key.clone(), edited(field, rng)); + } + } + } + if rng.next_unit() < 0.3 { + let key = format!("new{}", (rng.next_unit() * 6.0) as u32); + out.insert(key, random_value(rng, 2)); + } + Value::Object(out) + } + Value::Array(items) => { + let mut out: Vec<Value> = items.iter().map(|item| edited(item, rng)).collect(); + match (rng.next_unit() * 5.0) as u32 { + 0 if !out.is_empty() => { + let at = index_below(rng, out.len()); + out.remove(at); + } + 1 => { + let at = index_below(rng, out.len() + 1); + out.insert(at, random_value(rng, 1)); + } + 2 => out.push(random_value(rng, 1)), + _ => {} + } + Value::Array(out) + } + _ if rng.next_unit() < 0.3 => random_scalar(rng), + scalar => scalar.clone(), + } +} + +#[test] +fn every_patch_fixture_case_applies_and_is_what_diff_produces() { + if std::env::var_os(RECORD).is_some() { + let cases: Vec<Case> = inputs() + .into_iter() + .map(|(name, doc, result)| Case { + patch: serde_json::to_value(json_patch::diff(&doc, &result)).unwrap(), + name, + doc, + result, + }) + .collect(); + let text = serde_json::to_string_pretty(&cases).unwrap() + "\n"; + std::fs::write(fixture_path(), text).unwrap(); + } + let text = std::fs::read_to_string(fixture_path()).expect("the patch fixture exists"); + let cases: Vec<Case> = serde_json::from_str(&text).unwrap(); + let inputs = inputs(); + assert_eq!( + cases.len(), + inputs.len(), + "the fixture lists every input; re-record with {RECORD}=1" + ); + for (case, (name, doc, result)) in cases.iter().zip(inputs) { + assert_eq!( + (&case.name, &case.doc, &case.result), + (&name, &doc, &result), + "{name}: re-record with {RECORD}=1" + ); + assert_eq!( + serde_json::to_value(json_patch::diff(&case.doc, &case.result)).unwrap(), + case.patch, + "{name}: the client would send another patch" + ); + let patch: json_patch::Patch = serde_json::from_value(case.patch.clone()).unwrap(); + let mut applied = case.doc.clone(); + json_patch::patch(&mut applied, &patch).unwrap_or_else(|error| panic!("{name}: {error}")); + assert_eq!(applied, case.result, "{name}"); + } +} diff --git a/replicant-client/src/enrollment.rs b/replicant-client/src/enrollment.rs index 098e034..296b6ed 100644 --- a/replicant-client/src/enrollment.rs +++ b/replicant-client/src/enrollment.rs @@ -2,6 +2,9 @@ use crate::secret_store::Credentials; use std::time::Duration; +/// Longest api key or secret, in bytes, accepted from the server. +const MAX_CRED_LEN: usize = 128; + #[derive(Debug, thiserror::Error)] pub enum EnrollError { #[error("invalid or expired token")] @@ -14,7 +17,6 @@ pub enum EnrollError { InsecureUrl, } -const MAX_CRED_LEN: usize = 128; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); diff --git a/replicant-client/src/error_code.rs b/replicant-client/src/error_code.rs index 1d041b0..fdc82ae 100644 --- a/replicant-client/src/error_code.rs +++ b/replicant-client/src/error_code.rs @@ -1,107 +1,74 @@ -//! Stable, structured error codes carried alongside `SyncError` events. -//! -//! Consumers must be able to react to a sync error by the *action* it calls for -//! (clear the credential? retry? refuse to sync?) without substring-matching a -//! free-form message. Every `SyncError` event carries a [`ReplicantErrorCode`] -//! whose numeric value is stable and exported to C. +//! Stable codes for `SyncError` events; the event's text is the protocol code itself. -/// Structured error code carried by every `SyncError` event. -/// -/// The numeric values are STABLE and exported to C via cbindgen. They are -/// banded by the action a consumer should take: -/// -/// - `0` — unknown / uncategorized. -/// - `1xxx` — **credential rejected**: the stored credential is bad. The -/// consumer should clear it and re-enroll. See [`is_credential_rejection`]. -/// - `2xxx` — **transient**: retry later; NEVER clear credentials. This band -/// includes the timestamp reasons (`2101`, `2102`), which are client/server -/// clock skew — not a bad credential — and so must never trigger a clear. -/// - `3xxx` — **protocol**: the exchange was malformed or violated the contract. -/// - `4xxx` — **identity drift**: the local identity diverged from the account; -/// refuse to sync, but do NOT clear credentials. -/// - `5xxx` — **unresolved divergence**: a local edit could not be reconciled -/// with the server's copy. Retrying cannot help; surface it to the user. +/// cbindgen:prefix-with-name #[repr(i32)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReplicantErrorCode { - /// Unknown or uncategorized error. Unknown = 0, - - // 1xxx — credential rejected (clear the stored credential and re-enroll) - /// The API key is not recognized by the server. - InvalidApiKey = 1001, - /// The HMAC signature did not verify. - InvalidSignature = 1002, - /// The credential authenticated but is not bound to an enrolled user. - CredentialNotEnrolled = 1003, - - // 2xxx — transient (retry; never clear credentials) - /// The socket/transport failed to connect. - ConnectionFailed = 2001, - /// A join or call timed out. - Timeout = 2002, - /// The signed timestamp was outside the server's acceptance window - /// (client/server clock skew, not a bad credential). - TimestampExpired = 2101, - /// The timestamp field was malformed or unparseable (treated as clock skew). - InvalidTimestamp = 2102, - - // 3xxx — protocol - /// A required join parameter was missing. - MissingParams = 3001, - /// The join topic's user id did not match the credential's user. - TopicUserMismatch = 3002, - /// Generic malformed or unexpected server reply. + /// The server refused the credentials; the engine is halted until they change. + AuthInvalid = 1001, + /// No stored credentials; the engine is halted until someone signs in. + NotEnrolled = 1003, + /// The server needs a newer client; halted. + UpdateRequired = 2003, + /// This computer's clock is off; not fatal, the engine keeps retrying. + ClockSkew = 2101, ProtocolError = 3003, - - // 4xxx — identity drift (refuse to sync; do NOT clear credentials) - /// The server-reported user id diverged from the local identity. + /// A subscribed scope was refused and dropped. + SubscriptionForbidden = 3004, + /// The data dir belongs to another account; halted. IdentityDrift = 4001, + AccountDisabled = 4002, + /// The server refused this document's changes; its next local edit retries them. If the + /// refused change was a delete, the server's version is back instead, and edits made before + /// the delete are in Kept copies (`recovered_id`). Also for `Forbidden` and `TooLarge`. + Validation = 5002, + Forbidden = 5003, + TooLarge = 5004, + /// The document became a read-only publication; unsent edits are in Kept copies. + BecamePublication = 5005, + /// Its id belongs to another account; the content is in Kept copies. + CreateRejected = 5006, + /// The server kept storing something other than what was uploaded; the document stops + /// uploading until its next local edit. Local content is kept. + Diverged = 5007, + /// Reserved: a delete the server refused (the document is back). Not emitted before 0.8; + /// until then a refused delete arrives as `Validation`, `Forbidden` or `TooLarge`. + DeleteRefused = 5008, + /// The local database failed while checking a join; not fatal, the engine retries. + LocalDatabase = 6001, +} - // 5xxx — unresolved divergence (surface to the user; do NOT retry) - /// A local edit could not be rebased onto the server's current content. - /// The server's copy is now local truth and the edit was discarded. - UpdateConflict = 5001, +/// The stable code for a protocol or local error code. +pub fn error_code_for(code: &str) -> ReplicantErrorCode { + match code { + "auth_invalid" => ReplicantErrorCode::AuthInvalid, + "not_enrolled" => ReplicantErrorCode::NotEnrolled, + "update_required" => ReplicantErrorCode::UpdateRequired, + "clock_skew" => ReplicantErrorCode::ClockSkew, + "protocol_error" => ReplicantErrorCode::ProtocolError, + "subscription_forbidden" => ReplicantErrorCode::SubscriptionForbidden, + "identity_drift" => ReplicantErrorCode::IdentityDrift, + "account_disabled" => ReplicantErrorCode::AccountDisabled, + "validation" => ReplicantErrorCode::Validation, + "forbidden" => ReplicantErrorCode::Forbidden, + "too_large" => ReplicantErrorCode::TooLarge, + "became_publication" => ReplicantErrorCode::BecamePublication, + "create_rejected" => ReplicantErrorCode::CreateRejected, + "diverged" => ReplicantErrorCode::Diverged, + "delete_refused" => ReplicantErrorCode::DeleteRefused, + "store_error" => ReplicantErrorCode::LocalDatabase, + _ => ReplicantErrorCode::Unknown, + } } -/// True iff `code` is in the credential-rejection band (`1xxx`). -/// -/// A `true` result means the consumer should clear the stored credential and -/// re-enroll. This is the single source of truth for the band check so bindings -/// do not re-derive the range. +/// Whether `code` means the credentials were refused or are missing. pub fn is_credential_rejection(code: ReplicantErrorCode) -> bool { (1000..2000).contains(&(code as i32)) } -/// Map a server join-rejection reason string to its [`ReplicantErrorCode`]. -/// -/// The reasons are the atoms the phoenix server sends in `{:error, %{reason: -/// "<atom>"}}` (see `replicant_server` `Sync.Channel`/`Auth`). A reason that is -/// present but not recognized is treated as a protocol contract mismatch -/// ([`ReplicantErrorCode::ProtocolError`]); the *absent*-reason case is handled -/// by the caller ([`crate::websocket::error_code_for_join_reject`]). -pub fn error_code_for_reason(reason: &str) -> ReplicantErrorCode { - match reason { - "invalid_api_key" => ReplicantErrorCode::InvalidApiKey, - "invalid_signature" => ReplicantErrorCode::InvalidSignature, - "credential_not_enrolled" => ReplicantErrorCode::CredentialNotEnrolled, - "timestamp_expired" => ReplicantErrorCode::TimestampExpired, - "invalid_timestamp" => ReplicantErrorCode::InvalidTimestamp, - "missing_params" => ReplicantErrorCode::MissingParams, - "topic_user_mismatch" => ReplicantErrorCode::TopicUserMismatch, - "invalid_topic" => ReplicantErrorCode::ProtocolError, - _ => ReplicantErrorCode::ProtocolError, - } -} - -/// Band check exposed over FFI: `true` iff `code` is a credential rejection. -/// -/// Bindings should treat a `true` result as "clear the stored credential and -/// re-enroll". Implemented once here so consumers do not re-implement the band -/// logic against the raw numeric values. -/// -/// # Safety -/// This function is pure and takes the code by value; it is always safe to call. +/// Whether `code` (a `ReplicantErrorCode`) means the credentials were refused or are missing. +/// Never clear the stored credentials because of this: a sign-out is always explicit. #[no_mangle] pub extern "C" fn replicant_error_is_credential_rejection(code: i32) -> bool { (1000..2000).contains(&code) @@ -112,91 +79,43 @@ mod tests { use super::*; #[test] - fn every_server_reason_maps_to_its_code() { - assert_eq!( - error_code_for_reason("invalid_api_key"), - ReplicantErrorCode::InvalidApiKey - ); - assert_eq!( - error_code_for_reason("invalid_signature"), - ReplicantErrorCode::InvalidSignature - ); - assert_eq!( - error_code_for_reason("credential_not_enrolled"), - ReplicantErrorCode::CredentialNotEnrolled - ); - assert_eq!( - error_code_for_reason("timestamp_expired"), - ReplicantErrorCode::TimestampExpired - ); - assert_eq!( - error_code_for_reason("invalid_timestamp"), - ReplicantErrorCode::InvalidTimestamp - ); - assert_eq!( - error_code_for_reason("missing_params"), - ReplicantErrorCode::MissingParams - ); - assert_eq!( - error_code_for_reason("topic_user_mismatch"), - ReplicantErrorCode::TopicUserMismatch - ); - assert_eq!( - error_code_for_reason("invalid_topic"), - ReplicantErrorCode::ProtocolError - ); + fn error_codes_map_every_v2_code() { + for (code, expected) in [ + ("auth_invalid", 1001), + ("not_enrolled", 1003), + ("update_required", 2003), + ("clock_skew", 2101), + ("protocol_error", 3003), + ("subscription_forbidden", 3004), + ("identity_drift", 4001), + ("account_disabled", 4002), + ("validation", 5002), + ("forbidden", 5003), + ("too_large", 5004), + ("became_publication", 5005), + ("create_rejected", 5006), + ("diverged", 5007), + ("delete_refused", 5008), + ("store_error", 6001), + ] { + assert_eq!(error_code_for(code) as i32, expected, "{code}"); + } } #[test] - fn unrecognized_reason_is_protocol_error() { - assert_eq!( - error_code_for_reason("something_new"), - ReplicantErrorCode::ProtocolError - ); + fn unknown_codes_are_unknown() { + assert_eq!(error_code_for("something_new"), ReplicantErrorCode::Unknown); } #[test] - fn credential_rejection_band_is_1xxx_only() { - // 1xxx band: true - assert!(is_credential_rejection(ReplicantErrorCode::InvalidApiKey)); - assert!(is_credential_rejection( - ReplicantErrorCode::InvalidSignature - )); - assert!(is_credential_rejection( - ReplicantErrorCode::CredentialNotEnrolled - )); - - // every other band: false - assert!(!is_credential_rejection(ReplicantErrorCode::Unknown)); + fn only_refused_or_missing_credentials_are_credential_rejections() { + assert!(is_credential_rejection(ReplicantErrorCode::AuthInvalid)); + assert!(is_credential_rejection(ReplicantErrorCode::NotEnrolled)); assert!(!is_credential_rejection( - ReplicantErrorCode::ConnectionFailed - )); - assert!(!is_credential_rejection(ReplicantErrorCode::Timeout)); - assert!(!is_credential_rejection( - ReplicantErrorCode::TimestampExpired - )); - assert!(!is_credential_rejection( - ReplicantErrorCode::InvalidTimestamp - )); - assert!(!is_credential_rejection(ReplicantErrorCode::MissingParams)); - assert!(!is_credential_rejection( - ReplicantErrorCode::TopicUserMismatch - )); - assert!(!is_credential_rejection(ReplicantErrorCode::ProtocolError)); - assert!(!is_credential_rejection(ReplicantErrorCode::IdentityDrift)); - assert!(!is_credential_rejection(ReplicantErrorCode::UpdateConflict)); - } - - #[test] - fn ffi_band_check_matches_rust_helper() { - assert!(replicant_error_is_credential_rejection( - ReplicantErrorCode::CredentialNotEnrolled as i32 - )); - assert!(!replicant_error_is_credential_rejection( - ReplicantErrorCode::Timeout as i32 - )); - assert!(!replicant_error_is_credential_rejection( - ReplicantErrorCode::IdentityDrift as i32 + ReplicantErrorCode::AccountDisabled )); + assert!(!is_credential_rejection(ReplicantErrorCode::ClockSkew)); + assert!(replicant_error_is_credential_rejection(1001)); + assert!(!replicant_error_is_credential_rejection(4001)); } } diff --git a/replicant-client/src/events.rs b/replicant-client/src/events.rs index f00e8d4..4650438 100644 --- a/replicant-client/src/events.rs +++ b/replicant-client/src/events.rs @@ -1,1775 +1,660 @@ -//! Event callback system for the sync client -//! -//! This module provides a thread-safe event system that allows applications to receive -//! real-time notifications about document changes, sync operations, and connection status. -//! -//! # Key Features -//! -//! - **Thread-Safe Design**: Events can be generated from any thread but callbacks are -//! only invoked on the thread that registered them -//! - **Type-Specific Callbacks**: Separate callback types for documents, sync, errors, -//! connections, and conflicts -//! - **Event Filtering**: Subscribe to specific event types or all events within a category -//! - **Context Passing**: Pass application context data to callbacks -//! - **Offline/Online Events**: Receive events for both local and synchronized operations -//! -//! # Callback Types -//! -//! - `DocumentEventCallback`: DocumentCreated, DocumentUpdated, DocumentDeleted -//! - `SyncEventCallback`: SyncStarted, SyncCompleted -//! - `ErrorEventCallback`: SyncError -//! - `ConnectionEventCallback`: ConnectionLost, ConnectionAttempted, ConnectionSucceeded -//! - `ConflictEventCallback`: ConflictDetected -//! -//! # Thread Safety -//! -//! The event system uses a single-thread callback model: -//! 1. Events can be generated from any thread -//! 2. Events are queued for processing -//! 3. Callbacks are only invoked when `process_events()` is called -//! 4. All callbacks execute on the thread that registered them -//! -//! This design eliminates the need for complex synchronization in user code. +//! Host callbacks. The first registration fixes the thread that must run them; events wait in +//! the handle's queue until `replicant_process_events` is called on that thread. -use crate::error_code::ReplicantErrorCode; -use replicant_core::{errors::ClientError, SyncResult}; use std::ffi::{c_char, c_void, CString}; -use std::sync::{mpsc, Mutex}; +use std::ptr; +use std::sync::Mutex; use std::thread::{self, ThreadId}; -use uuid::Uuid; -/// Event types that can be emitted by the sync client -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq)] +use crate::error_code::error_code_for; +use crate::host::{lock, HostEvent, Origin}; + +/// cbindgen:prefix-with-name +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EventType { - /// A new document was created. - /// - /// Fires for THIS client's own `create_document` call as well as for a - /// document that arrived from the server. Check the event's origin before - /// treating it as a change made elsewhere. - DocumentCreated = 0, - /// An existing document was updated. - /// - /// Fires for THIS client's own `update_document` call as well as for a - /// patch applied from the server. Check the event's origin before treating - /// it as a change made elsewhere. - DocumentUpdated = 1, - /// A document was deleted. - /// - /// Fires for THIS client's own `delete_document` call as well as for a - /// deletion applied from the server. Check the event's origin before - /// treating it as a change made elsewhere. + /// A document was created or changed; `EventOrigin` says by whom. + DocumentChanged = 1, DocumentDeleted = 2, - /// Synchronization process started SyncStarted = 3, - /// Synchronization completed successfully + /// Every subscribed scope caught up, once per connection. Not a promise that uploads are done. SyncCompleted = 4, - /// An error occurred during synchronization SyncError = 5, - /// A conflict was detected between document versions + /// Local content was set aside in Kept copies. ConflictDetected = 6, - /// Connection to server was lost ConnectionLost = 7, - /// A connection attempt was made to the server ConnectionAttempted = 8, - /// Successfully connected to the server ConnectionSucceeded = 9, - /// The server-authoritative user id was adopted, replacing the local one - IdentityChanged = 10, + /// Changes were trimmed before this engine read them, or this handle fell more than 4096 + /// events behind: reload every list. + DatabaseChanged = 11, + /// The data dir adopted the signed-in account's user id and restamped its documents: + /// re-read `replicant_get_user_id` and reload lists. On the sync callback. + IdentityAdopted = 12, } -/// Where a document event came from. -/// -/// Document events are emitted for this client's own writes as well as for -/// changes applied from the server, and the rest of the payload cannot tell the -/// two apart: `user_id` is the document owner, not the writer, and delivery is -/// asynchronous. Consumers that only care about changes made elsewhere should -/// ignore `Local` events. -#[repr(C)] +/// cbindgen:prefix-with-name +#[repr(i32)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EventOrigin { - /// This client wrote the document itself. + /// A handle of this engine wrote it (this process, this copy of the library): another + /// handle's write on the same engine is `Local` too. Local = 0, - /// The change was applied from the server: a broadcast from another client - /// or instance, or a sync pass. + /// Sync wrote it: a download, a settle, a conflict revert or a sweep, in any process. Remote = 1, + /// Another process, or another copy of the library in this process, on the same data dir. + OtherProcess = 2, } -// ============================================================================= -// Rust-Native Event Types -// ============================================================================= - -/// Rust-native event enum with typed variants -/// -/// This provides an idiomatic Rust interface for event handling, with each variant -/// containing only the relevant data for that event type. -/// -/// # Example -/// -/// ```rust,no_run -/// use replicant_client::events::{EventDispatcher, SyncEvent}; -/// -/// let dispatcher = EventDispatcher::new(); -/// -/// dispatcher.register_rust_callback(|event| { -/// match event { -/// SyncEvent::DocumentCreated { id, title, .. } => { -/// println!("Created: {} - {}", id, title); -/// } -/// SyncEvent::SyncCompleted { document_count } => { -/// println!("Synced {} documents", document_count); -/// } -/// _ => {} -/// } -/// }).unwrap(); -/// ``` -#[derive(Debug, Clone)] -pub enum SyncEvent { - /// A new document was created, locally or from sync (see `origin`) - DocumentCreated { - id: String, - title: String, - content: serde_json::Value, - user_id: Option<String>, - author_name: Option<String>, - visibility: Option<String>, - origin: EventOrigin, - }, - /// An existing document was updated, locally or from sync (see `origin`) - DocumentUpdated { - id: String, - title: String, - content: serde_json::Value, - user_id: Option<String>, - author_name: Option<String>, - visibility: Option<String>, - origin: EventOrigin, - }, - /// A document was deleted, locally or from sync (see `origin`) - DocumentDeleted { id: String, origin: EventOrigin }, - /// Synchronization started - SyncStarted, - /// Synchronization completed - SyncCompleted { document_count: u64 }, - /// A sync error occurred - SyncError { - code: ReplicantErrorCode, - message: String, - }, - /// A conflict was detected - ConflictDetected { - document_id: String, - winning_content: Option<String>, - losing_content: Option<String>, - }, - /// Connection to server was lost - ConnectionLost { server_url: String }, - /// A connection attempt was made - ConnectionAttempted { server_url: String }, - /// Successfully connected to server - ConnectionSucceeded { server_url: String }, - /// The server-authoritative user id was adopted, replacing the local one - IdentityChanged { - old_user_id: String, - new_user_id: String, - email: String, - }, -} - -impl SyncEvent { - /// Get the event type for this event - pub fn event_type(&self) -> EventType { - match self { - SyncEvent::DocumentCreated { .. } => EventType::DocumentCreated, - SyncEvent::DocumentUpdated { .. } => EventType::DocumentUpdated, - SyncEvent::DocumentDeleted { .. } => EventType::DocumentDeleted, - SyncEvent::SyncStarted => EventType::SyncStarted, - SyncEvent::SyncCompleted { .. } => EventType::SyncCompleted, - SyncEvent::SyncError { .. } => EventType::SyncError, - SyncEvent::ConflictDetected { .. } => EventType::ConflictDetected, - SyncEvent::ConnectionLost { .. } => EventType::ConnectionLost, - SyncEvent::ConnectionAttempted { .. } => EventType::ConnectionAttempted, - SyncEvent::ConnectionSucceeded { .. } => EventType::ConnectionSucceeded, - SyncEvent::IdentityChanged { .. } => EventType::IdentityChanged, - } - } - - /// Convert from internal QueuedEvent to SyncEvent - fn from_queued(event: &QueuedEvent) -> Self { - match event.event_type { - EventType::DocumentCreated => SyncEvent::DocumentCreated { - id: event.document_id.clone().unwrap_or_default(), - title: event - .title - .clone() - .unwrap_or_else(|| "Untitled".to_string()), - content: event - .content - .as_ref() - .and_then(|c| serde_json::from_str(c).ok()) - .unwrap_or(serde_json::Value::Null), - user_id: event.user_id.clone(), - author_name: event.author_name.clone(), - visibility: event.visibility.clone(), - origin: event.origin, - }, - EventType::DocumentUpdated => SyncEvent::DocumentUpdated { - id: event.document_id.clone().unwrap_or_default(), - title: event - .title - .clone() - .unwrap_or_else(|| "Untitled".to_string()), - content: event - .content - .as_ref() - .and_then(|c| serde_json::from_str(c).ok()) - .unwrap_or(serde_json::Value::Null), - user_id: event.user_id.clone(), - author_name: event.author_name.clone(), - visibility: event.visibility.clone(), - origin: event.origin, - }, - EventType::DocumentDeleted => SyncEvent::DocumentDeleted { - id: event.document_id.clone().unwrap_or_default(), - origin: event.origin, - }, - EventType::SyncStarted => SyncEvent::SyncStarted, - EventType::SyncCompleted => SyncEvent::SyncCompleted { - document_count: event.numeric_data, - }, - EventType::SyncError => SyncEvent::SyncError { - code: event.error_code, - message: event - .error - .clone() - .unwrap_or_else(|| "Unknown error".to_string()), - }, - EventType::ConflictDetected => SyncEvent::ConflictDetected { - document_id: event.document_id.clone().unwrap_or_default(), - winning_content: event.content.clone(), - losing_content: event.error.clone(), - }, - EventType::ConnectionLost => SyncEvent::ConnectionLost { - server_url: event.title.clone().unwrap_or_default(), - }, - EventType::ConnectionAttempted => SyncEvent::ConnectionAttempted { - server_url: event.title.clone().unwrap_or_default(), - }, - EventType::ConnectionSucceeded => SyncEvent::ConnectionSucceeded { - server_url: event.title.clone().unwrap_or_default(), - }, - EventType::IdentityChanged => SyncEvent::IdentityChanged { - old_user_id: event.document_id.clone().unwrap_or_default(), - new_user_id: event.user_id.clone().unwrap_or_default(), - email: event.title.clone().unwrap_or_default(), - }, +impl From<Origin> for EventOrigin { + fn from(origin: Origin) -> Self { + match origin { + Origin::Local => EventOrigin::Local, + Origin::Server => EventOrigin::Remote, + Origin::OtherProcess => EventOrigin::OtherProcess, } } } -// ============================================================================= -// Type-Specific Callback Types (C FFI) -// ============================================================================= +/// `DocumentChanged` / `DocumentDeleted`. For a deletion only `document_id` is set; for a change +/// `title`, `owner_id` and `author_id` may be null. `title` comes from +/// `ReplicantConfig.title_pointer`: always null without one. +/// `visibility` is `public` (curated or read-only) or `private`. `content` is the document's +/// content, the host's own JSON. Every id is a lowercase hyphenated UUID. +/// Strings are valid only during the call; copy what you keep. +pub type ReplicantDocumentEventCallback = Option< + extern "C" fn( + event_type: EventType, + document_id: *const c_char, + title: *const c_char, + content: *const c_char, + owner_id: *const c_char, + author_id: *const c_char, + visibility: *const c_char, + read_only: bool, + origin: EventOrigin, + context: *mut c_void, + ), +>; -/// Document event callback for DocumentCreated, DocumentUpdated, DocumentDeleted -/// -/// Fires for this client's OWN writes as well as for changes applied from the -/// server. Check `origin` before treating an event as a change made elsewhere. -/// -/// # Parameters -/// * `event_type` - The specific document event type -/// * `document_id` - UUID of the document (always non-null) -/// * `title` - Document title (null for Deleted events) -/// * `content` - Full document JSON (null for Deleted events) -/// * `user_id` - Owner UUID (null if unknown) -/// * `author_name` - Author display name (null if unknown) -/// * `visibility` - "private"/"public" (null if unknown) -/// * `origin` - `Local` if this client wrote the document, `Remote` if the -/// change was applied from the server -/// * `context` - User-defined context pointer -pub type DocumentEventCallback = extern "C" fn( +/// `ReplicantDocumentEventCallback` once registered (never null). +type DocumentFn = extern "C" fn( event_type: EventType, document_id: *const c_char, title: *const c_char, content: *const c_char, - user_id: *const c_char, - author_name: *const c_char, + owner_id: *const c_char, + author_id: *const c_char, visibility: *const c_char, + read_only: bool, origin: EventOrigin, context: *mut c_void, ); -/// Sync event callback for SyncStarted, SyncCompleted -/// -/// # Parameters -/// * `event_type` - SyncStarted or SyncCompleted -/// * `document_count` - Number of documents synced (0 for SyncStarted) -/// * `context` - User-defined context pointer -pub type SyncEventCallback = - extern "C" fn(event_type: EventType, document_count: u64, context: *mut c_void); +/// `SyncStarted`, `SyncCompleted`, `DatabaseChanged`, `IdentityAdopted`. +/// Strings are valid only during the call; copy what you keep. +pub type ReplicantSyncEventCallback = + Option<extern "C" fn(event_type: EventType, context: *mut c_void)>; + +/// `ReplicantSyncEventCallback` once registered (never null). +type SyncFn = extern "C" fn(event_type: EventType, context: *mut c_void); + +/// `error_code` is a `ReplicantErrorCode`; `error` is the protocol code, e.g. "clock_skew"; +/// `document_id` is null unless the error is about one document; `scope` is null unless it is +/// about one subscribed scope (e.g. `subscription_forbidden`); `fatal` means halted; +/// `recovered_id` names local content kept aside with the error (a Kept copies id, reasons as +/// in `ReplicantConflictEventCallback`), -1 if none. +/// Per engine: only the handles of the engine that applied the rule get this event; another +/// process, or another copy of the library, sees just `DocumentChanged`. +/// `replicant_list_recovered` is the durable record. `recovered_id` may already be dismissed or +/// restored by another process: `restore_*` then returns `ErrorNotFound`. +/// Strings are valid only during the call; copy what you keep. +pub type ReplicantErrorEventCallback = Option< + extern "C" fn( + event_type: EventType, + error_code: i32, + error: *const c_char, + document_id: *const c_char, + scope: *const c_char, + fatal: bool, + recovered_id: i64, + context: *mut c_void, + ), +>; -/// Error event callback for SyncError -/// -/// # Parameters -/// * `event_type` - Always SyncError -/// * `error_code` - Stable `ReplicantErrorCode` value; use -/// `replicant_error_is_credential_rejection` to decide whether to clear the -/// stored credential -/// * `error` - Error message (always non-null) -/// * `context` - User-defined context pointer -pub type ErrorEventCallback = extern "C" fn( +/// `ReplicantErrorEventCallback` once registered (never null). +type ErrorFn = extern "C" fn( event_type: EventType, error_code: i32, error: *const c_char, + document_id: *const c_char, + scope: *const c_char, + fatal: bool, + recovered_id: i64, context: *mut c_void, ); -/// Connection event callback for ConnectionLost, ConnectionAttempted, ConnectionSucceeded -/// -/// # Parameters -/// * `event_type` - The connection event type -/// * `connected` - true if connected (valid for Lost/Succeeded), false otherwise -/// * `attempt_number` - Reconnection attempt number (valid for ConnectionAttempted) -/// * `context` - User-defined context pointer -pub type ConnectionEventCallback = extern "C" fn( +/// `attempt_number` counts dials since the last successful connection. +/// Strings are valid only during the call; copy what you keep. +pub type ReplicantConnectionEventCallback = Option< + extern "C" fn( + event_type: EventType, + connected: bool, + attempt_number: u32, + context: *mut c_void, + ), +>; + +/// `ReplicantConnectionEventCallback` once registered (never null). +type ConnectionFn = extern "C" fn( event_type: EventType, connected: bool, attempt_number: u32, context: *mut c_void, ); -/// Conflict event callback for ConflictDetected -/// -/// # Parameters -/// * `event_type` - Always ConflictDetected -/// * `document_id` - UUID of the conflicted document (always non-null) -/// * `winning_content` - Content of the winning version (always non-null) -/// * `losing_content` - Content of the losing version (may be null) -/// * `context` - User-defined context pointer -pub type ConflictEventCallback = extern "C" fn( - event_type: EventType, - document_id: *const c_char, - winning_content: *const c_char, - losing_content: *const c_char, - context: *mut c_void, -); +/// `reason`: `conflict`, `field_conflict`, `delete_wins` or `delete_superseded` (a delete undone +/// because a newer version arrived). `recovered_id` is the Kept copies id, or -1 when nothing +/// was kept. +/// A kept copy's reason is one of `conflict`, `field_conflict`, `delete_wins`, +/// `delete_superseded`, `delete_refused`, `delete_publication`, `became_publication`, +/// `create_rejected` or `unmigratable` (set aside while upgrading the database, with no event). +/// `paths_json` is a JSON array of strings, each a JSON Pointer, for `field_conflict`; else null. +/// Per engine: only the handles of the engine that applied the rule get this event; another +/// process, or another copy of the library, sees just `DocumentChanged`. +/// `replicant_list_recovered` is the durable record. `recovered_id` may already be dismissed or +/// restored by another process: `restore_*` then returns `ErrorNotFound`. +/// Strings are valid only during the call; copy what you keep. +pub type ReplicantConflictEventCallback = Option< + extern "C" fn( + event_type: EventType, + document_id: *const c_char, + reason: *const c_char, + recovered_id: i64, + paths_json: *const c_char, + context: *mut c_void, + ), +>; -/// Identity event callback for IdentityChanged -/// -/// # Parameters -/// * `event_type` - Always IdentityChanged -/// * `old_user_id` - The provisional/previous user id (always non-null) -/// * `new_user_id` - The adopted canonical user id (always non-null) -/// * `email` - The email the server resolved the id from (may be empty) -/// * `context` - User-defined context pointer -pub type IdentityEventCallback = extern "C" fn( +/// `ReplicantConflictEventCallback` once registered (never null). +type ConflictFn = extern "C" fn( event_type: EventType, - old_user_id: *const c_char, - new_user_id: *const c_char, - email: *const c_char, + document_id: *const c_char, + reason: *const c_char, + recovered_id: i64, + paths_json: *const c_char, context: *mut c_void, ); -// ============================================================================= -// Callback Entry Types (Internal) -// ============================================================================= - -struct DocumentCallbackEntry { - callback: DocumentEventCallback, - context: *mut c_void, - event_filter: Option<EventType>, -} - -struct SyncCallbackEntry { - callback: SyncEventCallback, - context: *mut c_void, -} - -struct ErrorCallbackEntry { - callback: ErrorEventCallback, - context: *mut c_void, -} - -struct ConnectionCallbackEntry { - callback: ConnectionEventCallback, - context: *mut c_void, -} - -struct ConflictCallbackEntry { - callback: ConflictEventCallback, - context: *mut c_void, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DispatchError { + NoCallbacks, + WrongThread, } -struct IdentityCallbackEntry { - callback: IdentityEventCallback, +#[derive(Clone, Copy)] +struct Registered<F> { + callback: F, context: *mut c_void, } -// Safety: Callback entries are only accessed from the registered thread -unsafe impl Send for DocumentCallbackEntry {} -unsafe impl Sync for DocumentCallbackEntry {} -unsafe impl Send for SyncCallbackEntry {} -unsafe impl Sync for SyncCallbackEntry {} -unsafe impl Send for ErrorCallbackEntry {} -unsafe impl Sync for ErrorCallbackEntry {} -unsafe impl Send for ConnectionCallbackEntry {} -unsafe impl Sync for ConnectionCallbackEntry {} -unsafe impl Send for ConflictCallbackEntry {} -unsafe impl Sync for ConflictCallbackEntry {} -unsafe impl Send for IdentityCallbackEntry {} -unsafe impl Sync for IdentityCallbackEntry {} - -// ============================================================================= -// Rust Callback Entry (Internal) -// ============================================================================= - -struct RustCallbackEntry { - callback: Box<dyn Fn(SyncEvent) + Send>, - event_filter: Option<EventType>, -} - -#[derive(Debug, Clone)] -pub struct QueuedEvent { - event_type: EventType, - document_id: Option<String>, - title: Option<String>, - content: Option<String>, - error: Option<String>, - error_code: ReplicantErrorCode, - numeric_data: u64, - boolean_data: bool, - user_id: Option<String>, - author_name: Option<String>, - visibility: Option<String>, - origin: EventOrigin, -} - -/// Thread-safe event dispatcher for managing callbacks and event processing -/// -/// The EventDispatcher uses a single-thread callback model where events can be -/// generated from any thread but callbacks are only invoked on the thread that -/// registered them. This eliminates the need for complex synchronization in user code. -/// -/// # Example -/// -/// ```rust,no_run -/// use replicant_client::events::{EventDispatcher, EventOrigin, EventType}; -/// use std::ffi::c_void; -/// -/// // Define callback function -/// extern "C" fn my_document_callback( -/// event_type: EventType, -/// document_id: *const std::ffi::c_char, -/// title: *const std::ffi::c_char, -/// content: *const std::ffi::c_char, -/// user_id: *const std::ffi::c_char, -/// author_name: *const std::ffi::c_char, -/// visibility: *const std::ffi::c_char, -/// origin: EventOrigin, -/// _context: *mut c_void -/// ) { -/// println!("Document event: {:?}", event_type); -/// } -/// -/// # fn main() -> Result<(), Box<dyn std::error::Error>> { -/// let dispatcher = EventDispatcher::new(); -/// -/// // Register callback (sets callback thread to current thread) -/// dispatcher.register_document_callback(my_document_callback, std::ptr::null_mut(), None)?; -/// -/// // In main loop -/// loop { -/// let processed = dispatcher.process_events()?; -/// // ... do other work -/// # break; // Exit loop for doctest -/// } -/// # Ok(()) -/// # } -/// ``` -pub struct EventDispatcher { - // Type-specific C FFI callback storage - document_callbacks: Mutex<Vec<DocumentCallbackEntry>>, - sync_callbacks: Mutex<Vec<SyncCallbackEntry>>, - error_callbacks: Mutex<Vec<ErrorCallbackEntry>>, - connection_callbacks: Mutex<Vec<ConnectionCallbackEntry>>, - conflict_callbacks: Mutex<Vec<ConflictCallbackEntry>>, - identity_callbacks: Mutex<Vec<IdentityCallbackEntry>>, - // Rust-native callback storage - rust_callbacks: Mutex<Vec<RustCallbackEntry>>, - // Event queue - event_queue: Mutex<mpsc::Receiver<QueuedEvent>>, - event_sender: mpsc::Sender<QueuedEvent>, - callback_thread_id: Mutex<Option<ThreadId>>, +#[derive(Clone, Default)] +struct Callbacks { + thread: Option<ThreadId>, + document: Option<(Registered<DocumentFn>, Option<EventType>)>, + sync: Option<Registered<SyncFn>>, + error: Option<Registered<ErrorFn>>, + connection: Option<Registered<ConnectionFn>>, + conflict: Option<Registered<ConflictFn>>, } -impl EventDispatcher { - pub fn new() -> Self { - let (sender, receiver) = mpsc::channel(); - Self { - document_callbacks: Mutex::new(Vec::new()), - sync_callbacks: Mutex::new(Vec::new()), - error_callbacks: Mutex::new(Vec::new()), - connection_callbacks: Mutex::new(Vec::new()), - conflict_callbacks: Mutex::new(Vec::new()), - identity_callbacks: Mutex::new(Vec::new()), - rust_callbacks: Mutex::new(Vec::new()), - event_queue: Mutex::new(receiver), - event_sender: sender, - callback_thread_id: Mutex::new(None), - } - } +// The contexts are only ever passed back to callbacks on the registering thread. +unsafe impl Send for Callbacks {} - /// Helper to set callback thread ID on first registration - fn ensure_callback_thread(&self) -> SyncResult<()> { - let mut thread_id = self - .callback_thread_id - .lock() - .map_err(|_| ClientError::LockError("thread ID".into()))?; - if thread_id.is_none() { - *thread_id = Some(thread::current().id()); - tracing::info!( - "Event callbacks will be processed on thread: {:?}", - thread::current().id() - ); - } - Ok(()) - } +#[derive(Default)] +pub struct Dispatcher(Mutex<Callbacks>); - /// Register a callback for document events (Created, Updated, Deleted) - /// - /// # Parameters - /// * `callback` - Function to call for document events - /// * `context` - User-defined context pointer passed to callback - /// * `event_filter` - Optional filter: DocumentCreated, DocumentUpdated, DocumentDeleted, or None for all - pub fn register_document_callback( +/// Each `register_*` replaces the kind's previous callback and context; `None` removes it. The +/// first registration binds the calling thread; later ones from another thread are refused, so a +/// change never races a pump and applies from the next event on. +impl Dispatcher { + /// `filter`: one of `DocumentChanged` / `DocumentDeleted`, or every document event. + pub fn register_document( &self, - callback: DocumentEventCallback, + callback: Option<DocumentFn>, context: *mut c_void, - event_filter: Option<EventType>, - ) -> SyncResult<()> { - self.ensure_callback_thread()?; - - let mut callbacks = self - .document_callbacks - .lock() - .map_err(|_| ClientError::LockError("document_callbacks".into()))?; - - callbacks.push(DocumentCallbackEntry { - callback, - context, - event_filter, - }); - + filter: Option<EventType>, + ) -> Result<(), DispatchError> { + self.bind()?.document = callback.map(|callback| (Registered { callback, context }, filter)); Ok(()) } - /// Register a callback for sync events (Started, Completed) - /// - /// # Parameters - /// * `callback` - Function to call for sync events - /// * `context` - User-defined context pointer passed to callback - pub fn register_sync_callback( + pub fn register_sync( &self, - callback: SyncEventCallback, + callback: Option<SyncFn>, context: *mut c_void, - ) -> SyncResult<()> { - self.ensure_callback_thread()?; - - let mut callbacks = self - .sync_callbacks - .lock() - .map_err(|_| ClientError::LockError("sync_callbacks".into()))?; - - callbacks.push(SyncCallbackEntry { callback, context }); - + ) -> Result<(), DispatchError> { + self.bind()?.sync = callback.map(|callback| Registered { callback, context }); Ok(()) } - /// Register a callback for error events (SyncError) - /// - /// # Parameters - /// * `callback` - Function to call for error events - /// * `context` - User-defined context pointer passed to callback - pub fn register_error_callback( + pub fn register_error( &self, - callback: ErrorEventCallback, + callback: Option<ErrorFn>, context: *mut c_void, - ) -> SyncResult<()> { - self.ensure_callback_thread()?; - - let mut callbacks = self - .error_callbacks - .lock() - .map_err(|_| ClientError::LockError("error_callbacks".into()))?; - - callbacks.push(ErrorCallbackEntry { callback, context }); - + ) -> Result<(), DispatchError> { + self.bind()?.error = callback.map(|callback| Registered { callback, context }); Ok(()) } - /// Register a callback for connection events (Lost, Attempted, Succeeded) - /// - /// # Parameters - /// * `callback` - Function to call for connection events - /// * `context` - User-defined context pointer passed to callback - pub fn register_connection_callback( + pub fn register_connection( &self, - callback: ConnectionEventCallback, + callback: Option<ConnectionFn>, context: *mut c_void, - ) -> SyncResult<()> { - self.ensure_callback_thread()?; - - let mut callbacks = self - .connection_callbacks - .lock() - .map_err(|_| ClientError::LockError("connection_callbacks".into()))?; - - callbacks.push(ConnectionCallbackEntry { callback, context }); - + ) -> Result<(), DispatchError> { + self.bind()?.connection = callback.map(|callback| Registered { callback, context }); Ok(()) } - /// Register a callback for conflict events (ConflictDetected) - /// - /// # Parameters - /// * `callback` - Function to call for conflict events - /// * `context` - User-defined context pointer passed to callback - pub fn register_conflict_callback( + pub fn register_conflict( &self, - callback: ConflictEventCallback, + callback: Option<ConflictFn>, context: *mut c_void, - ) -> SyncResult<()> { - self.ensure_callback_thread()?; - - let mut callbacks = self - .conflict_callbacks - .lock() - .map_err(|_| ClientError::LockError("conflict_callbacks".into()))?; - - callbacks.push(ConflictCallbackEntry { callback, context }); - - Ok(()) - } - - pub fn register_identity_callback( - &self, - callback: IdentityEventCallback, - context: *mut c_void, - ) -> SyncResult<()> { - self.ensure_callback_thread()?; - - let mut callbacks = self - .identity_callbacks - .lock() - .map_err(|_| ClientError::LockError("identity_callbacks".into()))?; - - callbacks.push(IdentityCallbackEntry { callback, context }); - - Ok(()) - } - - /// Register a Rust-native callback for all events - /// - /// This provides an idiomatic Rust interface using the `SyncEvent` enum. - /// The callback receives typed event variants with only relevant data. - /// - /// # Parameters - /// * `callback` - Closure to call for events - /// - /// # Example - /// - /// ```rust,no_run - /// use replicant_client::events::{EventDispatcher, SyncEvent}; - /// - /// let dispatcher = EventDispatcher::new(); - /// - /// dispatcher.register_rust_callback(|event| { - /// match event { - /// SyncEvent::DocumentCreated { id, title, .. } => { - /// println!("Document created: {} - {}", id, title); - /// } - /// SyncEvent::SyncCompleted { document_count } => { - /// println!("Synced {} documents", document_count); - /// } - /// SyncEvent::ConnectionSucceeded { server_url } => { - /// println!("Connected to {}", server_url); - /// } - /// _ => {} - /// } - /// }).unwrap(); - /// ``` - pub fn register_rust_callback<F>(&self, callback: F) -> SyncResult<()> - where - F: Fn(SyncEvent) + Send + 'static, - { - self.ensure_callback_thread()?; - - let mut callbacks = self - .rust_callbacks - .lock() - .map_err(|_| ClientError::LockError("rust_callbacks".into()))?; - - callbacks.push(RustCallbackEntry { - callback: Box::new(callback), - event_filter: None, - }); - - Ok(()) - } - - /// Register a Rust-native callback with event type filtering - /// - /// # Parameters - /// * `callback` - Closure to call for events - /// * `event_filter` - Only receive events of this type - pub fn register_rust_callback_filtered<F>( - &self, - callback: F, - event_filter: EventType, - ) -> SyncResult<()> - where - F: Fn(SyncEvent) + Send + 'static, - { - self.ensure_callback_thread()?; - - let mut callbacks = self - .rust_callbacks - .lock() - .map_err(|_| ClientError::LockError("rust_callbacks".into()))?; - - callbacks.push(RustCallbackEntry { - callback: Box::new(callback), - event_filter: Some(event_filter), - }); - + ) -> Result<(), DispatchError> { + self.bind()?.conflict = callback.map(|callback| Registered { callback, context }); Ok(()) } - /// Emit a document-created event for a write this client made itself. - pub fn emit_document_created(&self, document_id: &Uuid, content: &serde_json::Value) { - self.emit_document_created_with_attribution( - document_id, - content, - None, - None, - None, - EventOrigin::Local, - ); - } - - /// Emit a document-created event, carrying attribution (owner user_id, author_name, - /// visibility) alongside the document content. - /// - /// `origin` says whether this client wrote the document or the change was - /// applied from the server; consumers use it to ignore their own echoes. - pub fn emit_document_created_with_attribution( - &self, - document_id: &Uuid, - content: &serde_json::Value, - user_id: Option<&Uuid>, - author_name: Option<&str>, - visibility: Option<&str>, - origin: EventOrigin, - ) { - // Extract title from content if present - let title = content - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("Untitled"); - self.queue_event( - EventType::DocumentCreated, - Some(document_id), - Some(title), - Some(content), - None, - 0, - false, - user_id.map(|id| id.to_string()), - author_name.map(|s| s.to_string()), - visibility.map(|s| s.to_string()), - origin, - ); - } - - /// Emit a document-updated event for a write this client made itself. - pub fn emit_document_updated(&self, document_id: &Uuid, content: &serde_json::Value) { - self.emit_document_updated_with_attribution( - document_id, - content, - None, - None, - None, - EventOrigin::Local, - ); - } - - /// Emit a document-updated event, carrying attribution (owner user_id, author_name, - /// visibility) alongside the document content. - /// - /// `origin` says whether this client wrote the document or the change was - /// applied from the server; consumers use it to ignore their own echoes. - pub fn emit_document_updated_with_attribution( - &self, - document_id: &Uuid, - content: &serde_json::Value, - user_id: Option<&Uuid>, - author_name: Option<&str>, - visibility: Option<&str>, - origin: EventOrigin, - ) { - // Extract title from content if present - let title = content - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("Untitled"); - self.queue_event( - EventType::DocumentUpdated, - Some(document_id), - Some(title), - Some(content), - None, - 0, - false, - user_id.map(|id| id.to_string()), - author_name.map(|s| s.to_string()), - visibility.map(|s| s.to_string()), - origin, - ); - } - - /// Emit a document-deleted event. `origin` says whether this client deleted - /// the document or the deletion was applied from the server. - pub fn emit_document_deleted(&self, document_id: &Uuid, origin: EventOrigin) { - self.queue_event( - EventType::DocumentDeleted, - Some(document_id), - None, - None, - None, - 0, - false, - None, - None, - None, - origin, - ); - } - - pub fn emit_sync_started(&self) { - self.queue_event( - EventType::SyncStarted, - None, - None, - None, - None, - 0, - false, - None, - None, - None, - EventOrigin::Local, - ); - } - - pub fn emit_sync_completed(&self, synced_count: u64) { - self.queue_event( - EventType::SyncCompleted, - None, - None, - None, - None, - synced_count, - false, - None, - None, - None, - EventOrigin::Local, - ); - } - - pub fn emit_sync_error(&self, code: ReplicantErrorCode, error_message: &str) { - // SyncError is the only event that carries a structured code, so it - // bypasses the shared `queue_event` (whose other callers have no code) - // and builds the queued event directly. - let queued_event = QueuedEvent { - event_type: EventType::SyncError, - document_id: None, - title: None, - content: None, - error: Some(error_message.to_string()), - error_code: code, - numeric_data: 0, - boolean_data: false, - user_id: None, - author_name: None, - visibility: None, - origin: EventOrigin::Local, - }; - - if self.event_sender.send(queued_event).is_err() { - tracing::error!("Failed to queue event - receiver may have been dropped"); + fn bind(&self) -> Result<std::sync::MutexGuard<'_, Callbacks>, DispatchError> { + let mut callbacks = lock(&self.0); + let current = thread::current().id(); + if *callbacks.thread.get_or_insert(current) != current { + return Err(DispatchError::WrongThread); } + Ok(callbacks) } - pub fn emit_conflict_detected(&self, document_id: &Uuid) { - self.queue_event( - EventType::ConflictDetected, - Some(document_id), - None, - None, - None, - 0, - false, - None, - None, - None, - EventOrigin::Local, - ); - } - - pub fn emit_connection_lost(&self, server_url: &str) { - self.queue_event( - EventType::ConnectionLost, - None, - Some(server_url), - None, - None, - 0, - false, - None, - None, - None, - EventOrigin::Local, - ); - } - - pub fn emit_connection_attempted(&self, server_url: &str) { - self.queue_event( - EventType::ConnectionAttempted, - None, - Some(server_url), - None, - None, - 0, - false, - None, - None, - None, - EventOrigin::Local, - ); - } - - pub fn emit_connection_succeeded(&self, server_url: &str) { - self.queue_event( - EventType::ConnectionSucceeded, - None, - Some(server_url), - None, - None, - 0, - false, - None, - None, - None, - EventOrigin::Local, - ); - } - - pub fn emit_identity_changed(&self, old_user_id: &Uuid, new_user_id: &Uuid, email: &str) { - // document_id carries the old id, title the email, user_id the new id. - self.queue_event( - EventType::IdentityChanged, - Some(old_user_id), - Some(email), - None, - None, - 0, - false, - Some(new_user_id.to_string()), - None, - None, - EventOrigin::Local, - ); - } - - /// Queue an event for later processing on the callback thread - #[allow(clippy::too_many_arguments)] // FFI callback constraints - fn queue_event( + /// Runs the callbacks for the events `take` hands over, stopping early once `stop()` is + /// true; returns the number delivered. Refused, and nothing taken, before any registration + /// or off the registering thread. Each event reads the callbacks afresh, so a registration + /// made by a callback applies to the rest of the batch. + pub fn process( &self, - event_type: EventType, - document_id: Option<&Uuid>, - title: Option<&str>, - content: Option<&serde_json::Value>, - error: Option<&str>, - numeric_data: u64, - boolean_data: bool, - user_id: Option<String>, - author_name: Option<String>, - visibility: Option<String>, - origin: EventOrigin, - ) { - let queued_event = QueuedEvent { - event_type, - document_id: document_id.map(|id| id.to_string()), - title: title.map(|t| t.to_string()), - content: content.map(|c| serde_json::to_string(c).unwrap_or_else(|_| "{}".to_string())), - error: error.map(|e| e.to_string()), - error_code: ReplicantErrorCode::Unknown, - numeric_data, - boolean_data, - user_id, - author_name, - visibility, - origin, - }; - - if self.event_sender.send(queued_event).is_err() { - tracing::error!("Failed to queue event - receiver may have been dropped"); + take: impl FnOnce() -> Vec<HostEvent>, + stop: impl Fn() -> bool, + ) -> Result<usize, DispatchError> { + self.may_process()?; + let mut delivered = 0; + for event in take() { + if stop() { + break; + } + let callbacks = lock(&self.0).clone(); + callbacks.dispatch(&event); + delivered += 1; } + Ok(delivered) } - /// Check if any callbacks are registered - fn has_callbacks(&self) -> SyncResult<bool> { - let doc = self - .document_callbacks - .lock() - .map_err(|_| ClientError::LockError("document_callbacks".into()))?; - let sync = self - .sync_callbacks - .lock() - .map_err(|_| ClientError::LockError("sync_callbacks".into()))?; - let error = self - .error_callbacks - .lock() - .map_err(|_| ClientError::LockError("error_callbacks".into()))?; - let conn = self - .connection_callbacks - .lock() - .map_err(|_| ClientError::LockError("connection_callbacks".into()))?; - let conflict = self - .conflict_callbacks - .lock() - .map_err(|_| ClientError::LockError("conflict_callbacks".into()))?; - let identity = self - .identity_callbacks - .lock() - .map_err(|_| ClientError::LockError("identity_callbacks".into()))?; - let rust = self - .rust_callbacks - .lock() - .map_err(|_| ClientError::LockError("rust_callbacks".into()))?; - - Ok(!doc.is_empty() - || !sync.is_empty() - || !error.is_empty() - || !conn.is_empty() - || !conflict.is_empty() - || !identity.is_empty() - || !rust.is_empty()) - } - - /// Process all queued events. This MUST be called on the same thread where callbacks were registered. - pub fn process_events(&self) -> SyncResult<usize> { - // Verify we're on the correct thread - { - let thread_id = self - .callback_thread_id - .lock() - .map_err(|_| ClientError::LockError("thread ID".into()))?; - if let Some(expected_thread_id) = *thread_id { - if thread::current().id() != expected_thread_id { - return Err(ClientError::ThreadSafetyViolation.into()); - } - } else { - return Err(ClientError::NoCallbacksRegistered.into()); + /// Whether `process` may run on this thread. The bound thread never changes once set. + pub fn may_process(&self) -> Result<(), DispatchError> { + match lock(&self.0).thread { + None => Err(DispatchError::NoCallbacks), + Some(registered) if registered != thread::current().id() => { + Err(DispatchError::WrongThread) } + Some(_) => Ok(()), } + } +} - if !self.has_callbacks()? { - return Ok(0); - } - - // Lock all callback vectors - let document_callbacks = self - .document_callbacks - .lock() - .map_err(|_| ClientError::LockError("document_callbacks".into()))?; - let sync_callbacks = self - .sync_callbacks - .lock() - .map_err(|_| ClientError::LockError("sync_callbacks".into()))?; - let error_callbacks = self - .error_callbacks - .lock() - .map_err(|_| ClientError::LockError("error_callbacks".into()))?; - let connection_callbacks = self - .connection_callbacks - .lock() - .map_err(|_| ClientError::LockError("connection_callbacks".into()))?; - let conflict_callbacks = self - .conflict_callbacks - .lock() - .map_err(|_| ClientError::LockError("conflict_callbacks".into()))?; - let identity_callbacks = self - .identity_callbacks - .lock() - .map_err(|_| ClientError::LockError("identity_callbacks".into()))?; - let rust_callbacks = self - .rust_callbacks - .lock() - .map_err(|_| ClientError::LockError("rust_callbacks".into()))?; - - let receiver = self - .event_queue - .lock() - .map_err(|_| ClientError::LockError("event queue".into()))?; - - let mut processed_count = 0; - let mut temp_strings: Vec<CString> = Vec::new(); - - // Process all available events - while let Ok(queued_event) = receiver.try_recv() { - // Dispatch to Rust callbacks first (no FFI overhead) - if !rust_callbacks.is_empty() { - let sync_event = SyncEvent::from_queued(&queued_event); - for entry in rust_callbacks.iter() { - if let Some(filter) = entry.event_filter { - if filter != queued_event.event_type { - continue; - } - } - (entry.callback)(sync_event.clone()); - } - } - - // Then dispatch to C FFI callbacks - temp_strings.clear(); - - // Convert strings to C-compatible format - let document_id_cstr = queued_event.document_id.as_ref().map(|id| { - let cstr = CString::new(id.as_str()).unwrap_or_else(|_| CString::new("").unwrap()); - let ptr = cstr.as_ptr(); - temp_strings.push(cstr); - ptr - }); - - let title_cstr = queued_event.title.as_ref().map(|t| { - let cstr = CString::new(t.as_str()).unwrap_or_else(|_| CString::new("").unwrap()); - let ptr = cstr.as_ptr(); - temp_strings.push(cstr); - ptr - }); - - let content_cstr = queued_event.content.as_ref().map(|c| { - let cstr = CString::new(c.as_str()).unwrap_or_else(|_| CString::new("{}").unwrap()); - let ptr = cstr.as_ptr(); - temp_strings.push(cstr); - ptr - }); - - let error_cstr = queued_event.error.as_ref().map(|e| { - let cstr = CString::new(e.as_str()).unwrap_or_else(|_| CString::new("").unwrap()); - let ptr = cstr.as_ptr(); - temp_strings.push(cstr); - ptr - }); - - let user_id_cstr = queued_event.user_id.as_ref().map(|u| { - let cstr = CString::new(u.as_str()).unwrap_or_else(|_| CString::new("").unwrap()); - let ptr = cstr.as_ptr(); - temp_strings.push(cstr); - ptr - }); - - let author_name_cstr = queued_event.author_name.as_ref().map(|a| { - let cstr = CString::new(a.as_str()).unwrap_or_else(|_| CString::new("").unwrap()); - let ptr = cstr.as_ptr(); - temp_strings.push(cstr); - ptr - }); - - let visibility_cstr = queued_event.visibility.as_ref().map(|v| { - let cstr = CString::new(v.as_str()).unwrap_or_else(|_| CString::new("").unwrap()); - let ptr = cstr.as_ptr(); - temp_strings.push(cstr); - ptr - }); - - // Dispatch to appropriate callback type based on event type - match queued_event.event_type { - EventType::DocumentCreated - | EventType::DocumentUpdated - | EventType::DocumentDeleted => { - let doc_id_ptr = document_id_cstr.unwrap_or(std::ptr::null()); - let title_ptr = title_cstr.unwrap_or(std::ptr::null()); - let content_ptr = content_cstr.unwrap_or(std::ptr::null()); - let user_id_ptr = user_id_cstr.unwrap_or(std::ptr::null()); - let author_name_ptr = author_name_cstr.unwrap_or(std::ptr::null()); - let visibility_ptr = visibility_cstr.unwrap_or(std::ptr::null()); - - for entry in document_callbacks.iter() { - if let Some(filter) = entry.event_filter { - if filter != queued_event.event_type { - continue; - } - } - (entry.callback)( - queued_event.event_type, - doc_id_ptr, - title_ptr, - content_ptr, - user_id_ptr, - author_name_ptr, - visibility_ptr, - queued_event.origin, - entry.context, - ); - } - } - - EventType::SyncStarted | EventType::SyncCompleted => { - for entry in sync_callbacks.iter() { - (entry.callback)( - queued_event.event_type, - queued_event.numeric_data, - entry.context, - ); - } - } - - EventType::SyncError => { - let error_ptr = error_cstr.unwrap_or(std::ptr::null()); - let error_code = queued_event.error_code as i32; - for entry in error_callbacks.iter() { +impl Callbacks { + fn dispatch(&self, event: &HostEvent) { + match event { + HostEvent::DocumentChanged { document, origin } => { + let id = text(&document.id.to_string()); + let title = document.title.as_deref().map(text); + let content = text(&document.content.to_string()); + let owner = document.owner_id.map(|owner| text(&owner.to_string())); + let author = document.author_id.map(|author| text(&author.to_string())); + let visibility = text(document.visibility); + if let Some((entry, filter)) = &self.document { + if filter.is_none_or(|wanted| wanted == EventType::DocumentChanged) { (entry.callback)( - queued_event.event_type, - error_code, - error_ptr, + EventType::DocumentChanged, + id.as_ptr(), + or_null(&title), + content.as_ptr(), + or_null(&owner), + or_null(&author), + visibility.as_ptr(), + document.read_only, + (*origin).into(), entry.context, ); } } - - EventType::ConnectionLost - | EventType::ConnectionAttempted - | EventType::ConnectionSucceeded => { - for entry in connection_callbacks.iter() { + } + HostEvent::DocumentDeleted { doc_id, origin } => { + let id = text(&doc_id.to_string()); + if let Some((entry, filter)) = &self.document { + if filter.is_none_or(|wanted| wanted == EventType::DocumentDeleted) { (entry.callback)( - queued_event.event_type, - queued_event.boolean_data, - queued_event.numeric_data as u32, + EventType::DocumentDeleted, + id.as_ptr(), + ptr::null(), + ptr::null(), + ptr::null(), + ptr::null(), + ptr::null(), + false, + (*origin).into(), entry.context, ); } } - - EventType::ConflictDetected => { - let doc_id_ptr = document_id_cstr.unwrap_or(std::ptr::null()); - let winning_ptr = content_cstr.unwrap_or(std::ptr::null()); - let losing_ptr = error_cstr.unwrap_or(std::ptr::null()); // Use error field for losing content - - for entry in conflict_callbacks.iter() { - (entry.callback)( - queued_event.event_type, - doc_id_ptr, - winning_ptr, - losing_ptr, - entry.context, - ); - } + } + HostEvent::SyncStarted => self.sync_event(EventType::SyncStarted), + HostEvent::SyncCompleted => self.sync_event(EventType::SyncCompleted), + HostEvent::DatabaseChanged => self.sync_event(EventType::DatabaseChanged), + HostEvent::IdentityAdopted { .. } => self.sync_event(EventType::IdentityAdopted), + HostEvent::SyncError { + code, + doc_id, + scope, + fatal, + recovered_id, + } => { + let error = text(code); + let document_id = doc_id.map(|id| text(&id.to_string())); + let scope = scope.as_deref().map(text); + if let Some(entry) = &self.error { + (entry.callback)( + EventType::SyncError, + error_code_for(code) as i32, + error.as_ptr(), + or_null(&document_id), + or_null(&scope), + *fatal, + recovered_id.unwrap_or(-1), + entry.context, + ); } - - EventType::IdentityChanged => { - let old_ptr = document_id_cstr.unwrap_or(std::ptr::null()); - let new_ptr = user_id_cstr.unwrap_or(std::ptr::null()); - let email_ptr = title_cstr.unwrap_or(std::ptr::null()); - - for entry in identity_callbacks.iter() { - (entry.callback)( - queued_event.event_type, - old_ptr, - new_ptr, - email_ptr, - entry.context, - ); - } + } + HostEvent::Conflict { + doc_id, + reason, + recovered_id, + paths, + } => { + let id = text(&doc_id.to_string()); + let reason = text(reason); + let paths_json = (!paths.is_empty()) + .then(|| text(&serde_json::to_string(paths).unwrap_or_default())); + if let Some(entry) = &self.conflict { + (entry.callback)( + EventType::ConflictDetected, + id.as_ptr(), + reason.as_ptr(), + recovered_id.unwrap_or(-1), + or_null(&paths_json), + entry.context, + ); } } - - processed_count += 1; + HostEvent::ConnectionAttempted { attempt } => { + self.connection_event(EventType::ConnectionAttempted, false, *attempt) + } + HostEvent::ConnectionSucceeded => { + self.connection_event(EventType::ConnectionSucceeded, true, 0) + } + HostEvent::ConnectionLost => self.connection_event(EventType::ConnectionLost, false, 0), } + } - Ok(processed_count) + fn sync_event(&self, event_type: EventType) { + if let Some(entry) = &self.sync { + (entry.callback)(event_type, entry.context); + } } - /// Get the number of events waiting to be processed - pub fn pending_event_count(&self) -> usize { - // We can't easily check the channel length without consuming from it, - // so we'll estimate by trying to process events and counting them - self.process_events().unwrap_or_default() + fn connection_event(&self, event_type: EventType, connected: bool, attempt: u32) { + if let Some(entry) = &self.connection { + (entry.callback)(event_type, connected, attempt, entry.context); + } } } -impl Default for EventDispatcher { - fn default() -> Self { - Self::new() - } +/// Interior NULs cannot cross into C; such a value arrives empty. +fn text(value: &str) -> CString { + CString::new(value).unwrap_or_default() +} + +fn or_null(value: &Option<CString>) -> *const c_char { + value.as_ref().map_or(ptr::null(), |text| text.as_ptr()) } #[cfg(test)] mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::{Arc, Mutex}; - - #[test] - fn test_event_dispatcher_creation() { - let dispatcher = EventDispatcher::new(); - assert!(dispatcher.document_callbacks.lock().unwrap().is_empty()); - assert!(dispatcher.sync_callbacks.lock().unwrap().is_empty()); - - // Should have no pending events initially - let result = dispatcher.process_events(); - assert!(result.is_err()); // No callbacks registered yet - } - - #[test] - fn test_document_callback_registration_and_processing() { - let dispatcher = EventDispatcher::new(); - let callback_count = Arc::new(AtomicUsize::new(0)); - let count_clone = callback_count.clone(); - - extern "C" fn test_callback( - _event_type: EventType, - _doc_id: *const c_char, - _title: *const c_char, - _content: *const c_char, - _user_id: *const c_char, - _author_name: *const c_char, - _visibility: *const c_char, - _origin: EventOrigin, - context: *mut c_void, - ) { - let count = unsafe { &*(context as *const AtomicUsize) }; - count.fetch_add(1, Ordering::SeqCst); - } + use std::ffi::CStr; - let result = dispatcher.register_document_callback( - test_callback, - &*count_clone as *const AtomicUsize as *mut c_void, - None, - ); + use serde_json::json; + use uuid::Uuid; - assert!(result.is_ok()); - assert_eq!(dispatcher.document_callbacks.lock().unwrap().len(), 1); + use super::*; + use crate::store::StoredDocument; - // Emit an event - let doc_id = Uuid::new_v4(); - dispatcher - .emit_document_created(&doc_id, &serde_json::json!({"title": "Test", "test": true})); + type Log = Mutex<Vec<String>>; - // Process events (should invoke callback) - let processed = dispatcher.process_events().unwrap(); - assert_eq!(processed, 1); - assert_eq!(callback_count.load(Ordering::SeqCst), 1); + fn log(context: *mut c_void) -> &'static Log { + unsafe { &*(context as *const Log) } } - #[test] - fn test_document_event_filtering() { - let dispatcher = EventDispatcher::new(); - let created_count = Arc::new(AtomicUsize::new(0)); - let updated_count = Arc::new(AtomicUsize::new(0)); - - let created_clone = created_count.clone(); - let updated_clone = updated_count.clone(); - - extern "C" fn count_callback( - _event_type: EventType, - _doc_id: *const c_char, - _title: *const c_char, - _content: *const c_char, - _user_id: *const c_char, - _author_name: *const c_char, - _visibility: *const c_char, - _origin: EventOrigin, - context: *mut c_void, - ) { - let count = unsafe { &*(context as *const AtomicUsize) }; - count.fetch_add(1, Ordering::SeqCst); + fn read(value: *const c_char) -> String { + if value.is_null() { + "null".into() + } else { + unsafe { CStr::from_ptr(value) } + .to_string_lossy() + .into_owned() } - - // Register callback only for created events - dispatcher - .register_document_callback( - count_callback, - &*created_clone as *const AtomicUsize as *mut c_void, - Some(EventType::DocumentCreated), - ) - .unwrap(); - - // Register callback only for updated events - dispatcher - .register_document_callback( - count_callback, - &*updated_clone as *const AtomicUsize as *mut c_void, - Some(EventType::DocumentUpdated), - ) - .unwrap(); - - let doc_id = Uuid::new_v4(); - - // Emit created event - dispatcher.emit_document_created(&doc_id, &serde_json::json!({"title": "Test"})); - dispatcher.process_events().unwrap(); - assert_eq!(created_count.load(Ordering::SeqCst), 1); - assert_eq!(updated_count.load(Ordering::SeqCst), 0); - - // Emit updated event - dispatcher.emit_document_updated(&doc_id, &serde_json::json!({"title": "Test"})); - dispatcher.process_events().unwrap(); - assert_eq!(created_count.load(Ordering::SeqCst), 1); - assert_eq!(updated_count.load(Ordering::SeqCst), 1); - - // Emit deleted event (neither should trigger - they're filtered) - dispatcher.emit_document_deleted(&doc_id, EventOrigin::Local); - dispatcher.process_events().unwrap(); - assert_eq!(created_count.load(Ordering::SeqCst), 1); - assert_eq!(updated_count.load(Ordering::SeqCst), 1); } - #[test] - fn test_thread_safety_check() { - let dispatcher = Arc::new(EventDispatcher::new()); - let callback_count = Arc::new(AtomicUsize::new(0)); - let count_clone = callback_count.clone(); - - extern "C" fn test_callback(_event_type: EventType, _doc_count: u64, context: *mut c_void) { - let count = unsafe { &*(context as *const AtomicUsize) }; - count.fetch_add(1, Ordering::SeqCst); - } - - // Register callback on main thread - dispatcher - .register_sync_callback( - test_callback, - &*count_clone as *const AtomicUsize as *mut c_void, - ) - .unwrap(); - - let dispatcher_clone = dispatcher.clone(); - - // Try to process events from another thread (should fail) - let handle = std::thread::spawn(move || dispatcher_clone.process_events()); - - let result = handle.join().unwrap(); - assert!(result.is_err()); // Should fail because we're on wrong thread - - // Process events on main thread (should work) - dispatcher.emit_sync_started(); - let processed = dispatcher.process_events().unwrap(); - assert_eq!(processed, 1); - assert_eq!(callback_count.load(Ordering::SeqCst), 1); + extern "C" fn on_document( + event_type: EventType, + document_id: *const c_char, + title: *const c_char, + _content: *const c_char, + owner_id: *const c_char, + _author_id: *const c_char, + visibility: *const c_char, + read_only: bool, + origin: EventOrigin, + context: *mut c_void, + ) { + log(context).lock().unwrap().push(format!( + "{event_type:?} {} {} {} {} {read_only} {origin:?}", + read(document_id), + read(title), + read(owner_id), + read(visibility) + )); } - #[test] - fn test_multiple_callback_types() { - let dispatcher = EventDispatcher::new(); - let doc_count = Arc::new(AtomicUsize::new(0)); - let sync_count = Arc::new(AtomicUsize::new(0)); - - let doc_clone = doc_count.clone(); - let sync_clone = sync_count.clone(); - - extern "C" fn doc_callback( - _event_type: EventType, - _doc_id: *const c_char, - _title: *const c_char, - _content: *const c_char, - _user_id: *const c_char, - _author_name: *const c_char, - _visibility: *const c_char, - _origin: EventOrigin, - context: *mut c_void, - ) { - let count = unsafe { &*(context as *const AtomicUsize) }; - count.fetch_add(1, Ordering::SeqCst); - } - - extern "C" fn sync_callback(_event_type: EventType, _doc_count: u64, context: *mut c_void) { - let count = unsafe { &*(context as *const AtomicUsize) }; - count.fetch_add(1, Ordering::SeqCst); - } - - // Register callbacks for different event types - dispatcher - .register_document_callback( - doc_callback, - &*doc_clone as *const AtomicUsize as *mut c_void, - None, - ) - .unwrap(); - - dispatcher - .register_sync_callback( - sync_callback, - &*sync_clone as *const AtomicUsize as *mut c_void, - ) - .unwrap(); - - // Emit multiple events of different types - let doc_id = Uuid::new_v4(); - dispatcher.emit_document_created(&doc_id, &serde_json::json!({"title": "Test1"})); - dispatcher.emit_document_updated(&doc_id, &serde_json::json!({"title": "Test2"})); - dispatcher.emit_document_deleted(&doc_id, EventOrigin::Local); - dispatcher.emit_sync_started(); - dispatcher.emit_sync_completed(5); - - // Process all events at once - let processed = dispatcher.process_events().unwrap(); - assert_eq!(processed, 5); - assert_eq!(doc_count.load(Ordering::SeqCst), 3); // 3 document events - assert_eq!(sync_count.load(Ordering::SeqCst), 2); // 2 sync events - - // Process again (should be no more events) - let processed = dispatcher.process_events().unwrap(); - assert_eq!(processed, 0); + extern "C" fn on_sync(event_type: EventType, context: *mut c_void) { + log(context).lock().unwrap().push(format!("{event_type:?}")); } - #[test] - fn identity_changed_delivers_to_rust_callback() { - let dispatcher = EventDispatcher::new(); - let captured: Arc<Mutex<Option<SyncEvent>>> = Arc::new(Mutex::new(None)); - let c = captured.clone(); - dispatcher - .register_rust_callback(move |e| *c.lock().unwrap() = Some(e)) - .unwrap(); - - let old = Uuid::new_v4(); - let new = Uuid::new_v4(); - dispatcher.emit_identity_changed(&old, &new, "user@example.com"); - assert_eq!(dispatcher.process_events().unwrap(), 1); - - let result = captured.lock().unwrap().clone(); - match result { - Some(SyncEvent::IdentityChanged { - old_user_id, - new_user_id, - email, - }) => { - assert_eq!(old_user_id, old.to_string()); - assert_eq!(new_user_id, new.to_string()); - assert_eq!(email, "user@example.com"); - } - other => panic!("expected IdentityChanged, got {:?}", other), - } + extern "C" fn on_error( + event_type: EventType, + error_code: i32, + error: *const c_char, + document_id: *const c_char, + scope: *const c_char, + fatal: bool, + recovered_id: i64, + context: *mut c_void, + ) { + log(context).lock().unwrap().push(format!( + "{event_type:?} {error_code} {} {} {} {fatal} {recovered_id}", + read(error), + read(document_id), + read(scope) + )); } - #[test] - fn identity_changed_delivers_to_ffi_callback() { - let dispatcher = EventDispatcher::new(); - let count = Arc::new(AtomicUsize::new(0)); - let count_clone = count.clone(); - - extern "C" fn identity_callback( - event_type: EventType, - old_user_id: *const c_char, - new_user_id: *const c_char, - email: *const c_char, - context: *mut c_void, - ) { - assert_eq!(event_type, EventType::IdentityChanged); - assert!(!old_user_id.is_null()); - assert!(!new_user_id.is_null()); - assert!(!email.is_null()); - let c = unsafe { &*(context as *const AtomicUsize) }; - c.fetch_add(1, Ordering::SeqCst); - } - - dispatcher - .register_identity_callback( - identity_callback, - &*count_clone as *const AtomicUsize as *mut c_void, - ) - .unwrap(); - - let old = Uuid::new_v4(); - let new = Uuid::new_v4(); - dispatcher.emit_identity_changed(&old, &new, "user@example.com"); - assert_eq!(dispatcher.process_events().unwrap(), 1); - assert_eq!(count.load(Ordering::SeqCst), 1); + extern "C" fn on_connection( + event_type: EventType, + connected: bool, + attempt_number: u32, + context: *mut c_void, + ) { + log(context) + .lock() + .unwrap() + .push(format!("{event_type:?} {connected} {attempt_number}")); } - #[test] - fn test_error_callback() { - let dispatcher = EventDispatcher::new(); - let error_count = Arc::new(AtomicUsize::new(0)); - let error_clone = error_count.clone(); - - extern "C" fn error_callback( - _event_type: EventType, - _error_code: i32, - _error: *const c_char, - context: *mut c_void, - ) { - let count = unsafe { &*(context as *const AtomicUsize) }; - count.fetch_add(1, Ordering::SeqCst); - } - - dispatcher - .register_error_callback( - error_callback, - &*error_clone as *const AtomicUsize as *mut c_void, - ) - .unwrap(); - - dispatcher.emit_sync_error(ReplicantErrorCode::Unknown, "Test error"); - - let processed = dispatcher.process_events().unwrap(); - assert_eq!(processed, 1); - assert_eq!(error_count.load(Ordering::SeqCst), 1); + extern "C" fn on_conflict( + event_type: EventType, + document_id: *const c_char, + reason: *const c_char, + recovered_id: i64, + paths_json: *const c_char, + context: *mut c_void, + ) { + log(context).lock().unwrap().push(format!( + "{event_type:?} {} {} {recovered_id} {}", + read(document_id), + read(reason), + read(paths_json) + )); } #[test] - fn test_connection_callback() { - let dispatcher = EventDispatcher::new(); - let conn_count = Arc::new(AtomicUsize::new(0)); - let conn_clone = conn_count.clone(); - - extern "C" fn conn_callback( - _event_type: EventType, - _connected: bool, - _attempt: u32, - context: *mut c_void, - ) { - let count = unsafe { &*(context as *const AtomicUsize) }; - count.fetch_add(1, Ordering::SeqCst); - } - + fn every_host_event_reaches_its_callback() { + let seen: Log = Mutex::new(Vec::new()); + let context = &seen as *const Log as *mut c_void; + let dispatcher = Dispatcher::default(); dispatcher - .register_connection_callback( - conn_callback, - &*conn_clone as *const AtomicUsize as *mut c_void, - ) + .register_document(Some(on_document), context, None) .unwrap(); - - dispatcher.emit_connection_lost("ws://localhost:8080"); - dispatcher.emit_connection_attempted("ws://localhost:8080"); - dispatcher.emit_connection_succeeded("ws://localhost:8080"); - - let processed = dispatcher.process_events().unwrap(); - assert_eq!(processed, 3); - assert_eq!(conn_count.load(Ordering::SeqCst), 3); - } - - #[test] - fn test_conflict_callback() { - let dispatcher = EventDispatcher::new(); - let conflict_count = Arc::new(AtomicUsize::new(0)); - let conflict_clone = conflict_count.clone(); - - extern "C" fn conflict_callback( - _event_type: EventType, - _doc_id: *const c_char, - _winning: *const c_char, - _losing: *const c_char, - context: *mut c_void, - ) { - let count = unsafe { &*(context as *const AtomicUsize) }; - count.fetch_add(1, Ordering::SeqCst); - } - + dispatcher.register_sync(Some(on_sync), context).unwrap(); + dispatcher.register_error(Some(on_error), context).unwrap(); dispatcher - .register_conflict_callback( - conflict_callback, - &*conflict_clone as *const AtomicUsize as *mut c_void, - ) + .register_connection(Some(on_connection), context) .unwrap(); - - let doc_id = Uuid::new_v4(); - dispatcher.emit_conflict_detected(&doc_id); - - let processed = dispatcher.process_events().unwrap(); - assert_eq!(processed, 1); - assert_eq!(conflict_count.load(Ordering::SeqCst), 1); - } - - #[test] - fn test_rust_callback() { - let dispatcher = EventDispatcher::new(); - let events_received = Arc::new(Mutex::new(Vec::<String>::new())); - let events_clone = events_received.clone(); - - // Register Rust callback dispatcher - .register_rust_callback(move |event| { - let desc = match &event { - SyncEvent::DocumentCreated { title, .. } => format!("created:{}", title), - SyncEvent::SyncCompleted { document_count } => { - format!("synced:{}", document_count) - } - SyncEvent::ConnectionSucceeded { server_url } => { - format!("connected:{}", server_url) - } - _ => format!("other:{:?}", event.event_type()), - }; - events_clone.lock().unwrap().push(desc); - }) + .register_conflict(Some(on_conflict), context) .unwrap(); - - // Emit events - let doc_id = Uuid::new_v4(); - dispatcher.emit_document_created(&doc_id, &serde_json::json!({"title": "Test Doc"})); - dispatcher.emit_sync_completed(42); - dispatcher.emit_connection_succeeded("ws://localhost:8080"); - - // Process events - let processed = dispatcher.process_events().unwrap(); - assert_eq!(processed, 3); - - // Verify events were received - let events = events_received.lock().unwrap(); - assert_eq!(events.len(), 3); - assert_eq!(events[0], "created:Test Doc"); - assert_eq!(events[1], "synced:42"); - assert_eq!(events[2], "connected:ws://localhost:8080"); + let doc_id = Uuid::from_u128(1); + let owner = Uuid::from_u128(0xA); + let document = StoredDocument { + id: doc_id, + owner_id: Some(owner), + author_id: None, + title: Some("T".into()), + content: json!({"title": "T"}), + read_only: false, + visibility: "private", + source_doc_id: None, + derived_from: None, + created_at: "c".into(), + updated_at: "u".into(), + }; + let events = vec![ + HostEvent::ConnectionAttempted { attempt: 1 }, + HostEvent::ConnectionSucceeded, + HostEvent::SyncStarted, + HostEvent::DocumentChanged { + document, + origin: Origin::OtherProcess, + }, + HostEvent::DocumentDeleted { + doc_id, + origin: Origin::Server, + }, + HostEvent::Conflict { + doc_id, + reason: "field_conflict".into(), + recovered_id: Some(3), + paths: vec!["/s".into()], + }, + HostEvent::SyncError { + code: "clock_skew".into(), + doc_id: None, + scope: None, + fatal: false, + recovered_id: None, + }, + HostEvent::SyncError { + code: "subscription_forbidden".into(), + doc_id: None, + scope: Some("collection:curated".into()), + fatal: false, + recovered_id: None, + }, + HostEvent::SyncError { + code: "forbidden".into(), + doc_id: Some(doc_id), + scope: None, + fatal: false, + recovered_id: Some(4), + }, + HostEvent::SyncCompleted, + HostEvent::DatabaseChanged, + HostEvent::IdentityAdopted { user_id: owner }, + HostEvent::ConnectionLost, + ]; + assert_eq!(dispatcher.process(|| events, || false), Ok(13)); + assert_eq!( + *seen.lock().unwrap(), + vec![ + "ConnectionAttempted false 1".to_string(), + "ConnectionSucceeded true 0".to_string(), + "SyncStarted".to_string(), + format!("DocumentChanged {doc_id} T {owner} private false OtherProcess"), + format!("DocumentDeleted {doc_id} null null null false Remote"), + format!("ConflictDetected {doc_id} field_conflict 3 [\"/s\"]"), + "SyncError 2101 clock_skew null null false -1".to_string(), + "SyncError 3004 subscription_forbidden null collection:curated false -1" + .to_string(), + format!("SyncError 5003 forbidden {doc_id} null false 4"), + "SyncCompleted".to_string(), + "DatabaseChanged".to_string(), + "IdentityAdopted".to_string(), + "ConnectionLost false 0".to_string(), + ] + ); } #[test] - fn test_rust_and_c_callbacks_together() { - let dispatcher = EventDispatcher::new(); - - // Rust callback - let rust_count = Arc::new(AtomicUsize::new(0)); - let rust_clone = rust_count.clone(); - dispatcher - .register_rust_callback(move |_event| { - rust_clone.fetch_add(1, Ordering::SeqCst); - }) - .unwrap(); - - // C FFI callback - let c_count = Arc::new(AtomicUsize::new(0)); - let c_clone = c_count.clone(); - - extern "C" fn c_callback(_event_type: EventType, _doc_count: u64, context: *mut c_void) { - let count = unsafe { &*(context as *const AtomicUsize) }; - count.fetch_add(1, Ordering::SeqCst); - } - + fn processing_before_registration_or_off_thread_is_refused_and_takes_nothing() { + let dispatcher = Dispatcher::default(); + assert_eq!( + dispatcher.process( + || unreachable!("nothing is taken before a registration"), + || false + ), + Err(DispatchError::NoCallbacks) + ); + let seen: Log = Mutex::new(Vec::new()); dispatcher - .register_sync_callback(c_callback, &*c_clone as *const AtomicUsize as *mut c_void) + .register_sync(Some(on_sync), &seen as *const Log as *mut c_void) .unwrap(); - - // Emit sync events - dispatcher.emit_sync_started(); - dispatcher.emit_sync_completed(10); - - // Process events - let processed = dispatcher.process_events().unwrap(); - assert_eq!(processed, 2); - - // Both callbacks should have been invoked - assert_eq!(rust_count.load(Ordering::SeqCst), 2); - assert_eq!(c_count.load(Ordering::SeqCst), 2); + std::thread::scope(|scope| { + scope.spawn(|| { + assert_eq!( + dispatcher + .process(|| unreachable!("nothing is taken off the thread"), || false), + Err(DispatchError::WrongThread) + ); + }); + }); + assert_eq!( + dispatcher.process(|| vec![HostEvent::SyncStarted], || false), + Ok(1) + ); + assert_eq!(*seen.lock().unwrap(), vec!["SyncStarted".to_string()]); } } diff --git a/replicant-client/src/ffi.rs b/replicant-client/src/ffi.rs index e2afddd..ead385a 100644 --- a/replicant-client/src/ffi.rs +++ b/replicant-client/src/ffi.rs @@ -1,1718 +1,1400 @@ -//! C FFI interface for the sync client -//! -//! This module provides C-compatible functions for using the sync client from C/C++. -//! The generated header file will be available after building. +//! C API. A handle is one attachment to the engine for its data dir; every handle in the +//! process on that data dir shares one engine (`host::attach`). No call waits on the network. -use serde_json::Value; -use std::cell::Cell; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; +use std::path::{Path, PathBuf}; use std::ptr; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::time::Duration; + +use serde::Serialize; +use serde_json::Value; use tokio::runtime::Runtime; use uuid::Uuid; -use crate::error_code::ReplicantErrorCode; +use crate::engine::doc::FieldConflict; +use crate::engine::list_merge::{ListMergeConfig, ListMergePolicy, PathPattern}; +use crate::engine::machine::{ConnectionView, EngineState, HaltReason, SyncView}; use crate::events::{ - ConflictEventCallback, ConnectionEventCallback, DocumentEventCallback, ErrorEventCallback, - EventDispatcher, EventOrigin, EventType, IdentityEventCallback, SyncEventCallback, + DispatchError, Dispatcher, EventType, ReplicantConflictEventCallback, + ReplicantConnectionEventCallback, ReplicantDocumentEventCallback, ReplicantErrorEventCallback, + ReplicantSyncEventCallback, }; -use crate::{Client as CoreClient, ClientDatabase}; - -/// Opaque handle to a Replicant client instance +use crate::host::{self, Handle, HostConfig, OpenError}; +use crate::store::StoreError; + +/// Opaque handle. Every call except `replicant_process_events`, the +/// `replicant_register_*_callback` calls and the destroy calls is thread-safe; the first two run +/// only on the thread the first registration bound, and a destroy must not overlap any other +/// call on the handle (see `replicant_destroy`). Never call from an audio thread: every read and +/// write is a SQLite transaction. pub struct Replicant { - engine: Arc<std::sync::Mutex<Option<Arc<CoreClient>>>>, - database: Arc<ClientDatabase>, - runtime: Runtime, - pub(crate) event_dispatcher: Arc<EventDispatcher>, - /// The background task `replicant_create` spawns to build the sync engine. - /// Kept so shutdown can wait for it instead of cancelling it: cancelling it - /// mid-`establish()` is what orphaned a sqlite worker thread in DEV-1118. - init_task: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>, - /// Identifies this instance's runtime threads, so shutdown can tell that it - /// was called from inside its own runtime (where joining would deadlock). - instance_id: u64, -} - -/// `begin_teardown` hands the handle to its thread as an address, which erases -/// the `Send` bound the compiler would otherwise check. Keep checking it here, -/// so that adding a `!Send` field to `Replicant` fails to build instead of -/// silently moving something across threads. -const _: fn() = || { - fn assert_send<T: Send>() {} - assert_send::<Replicant>(); -}; - -static NEXT_INSTANCE_ID: AtomicU64 = AtomicU64::new(1); - -thread_local! { - /// `instance_id` of the Replicant whose tokio runtime owns this thread, or - /// 0 for any other thread. - static RUNTIME_OWNER: Cell<u64> = const { Cell::new(0) }; -} - -/// A teardown started by `replicant_destroy`, and whether the caller is allowed -/// to wait for it. -enum Teardown { - /// Running on its own thread, which this caller may join. - Waitable(std::thread::JoinHandle<()>), - /// Running, but not joinable from this caller — or never started at all. - /// Either way nothing may block on it. - Detached, -} - -impl Replicant { - /// Closes everything this handle owns and joins every thread it started, - /// then frees it. Always runs on the dedicated thread `begin_teardown` - /// spawns, so that dropping the runtime is never done from inside that - /// runtime, and so that no FFI caller is held up by it. - /// - /// Deliberately unbounded. Nothing waits on this thread unless a caller - /// asks to, so there is no host to hang — and every bound one could impose - /// here would mean *abandoning* a pool mid-close, which is the very thing - /// this exists to prevent. It finishes when sqlite lets it finish (each step - /// is bounded in practice by sqlite's 5s busy timeout per contended - /// statement). - /// - /// Order matters, and every pool close is unconditional — nothing that could - /// stall may stand between this function and a `Pool::close()`, because - /// `Pool::close()` is the only thing that joins a sqlite worker thread - /// (dropping the runtime joins tokio's threads and no others): - /// - /// 1. Close the handle's own pool, awaited. First, so that a hang anywhere - /// later cannot keep it open. It is independent of the sync engine's pool, - /// so closing it early disturbs nothing. `Pool::close()` resolves only - /// when the pool's size reaches zero, i.e. every connection — including - /// ones still being established — is done. - /// 2. Wait for the background init task. Its `ClientDatabase::new` may have - /// a sqlx worker thread sitting inside `establish()` retrying on - /// SQLITE_BUSY; cancelling the task (which is what dropping the runtime - /// does) abandons that thread *and* the pool handle that could have - /// closed it. Awaiting the task means that pool always exists to close. - /// Bounded in practice by the socket connect timeout and sqlite's busy - /// timeout, both of which are finite. - /// 3. Close the sync engine's pool (and release its socket best-effort — see - /// `Client::shutdown`, which closes the pool before touching the socket - /// for the same reason). - /// 4. Drop the runtime. A plain drop joins the runtime's worker and - /// blocking threads (`shutdown_background`/`shutdown_timeout` would - /// deliberately not), so on return no tokio thread of ours is left. - fn shutdown(self) { - let Replicant { - engine, - database, - runtime, - event_dispatcher, - init_task, - instance_id, - } = self; - - let started = Instant::now(); - - // Poisoning must never be read as "nothing to close". Every other FFI - // function holds these guards across a `block_on` with `unwrap()`, so a - // single panicking operation poisons the mutex — and skipping the close - // would leave behind exactly the threads this exists to join. - let init_task = init_task - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .take(); - - // 1. The handle's own pool, before anything that could stall. - runtime.block_on(database.close()); - drop(database); - - // 2. Background init: let it finish so nothing is abandoned mid-connect. - if let Some(handle) = init_task { - if let Err(e) = runtime.block_on(handle) { - tracing::warn!("FFI: background init ended abnormally during shutdown: {e}"); - } - } - - // 3. The sync engine's own pool, then its socket. - let client = engine - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .take(); - if let Some(client) = client { - runtime.block_on(client.shutdown()); - drop(client); - } - drop(engine); - drop(event_dispatcher); - - // 4. Joins the runtime's worker and blocking threads. - drop(runtime); - - tracing::info!( - "FFI: Replicant {} shut down in {:?} ({} sqlite worker thread(s) started \ - by this process)", - instance_id, - started.elapsed(), - crate::database::sqlite_workers_started() - ); - } + handle: Handle, + dispatcher: Dispatcher, + /// `PUMP_*`: whether `replicant_process_events` is running callbacks, and whether a + /// callback asked to destroy the handle meanwhile. + pump: AtomicU8, +} - /// Runs `fut` on this instance's runtime, marking the calling thread as one - /// of that runtime's own for the duration. - /// - /// Every FFI function that drives async work goes through here. The mark is - /// what makes a *re-entrant* destroy — one issued from a callback dispatched - /// while an FFI call is still on this thread's stack — take the `Detached` - /// path instead of waiting for a teardown that cannot finish until this - /// frame returns. (Freeing the handle from inside a call that is still using - /// it is a use-after-free no library can undo; the mark keeps it from also - /// being a deadlock.) - fn block_on<F: std::future::Future>(&self, fut: F) -> F::Output { - struct Restore(u64); - impl Drop for Restore { - fn drop(&mut self) { - RUNTIME_OWNER.with(|owner| owner.set(self.0)); - } - } +const PUMP_IDLE: u8 = 0; +const PUMP_DISPATCHING: u8 = 1; +const PUMP_DESTROY_REQUESTED: u8 = 2; - let _restore = Restore(RUNTIME_OWNER.with(|owner| owner.replace(self.instance_id))); - self.runtime.block_on(fut) - } +/// The C ABI version is MAJOR.MINOR. A breaking change bumps the major; an addition (a function, +/// a struct field at the end, an enum value) bumps the minor. A host needs the library's major +/// equal to the header's, and its minor at least the minor that added what the host uses. +/// Hosts treat unknown enum values as unknown: an unknown `ReplicantEventType` is ignored, an +/// unknown `ReplicantHaltReason` is `Other`, an unknown error code is 0 (`Unknown`). +pub const REPLICANT_ABI_VERSION_MAJOR: u32 = 1; +/// See `REPLICANT_ABI_VERSION_MAJOR`. +pub const REPLICANT_ABI_VERSION_MINOR: u32 = 0; - /// Hands the handle to a dedicated thread that tears it down, and reports - /// whether this caller may wait for that thread. - /// - /// The teardown never runs on the caller's thread: dropping a tokio runtime - /// from inside itself panics, and joining a runtime's threads from one of - /// them deadlocks. A caller that *is* one of this instance's runtime threads - /// (a destroy from inside a Replicant callback, say) therefore gets - /// `Detached` — the teardown still happens, it just cannot be waited for - /// from there — rather than a deadlock or a panic across the FFI boundary. - fn begin_teardown(handle: Box<Replicant>) -> Teardown { - let instance_id = handle.instance_id; - let called_from_own_runtime = RUNTIME_OWNER.with(|owner| owner.get()) == instance_id; - - // Handed over as an address, not as a `Box`, so that a failed spawn - // leaks the handle instead of dropping it on the caller's thread. That - // drop would block the caller joining the runtime — against this - // function's whole contract — and would panic outright if the caller is - // itself a runtime thread. A leak is the lesser evil: the memory stays - // valid for whatever is still running in it. - let raw = Box::into_raw(handle) as usize; - - let worker = std::thread::Builder::new() - .name("replicant-shutdown".to_string()) - .spawn(move || { - // SAFETY: `raw` came from `Box::into_raw` just above and is - // handed to exactly one thread, which is the only owner now. - unsafe { Box::from_raw(raw as *mut Replicant) }.shutdown() - }); - - match worker { - Err(e) => { - tracing::error!( - "FFI: could not spawn the shutdown thread ({e}); \ - Replicant {instance_id} was leaked deliberately and its \ - threads will outlive this handle" - ); - Teardown::Detached - } - Ok(_) if called_from_own_runtime => { - tracing::warn!( - "FFI: Replicant {} was destroyed from its own runtime thread; \ - its teardown cannot be waited for from there.", - instance_id - ); - Teardown::Detached - } - Ok(worker) => Teardown::Waitable(worker), - } - } +/// The library's ABI version, packed: `(major << 16) | minor`. +#[no_mangle] +pub extern "C" fn replicant_abi_version() -> u32 { + (REPLICANT_ABI_VERSION_MAJOR << 16) | REPLICANT_ABI_VERSION_MINOR } -/// Waits up to `timeout` for a teardown thread, without ever joining one that -/// cannot be joined from here. Returns whether it finished. -fn wait_for_teardown(teardown: Teardown, timeout: Duration) -> bool { - let worker = match teardown { - Teardown::Waitable(worker) => worker, - Teardown::Detached => return false, - }; - - let deadline = Instant::now() + timeout; - while !worker.is_finished() { - if Instant::now() >= deadline { - // The thread keeps running: abandoning the teardown is what leaves - // threads in an unloadable module, so it is never cut short. - tracing::warn!( - "FFI: teardown did not finish within {:?} and is still running \ - ({} sqlite worker thread(s) started by this process)", - timeout, - crate::database::sqlite_workers_started() - ); - return false; - } - std::thread::sleep(Duration::from_millis(1)); - } - - // A panic in the teardown thread means the teardown did NOT complete: say so - // rather than reporting a clean shutdown. - match worker.join() { - Ok(()) => true, - Err(_) => { - tracing::error!( - "FFI: the teardown thread panicked; pools may not have been closed and \ - Replicant threads may still be running" - ); - false - } - } +/// Longest email, in bytes. +pub const REPLICANT_EMAIL_MAX_LEN: usize = 254; +/// A user id's length; its buffer needs one more for the NUL. +pub const REPLICANT_USER_ID_LEN: usize = 36; +/// A document id's length; its buffer needs one more for the NUL. +pub const REPLICANT_DOCUMENT_ID_LEN: usize = 36; + +/// Every entry point's body runs in here: a panic becomes `ErrorUnknown` instead of unwinding +/// into C, which would abort the host (a DAW). +fn guard(body: impl FnOnce() -> SyncResult) -> SyncResult { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)).unwrap_or_else(|_| { + tracing::error!("a replicant call panicked"); + SyncResult::ErrorUnknown + }) } -/// Result codes for C API functions -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq)] +/// cbindgen:prefix-with-name +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SyncResult { Success = 0, ErrorInvalidInput = -1, + /// Enrollment only: the server could not be reached, or answered with an unexpected status. ErrorConnection = -2, ErrorDatabase = -3, ErrorSerialization = -4, + /// A newer build migrated the database; this build cannot open it. + ErrorNewerSchema = -5, + /// An engine is open on this data dir with a different server, host, list merge config or + /// title pointer. + /// A programming error: every binary on a data dir must pass the same config. Never fall + /// back to a temporary library. + ErrorConfigMismatch = -6, + ErrorNotFound = -7, + /// Another account's document, or a read-only publication. + ErrorNotWritable = -8, + /// The id exists here or was deleted. An import should skip the id, not count a failure. + ErrorAlreadyExists = -9, + /// Migrating a v1 library failed. Show "Your library needs attention"; never fall back to a + /// temporary library. Either the backup itself failed: no backup was written and the + /// database is unchanged. Or the migration failed after the backup: the documents are + /// unchanged but 0.6 builds cannot open the database; restoring the backup + /// (`<database>.v1-backup`, or `<database>.v1-backup-<unix seconds>` when that name was + /// taken) is the way back. + ErrorMigrationFailed = -10, + /// Another process kept the database locked; try `replicant_create` again shortly. + ErrorBusy = -11, + /// `replicant_process_events` or a `replicant_register_*_callback` call came from another + /// thread than the one the handle's first registration bound. + ErrorWrongThread = -12, + /// `replicant_process_events` was called before any callback was registered. + ErrorNoCallbacks = -13, + /// The kept copy's document was deleted: `replicant_restore_document` re-creates the copy. + ErrorDocumentGone = -14, + /// An output buffer is smaller than the value plus its NUL; see the `REPLICANT_*_LEN` limits. + ErrorBufferTooSmall = -15, + /// The server refused the enrollment code (wrong or expired): ask for a new one. + ErrorTokenRejected = -16, ErrorUnknown = -99, } -/// Document structure for C API +/// Every string is UTF-8 and copied by `replicant_create`. #[repr(C)] -pub struct Document { - pub id: *mut c_char, - pub title: *mut c_char, - pub content: *mut c_char, - pub sync_revision: i64, +pub struct ReplicantConfig { + /// `sizeof(ReplicantConfig)`. Smaller than the ABI 1.0 struct is refused + /// (`ErrorInvalidInput`); the library reads only the fields it knows. + pub struct_size: u32, + /// Holds the database file and the stored credentials. + pub data_dir: *const c_char, + /// File name inside `data_dir`, e.g. "tonaldb.sqlite3"; a path separator or `..` is refused. + pub database_file: *const c_char, + pub server_url: *const c_char, + /// May be null (`""` counts as null). Signs joins when the stored credentials carry no email. + /// Credentials stored by 0.6 carry none: with a null email here the engine reports + /// `NotEnrolled`, so a host upgrading from 0.6 must pass the user's email. Not part of the + /// shared-engine check: a later handle on an open data dir never gets `ErrorConfigMismatch` + /// for a different email, and the email of the handle that started the engine is the one used. + pub email: *const c_char, + /// Named in the User-Agent, e.g. "Entonal Studio" and "2.0.1 CLAP". + pub host_app: *const c_char, + pub host_version: *const c_char, + /// A `ReplicantListMerge`: how a list both sides changed is merged when no rule matches. + /// An `int32_t` so that a value from C outside the enum is refused, not undefined. + pub list_merge: i32, + /// May be null. A JSON array of `{"path": "/pitches", "policy": "atomic"}`; + /// policies `append`, `atomic`, `full`. `*` matches one key or index; the rule with the most + /// literal segments wins, a tie goes to the first listed. + pub list_merge_rules_json: *const c_char, + /// May be null (`""` counts as null). An RFC 6901 JSON Pointer, e.g. "/title", to the string + /// in each document's content that is its `title`: in reads, callbacks, kept copies and the + /// `title:` search field. Null: the library assigns no titles. Anything else that is not a + /// JSON Pointer is refused (`ErrorInvalidInput`). The shared-engine check + /// (`ErrorConfigMismatch`) covers handles in one process. Every process on a data dir must + /// pass the same pointer: a different pointer in another process is not detected, each launch + /// with one recomputes every title and the search index, and titles end up mixed while both + /// run. + pub title_pointer: *const c_char, } -/// Create a new sync engine instance -/// -/// # Arguments -/// * `database_url` - SQLite database URL (e.g., "sqlite:client.db?mode=rwc") -/// * `server_url` - WebSocket server URL (e.g., "ws://localhost:8080/ws") -/// * `email` - User email address -/// * `api_key` - Application API key (rpa_ prefix) -/// * `api_secret` - Application API secret (rps_ prefix) -/// -/// # Returns -/// * Pointer to SyncEngine on success, null on failure -/// -/// # Events -/// Connection and initial sync run in the background. ConnectionSucceeded is -/// emitted once per successful connect (at start-up or on a later reconnect), -/// after the engine uses that connection. SyncStarted and SyncCompleted then -/// bracket the full sync that follows; SyncCompleted means the server answered. -/// An offline start emits neither until the server is reached. An engine with -/// no API key, or whose database has never adopted an identity (no `user_id` -/// on this or an earlier run), never connects, so it emits neither. -/// -/// # Safety -/// Caller must ensure all pointers are valid, non-null C strings -#[no_mangle] -pub unsafe extern "C" fn replicant_create( - database_url: *const c_char, - server_url: *const c_char, - email: *const c_char, - api_key: *const c_char, - api_secret: *const c_char, - user_id: *const c_char, -) -> *mut Replicant { - if database_url.is_null() - || server_url.is_null() - || email.is_null() - || api_key.is_null() - || api_secret.is_null() - { - return ptr::null_mut(); +/// Merge policy for a list changed on both sides. `ReplicantListMerge_Full` is refused for now. +/// cbindgen:prefix-with-name +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplicantListMerge { + /// Element by element while positions line up on both sides; otherwise the server's list is + /// kept and the local one set aside. + Append = 0, + /// Any change on both sides keeps the server's list and sets the local one aside. + Atomic = 1, + Full = 2, +} + +#[derive(serde::Deserialize)] +struct ListMergeRule { + path: String, + policy: String, +} + +/// `value` is a `ReplicantListMerge`. +fn list_merge_policy(value: i32) -> Option<ListMergePolicy> { + match value { + 0 => Some(ListMergePolicy::Append), + 1 => Some(ListMergePolicy::Atomic), + 2 => Some(ListMergePolicy::Full), + _ => None, } +} - // Canonical user id from stored credentials; null means no enrolled - // identity (offline/local-only). A non-null id must be a real UUID. - let canonical_user_id = if user_id.is_null() { - None +fn list_merge_policy_named(name: &str) -> Option<ListMergePolicy> { + match name { + "append" => Some(ListMergePolicy::Append), + "atomic" => Some(ListMergePolicy::Atomic), + "full" => Some(ListMergePolicy::Full), + _ => None, + } +} + +/// `None` for an unknown policy or unparsable rules; `Engine::start` validates the rest. +unsafe fn list_merge_arg(config: &ReplicantConfig) -> Option<ListMergeConfig> { + let default = list_merge_policy(config.list_merge)?; + let rules = if config.list_merge_rules_json.is_null() { + Vec::new() } else { - match CStr::from_ptr(user_id) - .to_str() - .ok() - .and_then(|s| Uuid::parse_str(s).ok()) - { - Some(id) if !id.is_nil() => Some(id), - _ => return ptr::null_mut(), - } + let rules: Vec<ListMergeRule> = + serde_json::from_str(str_arg(config.list_merge_rules_json)?).ok()?; + rules + .into_iter() + .map(|rule| { + Some(( + PathPattern(rule.path), + list_merge_policy_named(&rule.policy)?, + )) + }) + .collect::<Option<Vec<_>>>()? }; + Some(ListMergeConfig { default, rules }) +} - let database_url = match CStr::from_ptr(database_url).to_str() { - Ok(s) => s, - Err(_) => return ptr::null_mut(), - }; +/// cbindgen:prefix-with-name +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplicantConnection { + /// Never reported: `replicant_create` returns after the engine left it. + Idle = 0, + Disconnected = 1, + Connecting = 2, + Connected = 3, + /// Not retrying on its own; see `halt_reason`. + Halted = 4, + Stopped = 5, +} - let server_url = match CStr::from_ptr(server_url).to_str() { - Ok(s) => s, - Err(_) => return ptr::null_mut(), - }; +/// cbindgen:prefix-with-name +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplicantSync { + Idle = 0, + CatchingUp = 1, + Live = 2, +} - let email = match CStr::from_ptr(email).to_str() { - Ok(s) => s, - Err(_) => return ptr::null_mut(), - }; +/// cbindgen:prefix-with-name +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplicantHaltReason { + None = 0, + /// No stored credentials, or stored ones that can never be read (key missing, not + /// decryptable): sign in. + NotEnrolled = 1, + /// The server refused the credentials: sign in again. + AuthInvalid = 2, + /// The server needs a newer client. + UpdateRequired = 3, + /// The server disabled the account. A different account signed in on this data dir, in any + /// process, is picked up within about 3 s; the same account stays halted. + AccountDisabled = 4, + /// This data dir belongs to another account. + IdentityDrift = 5, + Other = 6, +} - let api_key = match CStr::from_ptr(api_key).to_str() { - Ok(s) => s, - Err(_) => return ptr::null_mut(), - }; +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReplicantState { + /// Set to `sizeof(ReplicantState)` before `replicant_get_state`; smaller than the ABI 1.0 + /// struct is refused. The library writes only the fields it knows and sets this to their + /// size, so a host from a later version can tell which of its fields were filled. + pub struct_size: u32, + pub connection: ReplicantConnection, + pub sync: ReplicantSync, + pub halt_reason: ReplicantHaltReason, +} - let api_secret = match CStr::from_ptr(api_secret).to_str() { - Ok(s) => s, - Err(_) => return ptr::null_mut(), - }; +/// The ABI 1.0 struct sizes: later versions append fields and keep accepting these. +// A field added after 1.0 is read through a raw pointer, and only when `struct_size` covers it. +const CONFIG_SIZE_V1_0: usize = + std::mem::offset_of!(ReplicantConfig, title_pointer) + std::mem::size_of::<*const c_char>(); +const STATE_SIZE_V1_0: usize = + std::mem::offset_of!(ReplicantState, halt_reason) + std::mem::size_of::<ReplicantHaltReason>(); + +impl From<&EngineState> for ReplicantState { + fn from(state: &EngineState) -> Self { + let (connection, halt_reason) = match &state.connection { + ConnectionView::Idle => (ReplicantConnection::Idle, ReplicantHaltReason::None), + ConnectionView::Disconnected => { + (ReplicantConnection::Disconnected, ReplicantHaltReason::None) + } + ConnectionView::Connecting => { + (ReplicantConnection::Connecting, ReplicantHaltReason::None) + } + ConnectionView::Connected => { + (ReplicantConnection::Connected, ReplicantHaltReason::None) + } + ConnectionView::Stopped => (ReplicantConnection::Stopped, ReplicantHaltReason::None), + ConnectionView::Halted(reason) => ( + ReplicantConnection::Halted, + match reason { + HaltReason::NotEnrolled => ReplicantHaltReason::NotEnrolled, + HaltReason::AuthInvalid => ReplicantHaltReason::AuthInvalid, + HaltReason::UpdateRequired => ReplicantHaltReason::UpdateRequired, + HaltReason::AccountDisabled => ReplicantHaltReason::AccountDisabled, + HaltReason::Other(code) if code == "identity_drift" => { + ReplicantHaltReason::IdentityDrift + } + HaltReason::Other(_) => ReplicantHaltReason::Other, + }, + ), + }; + let sync = match state.sync { + SyncView::Idle => ReplicantSync::Idle, + SyncView::CatchingUp => ReplicantSync::CatchingUp, + SyncView::Live => ReplicantSync::Live, + }; + ReplicantState { + struct_size: std::mem::size_of::<ReplicantState>() as u32, + connection, + sync, + halt_reason, + } + } +} - let instance_id = NEXT_INSTANCE_ID.fetch_add(1, Ordering::Relaxed); +unsafe fn str_arg<'a>(value: *const c_char) -> Option<&'a str> { + if value.is_null() { + None + } else { + CStr::from_ptr(value).to_str().ok() + } +} - // Equivalent to `Runtime::new()`, plus named threads and an owner marker so - // shutdown can recognise its own runtime threads (see `destroy_blocking`). - let runtime = match tokio::runtime::Builder::new_multi_thread() - .enable_all() - .thread_name(format!("replicant-{instance_id}")) - .on_thread_start(move || RUNTIME_OWNER.with(|owner| owner.set(instance_id))) - .on_thread_stop(|| RUNTIME_OWNER.with(|owner| owner.set(0))) - .build() - { - Ok(rt) => rt, - Err(_) => return ptr::null_mut(), - }; +/// Null is `None`; a non-null string that is not UTF-8 is refused. +unsafe fn nullable_str_arg<'a>(value: *const c_char) -> Result<Option<&'a str>, SyncResult> { + if value.is_null() { + Ok(None) + } else { + str_arg(value) + .map(Some) + .ok_or(SyncResult::ErrorInvalidInput) + } +} - let database = match runtime.block_on(async { ClientDatabase::new(database_url).await }) { - Ok(db) => Arc::new(db), - Err(_) => return ptr::null_mut(), - }; +unsafe fn uuid_arg(value: *const c_char) -> Option<Uuid> { + str_arg(value).and_then(|text| Uuid::parse_str(text).ok()) +} - // Failing here hands no handle back, so nothing will ever call - // `replicant_destroy` for this pool: close it on the way out, or its sqlite - // worker threads outlive a `replicant_create` the caller believes did - // nothing. Contended creation (migrations blocked on SQLITE_BUSY) is - // exactly when this path is taken. - let close_and_fail = |runtime: Runtime, database: Arc<ClientDatabase>| -> *mut Replicant { - runtime.block_on(database.close()); - drop(database); - drop(runtime); - ptr::null_mut() - }; +unsafe fn json_arg(value: *const c_char) -> Result<Value, SyncResult> { + let text = str_arg(value).ok_or(SyncResult::ErrorInvalidInput)?; + serde_json::from_str(text).map_err(|_| SyncResult::ErrorSerialization) +} - // Run migrations - if runtime - .block_on(async { database.run_migrations().await }) - .is_err() - { - return close_and_fail(runtime, database); +fn store_result(error: &StoreError) -> SyncResult { + match error { + StoreError::NotFound(_) | StoreError::NoKeptCopy(_) => SyncResult::ErrorNotFound, + StoreError::NoFieldConflict(_) => SyncResult::ErrorInvalidInput, + StoreError::NotWritable(_) => SyncResult::ErrorNotWritable, + StoreError::DocumentGone(_) => SyncResult::ErrorDocumentGone, + StoreError::AlreadyExists(_) => SyncResult::ErrorAlreadyExists, + StoreError::Json(_) => SyncResult::ErrorSerialization, + StoreError::BadSearchQuery(_) => SyncResult::ErrorInvalidInput, + _ => SyncResult::ErrorDatabase, } +} - // Ensure user config exists so offline operations work immediately - if runtime - .block_on(async { - database - .ensure_user_config_with_identifier(server_url, email) - .await - }) - .is_err() - { - return close_and_fail(runtime, database); +/// Writes the 36-character id and a NUL; `out` must hold 37 bytes. +unsafe fn write_id(out: *mut c_char, id: Uuid) { + let text = id.to_string(); + ptr::copy_nonoverlapping(text.as_ptr(), out as *mut u8, text.len()); + out.add(text.len()).write(0); +} + +/// Nulls a caller's out pointer first, so it is null after any failure. +unsafe fn clear_out(out: *mut *mut c_char) { + if !out.is_null() { + *out = ptr::null_mut(); + } +} + +fn registered(result: Result<(), DispatchError>) -> SyncResult { + match result { + Ok(()) => SyncResult::Success, + Err(_) => SyncResult::ErrorWrongThread, } +} - let event_dispatcher = Arc::new(EventDispatcher::new()); - let engine = Arc::new(std::sync::Mutex::new(None)); - - // Spawn background task to create sync engine (connect + initial sync) - let engine_slot = engine.clone(); - let event_dispatcher_clone = event_dispatcher.clone(); - let database_url = database_url.to_string(); - let server_url = server_url.to_string(); - let email = email.to_string(); - let api_key = api_key.to_string(); - let api_secret = api_secret.to_string(); - let init_task = runtime.spawn(async move { - match CoreClient::open( - &database_url, - &server_url, - &email, - &api_key, - &api_secret, - canonical_user_id, - Some(event_dispatcher_clone.clone()), - ) - .await - { - Ok(client) => { - let client = Arc::new(client); - // Never panic here: the client is already built, and unwinding - // out of this task would drop it — and its pool — un-closed, - // leaving a sqlx worker thread per connection running in this - // module. A poisoned mutex is recovered instead, so shutdown - // still finds the client and can close it. - // The slot must be empty: this task is its only writer, and - // teardown waits for this task before taking the slot. If that - // ever stopped holding, the assignment would drop a live client - // — and its pool — un-closed, which is the hazard itself. - match engine_slot.lock() { - Ok(mut slot) => { - debug_assert!(slot.is_none(), "engine slot was already occupied"); - *slot = Some(client.clone()); - } - Err(poisoned) => { - tracing::warn!( - "FFI: engine slot mutex was poisoned; storing the client anyway \ - so its pool can still be closed" - ); - let mut slot = poisoned.into_inner(); - debug_assert!(slot.is_none(), "engine slot was already occupied"); - *slot = Some(client.clone()); - } - } - // Started only once it is in the slot, so a consumer reacting to - // ConnectionSucceeded or SyncCompleted already uses this client, - // and documents written through the offline path meanwhile are - // uploaded by its initial sync. - client.start().await; - } - Err(e) => { - // The pool this init opened is already closed: - // `open` closes it on every failure path, which - // is what joins its sqlite worker threads. Nothing is left to - // clean up here, and the slot stays `None`. - event_dispatcher_clone.emit_sync_error( - ReplicantErrorCode::Unknown, - &format!("Background init failed: {}", e), - ); - } - } - }); +fn dispatch_result(error: DispatchError) -> SyncResult { + match error { + DispatchError::WrongThread => SyncResult::ErrorWrongThread, + DispatchError::NoCallbacks => SyncResult::ErrorNoCallbacks, + } +} - Box::into_raw(Box::new(Replicant { - engine, - database, - runtime, - event_dispatcher, - init_task: std::sync::Mutex::new(Some(init_task)), - instance_id, - })) +/// Hands `value` to the caller as JSON; free it with `replicant_string_free`. +unsafe fn write_json(out: *mut *mut c_char, value: &impl Serialize) -> SyncResult { + match serde_json::to_string(value) + .ok() + .and_then(|json| CString::new(json).ok()) + { + Some(json) => { + *out = json.into_raw(); + SyncResult::Success + } + None => SyncResult::ErrorSerialization, + } } -/// Destroy a sync engine instance and free memory -/// -/// NON-BLOCKING, fire-and-forget: it returns immediately and the teardown — -/// closing both sqlite pools and joining this instance's threads — finishes on -/// a Replicant-owned thread afterwards. The handle is invalid the moment this -/// returns; no further call may use it. -/// -/// This is safe to call on a UI or audio-host thread (plugin scans, project -/// close) precisely because it does not wait. The consequence is that threads -/// Replicant started can still be running Replicant code for a short time after -/// it returns — normally milliseconds, longer if sqlite is contended. -/// -/// THEREFORE: any caller that can be UNLOADED FROM MEMORY — every plugin — -/// MUST make sure its own binary is never unmapped, or one of those threads -/// will be executing code at an address that no longer exists (the DEV-1118 -/// crash: an execute access violation on an unmapped image). Pin the module -/// once, at load: +/// Attaches to the engine for `config`'s data dir, starting it if this process has none yet. +/// Opens (and after an upgrade migrates) the database on this thread; never waits on the +/// network. On success `*out_handle` is set; on any failure, a null `config` included, it is null. /// -/// * Windows: `GetModuleHandleExW` with `GET_MODULE_HANDLE_EX_FLAG_PIN` -/// (plus `GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS`) on an address in your -/// own module. -/// * macOS / Linux: `dlopen` your own image with `RTLD_NOLOAD | RTLD_NODELETE`. -/// -/// Pinning is the primary defence, not a workaround. A host process that only -/// ever exits (a standalone app) does not need it; anything a host can unload -/// does. -/// -/// UPGRADING FROM 0.6.2 OR EARLIER WITHOUT PINNING IS A REGRESSION. The old -/// destroy dropped the handle, which joined the tokio runtime's threads before -/// returning and left only sqlx's workers running; this one returns with the -/// background init, both pools, the runtime's threads and the sqlx workers all -/// still live. The total time any thread of ours is alive goes down — the pools -/// are now actually closed, so nothing lingers for the life of the process — but -/// the exposure *at the moment destroy returns* goes up. -/// -/// A second instance may be created immediately; it shares nothing with the one -/// being torn down. Note only that the outgoing instance may still be writing to -/// the same database file for a moment, so an immediate re-create against it can -/// see sqlite contention (and `replicant_create` returns NULL if that outlasts -/// sqlite's 5s busy timeout). `replicant_destroy_and_wait` avoids the overlap. -/// -/// Callers that *can* afford to wait — standalone apps shutting down, tests — -/// should use `replicant_destroy_and_wait` instead, which reports whether the -/// teardown actually finished. +/// The stored credentials are read before this returns, so `replicant_get_state` is already +/// true: `Halted`/`NotEnrolled` without usable credentials (none stored, or ones that can never +/// be read), else dialling (or, for a later handle, the shared engine's current state). An +/// engine that starts without usable credentials also sends one fatal `not_enrolled` error, to +/// the handle that started it; a later handle on that engine is not sent it again and reads +/// `replicant_get_state` instead. /// /// # Safety -/// Caller must ensure engine pointer was created by replicant_create and hasn't been freed. -/// As with any free function, no other call on this handle may be in progress on -/// any thread — which includes destroying it from inside a Replicant callback -/// while the call that dispatched that callback is still on the stack. +/// `config` and `out_handle` must be valid; the config's strings valid C strings (`email` may be null). #[no_mangle] -pub unsafe extern "C" fn replicant_destroy(engine: *mut Replicant) { - if engine.is_null() { - return; +pub unsafe extern "C" fn replicant_create( + config: *const ReplicantConfig, + out_handle: *mut *mut Replicant, +) -> SyncResult { + if !out_handle.is_null() { + *out_handle = ptr::null_mut(); } + guard(|| { + if config.is_null() || out_handle.is_null() { + return SyncResult::ErrorInvalidInput; + } + let config = &*config; + if (config.struct_size as usize) < CONFIG_SIZE_V1_0 { + return SyncResult::ErrorInvalidInput; + } + let ( + Some(data_dir), + Some(database_file), + Some(server_url), + Some(host_app), + Some(host_version), + ) = ( + str_arg(config.data_dir), + str_arg(config.database_file), + str_arg(config.server_url), + str_arg(config.host_app), + str_arg(config.host_version), + ) + else { + return SyncResult::ErrorInvalidInput; + }; + let (Some(list_merge), Ok(fallback_email), Ok(title_pointer)) = ( + list_merge_arg(config), + nullable_str_arg(config.email), + nullable_str_arg(config.title_pointer), + ) else { + return SyncResult::ErrorInvalidInput; + }; + let host_config = HostConfig { + data_dir: PathBuf::from(data_dir), + database_file: database_file.to_string(), + server_url: server_url.to_string(), + fallback_email: fallback_email + .filter(|email| !email.is_empty()) + .map(str::to_string), + host_app: host_app.to_string(), + host_version: host_version.to_string(), + list_merge, + title_pointer: title_pointer + .filter(|pointer| !pointer.is_empty()) + .map(str::to_string), + }; + match host::attach(host_config) { + Ok(handle) => { + *out_handle = Box::into_raw(Box::new(Replicant { + handle, + dispatcher: Dispatcher::default(), + pump: AtomicU8::new(PUMP_IDLE), + })); + SyncResult::Success + } + Err(OpenError::NewerSchema) => SyncResult::ErrorNewerSchema, + Err(OpenError::MigrationFailed(message)) => { + tracing::error!(%message, "replicant_create: v1 migration failed"); + SyncResult::ErrorMigrationFailed + } + Err(OpenError::Busy) => SyncResult::ErrorBusy, + Err(OpenError::ConfigMismatch(_)) => SyncResult::ErrorConfigMismatch, + Err(OpenError::Config(message)) => { + tracing::warn!(%message, "replicant_create: invalid config"); + SyncResult::ErrorInvalidInput + } + Err(error) => { + tracing::warn!(%error, "replicant_create failed"); + SyncResult::ErrorDatabase + } + } + }) +} - let handle = Box::from_raw(engine); +/// `replicant_process_events` is running this handle's callbacks: it frees the handle when +/// it returns. True when the free was handed over. +unsafe fn defer_to_the_pump(handle: *mut Replicant) -> bool { + (*handle) + .pump + .compare_exchange( + PUMP_DISPATCHING, + PUMP_DESTROY_REQUESTED, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_ok() +} - // Nothing may unwind into C. A panic here (a poisoned lock, a failing - // thread spawn) would otherwise cross the FFI boundary, which is UB. - // Dropping the returned Teardown detaches the thread; it does not stop it. +unsafe fn free(handle: *mut Replicant) { + let replicant = Box::from_raw(handle); if std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { - let _ = Replicant::begin_teardown(handle); + let Replicant { handle, .. } = *replicant; + drop(handle.close()); })) .is_err() { - tracing::error!("FFI: replicant_destroy panicked; the handle was leaked deliberately"); + tracing::error!("replicant_destroy panicked"); } } -/// Destroy a sync engine instance and wait for its teardown to finish. +/// Detaches and frees the handle. Returns at once: when this was the engine's last handle, the +/// engine stops on a Replicant thread afterwards (never waiting on the network). /// -/// Same as `replicant_destroy` (the handle is invalid either way, whatever this -/// returns) but BLOCKS for up to `timeout_ms` waiting for Replicant to close -/// its sqlite pools and join its threads. +/// A destroy must not overlap any other call on this handle, on any thread: the host ends every +/// other use of the handle first. The one exception is a destroy from inside one of this +/// handle's callbacks, which is deferred: the free happens when `replicant_process_events` +/// returns. Other handles, including those on the same data dir, are unaffected. /// -/// Returns `true` when the teardown completed: no REPLICANT-OWNED thread — its -/// tokio runtime, its sqlite workers — is running any more. That is the claim, -/// and it is narrower than "this module has no threads in it": libraries linked -/// into Replicant (the WebSocket and TLS stacks in particular) may keep threads -/// of their own that Replicant neither owns nor can join, so a module that has -/// ever connected should still be pinned rather than unloaded. +/// Unloading: library code can still run after this returns, and even after +/// `replicant_destroy_and_wait` returns true, because a DNS lookup the engine started finishes +/// on its own thread, bounded only by the OS resolver. Never unload this library while the +/// process runs: a plugin pins its module (`RTLD_NODELETE`, or +/// `GET_MODULE_HANDLE_EX_FLAG_PIN` on Windows). /// -/// Returns `false` if the wait ran out, or if the caller cannot wait (destroy -/// from inside a Replicant callback on a Replicant runtime thread) — the teardown -/// then carries on in the background. -/// -/// `timeout_ms` of 0 does not wait at all; it reports whether the teardown had -/// already finished. Pass a large value (`UINT32_MAX`) to wait in effect -/// indefinitely. -/// -/// Intended for standalone applications and tests. A plugin should call -/// `replicant_destroy` and pin its module instead of blocking a host thread. +/// # Safety +/// `handle` must come from `replicant_create` and not be used again. +#[no_mangle] +pub unsafe extern "C" fn replicant_destroy(handle: *mut Replicant) { + if handle.is_null() || defer_to_the_pump(handle) { + return; + } + free(handle); +} + +/// `replicant_destroy`, then waits up to `timeout_ms` for the engine to stop and its runtime to +/// shut down. True when it has, or when other handles keep the engine running. It does not make +/// unloading safe (see `replicant_destroy`). The same overlap rule applies. Called from inside +/// one of this handle's callbacks it cannot wait: it returns false at once and the handle is +/// freed when `replicant_process_events` returns. /// /// # Safety -/// Caller must ensure engine pointer was created by replicant_create and hasn't been freed +/// As `replicant_destroy`. #[no_mangle] pub unsafe extern "C" fn replicant_destroy_and_wait( - engine: *mut Replicant, + handle: *mut Replicant, timeout_ms: u32, ) -> bool { - if engine.is_null() { + if handle.is_null() { return true; } - - let handle = Box::from_raw(engine); - - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { - let teardown = Replicant::begin_teardown(handle); - wait_for_teardown(teardown, Duration::from_millis(u64::from(timeout_ms))) - })) { - Ok(finished) => finished, - Err(_) => { - tracing::error!( - "FFI: replicant_destroy_and_wait panicked; the handle was leaked deliberately" - ); - false - } + if defer_to_the_pump(handle) { + return false; } + let replicant = Box::from_raw(handle); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + let Replicant { handle, .. } = *replicant; + handle + .close() + .wait(Duration::from_millis(u64::from(timeout_ms))) + })) + .unwrap_or(false) } -/// Create a new document -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `content_json` - Document content as JSON string (should include any title as part of the JSON) -/// * `out_document_id` - Output buffer for document ID (must be at least 37 chars) -/// -/// # Returns -/// * CSyncResult indicating success or failure +/// Creates a document owned by the signed-in (or provisional) user; writes its id into +/// `out_document_id` (`REPLICANT_DOCUMENT_ID_LEN + 1` bytes). /// /// # Safety -/// Caller must ensure engine is valid, content_json is a valid C string, and out_document_id has space for 37 bytes +/// Valid handle, C string, and a `REPLICANT_DOCUMENT_ID_LEN + 1`-byte buffer. #[no_mangle] pub unsafe extern "C" fn replicant_create_document( - engine: *mut Replicant, + handle: *mut Replicant, content_json: *const c_char, out_document_id: *mut c_char, ) -> SyncResult { - if engine.is_null() || content_json.is_null() || out_document_id.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &mut *engine; - - let content_json = match CStr::from_ptr(content_json).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let content: Value = match serde_json::from_str(content_json) { - Ok(c) => c, - Err(_) => return SyncResult::ErrorSerialization, - }; - - let engine_guard = engine.engine.lock().unwrap(); - let doc_id = if let Some(ref sync_engine) = *engine_guard { - // Online mode - use sync engine - match engine.block_on(async { sync_engine.create_document(content.clone()).await }) { - Ok(doc) => { - // Emit event to FFI event dispatcher - engine - .event_dispatcher - .emit_document_created_with_attribution( - &doc.id, - &content, - doc.user_id.as_ref(), - doc.author_name.as_deref(), - doc.visibility.as_deref(), - EventOrigin::Local, - ); - doc.id - } - Err(_) => return SyncResult::ErrorConnection, + guard(|| { + if handle.is_null() || out_document_id.is_null() { + return SyncResult::ErrorInvalidInput; } - } else { - drop(engine_guard); - // Offline mode - create locally - let doc_id = Uuid::new_v4(); - let user_id = match engine.block_on(async { engine.database.get_user_id().await }) { - Ok(id) => id, - Err(_) => return SyncResult::ErrorDatabase, - }; - - let doc = replicant_core::models::Document { - id: doc_id, - user_id: Some(user_id), - content: content.clone(), - sync_revision: 1, - content_hash: None, - title: None, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, + let content = match json_arg(content_json) { + Ok(content) => content, + Err(result) => return result, }; - - if engine - .block_on(async { engine.database.save_document(&doc).await }) - .is_err() + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant + .handle + .block_on(store.create_document(None, content)) { - return SyncResult::ErrorDatabase; - } - - // Emit event for offline document creation - engine - .event_dispatcher - .emit_document_created_with_attribution( - &doc_id, - &content, - doc.user_id.as_ref(), - doc.author_name.as_deref(), - doc.visibility.as_deref(), - EventOrigin::Local, - ); - - doc_id - }; - - // Copy document ID to output buffer - let id_string = doc_id.to_string(); - let id_bytes = id_string.as_bytes(); - if id_bytes.len() >= 36 { - unsafe { - ptr::copy_nonoverlapping(id_bytes.as_ptr(), out_document_id as *mut u8, 36); - out_document_id.add(36).write(0); // null terminator + Ok(doc_id) => { + replicant.handle.notify_outbox(); + write_id(out_document_id, doc_id); + SyncResult::Success + } + Err(error) => store_result(&error), } - } - - SyncResult::Success + }) } -/// Create a new document with a specified ID -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `document_id` - UUID string to use as the document ID -/// * `content_json` - Document content as JSON string -/// -/// # Returns -/// * `SyncResult::Success` - Document created successfully -/// * `SyncResult::ErrorInvalidInput` - Invalid UUID format or null pointers -/// * `SyncResult::ErrorSerialization` - Invalid JSON content -/// * `SyncResult::ErrorDatabase` - Database operation failed -/// * `SyncResult::ErrorConnection` - Sync to server failed (document saved locally) -/// -/// # Note -/// If a document with the specified ID already exists, it will be overwritten (upsert behavior). -/// Use this for ID preservation during data migration or import scenarios. +/// Creates a document with a chosen id. Refused (`ErrorAlreadyExists`) when the id exists here +/// or was deleted. /// /// # Safety -/// Caller must ensure engine is valid, document_id and content_json are valid C strings +/// Valid handle and C strings. #[no_mangle] pub unsafe extern "C" fn replicant_create_document_with_id( - engine: *mut Replicant, + handle: *mut Replicant, document_id: *const c_char, content_json: *const c_char, ) -> SyncResult { - if engine.is_null() || document_id.is_null() || content_json.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &mut *engine; - - let document_id_str = match CStr::from_ptr(document_id).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let doc_id = match Uuid::parse_str(document_id_str) { - Ok(id) => id, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let content_json = match CStr::from_ptr(content_json).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let content: Value = match serde_json::from_str(content_json) { - Ok(c) => c, - Err(_) => return SyncResult::ErrorSerialization, - }; - - let engine_guard = engine.engine.lock().unwrap(); - if let Some(ref sync_engine) = *engine_guard { - // Online mode - use sync engine - match engine.block_on(async { - sync_engine - .create_document_with_id(doc_id, content.clone()) - .await - }) { - Ok(doc) => { - engine - .event_dispatcher - .emit_document_created_with_attribution( - &doc.id, - &content, - doc.user_id.as_ref(), - doc.author_name.as_deref(), - doc.visibility.as_deref(), - EventOrigin::Local, - ); - } - Err(_) => return SyncResult::ErrorConnection, - } - } else { - drop(engine_guard); - // Offline mode - create locally - let user_id = match engine.block_on(async { engine.database.get_user_id().await }) { - Ok(id) => id, - Err(_) => return SyncResult::ErrorDatabase, + guard(|| { + let (false, Some(doc_id)) = (handle.is_null(), uuid_arg(document_id)) else { + return SyncResult::ErrorInvalidInput; }; - - let doc = replicant_core::models::Document { - id: doc_id, - user_id: Some(user_id), - content: content.clone(), - sync_revision: 1, - content_hash: None, - title: None, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, + let content = match json_arg(content_json) { + Ok(content) => content, + Err(result) => return result, }; - - if engine - .block_on(async { engine.database.save_document(&doc).await }) - .is_err() + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant + .handle + .block_on(store.create_document(Some(doc_id), content)) { - return SyncResult::ErrorDatabase; + Ok(_) => { + replicant.handle.notify_outbox(); + SyncResult::Success + } + Err(error) => store_result(&error), } - - engine - .event_dispatcher - .emit_document_created_with_attribution( - &doc_id, - &content, - doc.user_id.as_ref(), - doc.author_name.as_deref(), - doc.visibility.as_deref(), - EventOrigin::Local, - ); - } - - SyncResult::Success + }) } -/// Update an existing document -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `document_id` - Document ID to update -/// * `content_json` - New document content as JSON string -/// -/// # Returns -/// * CSyncResult indicating success or failure -/// /// # Safety -/// Caller must ensure engine is valid and both document_id and content_json are valid C strings +/// Valid handle and C strings. #[no_mangle] pub unsafe extern "C" fn replicant_update_document( - engine: *mut Replicant, + handle: *mut Replicant, document_id: *const c_char, content_json: *const c_char, ) -> SyncResult { - if engine.is_null() || document_id.is_null() || content_json.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &mut *engine; - - let document_id = match CStr::from_ptr(document_id).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let doc_uuid = match Uuid::parse_str(document_id) { - Ok(id) => id, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let content_json = match CStr::from_ptr(content_json).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let content: Value = match serde_json::from_str(content_json) { - Ok(c) => c, - Err(_) => return SyncResult::ErrorSerialization, - }; - - let engine_guard = engine.engine.lock().unwrap(); - if let Some(ref sync_engine) = *engine_guard { - // Online mode - match engine.block_on(async { sync_engine.update_document(doc_uuid, content).await }) { - Ok(_) => SyncResult::Success, - Err(_) => SyncResult::ErrorConnection, - } - } else { - drop(engine_guard); - // Offline mode - update locally - let doc = match engine.block_on(async { engine.database.get_document(&doc_uuid).await }) { - Ok(d) => d, - Err(_) => return SyncResult::ErrorDatabase, + guard(|| { + let (false, Some(doc_id)) = (handle.is_null(), uuid_arg(document_id)) else { + return SyncResult::ErrorInvalidInput; }; - - let mut updated_doc = doc; - updated_doc.content = content; - updated_doc.sync_revision += 1; - updated_doc.content_hash = None; // Will be recalculated on server - updated_doc.updated_at = chrono::Utc::now(); - - match engine.block_on(async { engine.database.save_document(&updated_doc).await }) { - Ok(_) => { - // Emit event for offline document update - engine - .event_dispatcher - .emit_document_updated_with_attribution( - &doc_uuid, - &updated_doc.content, - updated_doc.user_id.as_ref(), - updated_doc.author_name.as_deref(), - updated_doc.visibility.as_deref(), - EventOrigin::Local, - ); + let content = match json_arg(content_json) { + Ok(content) => content, + Err(result) => return result, + }; + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant + .handle + .block_on(store.update_document(doc_id, content)) + { + Ok(()) => { + replicant.handle.notify_outbox(); SyncResult::Success } - Err(_) => SyncResult::ErrorDatabase, + Err(error) => store_result(&error), } - } + }) } -/// Delete a document -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `document_id` - Document ID to delete -/// -/// # Returns -/// * CSyncResult indicating success or failure -/// /// # Safety -/// Caller must ensure engine is valid and document_id is a valid C string +/// Valid handle and C string. #[no_mangle] pub unsafe extern "C" fn replicant_delete_document( - engine: *mut Replicant, + handle: *mut Replicant, document_id: *const c_char, ) -> SyncResult { - if engine.is_null() || document_id.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &mut *engine; - - let document_id = match CStr::from_ptr(document_id).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let doc_uuid = match Uuid::parse_str(document_id) { - Ok(id) => id, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let engine_guard = engine.engine.lock().unwrap(); - if let Some(ref sync_engine) = *engine_guard { - // Online mode - match engine.block_on(async { sync_engine.delete_document(doc_uuid).await }) { - Ok(_) => SyncResult::Success, - Err(_) => SyncResult::ErrorConnection, - } - } else { - drop(engine_guard); - // Offline mode - match engine.block_on(async { engine.database.delete_document(&doc_uuid).await }) { - Ok(_) => { - // Emit event for offline document deletion - engine - .event_dispatcher - .emit_document_deleted(&doc_uuid, EventOrigin::Local); + guard(|| { + let (false, Some(doc_id)) = (handle.is_null(), uuid_arg(document_id)) else { + return SyncResult::ErrorInvalidInput; + }; + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant.handle.block_on(store.delete_document(doc_id)) { + Ok(()) => { + replicant.handle.notify_outbox(); SyncResult::Success } - Err(_) => SyncResult::ErrorDatabase, + Err(error) => store_result(&error), } - } + }) } -/// Free a C string allocated by this library +/// The document as a JSON object. `ErrorNotFound` when missing or deleted. +/// +/// JSON naming in this API: a document's own id is `id`; any other record that refers to a +/// document names it `doc_id`. Ids are lowercase hyphenated UUID strings. A document object has: +/// - `id` (string): the document's id. +/// - `owner_id` (string or null): the owning user; null only for a legacy document with none. +/// - `author_id` (string or null): the author the server reports; null until it has synced. +/// - `title` (string or null): the string at `ReplicantConfig.title_pointer` in the content, +/// cut to 128 characters; null without a pointer or when that value is not a string. +/// - `content`: the host's own JSON value, as last written or synced. +/// - `read_only` (bool): the server marked it read-only (a publication); writes are refused. +/// - `visibility` (string): `public` (curated or read-only) or `private`. +/// - `source_doc_id`, `derived_from` (string or null): ids of the documents it came from. +/// - `created_at`, `updated_at` (string): RFC 3339 in UTC, e.g. +/// `2026-10-01T07:51:25.301096+00:00`. Times on this device: when the document was first +/// stored here, and when its content last changed here (a local edit or a synced change). /// /// # Safety -/// Caller must ensure the string was allocated by this library and hasn't been freed +/// Valid handle, C string and out pointer; free the result with `replicant_string_free`. #[no_mangle] -pub unsafe extern "C" fn replicant_string_free(s: *mut c_char) { - if !s.is_null() { - let _ = CString::from_raw(s); - } +pub unsafe extern "C" fn replicant_get_document( + handle: *mut Replicant, + document_id: *const c_char, + out_json: *mut *mut c_char, +) -> SyncResult { + guard(|| { + clear_out(out_json); + let (false, false, Some(doc_id)) = + (handle.is_null(), out_json.is_null(), uuid_arg(document_id)) + else { + return SyncResult::ErrorInvalidInput; + }; + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant.handle.block_on(store.get_document(doc_id)) { + Ok(Some(document)) => write_json(out_json, &document), + Ok(None) => SyncResult::ErrorNotFound, + Err(error) => store_result(&error), + } + }) } -/// Get the engine's own frozen user UUID -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `out_user_id` - Output pointer for user UUID string (caller must free with replicant_string_free) -/// -/// # Returns -/// * SyncResult::Success if the user ID was retrieved -/// * SyncResult::ErrorInvalidInput if engine or out_user_id is null -/// * SyncResult::ErrorDatabase if the user ID could not be read +/// Every visible document as a JSON array of document objects (see `replicant_get_document`). /// /// # Safety -/// Caller must ensure engine is valid and out_user_id is a valid pointer +/// Valid handle and out pointer; free the result with `replicant_string_free`. #[no_mangle] -pub unsafe extern "C" fn replicant_get_user_id( - engine: *mut Replicant, - out_user_id: *mut *mut c_char, +pub unsafe extern "C" fn replicant_get_all_documents( + handle: *mut Replicant, + out_json: *mut *mut c_char, ) -> SyncResult { - if engine.is_null() || out_user_id.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - let user_id = match engine.block_on(async { engine.database.get_user_id().await }) { - Ok(id) => id, - Err(_) => return SyncResult::ErrorDatabase, - }; + guard(|| { + clear_out(out_json); + if handle.is_null() || out_json.is_null() { + return SyncResult::ErrorInvalidInput; + } + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant.handle.block_on(store.list_documents()) { + Ok(documents) => write_json(out_json, &documents), + Err(error) => store_result(&error), + } + }) +} - match CString::new(user_id.to_string()) { - Ok(c_str) => { - *out_user_id = c_str.into_raw(); - SyncResult::Success +/// Document ids as a JSON array of strings; `include_deleted` adds documents whose delete is not +/// yet sent. +/// +/// # Safety +/// Valid handle and out pointer; free the result with `replicant_string_free`. +#[no_mangle] +pub unsafe extern "C" fn replicant_get_all_document_ids( + handle: *mut Replicant, + include_deleted: bool, + out_json: *mut *mut c_char, +) -> SyncResult { + guard(|| { + clear_out(out_json); + if handle.is_null() || out_json.is_null() { + return SyncResult::ErrorInvalidInput; } - Err(_) => SyncResult::ErrorSerialization, - } + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant + .handle + .block_on(store.document_ids(include_deleted)) + { + Ok(ids) => write_json(out_json, &ids), + Err(error) => store_result(&error), + } + }) } -/// Get library version string +/// # Safety +/// Valid handle and out pointer. #[no_mangle] -pub extern "C" fn replicant_get_version() -> *mut c_char { - let version = env!("CARGO_PKG_VERSION"); - match CString::new(version) { - Ok(s) => s.into_raw(), - Err(_) => ptr::null_mut(), - } +pub unsafe extern "C" fn replicant_count_documents( + handle: *mut Replicant, + out_count: *mut u64, +) -> SyncResult { + guard(|| { + if handle.is_null() || out_count.is_null() { + return SyncResult::ErrorInvalidInput; + } + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant.handle.block_on(store.count_documents()) { + Ok(count) => { + *out_count = count; + SyncResult::Success + } + Err(error) => store_result(&error), + } + }) } -/// Register a callback for document events (Created, Updated, Deleted) -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `callback` - C callback function to invoke for document events -/// * `context` - User-defined context pointer passed to callback -/// * `event_filter` - Optional filter: 0=Created, 1=Updated, 2=Deleted, -1=all document events -/// -/// # Returns -/// * SyncResult indicating success or failure +/// Documents with changes the server has not acknowledged, except parked ones +/// (`replicant_list_parked`). /// /// # Safety -/// Caller must ensure engine is valid, callback is a valid function pointer, and context pointer outlives the callback registration +/// Valid handle and out pointer. #[no_mangle] -pub unsafe extern "C" fn replicant_register_document_callback( - engine: *mut Replicant, - callback: DocumentEventCallback, - context: *mut c_void, - event_filter: i32, +pub unsafe extern "C" fn replicant_count_pending_sync( + handle: *mut Replicant, + out_count: *mut u64, ) -> SyncResult { - if engine.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - let filter = if event_filter >= 0 { - match event_filter { - 0 => Some(EventType::DocumentCreated), - 1 => Some(EventType::DocumentUpdated), - 2 => Some(EventType::DocumentDeleted), - _ => return SyncResult::ErrorInvalidInput, + guard(|| { + if handle.is_null() || out_count.is_null() { + return SyncResult::ErrorInvalidInput; } - } else { - None - }; - - match engine - .event_dispatcher - .register_document_callback(callback, context, filter) - { - Ok(_) => SyncResult::Success, - Err(_) => SyncResult::ErrorUnknown, - } + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant.handle.block_on(store.count_pending_sync()) { + Ok(count) => { + *out_count = count; + SyncResult::Success + } + Err(error) => store_result(&error), + } + }) } -/// Register a callback for sync events (Started, Completed) +/// Whether the engine is connected now. `replicant_get_state` says more. /// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `callback` - C callback function to invoke for sync events -/// * `context` - User-defined context pointer passed to callback -/// -/// # Returns -/// * SyncResult indicating success or failure +/// # Safety +/// `handle` must be valid or null. +#[no_mangle] +pub unsafe extern "C" fn replicant_is_connected(handle: *mut Replicant) -> bool { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + !handle.is_null() && (*handle).handle.state().connection == ConnectionView::Connected + })) + .unwrap_or(false) +} + +/// Connection, sync phase and halt reason, for a status indicator. Set +/// `out_state->struct_size = sizeof(ReplicantState)` first. True from the moment +/// `replicant_create` returns: with no usable stored credentials it is already +/// `Halted`/`NotEnrolled`. /// /// # Safety -/// Caller must ensure engine is valid, callback is a valid function pointer, and context pointer outlives the callback registration +/// Valid handle and out pointer. #[no_mangle] -pub unsafe extern "C" fn replicant_register_sync_callback( - engine: *mut Replicant, - callback: SyncEventCallback, - context: *mut c_void, +pub unsafe extern "C" fn replicant_get_state( + handle: *mut Replicant, + out_state: *mut ReplicantState, ) -> SyncResult { - if engine.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - match engine - .event_dispatcher - .register_sync_callback(callback, context) - { - Ok(_) => SyncResult::Success, - Err(_) => SyncResult::ErrorUnknown, - } + guard(|| { + if handle.is_null() || out_state.is_null() { + return SyncResult::ErrorInvalidInput; + } + // Raw field writes: the host's struct may hold values that are not valid Rust enums. + let host_size = ptr::addr_of!((*out_state).struct_size).read() as usize; + if host_size < STATE_SIZE_V1_0 { + return SyncResult::ErrorInvalidInput; + } + let state = ReplicantState::from(&(*handle).handle.state()); + ptr::addr_of_mut!((*out_state).connection).write(state.connection); + ptr::addr_of_mut!((*out_state).sync).write(state.sync); + ptr::addr_of_mut!((*out_state).halt_reason).write(state.halt_reason); + ptr::addr_of_mut!((*out_state).struct_size).write(STATE_SIZE_V1_0 as u32); + SyncResult::Success + }) } -/// Register a callback for error events (SyncError) +/// Leaves `Halted` or retries now. Safe to call repeatedly: the engine dials at most once a second. /// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `callback` - C callback function to invoke for error events -/// * `context` - User-defined context pointer passed to callback -/// -/// # Returns -/// * SyncResult indicating success or failure +/// # Safety +/// Valid handle. +#[no_mangle] +pub unsafe extern "C" fn replicant_reconnect(handle: *mut Replicant) -> SyncResult { + guard(|| { + if handle.is_null() { + return SyncResult::ErrorInvalidInput; + } + (*handle).handle.reconnect(); + SyncResult::Success + }) +} + +/// The data dir's user id (provisional until the first join). /// /// # Safety -/// Caller must ensure engine is valid, callback is a valid function pointer, and context pointer outlives the callback registration +/// Valid handle and out pointer; free the result with `replicant_string_free`. #[no_mangle] -pub unsafe extern "C" fn replicant_register_error_callback( - engine: *mut Replicant, - callback: ErrorEventCallback, - context: *mut c_void, +pub unsafe extern "C" fn replicant_get_user_id( + handle: *mut Replicant, + out_user_id: *mut *mut c_char, ) -> SyncResult { - if engine.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; + guard(|| { + clear_out(out_user_id); + if handle.is_null() || out_user_id.is_null() { + return SyncResult::ErrorInvalidInput; + } + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant.handle.block_on(store.user_id()) { + Ok(user_id) => match CString::new(user_id.to_string()) { + Ok(text) => { + *out_user_id = text.into_raw(); + SyncResult::Success + } + Err(_) => SyncResult::ErrorSerialization, + }, + Err(error) => store_result(&error), + } + }) +} - match engine - .event_dispatcher - .register_error_callback(callback, context) - { - Ok(_) => SyncResult::Success, - Err(_) => SyncResult::ErrorUnknown, - } +/// # Safety +/// Valid handle and C string (a JSON array of JSON paths, e.g. `["$.body"]`). +#[no_mangle] +pub unsafe extern "C" fn replicant_configure_search( + handle: *mut Replicant, + paths_json: *const c_char, +) -> SyncResult { + guard(|| { + if handle.is_null() { + return SyncResult::ErrorInvalidInput; + } + let paths: Vec<String> = match json_arg(paths_json).map(serde_json::from_value) { + Ok(Ok(paths)) => paths, + Ok(Err(_)) => return SyncResult::ErrorSerialization, + Err(result) => return result, + }; + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant.handle.block_on(store.configure_search(&paths)) { + Ok(()) => SyncResult::Success, + Err(error) => store_result(&error), + } + }) } -/// Register a callback for connection events (Lost, Attempted, Succeeded) -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `callback` - C callback function to invoke for connection events -/// * `context` - User-defined context pointer passed to callback -/// -/// # Returns -/// * SyncResult indicating success or failure +/// FTS5 query (`music`, `tun*`, `"a phrase"`, `a AND b`, `title:word`, where `title` is the +/// document's title from `ReplicantConfig.title_pointer`); `limit` 0 means 100. A +/// query FTS5 cannot parse is `ErrorInvalidInput`. The result is a JSON array of document +/// objects (see `replicant_get_document`), best match first. /// /// # Safety -/// Caller must ensure engine is valid, callback is a valid function pointer, and context pointer outlives the callback registration +/// Valid handle, C string and out pointer; free the result with `replicant_string_free`. #[no_mangle] -pub unsafe extern "C" fn replicant_register_connection_callback( - engine: *mut Replicant, - callback: ConnectionEventCallback, - context: *mut c_void, +pub unsafe extern "C" fn replicant_search_documents( + handle: *mut Replicant, + query: *const c_char, + limit: u32, + out_json: *mut *mut c_char, ) -> SyncResult { - if engine.is_null() { - return SyncResult::ErrorInvalidInput; - } + guard(|| { + clear_out(out_json); + let (false, false, Some(query)) = (handle.is_null(), out_json.is_null(), str_arg(query)) + else { + return SyncResult::ErrorInvalidInput; + }; + let limit = if limit == 0 { 100 } else { limit }; + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant + .handle + .block_on(store.search_documents(query, limit)) + { + Ok(documents) => write_json(out_json, &documents), + Err(error) => store_result(&error), + } + }) +} - let engine = &*engine; +/// # Safety +/// Valid handle. +#[no_mangle] +pub unsafe extern "C" fn replicant_rebuild_search_index(handle: *mut Replicant) -> SyncResult { + guard(|| { + if handle.is_null() { + return SyncResult::ErrorInvalidInput; + } + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant.handle.block_on(store.rebuild_search_index()) { + Ok(()) => SyncResult::Success, + Err(error) => store_result(&error), + } + }) +} - match engine - .event_dispatcher - .register_connection_callback(callback, context) - { - Ok(_) => SyncResult::Success, - Err(_) => SyncResult::ErrorUnknown, - } +/// One kept copy as the host lists it. +#[derive(Serialize)] +struct KeptCopyJson<'a> { + recovered_id: i64, + doc_id: Uuid, + title: Option<String>, + reason: &'a str, + recovered_at: i64, + content: &'a Value, + fields: &'a Option<Vec<FieldConflict>>, } -/// Register a callback for conflict events (ConflictDetected) -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `callback` - C callback function to invoke for conflict events -/// * `context` - User-defined context pointer passed to callback -/// -/// # Returns -/// * SyncResult indicating success or failure +/// Kept copies (local content sync set aside), newest first, as a JSON array of objects: +/// - `recovered_id` (integer): the copy's id, for `replicant_dismiss_recovered` and the restores. +/// - `doc_id` (string): the document the copy was kept from (it may since have been deleted). +/// - `title` (string or null): the kept content's title, derived as a document's is. +/// - `reason` (string): `conflict`, `field_conflict`, `delete_wins`, `delete_superseded`, +/// `delete_refused`, `delete_publication`, `became_publication`, `create_rejected` or +/// `unmigratable` (set aside while upgrading the database). +/// - `recovered_at` (integer): when it was kept, in Unix seconds. +/// - `content`: the host's own JSON value, as it was locally when kept. +/// - `fields` (array or null): null for a whole-document copy; else one object per conflicting +/// path: `path` (string, a JSON Pointer), `local_value` (any JSON; null when removed locally), +/// `local_removed` (bool: the local side removed the path). +/// +/// Copies never expire: they stay until dismissed or restored. A copy that cannot be read is left +/// out (and logged). /// /// # Safety -/// Caller must ensure engine is valid, callback is a valid function pointer, and context pointer outlives the callback registration +/// Valid handle and out pointer; free the result with `replicant_string_free`. #[no_mangle] -pub unsafe extern "C" fn replicant_register_conflict_callback( - engine: *mut Replicant, - callback: ConflictEventCallback, - context: *mut c_void, +pub unsafe extern "C" fn replicant_list_recovered( + handle: *mut Replicant, + out_json: *mut *mut c_char, ) -> SyncResult { - if engine.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - match engine - .event_dispatcher - .register_conflict_callback(callback, context) - { - Ok(_) => SyncResult::Success, - Err(_) => SyncResult::ErrorUnknown, - } + guard(|| { + clear_out(out_json); + if handle.is_null() || out_json.is_null() { + return SyncResult::ErrorInvalidInput; + } + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant.handle.block_on(store.list_recovered()) { + Ok(copies) => { + let listed: Vec<KeptCopyJson> = copies + .iter() + .map(|copy| KeptCopyJson { + recovered_id: copy.id, + doc_id: copy.doc_id, + title: store.title_of(©.content), + reason: ©.reason, + recovered_at: copy.recovered_at, + content: ©.content, + fields: ©.fields, + }) + .collect(); + write_json(out_json, &listed) + } + Err(error) => store_result(&error), + } + }) } -/// Register a callback for IdentityChanged events -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `callback` - Function to call when the server-authoritative id is adopted -/// * `context` - User-defined context pointer passed to callback -/// -/// # Returns -/// * SyncResult indicating success or failure +/// Deletes a kept copy for good; `ErrorNotFound` when it is already gone. /// /// # Safety -/// Caller must ensure engine is valid, callback is a valid function pointer, and context pointer outlives the callback registration +/// Valid handle. #[no_mangle] -pub unsafe extern "C" fn replicant_register_identity_callback( - engine: *mut Replicant, - callback: IdentityEventCallback, - context: *mut c_void, +pub unsafe extern "C" fn replicant_dismiss_recovered( + handle: *mut Replicant, + recovered_id: i64, ) -> SyncResult { - if engine.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - match engine - .event_dispatcher - .register_identity_callback(callback, context) - { - Ok(_) => SyncResult::Success, - Err(_) => SyncResult::ErrorUnknown, - } + guard(|| { + if handle.is_null() { + return SyncResult::ErrorInvalidInput; + } + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant + .handle + .block_on(store.dismiss_recovered(recovered_id)) + { + Ok(true) => SyncResult::Success, + Ok(false) => SyncResult::ErrorNotFound, + Err(error) => store_result(&error), + } + }) } -/// Process all queued events on the current thread -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `out_processed_count` - Output pointer for number of events processed (optional) -/// -/// # Returns -/// * CSyncResult indicating success or failure -/// -/// # Important -/// This function MUST be called on the same thread where callbacks were registered. -/// Events are queued from any thread but only processed on the callback thread. +/// Re-creates a kept copy's full content as a new document, with a new id written to +/// `out_document_id` (`REPLICANT_DOCUMENT_ID_LEN + 1` bytes), and removes the copy. Works for +/// any copy, a field copy too (the way out when its document is gone). `ErrorNotFound` when the +/// copy is gone (another process may have dismissed or restored it). /// /// # Safety -/// Caller must ensure engine is valid and out_processed_count points to valid memory (if not null) +/// Valid handle and a `REPLICANT_DOCUMENT_ID_LEN + 1`-byte buffer. #[no_mangle] -pub unsafe extern "C" fn replicant_process_events( - engine: *mut Replicant, - out_processed_count: *mut u32, +pub unsafe extern "C" fn replicant_restore_document( + handle: *mut Replicant, + recovered_id: i64, + out_document_id: *mut c_char, ) -> SyncResult { - if engine.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - match engine.event_dispatcher.process_events() { - Ok(count) => { - if !out_processed_count.is_null() { - out_processed_count.write(count as u32); + guard(|| { + if handle.is_null() || out_document_id.is_null() { + return SyncResult::ErrorInvalidInput; + } + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant + .handle + .block_on(store.restore_document(recovered_id)) + { + Ok(doc_id) => { + replicant.handle.notify_outbox(); + write_id(out_document_id, doc_id); + SyncResult::Success } - SyncResult::Success + Err(error) => store_result(&error), } - Err(_) => SyncResult::ErrorUnknown, - } + }) } -/// Get a document by ID +/// Writes a field copy's kept values back at their paths as a local edit (every other field +/// keeps its current value) and removes the copy. /// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `document_id` - Document ID as UUID string -/// * `out_content` - Output pointer for document JSON content (caller must free with replicant_string_free) -/// -/// # Returns -/// * SyncResult::Success if document found and content returned -/// * SyncResult::ErrorInvalidInput if document not found or invalid ID +/// A list conflict is kept as the whole list, so restoring it puts that list back exactly. +/// `ErrorNotFound`: the copy is gone. `ErrorInvalidInput`: it is a whole-document copy (its +/// `fields` is null); it stays, and `replicant_restore_document` restores it. +/// `ErrorDocumentGone`: its document was deleted, and `ErrorNotWritable`: it became read-only; +/// the copy stays in both cases, and `replicant_restore_document` brings it back as a new +/// document. /// /// # Safety -/// Caller must ensure engine is valid, document_id is a valid C string, and out_content is a valid pointer +/// Valid handle. #[no_mangle] -pub unsafe extern "C" fn replicant_get_document( - engine: *mut Replicant, - document_id: *const c_char, - out_content: *mut *mut c_char, +pub unsafe extern "C" fn replicant_restore_fields( + handle: *mut Replicant, + recovered_id: i64, ) -> SyncResult { - if engine.is_null() || document_id.is_null() || out_content.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - let document_id = match CStr::from_ptr(document_id).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let doc_uuid = match Uuid::parse_str(document_id) { - Ok(id) => id, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let doc = match engine.block_on(async { engine.database.get_document(&doc_uuid).await }) { - Ok(d) => d, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - // Serialize document to JSON - let json = match serde_json::to_string(&doc) { - Ok(j) => j, - Err(_) => return SyncResult::ErrorSerialization, - }; - - match CString::new(json) { - Ok(c_str) => { - *out_content = c_str.into_raw(); - SyncResult::Success + guard(|| { + if handle.is_null() { + return SyncResult::ErrorInvalidInput; } - Err(_) => SyncResult::ErrorSerialization, - } + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant + .handle + .block_on(store.restore_fields(recovered_id)) + { + Ok(()) => { + replicant.handle.notify_outbox(); + SyncResult::Success + } + Err(error) => store_result(&error), + } + }) } -/// Get all documents as a JSON array -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `out_documents` - Output pointer for JSON array of documents (caller must free with replicant_string_free) -/// -/// # Returns -/// * SyncResult::Success with JSON array (empty array [] if no documents) +/// Documents that stopped uploading until their next local edit, as a JSON array of objects: +/// `doc_id` (string, the parked document) and `code` (string: `validation`, `forbidden`, +/// `too_large` or `diverged`). Parked documents are not counted in +/// `replicant_count_pending_sync`; a new local edit un-parks one. /// /// # Safety -/// Caller must ensure engine is valid and out_documents is a valid pointer +/// Valid handle and out pointer; free the result with `replicant_string_free`. #[no_mangle] -pub unsafe extern "C" fn replicant_get_all_documents( - engine: *mut Replicant, - out_documents: *mut *mut c_char, +pub unsafe extern "C" fn replicant_list_parked( + handle: *mut Replicant, + out_json: *mut *mut c_char, ) -> SyncResult { - if engine.is_null() || out_documents.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - let docs = match engine.block_on(async { engine.database.get_all_documents().await }) { - Ok(d) => d, - Err(_) => return SyncResult::ErrorDatabase, - }; - - // Serialize documents array to JSON - let json = match serde_json::to_string(&docs) { - Ok(j) => j, - Err(_) => return SyncResult::ErrorSerialization, - }; - - match CString::new(json) { - Ok(c_str) => { - *out_documents = c_str.into_raw(); - SyncResult::Success + guard(|| { + clear_out(out_json); + if handle.is_null() || out_json.is_null() { + return SyncResult::ErrorInvalidInput; } - Err(_) => SyncResult::ErrorSerialization, - } + let replicant = &*handle; + let store = replicant.handle.store(); + match replicant.handle.block_on(store.list_parked()) { + Ok(parked) => write_json(out_json, &parked), + Err(error) => store_result(&error), + } + }) } -/// Get all document ids as a JSON array -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `include_deleted` - If true, include tombstoned (deleted) documents -/// * `out_ids` - Output pointer for JSON array of id strings (caller must free with replicant_string_free) -/// -/// # Returns -/// * SyncResult::Success with JSON array (empty array [] if no documents) +/// `event_filter`: -1 every document event, 1 `DocumentChanged` only, 2 `DocumentDeleted` only. +/// One callback per kind: registering again replaces the previous callback, filter and context; +/// a null callback removes it (its events are then dropped when pumped). Once this returns, the +/// old context is never called again, even for the rest of a batch being pumped. The first +/// registration on a handle, of any kind and even a null one, binds its thread: later +/// registrations and `replicant_process_events` must come from that thread +/// (`ErrorWrongThread` otherwise). /// /// # Safety -/// Caller must ensure engine is valid and out_ids is a valid pointer +/// Valid handle; `context` must outlive the handle. #[no_mangle] -pub unsafe extern "C" fn replicant_get_all_document_ids( - engine: *mut Replicant, - include_deleted: bool, - out_ids: *mut *mut c_char, +pub unsafe extern "C" fn replicant_register_document_callback( + handle: *mut Replicant, + callback: ReplicantDocumentEventCallback, + context: *mut c_void, + event_filter: i32, ) -> SyncResult { - if engine.is_null() || out_ids.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - let ids = match engine - .block_on(async { engine.database.get_all_document_ids(include_deleted).await }) - { - Ok(d) => d, - Err(_) => return SyncResult::ErrorDatabase, - }; - - // Serialize ids array to JSON - let json = match serde_json::to_string(&ids) { - Ok(j) => j, - Err(_) => return SyncResult::ErrorSerialization, - }; - - match CString::new(json) { - Ok(c_str) => { - *out_ids = c_str.into_raw(); - SyncResult::Success + guard(|| { + if handle.is_null() { + return SyncResult::ErrorInvalidInput; } - Err(_) => SyncResult::ErrorSerialization, - } + let filter = match event_filter { + -1 => None, + 1 => Some(EventType::DocumentChanged), + 2 => Some(EventType::DocumentDeleted), + _ => return SyncResult::ErrorInvalidInput, + }; + registered( + (*handle) + .dispatcher + .register_document(callback, context, filter), + ) + }) } -/// Get the count of local documents -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `out_count` - Output pointer for document count -/// -/// # Returns -/// * SyncResult::Success with count written to out_count +/// `SyncStarted`, `SyncCompleted`, `DatabaseChanged` and `IdentityAdopted`. Replaces or removes +/// as `replicant_register_document_callback` does. /// /// # Safety -/// Caller must ensure engine is valid and out_count is a valid pointer +/// Valid handle; `context` must outlive the handle. #[no_mangle] -pub unsafe extern "C" fn replicant_count_documents( - engine: *mut Replicant, - out_count: *mut u64, +pub unsafe extern "C" fn replicant_register_sync_callback( + handle: *mut Replicant, + callback: ReplicantSyncEventCallback, + context: *mut c_void, ) -> SyncResult { - if engine.is_null() || out_count.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - let count = match engine.block_on(async { engine.database.count_documents().await }) { - Ok(d) => d, - Err(_) => return SyncResult::ErrorDatabase, - }; - - *out_count = count as u64; - SyncResult::Success + guard(|| { + if handle.is_null() { + return SyncResult::ErrorInvalidInput; + } + registered((*handle).dispatcher.register_sync(callback, context)) + }) } -/// Check if the sync engine is connected to the server -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// -/// # Returns -/// * true if connected, false if disconnected or engine is null +/// Replaces or removes as `replicant_register_document_callback` does. /// /// # Safety -/// Caller must ensure engine was created by replicant_create +/// Valid handle; `context` must outlive the handle. #[no_mangle] -pub unsafe extern "C" fn replicant_is_connected(engine: *mut Replicant) -> bool { - if engine.is_null() { - return false; - } - - let engine = &*engine; - - let engine_guard = engine.engine.lock().unwrap(); - match *engine_guard { - Some(ref sync_engine) => sync_engine.is_connected(), - None => false, - } +pub unsafe extern "C" fn replicant_register_error_callback( + handle: *mut Replicant, + callback: ReplicantErrorEventCallback, + context: *mut c_void, +) -> SyncResult { + guard(|| { + if handle.is_null() { + return SyncResult::ErrorInvalidInput; + } + registered((*handle).dispatcher.register_error(callback, context)) + }) } -/// Get the count of documents pending sync to server -/// -/// # Arguments -/// * `engine` - Sync engine instance -/// * `out_count` - Output pointer for pending document count -/// -/// # Returns -/// * SyncResult::Success with count written to out_count +/// Replaces or removes as `replicant_register_document_callback` does. /// /// # Safety -/// Caller must ensure engine is valid and out_count is a valid pointer +/// Valid handle; `context` must outlive the handle. #[no_mangle] -pub unsafe extern "C" fn replicant_count_pending_sync( - engine: *mut Replicant, - out_count: *mut u64, +pub unsafe extern "C" fn replicant_register_connection_callback( + handle: *mut Replicant, + callback: ReplicantConnectionEventCallback, + context: *mut c_void, ) -> SyncResult { - if engine.is_null() || out_count.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - // If we have a sync engine, use it; otherwise check database directly - let engine_guard = engine.engine.lock().unwrap(); - let count = if let Some(ref sync_engine) = *engine_guard { - match engine.block_on(async { sync_engine.count_pending_sync().await }) { - Ok(c) => c, - Err(_) => return SyncResult::ErrorDatabase, - } - } else { - drop(engine_guard); - // Offline mode - check pending documents in database - match engine.block_on(async { engine.database.get_pending_documents().await }) { - Ok(docs) => docs.len(), - Err(_) => return SyncResult::ErrorDatabase, + guard(|| { + if handle.is_null() { + return SyncResult::ErrorInvalidInput; } - }; - - *out_count = count as u64; - SyncResult::Success + registered((*handle).dispatcher.register_connection(callback, context)) + }) } -// ===== FTS (Full-Text Search) Functions ===== - -/// Configure which JSON paths to index for full-text search -/// -/// # Arguments -/// * `engine` - Replicant client instance -/// * `paths_json` - JSON array of JSON paths to index (e.g., '["$.body", "$.notes"]') -/// -/// # Returns -/// * SyncResult::Success if configuration succeeded -/// * SyncResult::ErrorInvalidInput if paths_json is invalid -/// * SyncResult::ErrorDatabase if index rebuild fails -/// -/// # Note -/// This replaces any existing configuration and rebuilds the search index. +/// Replaces or removes as `replicant_register_document_callback` does. /// /// # Safety -/// Caller must ensure engine is valid and paths_json is a valid C string +/// Valid handle; `context` must outlive the handle. #[no_mangle] -pub unsafe extern "C" fn replicant_configure_search( - engine: *mut Replicant, - paths_json: *const c_char, +pub unsafe extern "C" fn replicant_register_conflict_callback( + handle: *mut Replicant, + callback: ReplicantConflictEventCallback, + context: *mut c_void, ) -> SyncResult { - if engine.is_null() || paths_json.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - let paths_json = match CStr::from_ptr(paths_json).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - // Parse JSON array of paths - let paths: Vec<String> = match serde_json::from_str(paths_json) { - Ok(p) => p, - Err(_) => return SyncResult::ErrorSerialization, - }; - - match engine.block_on(async { engine.database.configure_search(&paths).await }) { - Ok(_) => SyncResult::Success, - Err(_) => SyncResult::ErrorDatabase, - } + guard(|| { + if handle.is_null() { + return SyncResult::ErrorInvalidInput; + } + registered((*handle).dispatcher.register_conflict(callback, context)) + }) } -/// Search documents using FTS5 full-text search +/// Runs the callbacks for every queued event. Must be called on the thread of the handle's first +/// registration (a null one binds too): refused with `ErrorWrongThread` elsewhere, without +/// disturbing a pump running on the bound thread, and `ErrorNoCallbacks` before any +/// registration; nothing is lost either way. The thread cannot be changed later. A +/// `replicant_destroy` of this handle from inside a callback frees it when this call returns; +/// the rest of the batch is not delivered. A call from inside a callback returns +/// `ErrorInvalidInput`. No rebind: if the registering thread ends, register again on a new +/// handle. /// -/// # Arguments -/// * `engine` - Replicant client instance -/// * `query` - FTS5 query string (e.g., "music", "tun*", "\"exact phrase\"") -/// * `limit` - Maximum number of results (0 for default of 100) -/// * `out_documents` - Output pointer for JSON array of matching documents -/// -/// # Returns -/// * SyncResult::Success with JSON array in out_documents -/// * SyncResult::ErrorInvalidInput if query is invalid -/// * SyncResult::ErrorDatabase if search fails -/// -/// # FTS5 Query Syntax -/// * Simple terms: "music" matches documents containing "music" -/// * Prefix: "tun*" matches "tuning", "tune", etc. -/// * Phrase: "\"equal temperament\"" matches exact phrase -/// * Boolean: "music AND theory", "piano OR keyboard" -/// * Column filter: "title:beethoven" searches only title field +/// Callbacks must not throw or unwind. Inside a callback every call is allowed except +/// `replicant_process_events` on the same handle; a `replicant_destroy` of it is deferred. /// /// # Safety -/// Caller must ensure engine is valid, query is a valid C string, -/// and out_documents is a valid pointer. Caller must free result with replicant_string_free. +/// Valid handle; `out_processed_count` may be null. #[no_mangle] -pub unsafe extern "C" fn replicant_search_documents( - engine: *mut Replicant, - query: *const c_char, - limit: u32, - out_documents: *mut *mut c_char, +pub unsafe extern "C" fn replicant_process_events( + handle: *mut Replicant, + out_processed_count: *mut u32, ) -> SyncResult { - if engine.is_null() || query.is_null() || out_documents.is_null() { + if handle.is_null() { return SyncResult::ErrorInvalidInput; } - - let engine = &*engine; - - let query = match CStr::from_ptr(query).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let limit = if limit == 0 { 100 } else { limit as i64 }; - - let docs = match engine.block_on(async { engine.database.search_documents(query, limit).await }) + let replicant = &*handle; + // Checked before the pump flag, so a call from another thread never holds it. + if let Err(error) = replicant.dispatcher.may_process() { + return dispatch_result(error); + } + if replicant + .pump + .compare_exchange( + PUMP_IDLE, + PUMP_DISPATCHING, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_err() { - Ok(d) => d, - Err(_) => return SyncResult::ErrorDatabase, - }; - - // Serialize documents array to JSON - let json = match serde_json::to_string(&docs) { - Ok(j) => j, - Err(_) => return SyncResult::ErrorSerialization, - }; - - match CString::new(json) { - Ok(c_str) => { - *out_documents = c_str.into_raw(); - SyncResult::Success + return SyncResult::ErrorInvalidInput; // already pumping (a re-entrant call) + } + let result = guard(|| { + let destroy_requested = || replicant.pump.load(Ordering::SeqCst) == PUMP_DESTROY_REQUESTED; + match replicant + .dispatcher + .process(|| replicant.handle.take_events(), destroy_requested) + { + Ok(processed) => { + if !out_processed_count.is_null() { + *out_processed_count = u32::try_from(processed).unwrap_or(u32::MAX); + } + SyncResult::Success + } + Err(error) => dispatch_result(error), } - Err(_) => SyncResult::ErrorSerialization, + }); + if replicant + .pump + .compare_exchange( + PUMP_DISPATCHING, + PUMP_IDLE, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_err() + { + free(handle); } + result } -/// Rebuild the full-text search index -/// -/// # Arguments -/// * `engine` - Replicant client instance -/// -/// # Returns -/// * SyncResult::Success if rebuild succeeded -/// * SyncResult::ErrorDatabase if rebuild fails -/// -/// # Note -/// This is called automatically by replicant_configure_search, but can be -/// called manually if needed (e.g., after bulk document imports). -/// /// # Safety -/// Caller must ensure engine is valid +/// `s` must come from this library and not be freed twice. #[no_mangle] -pub unsafe extern "C" fn replicant_rebuild_search_index(engine: *mut Replicant) -> SyncResult { - if engine.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - match engine.block_on(async { engine.database.rebuild_fts_index().await }) { - Ok(_) => SyncResult::Success, - Err(_) => SyncResult::ErrorDatabase, +pub unsafe extern "C" fn replicant_string_free(s: *mut c_char) { + if !s.is_null() { + drop(CString::from_raw(s)); } } -// ============================================================================ -// Enrollment + credential storage -// ============================================================================ +/// Found by `scripts/check_replicant_versions.py` in every binary that links this library. +#[used] +static VERSION_MARKER: &str = concat!("replicant-client-version=", env!("CARGO_PKG_VERSION"), "\0"); -/// Copies `s` plus a NUL terminator into `out` iff it fits within `cap` -/// bytes. Returns `false` — writing an empty C string when `cap > 0` — when -/// it does not fit; never writes past `cap`. On a multi-buffer call that -/// fails partway, earlier out buffers may already be populated — callers -/// must not read any out buffer unless the call returned success. Bytes of -/// `s` are copied verbatim, so an embedded NUL makes C readers see the -/// string truncated at that NUL. -/// -/// # Safety -/// `out` must point to a writable buffer of at least `cap` bytes. -unsafe fn write_cstr_buf(out: *mut c_char, cap: usize, s: &str) -> bool { - if cap == 0 { - return false; - } - let bytes = s.as_bytes(); - if bytes.len() + 1 > cap { - out.write(0); - return false; - } - ptr::copy_nonoverlapping(bytes.as_ptr(), out as *mut u8, bytes.len()); - out.add(bytes.len()).write(0); - true +/// This library's version, e.g. "0.7.0". Static: never free it. +#[no_mangle] +pub extern "C" fn replicant_get_version() -> *const c_char { + VERSION_MARKER["replicant-client-version=".len()..] + .as_ptr() + .cast() } -/// Requests an enrollment token be emailed to `email`. Standalone HTTP call -/// (no engine handle); runs on a dedicated thread with its own short-lived -/// runtime so this is safe to call even from inside an async runtime context. +// ===== Enrollment and sign-out ===== + +/// Asks the server to email an enrollment code to `email`. Needs no handle. Results: +/// - `Success`: the server accepted the request (HTTP 202). +/// - `ErrorInvalidInput`: a null or non-UTF-8 argument, an empty or over-long email, or a +/// `base_url` that is not https (`http://localhost` and `http://127.0.0.1` excepted). The +/// server is not contacted. +/// - `ErrorConnection`: the server could not be reached, timed out, or answered with any status +/// other than 202: a 4xx (429 when rate limited) or a 5xx. Retry later. +/// - `ErrorUnknown`: the library could not start the request. /// -/// BLOCKING: waits for the HTTP round-trip (connect ~10s / request ~30s -/// timeouts). TODO(#40): add a completion-callback async variant -/// (`replicant_enroll_request_async`) so consumers don't block a caller thread. +/// Blocks the calling thread for the HTTP round trip (up to about 10 s to connect and 30 s for +/// the request); never call it from an audio or UI thread. /// /// # Safety /// `base_url` and `email` must be valid, non-null C strings. @@ -1721,295 +1403,322 @@ pub unsafe extern "C" fn replicant_enroll_request( base_url: *const c_char, email: *const c_char, ) -> SyncResult { - if base_url.is_null() || email.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let base_url = match CStr::from_ptr(base_url).to_str() { - Ok(s) => s.to_string(), - Err(_) => return SyncResult::ErrorInvalidInput, - }; - let email = match CStr::from_ptr(email).to_str() { - Ok(s) => s.to_string(), - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let join_result = std::thread::spawn(move || { - let runtime = Runtime::new().map_err(|_| SyncResult::ErrorUnknown)?; - match runtime.block_on(crate::enrollment::request(&base_url, &email)) { - Ok(()) => Ok(()), - Err(_) => Err(SyncResult::ErrorConnection), + guard(|| { + let (Some(base_url), Some(email)) = (str_arg(base_url), str_arg(email)) else { + return SyncResult::ErrorInvalidInput; + }; + if email.is_empty() || email.len() > REPLICANT_EMAIL_MAX_LEN { + return SyncResult::ErrorInvalidInput; + } + let (base_url, email) = (base_url.to_string(), email.to_string()); + let join_result = std::thread::spawn(move || { + let runtime = Runtime::new().map_err(|_| SyncResult::ErrorUnknown)?; + runtime + .block_on(crate::enrollment::request(&base_url, &email)) + .map_err(|error| match error { + crate::enrollment::EnrollError::InsecureUrl => SyncResult::ErrorInvalidInput, + _ => SyncResult::ErrorConnection, + }) + }) + .join(); + match join_result { + Ok(Ok(())) => SyncResult::Success, + Ok(Err(result)) => result, + Err(_) => SyncResult::ErrorUnknown, } }) - .join(); - - match join_result { - Ok(Ok(())) => SyncResult::Success, - Ok(Err(err)) => err, - Err(_) => SyncResult::ErrorUnknown, - } } -/// Exchanges an enrollment token for a per-user credential. On success writes -/// the api_key, secret, and canonical user id (36-char UUID string) into the -/// out buffers; each `*_cap` is the writable size of its buffer in bytes and -/// the call fails (without overflowing) when a value does not fit. -/// -/// BLOCKING: waits for the HTTP round-trip (connect ~10s / request ~30s -/// timeouts). TODO(#40): add a completion-callback async variant -/// (`replicant_enroll_claim_async`) so consumers don't block a caller thread. +/// Exchanges an enrollment code for credentials and stores them in `data_dir` (encrypted at rest) +/// with `email`; the api key and secret never leave the library. Writes the user id into +/// `out_user_id` (`user_id_cap` bytes, at least `REPLICANT_USER_ID_LEN + 1`). +/// Results: +/// - `Success`: stored; this process's engines on `data_dir` sign in at once, and engines in +/// other processes within about 3 s. +/// - `ErrorInvalidInput`: a null or non-UTF-8 argument, an empty or over-long email, or a +/// `base_url` that is not https (localhost excepted). The server is not contacted. +/// - `ErrorBufferTooSmall`: `user_id_cap` is too small. The server is not contacted. +/// - `ErrorDatabase`: `data_dir` cannot hold credentials. This is checked before the server is +/// contacted, so the code stays valid; if storing still fails after the claim, the code was +/// used: request a new one. +/// - `ErrorTokenRejected`: the server refused the code (wrong or expired). +/// - `ErrorConnection`: the server could not be reached, timed out, or answered with any status +/// other than 200 or 401 (429 when rate limited). Retry later. +/// - `ErrorSerialization`: the server's reply was not valid credentials. +/// +/// Blocks the calling thread for the HTTP round trip (up to about 10 s to connect and 30 s for +/// the request); never call it from an audio or UI thread. /// /// # Safety -/// All string pointers must be valid, non-null C strings; each out pointer -/// must reference a writable buffer of at least its stated capacity. +/// All string pointers must be valid, non-null C strings; `out_user_id` must reference a +/// writable buffer of at least `user_id_cap` bytes. #[no_mangle] pub unsafe extern "C" fn replicant_enroll_claim( base_url: *const c_char, + data_dir: *const c_char, email: *const c_char, token: *const c_char, - out_api_key: *mut c_char, - api_key_cap: usize, - out_secret: *mut c_char, - secret_cap: usize, out_user_id: *mut c_char, user_id_cap: usize, ) -> SyncResult { - if base_url.is_null() - || email.is_null() - || token.is_null() - || out_api_key.is_null() - || out_secret.is_null() - || out_user_id.is_null() - { - return SyncResult::ErrorInvalidInput; - } - - let base_url = match CStr::from_ptr(base_url).to_str() { - Ok(s) => s.to_string(), - Err(_) => return SyncResult::ErrorInvalidInput, - }; - let email = match CStr::from_ptr(email).to_str() { - Ok(s) => s.to_string(), - Err(_) => return SyncResult::ErrorInvalidInput, - }; - let token = match CStr::from_ptr(token).to_str() { - Ok(s) => s.to_string(), - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - let join_result = std::thread::spawn(move || { - let runtime = Runtime::new().map_err(|_| SyncResult::ErrorUnknown)?; - runtime - .block_on(crate::enrollment::claim(&base_url, &email, &token)) - .map_err(|e| match e { - crate::enrollment::EnrollError::InvalidToken => SyncResult::ErrorInvalidInput, - // A malformed/incomplete 200 is a bad response, not a transport - // failure — keep it distinct from network errors so callers can - // tell "your code is wrong" from "the server misbehaved". - crate::enrollment::EnrollError::InvalidResponse => SyncResult::ErrorSerialization, - _ => SyncResult::ErrorConnection, - }) - }) - .join(); - - match join_result { - Ok(Ok(creds)) => { - if write_cstr_buf(out_api_key, api_key_cap, &creds.api_key) - && write_cstr_buf(out_secret, secret_cap, &creds.secret) - && write_cstr_buf(out_user_id, user_id_cap, &creds.user_id.to_string()) - { + guard(|| { + let (Some(base_url), Some(data_dir), Some(email), Some(token), false) = ( + str_arg(base_url), + str_arg(data_dir), + str_arg(email), + str_arg(token), + out_user_id.is_null(), + ) else { + return SyncResult::ErrorInvalidInput; + }; + if email.is_empty() || email.len() > REPLICANT_EMAIL_MAX_LEN { + return SyncResult::ErrorInvalidInput; + } + if user_id_cap < REPLICANT_USER_ID_LEN + 1 { + return SyncResult::ErrorBufferTooSmall; + } + if crate::secret_store::prepare(Path::new(data_dir)).is_err() { + return SyncResult::ErrorDatabase; + } + let (base_url, request_email, token) = + (base_url.to_string(), email.to_string(), token.to_string()); + let join_result = std::thread::spawn(move || { + let runtime = Runtime::new().map_err(|_| SyncResult::ErrorUnknown)?; + runtime + .block_on(crate::enrollment::claim(&base_url, &request_email, &token)) + .map_err(|error| match error { + crate::enrollment::EnrollError::InvalidToken => SyncResult::ErrorTokenRejected, + crate::enrollment::EnrollError::InsecureUrl => SyncResult::ErrorInvalidInput, + crate::enrollment::EnrollError::InvalidResponse => { + SyncResult::ErrorSerialization + } + crate::enrollment::EnrollError::Http(_) => SyncResult::ErrorConnection, + }) + }) + .join(); + let mut credentials = match join_result { + Ok(Ok(credentials)) => credentials, + Ok(Err(result)) => return result, + Err(_) => return SyncResult::ErrorUnknown, + }; + credentials.email = Some(email.to_string()); + match crate::secret_store::store(Path::new(data_dir), &credentials) { + Ok(()) => { + host::credentials_changed(Path::new(data_dir)); + write_id(out_user_id, credentials.user_id); SyncResult::Success - } else { - SyncResult::ErrorInvalidInput } + Err(_) => SyncResult::ErrorDatabase, } - Ok(Err(err)) => err, - Err(_) => SyncResult::ErrorUnknown, - } + }) } -/// Loads stored credentials from `data_dir`. Returns Success and fills the -/// out buffers (api_key, secret, canonical user id), or ErrorDatabase if none -/// are stored / unreadable. Each `*_cap` is the writable size of its buffer; -/// the call fails (without overflowing) when a value does not fit. +/// Removes the stored credentials (sign-out) and tells this process's engines on `data_dir`: +/// they halt as not enrolled and never join with the removed credentials. An engine in another +/// process ends its live connection within about a second and never joins with the removed +/// credentials. /// /// # Safety -/// `data_dir` must be a valid, non-null C string; each out pointer must -/// reference a writable buffer of at least its stated capacity. +/// Valid C string. #[no_mangle] -pub unsafe extern "C" fn replicant_load_credentials( - data_dir: *const c_char, - out_api_key: *mut c_char, - api_key_cap: usize, - out_secret: *mut c_char, - secret_cap: usize, - out_user_id: *mut c_char, - user_id_cap: usize, -) -> SyncResult { - if data_dir.is_null() || out_api_key.is_null() || out_secret.is_null() || out_user_id.is_null() - { - return SyncResult::ErrorInvalidInput; - } - - let data_dir = match CStr::from_ptr(data_dir).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - match crate::secret_store::load(std::path::Path::new(data_dir)) { - Ok(Some(creds)) => { - if write_cstr_buf(out_api_key, api_key_cap, &creds.api_key) - && write_cstr_buf(out_secret, secret_cap, &creds.secret) - && write_cstr_buf(out_user_id, user_id_cap, &creds.user_id.to_string()) - { +pub unsafe extern "C" fn replicant_clear_credentials(data_dir: *const c_char) -> SyncResult { + guard(|| { + let Some(data_dir) = str_arg(data_dir) else { + return SyncResult::ErrorInvalidInput; + }; + match crate::secret_store::clear(Path::new(data_dir)) { + Ok(()) => { + host::credentials_changed(Path::new(data_dir)); SyncResult::Success - } else { - SyncResult::ErrorInvalidInput } + Err(_) => SyncResult::ErrorDatabase, } - Ok(None) | Err(_) => SyncResult::ErrorDatabase, - } -} - -/// Stores credentials to `data_dir` (encrypted at rest). `user_id` is the -/// canonical id delivered by enrollment claim (36-char UUID string); a nil or -/// unparseable id is rejected — credentials are never stored without a real -/// identity. -/// -/// # Safety -/// All pointers must be valid, non-null C strings. -#[no_mangle] -pub unsafe extern "C" fn replicant_store_credentials( - data_dir: *const c_char, - api_key: *const c_char, - secret: *const c_char, - user_id: *const c_char, -) -> SyncResult { - if data_dir.is_null() || api_key.is_null() || secret.is_null() || user_id.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let data_dir = match CStr::from_ptr(data_dir).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - let api_key = match CStr::from_ptr(api_key).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - let secret = match CStr::from_ptr(secret).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - let user_id = match CStr::from_ptr(user_id) - .to_str() - .ok() - .and_then(|s| Uuid::parse_str(s).ok()) - { - Some(id) if !id.is_nil() => id, - _ => return SyncResult::ErrorInvalidInput, - }; - - let creds = crate::secret_store::Credentials { - api_key: api_key.to_string(), - secret: secret.to_string(), - user_id, - }; - - match crate::secret_store::store(std::path::Path::new(data_dir), &creds) { - Ok(()) => SyncResult::Success, - Err(_) => SyncResult::ErrorDatabase, - } -} - -/// Clears any stored credentials in `data_dir`. -/// -/// # Safety -/// `data_dir` must be a valid, non-null C string. -#[no_mangle] -pub unsafe extern "C" fn replicant_clear_credentials(data_dir: *const c_char) -> SyncResult { - if data_dir.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let data_dir = match CStr::from_ptr(data_dir).to_str() { - Ok(s) => s, - Err(_) => return SyncResult::ErrorInvalidInput, - }; - - match crate::secret_store::clear(std::path::Path::new(data_dir)) { - Ok(()) => SyncResult::Success, - Err(_) => SyncResult::ErrorDatabase, - } + }) } #[cfg(test)] mod tests { use super::*; - #[test] - fn write_cstr_buf_refuses_oversized_strings() { - let mut small = [1i8; 8]; - let fitted = unsafe { - write_cstr_buf( - small.as_mut_ptr() as *mut c_char, - small.len(), - "too-long-for-8", - ) - }; - assert!(!fitted); - assert_eq!(small[0], 0, "refused write must leave an empty C string"); - - let mut big = [1i8; 32]; - let fitted = unsafe { write_cstr_buf(big.as_mut_ptr() as *mut c_char, big.len(), "fits") }; - assert!(fitted); - assert_eq!(big[4], 0, "NUL terminator after the copied bytes"); - } - - #[test] - fn write_cstr_buf_exact_fit_boundary() { - // len + 1 == cap fits exactly; len == cap does not. - let mut buf = [1i8; 5]; - assert!(unsafe { write_cstr_buf(buf.as_mut_ptr() as *mut c_char, buf.len(), "four") }); - assert_eq!(buf[4], 0); - assert!(!unsafe { write_cstr_buf(buf.as_mut_ptr() as *mut c_char, buf.len(), "five!") }); - assert_eq!(buf[0], 0, "refused write must leave an empty C string"); - } - - #[test] - fn write_cstr_buf_zero_capacity_is_refused() { - let mut buf = [1i8; 1]; - assert!(!unsafe { write_cstr_buf(buf.as_mut_ptr() as *mut c_char, 0, "") }); - assert_eq!(buf[0], 1, "zero-cap buffer must not be touched"); - } - #[tokio::test] async fn enroll_ffi_is_callable_from_within_a_runtime() { - // Pre-guard code panicked here ("Cannot start a runtime from within a - // runtime"); the OS-thread hop makes this safe. The insecure URL makes - // the call fail fast without any network traffic. + // The calls run on their own thread, so a caller inside a runtime is fine. The + // insecure URL makes them fail fast without any network traffic. let url = CString::new("http://example.com").unwrap(); let email = CString::new("rt@test.com").unwrap(); let result = unsafe { replicant_enroll_request(url.as_ptr(), email.as_ptr()) }; assert_ne!(result, SyncResult::Success); let token = CString::new("TOK").unwrap(); - let mut key = [0i8; 129]; - let mut secret = [0i8; 129]; + let dir = tempfile::tempdir().unwrap(); + let data_dir = CString::new(dir.path().to_str().unwrap()).unwrap(); let mut uid = [0i8; 37]; let result = unsafe { replicant_enroll_claim( url.as_ptr(), + data_dir.as_ptr(), email.as_ptr(), token.as_ptr(), - key.as_mut_ptr() as *mut c_char, - key.len(), - secret.as_mut_ptr() as *mut c_char, - secret.len(), uid.as_mut_ptr() as *mut c_char, uid.len(), ) }; assert_ne!(result, SyncResult::Success); } + + #[test] + fn state_names_every_halt_reason() { + let halted = |reason| { + ReplicantState::from(&EngineState { + connection: ConnectionView::Halted(reason), + sync: SyncView::Idle, + }) + .halt_reason + }; + assert_eq!( + halted(HaltReason::NotEnrolled), + ReplicantHaltReason::NotEnrolled + ); + assert_eq!( + halted(HaltReason::AuthInvalid), + ReplicantHaltReason::AuthInvalid + ); + assert_eq!( + halted(HaltReason::UpdateRequired), + ReplicantHaltReason::UpdateRequired + ); + assert_eq!( + halted(HaltReason::AccountDisabled), + ReplicantHaltReason::AccountDisabled + ); + assert_eq!( + halted(HaltReason::Other("identity_drift".into())), + ReplicantHaltReason::IdentityDrift + ); + assert_eq!( + halted(HaltReason::Other("store_error".into())), + ReplicantHaltReason::Other + ); + assert_eq!( + ReplicantState::from(&EngineState { + connection: ConnectionView::Connected, + sync: SyncView::Live, + }), + ReplicantState { + struct_size: std::mem::size_of::<ReplicantState>() as u32, + connection: ReplicantConnection::Connected, + sync: ReplicantSync::Live, + halt_reason: ReplicantHaltReason::None, + } + ); + } + + #[test] + fn this_version_s_structs_are_the_abi_1_0_sizes() { + assert_eq!(std::mem::size_of::<ReplicantConfig>(), CONFIG_SIZE_V1_0); + assert_eq!(std::mem::size_of::<ReplicantState>(), STATE_SIZE_V1_0); + #[cfg(target_pointer_width = "64")] + assert_eq!((CONFIG_SIZE_V1_0, STATE_SIZE_V1_0), (80, 16)); + } + + #[test] + fn a_panic_inside_an_entry_point_becomes_error_unknown() { + assert_eq!(guard(|| panic!("boom")), SyncResult::ErrorUnknown); + } + + #[test] + fn the_version_marker_names_this_crate_version() { + assert_eq!( + VERSION_MARKER, + concat!("replicant-client-version=", env!("CARGO_PKG_VERSION"), "\0") + ); + assert_eq!( + unsafe { CStr::from_ptr(replicant_get_version()) } + .to_str() + .unwrap(), + env!("CARGO_PKG_VERSION") + ); + } + + #[test] + fn a_restored_document_is_uploaded_without_another_write() { + use crate::driver::test_server::ScriptedServer; + use crate::secret_store::{self, Credentials}; + use crate::store::test_support::{exec, ME}; + + let server_runtime = Runtime::new().unwrap(); + let server = server_runtime.block_on(ScriptedServer::start(ME)); + let dir = tempfile::tempdir().unwrap(); + secret_store::store( + dir.path(), + &Credentials { + api_key: "k1".into(), + secret: "rps_test".into(), + user_id: ME, + email: None, + }, + ) + .unwrap(); + let strings = [ + dir.path().to_str().unwrap(), + "replicant.sqlite3", + server.url.as_str(), + "a@b.c", + "Test Host", + "1.0", + ] + .map(|text| CString::new(text).unwrap()); + let config = ReplicantConfig { + struct_size: std::mem::size_of::<ReplicantConfig>() as u32, + data_dir: strings[0].as_ptr(), + database_file: strings[1].as_ptr(), + server_url: strings[2].as_ptr(), + email: strings[3].as_ptr(), + host_app: strings[4].as_ptr(), + host_version: strings[5].as_ptr(), + list_merge: 0, + list_merge_rules_json: ptr::null(), + title_pointer: ptr::null(), + }; + let mut handle = ptr::null_mut(); + assert_eq!( + unsafe { replicant_create(&config, &mut handle) }, + SyncResult::Success + ); + let replicant = unsafe { &*handle }; + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while replicant.handle.state().sync != SyncView::Live { + assert!( + std::time::Instant::now() < deadline, + "engine never went live" + ); + std::thread::sleep(Duration::from_millis(20)); + } + replicant.handle.block_on(exec( + &replicant.handle.store(), + "INSERT INTO recovered (doc_id, content, reason, recovered_at) \ + VALUES ('00000000-0000-0000-0000-000000000001', '{\"title\":\"Kept\"}', 'delete_wins', 100)", + )); + let recovered_id = replicant + .handle + .block_on(replicant.handle.store().list_recovered()) + .unwrap()[0] + .id; + let mut restored = [0 as c_char; 37]; + assert_eq!( + unsafe { replicant_restore_document(handle, recovered_id, restored.as_mut_ptr()) }, + SyncResult::Success + ); + let doc_id: Uuid = unsafe { CStr::from_ptr(restored.as_ptr()) } + .to_str() + .unwrap() + .parse() + .unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while server.uploads_for(doc_id).is_empty() { + assert!(std::time::Instant::now() < deadline, "never uploaded"); + std::thread::sleep(Duration::from_millis(20)); + } + assert!(unsafe { replicant_destroy_and_wait(handle, 10_000) }); + } } diff --git a/replicant-client/src/ffi_test.rs b/replicant-client/src/ffi_test.rs deleted file mode 100644 index cdcf9e2..0000000 --- a/replicant-client/src/ffi_test.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! C FFI test functions for the Replicant client -//! -//! This module provides test-only C-compatible functions for development and testing. -//! These functions are only available in debug builds. - -use crate::error_code::ReplicantErrorCode; -use crate::events::EventOrigin; -use crate::ffi::{Replicant, SyncResult}; -use uuid::Uuid; - -/// Trigger a test event (for development/testing purposes) -/// -/// # Arguments -/// * `engine` - Replicant client instance -/// * `event_type` - Event type to emit (0-7) -/// -/// # Returns -/// * SyncResult indicating success or failure -/// -/// # Event Types -/// * 0 - DocumentCreated -/// * 1 - DocumentUpdated -/// * 2 - DocumentDeleted -/// * 3 - SyncStarted -/// * 4 - SyncCompleted -/// * 5 - SyncError -/// * 6 - ConflictDetected -/// * 7 - ConnectionLost -/// * 8 - ConnectionAttempted -/// * 9 - ConnectionSucceeded -/// -/// # Safety -/// Caller must ensure engine is a valid pointer -#[cfg(debug_assertions)] -#[no_mangle] -pub unsafe extern "C" fn replicant_emit_test_event( - engine: *mut Replicant, - event_type: i32, -) -> SyncResult { - if engine.is_null() { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - match event_type { - 0 => { - let test_id = Uuid::new_v4(); - let test_content = serde_json::json!({"title": "Test Document (Created)", "test": "data", "created_at": chrono::Utc::now()}); - engine - .event_dispatcher - .emit_document_created(&test_id, &test_content); - } - 1 => { - let test_id = Uuid::new_v4(); - let test_content = serde_json::json!({"title": "Test Document (Updated)", "test": "updated", "updated_at": chrono::Utc::now()}); - engine - .event_dispatcher - .emit_document_updated(&test_id, &test_content); - } - 2 => { - let test_id = Uuid::new_v4(); - engine - .event_dispatcher - .emit_document_deleted(&test_id, EventOrigin::Local); - } - 3 => engine.event_dispatcher.emit_sync_started(), - 4 => engine.event_dispatcher.emit_sync_completed(5), - 5 => engine.event_dispatcher.emit_sync_error( - ReplicantErrorCode::Unknown, - "Test error message from replicant_emit_test_event", - ), - 6 => { - let test_id = Uuid::new_v4(); - engine.event_dispatcher.emit_conflict_detected(&test_id); - } - 7 => engine.event_dispatcher.emit_connection_lost("test-server"), - 8 => engine - .event_dispatcher - .emit_connection_attempted("test-server"), - 9 => engine - .event_dispatcher - .emit_connection_succeeded("test-server"), - _ => return SyncResult::ErrorInvalidInput, - } - - SyncResult::Success -} - -/// Trigger multiple test events in sequence (for stress testing callbacks) -/// -/// # Arguments -/// * `engine` - Replicant client instance -/// * `count` - Number of events to emit (1-100) -/// -/// # Returns -/// * SyncResult indicating success or failure -/// -/// # Safety -/// Caller must ensure engine is a valid pointer -#[cfg(debug_assertions)] -#[no_mangle] -pub unsafe extern "C" fn replicant_emit_test_event_burst( - engine: *mut Replicant, - count: i32, -) -> SyncResult { - if engine.is_null() || !(1..=100).contains(&count) { - return SyncResult::ErrorInvalidInput; - } - - let engine = &*engine; - - for i in 0..count { - let test_id = Uuid::new_v4(); - let test_content = serde_json::json!({ - "title": format!("Burst Test Document {}", i), - "test": "burst_event", - "sequence": i, - "timestamp": chrono::Utc::now() - }); - engine - .event_dispatcher - .emit_document_created(&test_id, &test_content); - } - - SyncResult::Success -} diff --git a/replicant-client/src/host/fanout.rs b/replicant-client/src/host/fanout.rs new file mode 100644 index 0000000..976f893 --- /dev/null +++ b/replicant-client/src/host/fanout.rs @@ -0,0 +1,492 @@ +//! Engine events become host events, appended to every attached handle's queue under one lock; +//! the same lock seeds a handle that attaches mid-connection. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use tokio::sync::mpsc; +use tracing::warn; +use uuid::Uuid; + +use super::lock; +use crate::driver::engine::EngineEvent; +use crate::engine::doc::DocEvent; +use crate::engine::machine::Lifecycle; +use crate::store::change_log::{ChangeOrigin, DocChange}; +use crate::store::{DocNotice, Store, StoredDocument}; + +/// Who made a document change: a handle of this engine, sync, or another process's host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Origin { + Local, + Server, + OtherProcess, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum HostEvent { + /// `attempt` counts dials since the last successful connection. + ConnectionAttempted { + attempt: u32, + }, + ConnectionSucceeded, + ConnectionLost, + SyncStarted, + SyncCompleted, + SyncError { + code: String, + doc_id: Option<Uuid>, + scope: Option<String>, + fatal: bool, + /// Local content kept aside with this error (`became_publication`, `create_rejected`, + /// `delete_publication`, a refused delete's `delete_refused`). + recovered_id: Option<i64>, + }, + DocumentChanged { + document: StoredDocument, + origin: Origin, + }, + DocumentDeleted { + doc_id: Uuid, + origin: Origin, + }, + /// Local content was kept aside; `paths` is set for a field conflict. + Conflict { + doc_id: Uuid, + reason: String, + recovered_id: Option<i64>, + paths: Vec<String>, + }, + /// Changes were trimmed before this engine read them, or this handle fell behind: reload + /// every list. + DatabaseChanged, + /// The data dir adopted the account's user id and restamped every owned document. + IdentityAdopted { + user_id: Uuid, + }, +} + +/// Events one handle may hold before its document events collapse into one `DatabaseChanged`. +pub(super) const QUEUE_CAP: usize = 4096; + +#[derive(Default)] +struct Queued { + events: VecDeque<HostEvent>, + /// Document events were collapsed; later ones are covered by the queued `DatabaseChanged`. + lagged: bool, +} + +#[derive(Default)] +pub(super) struct EventQueue(Mutex<Queued>); + +fn is_document_event(event: &HostEvent) -> bool { + matches!( + event, + HostEvent::DocumentChanged { .. } + | HostEvent::DocumentDeleted { .. } + | HostEvent::DatabaseChanged + ) +} + +impl EventQueue { + pub(super) fn push(&self, event: HostEvent) { + let mut queued = lock(&self.0); + let document = is_document_event(&event); + if document && queued.lagged { + return; + } + if matches!(event, HostEvent::ConnectionAttempted { .. }) { + // Back-to-back dials collapse into the latest, so a long offline spell cannot push + // other events out; a dial after any other event stays after it. + if let Some(last @ HostEvent::ConnectionAttempted { .. }) = queued.events.back_mut() { + *last = event; + return; + } + } + if queued.events.len() >= QUEUE_CAP { + if !queued.lagged { + queued + .events + .retain(|queued_event| !is_document_event(queued_event)); + queued.events.push_back(HostEvent::DatabaseChanged); + queued.lagged = true; + } + if document { + return; + } + if queued.events.len() >= QUEUE_CAP { + queued.events.pop_front(); + } + } + queued.events.push_back(event); + } + + pub(super) fn take(&self) -> Vec<HostEvent> { + let mut queued = lock(&self.0); + queued.lagged = false; + queued.events.drain(..).collect() + } +} + +/// What every attached handle has been told about the current connection. +#[derive(Default)] +struct Seed { + connected: bool, + started: bool, + completed: bool, + attempts: u32, +} + +#[derive(Default)] +pub(super) struct FanOut { + queues: Vec<Arc<EventQueue>>, + seed: Seed, + #[cfg(test)] + pub(super) published: usize, +} + +impl FanOut { + /// A new handle's queue, starting from this connection's events so far (spec §3). + pub(super) fn attach(&mut self) -> Arc<EventQueue> { + let queue = Arc::new(EventQueue::default()); + if self.seed.connected { + queue.push(HostEvent::ConnectionSucceeded); + if self.seed.started { + queue.push(HostEvent::SyncStarted); + } + if self.seed.completed { + queue.push(HostEvent::SyncCompleted); + } + } + self.queues.push(queue.clone()); + queue + } + + pub(super) fn detach(&mut self, queue: &Arc<EventQueue>) { + self.queues.retain(|attached| !Arc::ptr_eq(attached, queue)); + } + + fn publish(&mut self, event: HostEvent) { + match &event { + HostEvent::ConnectionSucceeded => { + self.seed = Seed { + connected: true, + ..Seed::default() + } + } + HostEvent::SyncStarted => self.seed.started = true, + HostEvent::SyncCompleted => self.seed.completed = true, + HostEvent::ConnectionLost => { + self.seed = Seed { + attempts: self.seed.attempts, + ..Seed::default() + } + } + _ => {} + } + #[cfg(test)] + { + self.published += 1; + } + for queue in &self.queues { + queue.push(event.clone()); + } + } + + fn publish_lifecycle(&mut self, lifecycle: Lifecycle) { + let event = match lifecycle { + Lifecycle::ConnectionAttempted => { + self.seed.attempts += 1; + HostEvent::ConnectionAttempted { + attempt: self.seed.attempts, + } + } + Lifecycle::ConnectionSucceeded => HostEvent::ConnectionSucceeded, + Lifecycle::ConnectionLost => HostEvent::ConnectionLost, + Lifecycle::SyncStarted => HostEvent::SyncStarted, + Lifecycle::SyncCompleted => HostEvent::SyncCompleted, + Lifecycle::SyncError { + code, + scope, + doc_id, + fatal, + } => HostEvent::SyncError { + code, + doc_id, + scope, + fatal, + recovered_id: None, + }, + }; + self.publish(event); + } +} + +/// Ends when the engine's owner stops (its event sender drops). +pub(super) async fn run( + store: Arc<Store>, + mut events: mpsc::UnboundedReceiver<EngineEvent>, + fanout: Arc<Mutex<FanOut>>, +) { + while let Some(event) = events.recv().await { + let host_event = match event { + EngineEvent::Lifecycle(lifecycle) => { + lock(&fanout).publish_lifecycle(lifecycle); + continue; + } + EngineEvent::Changed(change) => changed(&store, change).await, + EngineEvent::Doc(notice) => noticed(notice), + EngineEvent::DatabaseChanged => HostEvent::DatabaseChanged, + EngineEvent::IdentityAdopted { user_id } => HostEvent::IdentityAdopted { user_id }, + }; + lock(&fanout).publish(host_event); + } +} + +async fn changed(store: &Store, change: DocChange) -> HostEvent { + let origin = match change.origin { + ChangeOrigin::Local => Origin::Local, + ChangeOrigin::OtherProcess => Origin::OtherProcess, + ChangeOrigin::Server => Origin::Server, + }; + if change.deleted { + return HostEvent::DocumentDeleted { + doc_id: change.doc_id, + origin, + }; + } + match store.get_document(change.doc_id).await { + Ok(Some(document)) => HostEvent::DocumentChanged { document, origin }, + // Deleted again before this read. + Ok(None) => HostEvent::DocumentDeleted { + doc_id: change.doc_id, + origin, + }, + Err(error) => { + warn!(%error, doc_id = %change.doc_id, "could not read a changed document; asking the host to reload"); + HostEvent::DatabaseChanged + } + } +} + +fn noticed(notice: DocNotice) -> HostEvent { + let recovered_id = notice.kept.as_ref().map(|kept| kept.recovered_id); + match notice.event { + DocEvent::ConflictDetected => HostEvent::Conflict { + doc_id: notice.doc_id, + reason: notice + .kept + .map_or_else(|| "conflict".to_string(), |kept| kept.reason), + recovered_id, + paths: Vec::new(), + }, + DocEvent::FieldConflict { paths } => HostEvent::Conflict { + doc_id: notice.doc_id, + reason: "field_conflict".to_string(), + recovered_id, + paths, + }, + // Unedited, nothing was kept: still reported, since the document the user deleted is back. + DocEvent::DeleteSuperseded => HostEvent::Conflict { + doc_id: notice.doc_id, + reason: "delete_superseded".to_string(), + recovered_id, + paths: Vec::new(), + }, + DocEvent::SyncError { code } => HostEvent::SyncError { + code, + doc_id: Some(notice.doc_id), + scope: None, + fatal: false, + recovered_id, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::KeptCopy; + + #[test] + fn conflict_notices_become_conflict_events_with_their_kept_copy() { + let doc_id = Uuid::from_u128(1); + let kept = |reason: &str| { + Some(KeptCopy { + recovered_id: 7, + reason: reason.into(), + }) + }; + assert_eq!( + noticed(DocNotice { + doc_id, + event: DocEvent::ConflictDetected, + kept: kept("delete_wins") + }), + HostEvent::Conflict { + doc_id, + reason: "delete_wins".into(), + recovered_id: Some(7), + paths: vec![] + } + ); + assert_eq!( + noticed(DocNotice { + doc_id, + event: DocEvent::FieldConflict { + paths: vec!["/s".into()] + }, + kept: kept("field_conflict") + }), + HostEvent::Conflict { + doc_id, + reason: "field_conflict".into(), + recovered_id: Some(7), + paths: vec!["/s".into()] + } + ); + assert_eq!( + noticed(DocNotice { + doc_id, + event: DocEvent::SyncError { + code: "validation".into() + }, + kept: None + }), + HostEvent::SyncError { + code: "validation".into(), + doc_id: Some(doc_id), + scope: None, + fatal: false, + recovered_id: None + } + ); + assert_eq!( + noticed(DocNotice { + doc_id, + event: DocEvent::SyncError { + code: "forbidden".into() + }, + kept: kept("delete_refused") + }), + HostEvent::SyncError { + code: "forbidden".into(), + doc_id: Some(doc_id), + scope: None, + fatal: false, + recovered_id: Some(7) + } + ); + for (kept_copy, recovered_id) in [(kept("delete_superseded"), Some(7)), (None, None)] { + assert_eq!( + noticed(DocNotice { + doc_id, + event: DocEvent::DeleteSuperseded, + kept: kept_copy + }), + HostEvent::Conflict { + doc_id, + reason: "delete_superseded".into(), + recovered_id, + paths: vec![] + } + ); + } + } + + #[test] + fn a_full_queue_collapses_document_events_into_one_database_changed() { + let queue = EventQueue::default(); + let deleted = |n: u128| HostEvent::DocumentDeleted { + doc_id: Uuid::from_u128(n), + origin: Origin::Server, + }; + queue.push(HostEvent::SyncStarted); + for n in 1..QUEUE_CAP as u128 { + queue.push(deleted(n)); + } + queue.push(deleted(0)); + queue.push(HostEvent::SyncCompleted); + queue.push(deleted(1)); + assert_eq!( + queue.take(), + vec![ + HostEvent::SyncStarted, + HostEvent::DatabaseChanged, + HostEvent::SyncCompleted + ], + "document events collapse; lifecycle events stay in order" + ); + queue.push(deleted(2)); + assert_eq!( + queue.take(), + vec![deleted(2)], + "after a take, events flow again" + ); + } + + #[test] + fn dial_attempts_never_evict_a_kept_copy_notice() { + let queue = EventQueue::default(); + let conflict = HostEvent::Conflict { + doc_id: Uuid::from_u128(1), + reason: "field_conflict".into(), + recovered_id: Some(7), + paths: vec![], + }; + queue.push(conflict.clone()); + for attempt in 0..=QUEUE_CAP as u32 { + queue.push(HostEvent::ConnectionAttempted { attempt }); + } + assert_eq!( + queue.take(), + vec![ + conflict, + HostEvent::ConnectionAttempted { + attempt: QUEUE_CAP as u32 + } + ] + ); + } + + #[test] + fn a_dial_after_a_lost_connection_stays_after_it() { + let queue = EventQueue::default(); + queue.push(HostEvent::ConnectionAttempted { attempt: 1 }); + queue.push(HostEvent::ConnectionSucceeded); + queue.push(HostEvent::ConnectionLost); + queue.push(HostEvent::ConnectionAttempted { attempt: 1 }); + assert_eq!( + queue.take(), + vec![ + HostEvent::ConnectionAttempted { attempt: 1 }, + HostEvent::ConnectionSucceeded, + HostEvent::ConnectionLost, + HostEvent::ConnectionAttempted { attempt: 1 }, + ] + ); + } + + #[test] + fn a_dial_after_a_fatal_sync_error_stays_after_it() { + let queue = EventQueue::default(); + let fatal = HostEvent::SyncError { + code: "update_required".into(), + doc_id: None, + scope: None, + fatal: true, + recovered_id: None, + }; + queue.push(HostEvent::ConnectionAttempted { attempt: 1 }); + queue.push(fatal.clone()); + queue.push(HostEvent::ConnectionAttempted { attempt: 1 }); + assert_eq!( + queue.take(), + vec![ + HostEvent::ConnectionAttempted { attempt: 1 }, + fatal, + HostEvent::ConnectionAttempted { attempt: 1 }, + ] + ); + } +} diff --git a/replicant-client/src/host/mod.rs b/replicant-client/src/host/mod.rs new file mode 100644 index 0000000..8b2d9cd --- /dev/null +++ b/replicant-client/src/host/mod.rs @@ -0,0 +1,373 @@ +//! One engine per database per process: every handle on a data dir shares its socket, runtime +//! and sync; the last handle to close stops it. + +mod fanout; +#[cfg(test)] +mod tests; + +pub use fanout::{HostEvent, Origin}; + +use std::collections::HashMap; +use std::ffi::OsStr; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError, Weak}; +use std::thread::JoinHandle as ThreadHandle; +use std::time::{Duration, Instant}; + +use tokio::runtime::Runtime; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use crate::driver::engine::{Command, CredentialLoader, Engine, EngineConfig, EngineError}; +use crate::engine::list_merge::ListMergeConfig; +use crate::engine::machine::EngineState; +use crate::secret_store; +use crate::store::{is_json_pointer, Store}; +use crate::transport::wire::JoinAuth; +use fanout::{EventQueue, FanOut}; + +/// How long a stopping engine's runtime may take to end its threads. +const RUNTIME_JOIN: Duration = Duration::from_secs(2); +const WORKER_THREADS: usize = 2; + +/// One per database key. Its lock is held while that key's engine starts and while the slot is +/// emptied, not while the engine stops (that runs on the teardown thread), so the registry lock +/// itself is only ever held for a map lookup. +#[derive(Default)] +struct Slot(Mutex<Weak<Shared>>); + +static REGISTRY: LazyLock<Mutex<HashMap<PathBuf, Arc<Slot>>>> = LazyLock::new(Default::default); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostConfig { + /// Holds the database file and the stored credentials. + pub data_dir: PathBuf, + pub database_file: String, + pub server_url: String, + /// Signs joins when the stored credentials carry no email (stored by 0.6). + pub fallback_email: Option<String>, + /// Names the host in the `User-Agent`, e.g. "Entonal Studio" and "2.0.1 CLAP". + pub host_app: String, + pub host_version: String, + /// How lists both sides changed are merged; one policy per engine. + pub list_merge: ListMergeConfig, + /// JSON Pointer to each document's title; `None`: no titles. One per engine. + pub title_pointer: Option<String>, +} + +impl HostConfig { + /// Whether a handle with `other` may share this engine; the fallback email is not + /// compared, since stored credentials normally carry their own. + fn same_engine(&self, other: &HostConfig) -> bool { + self.server_url == other.server_url + && self.host_app == other.host_app + && self.host_version == other.host_version + && self.list_merge == other.list_merge + && self.title_pointer == other.title_pointer + } +} + +#[derive(Debug, thiserror::Error)] +pub enum OpenError { + #[error("the database was migrated by a newer replicant-client")] + NewerSchema, + #[error("migrating the v1 data failed: {0}")] + MigrationFailed(String), + #[error("the database is locked by another process")] + Busy, + #[error("an engine is already open on {0} with a different configuration")] + ConfigMismatch(PathBuf), + #[error("invalid configuration: {0}")] + Config(String), + #[error(transparent)] + Engine(EngineError), + #[error(transparent)] + Io(#[from] std::io::Error), +} + +struct Shared { + config: HostConfig, + engine: Engine, + fanout: Arc<Mutex<FanOut>>, + fanout_task: JoinHandle<()>, + runtime: Runtime, +} + +impl Shared { + /// Also returns the first handle's queue, attached before the fan-out runs so that no + /// early event (an immediate not-enrolled halt) goes to zero queues. + fn start( + key: PathBuf, + data_dir: PathBuf, + config: HostConfig, + ) -> Result<(Shared, Arc<EventQueue>), OpenError> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(WORKER_THREADS) + .thread_name("replicant-engine") + .enable_all() + .build()?; + let (events_tx, events_rx) = mpsc::unbounded_channel(); + let engine_config = EngineConfig { + server_url: config.server_url.clone(), + host_app: config.host_app.clone(), + host_version: config.host_version.clone(), + credentials: credential_loader(data_dir, config.fallback_email.clone()), + jitter_seed: rand::random(), + list_merge: config.list_merge.clone(), + title_pointer: config.title_pointer.clone(), + }; + let engine = runtime + .block_on(Engine::start(&key, engine_config, events_tx)) + .map_err(|error| match error { + EngineError::Store(store) if store.is_newer_schema() => OpenError::NewerSchema, + EngineError::Store(store) if store.is_migration_failed() => { + OpenError::MigrationFailed(store.to_string()) + } + EngineError::Store(store) if store.is_busy() => OpenError::Busy, + EngineError::Config(message) => OpenError::Config(message), + other => OpenError::Engine(other), + })?; + let mut fanout = FanOut::default(); + let first = fanout.attach(); + let fanout = Arc::new(Mutex::new(fanout)); + let fanout_task = runtime.spawn(fanout::run(engine.store(), events_rx, fanout.clone())); + #[cfg(test)] + tests::after_fanout_spawn(&fanout); + let shared = Shared { + config, + engine, + fanout, + fanout_task, + runtime, + }; + Ok((shared, first)) + } + + /// Never waits on the network; bounded by SQLite and `RUNTIME_JOIN`. + fn stop(self) { + let Shared { + engine, + fanout_task, + runtime, + .. + } = self; + runtime.block_on(async { + let store = engine.stop_owner().await; + if let Err(error) = fanout_task.await { + tracing::warn!(%error, "the event fan-out ended abnormally"); + } + store.close().await; + }); + runtime.shutdown_timeout(RUNTIME_JOIN); + } +} + +/// Attaches to the engine for `config`'s database, starting it if this process has none. +/// Opens (and on first use migrates) the store; never waits on the network. Must not be called +/// from inside a tokio runtime. +pub fn attach(config: HostConfig) -> Result<Handle, OpenError> { + config.list_merge.validate().map_err(OpenError::Config)?; + if let Some(pointer) = config.title_pointer.as_deref() { + if !is_json_pointer(pointer) { + return Err(OpenError::Config(format!( + "title pointer is not a JSON Pointer: {pointer}" + ))); + } + } + if Path::new(&config.database_file).file_name() != Some(OsStr::new(&config.database_file)) { + return Err(OpenError::Config(format!( + "database_file must be a file name: {}", + config.database_file + ))); + } + std::fs::create_dir_all(&config.data_dir)?; + let data_dir = std::fs::canonicalize(&config.data_dir)?; + let key = data_dir.join(&config.database_file); + let slot = lock(®ISTRY).entry(key.clone()).or_default().clone(); + let mut current = lock(&slot.0); + if let Some(shared) = current.upgrade() { + if !shared.config.same_engine(&config) { + return Err(OpenError::ConfigMismatch(key)); + } + drop(current); + let queue = lock(&shared.fanout).attach(); + return Ok(Handle { + shared: Some(shared), + slot, + queue, + }); + } + let (shared, queue) = Shared::start(key, data_dir, config)?; + let shared = Arc::new(shared); + *current = Arc::downgrade(&shared); + drop(current); + Ok(Handle { + shared: Some(shared), + slot, + queue, + }) +} + +/// Tells every engine in this process on `data_dir` that its stored credentials changed. +/// Returns how many were told. Waits for an engine of that data dir that is starting, never for +/// one on another data dir. +pub fn credentials_changed(data_dir: &Path) -> usize { + let Ok(data_dir) = std::fs::canonicalize(data_dir) else { + return 0; + }; + let slots: Vec<Arc<Slot>> = lock(®ISTRY) + .iter() + .filter(|(key, _)| key.parent() == Some(data_dir.as_path())) + .map(|(_, slot)| slot.clone()) + .collect(); + slots + .iter() + .filter(|slot| { + // The strong reference drops under the slot lock, so it can never be the last one. + let current = lock(&slot.0); + current + .upgrade() + .is_some_and(|shared| shared.engine.command(Command::CredentialsChanged)) + }) + .count() +} + +/// One attachment to an engine. Not `Clone`: the slot counts handles through the `Arc`. +pub struct Handle { + shared: Option<Arc<Shared>>, + slot: Arc<Slot>, + queue: Arc<EventQueue>, +} + +impl Handle { + fn shared(&self) -> &Shared { + self.shared + .as_deref() + .expect("a handle is open until close") + } + + pub fn store(&self) -> Arc<Store> { + self.shared().engine.store() + } + + /// Runs `future` on the engine's runtime. Must not be called from inside a tokio runtime. + pub fn block_on<F: Future>(&self, future: F) -> F::Output { + self.shared().runtime.block_on(future) + } + + /// Call after every write, so the engine uploads it and reports it. + pub fn notify_outbox(&self) { + self.shared().engine.notify_outbox(); + } + + /// Leaves `Halted` or retries now (at most one dial per second). + pub fn reconnect(&self) { + self.shared().engine.command(Command::Reconnect); + } + + pub fn state(&self) -> EngineState { + self.shared().engine.state() + } + + /// Everything queued for this handle since the last call, oldest first. + pub fn take_events(&self) -> Vec<HostEvent> { + self.queue.take() + } + + /// Returns at once; the engine stops in the background if this was its last handle. + pub fn close(mut self) -> Teardown { + self.detach() + } + + fn detach(&mut self) -> Teardown { + let Some(shared) = self.shared.take() else { + return Teardown(None); + }; + lock(&shared.fanout).detach(&self.queue); + let mut current = lock(&self.slot.0); + if Arc::strong_count(&shared) > 1 { + // Dropped under the lock, so two last handles closing together cannot both miss. + drop(shared); + drop(current); + return Teardown(None); + } + *current = Weak::new(); + drop(current); + let Ok(shared) = Arc::try_unwrap(shared) else { + unreachable!("slots hold only weak references, and this was the last handle"); + }; + let worker = std::thread::Builder::new() + .name("replicant-teardown".into()) + .spawn(move || shared.stop()) + .inspect_err(|error| tracing::warn!(%error, "could not spawn the teardown thread")) + .ok(); + Teardown(worker) + } +} + +impl Drop for Handle { + fn drop(&mut self) { + let _detached = self.detach(); + } +} + +/// An engine stopping after its last handle closed. +pub struct Teardown(Option<ThreadHandle<()>>); + +impl Teardown { + /// Waits up to `timeout`; true when the engine has stopped, or when other handles keep it + /// running and there was nothing to stop. Blocking-pool threads (a DNS lookup) can outlive + /// the runtime's shutdown: a host that unloads the library after this must expect them to + /// finish on their own. + pub fn wait(self, timeout: Duration) -> bool { + let Some(worker) = self.0 else { + return true; + }; + let deadline = Instant::now() + timeout; + while !worker.is_finished() { + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(1)); + } + worker.join().is_ok() + } +} + +/// `Err` when the stored files cannot be read right now; never reported as signed out. Damaged +/// files (see [`secret_store::is_damaged`]) read as signed out, so the user is asked to sign in. +fn credential_loader(data_dir: PathBuf, fallback_email: Option<String>) -> CredentialLoader { + let damaged_reported = AtomicBool::new(false); + Arc::new(move || { + let stored = match secret_store::load(&data_dir) { + Ok(stored) => stored, + Err(error) if secret_store::is_damaged(&error) => { + // Reported once per run of damaged reads, not at every recheck. + if !damaged_reported.swap(true, Ordering::Relaxed) { + tracing::warn!(%error, "stored credentials are damaged; treating them as signed out"); + } + return Ok(None); + } + Err(error) => return Err(error), + }; + damaged_reported.store(false, Ordering::Relaxed); + let Some(stored) = stored else { + return Ok(None); + }; + Ok(stored + .email + .or_else(|| fallback_email.clone()) + .map(|email| JoinAuth { + email, + api_key: stored.api_key, + api_secret: stored.secret, + })) + }) +} + +/// A panic elsewhere must not stop every later caller. +pub(crate) fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} diff --git a/replicant-client/src/host/tests.rs b/replicant-client/src/host/tests.rs new file mode 100644 index 0000000..b582c53 --- /dev/null +++ b/replicant-client/src/host/tests.rs @@ -0,0 +1,496 @@ +use std::cell::Cell; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use serde_json::json; +use tokio::runtime::Runtime; + +use super::fanout::FanOut; +use super::{ + attach, credential_loader, credentials_changed, lock, Handle, HostConfig, HostEvent, OpenError, + Origin, REGISTRY, +}; +use crate::driver::test_server::ScriptedServer; +use crate::engine::list_merge::{ListMergeConfig, ListMergePolicy}; +use crate::engine::machine::{ConnectionView, HaltReason, SyncView}; +use crate::secret_store::{self, Credentials}; +use crate::store::test_support::{count, exec, ME}; +use crate::store::Store; + +/// Nothing listens here, so an engine with credentials keeps failing to connect. +const OFFLINE: &str = "ws://127.0.0.1:9"; +const WAIT: Duration = Duration::from_secs(10); + +type FanOutHook = Box<dyn FnOnce(&Mutex<FanOut>)>; + +thread_local! { + static AFTER_FANOUT_SPAWN: Cell<Option<FanOutHook>> = const { Cell::new(None) }; +} + +/// Runs this thread's hook once the new engine's fan-out task is running. +pub(super) fn after_fanout_spawn(fanout: &Mutex<FanOut>) { + if let Some(hook) = AFTER_FANOUT_SPAWN.take() { + hook(fanout); + } +} + +fn config(data_dir: &Path, server_url: &str) -> HostConfig { + HostConfig { + data_dir: data_dir.to_path_buf(), + database_file: "replicant.sqlite3".into(), + server_url: server_url.into(), + fallback_email: Some("a@b.c".into()), + host_app: "Test Host".into(), + host_version: "1.0".into(), + list_merge: ListMergeConfig::default(), + title_pointer: Some("/title".into()), + } +} + +fn sign_in(data_dir: &Path, api_key: &str) { + secret_store::store( + data_dir, + &Credentials { + api_key: api_key.into(), + secret: "rps_test".into(), + user_id: ME, + email: None, + }, + ) + .unwrap(); +} + +/// A scripted v2 server on its own runtime; keep both alive for the test. +fn live_server() -> (Runtime, ScriptedServer) { + let runtime = Runtime::new().unwrap(); + let server = runtime.block_on(ScriptedServer::start(ME)); + (runtime, server) +} + +fn wait_until(what: &str, mut done: impl FnMut() -> bool) { + let deadline = Instant::now() + WAIT; + while !done() { + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + std::thread::sleep(Duration::from_millis(20)); + } +} + +/// Takes `handle`'s events until `done` holds for everything taken so far. +fn events_until( + handle: &Handle, + what: &str, + done: impl Fn(&[HostEvent]) -> bool, +) -> Vec<HostEvent> { + let mut seen = Vec::new(); + wait_until(what, || { + seen.extend(handle.take_events()); + done(&seen) + }); + seen +} + +fn halted_not_enrolled(handle: &Handle) -> bool { + handle.state().connection == ConnectionView::Halted(HaltReason::NotEnrolled) +} + +#[test] +fn two_handles_on_one_data_dir_share_one_engine() { + let dir = tempfile::tempdir().unwrap(); + let first = attach(config(dir.path(), OFFLINE)).unwrap(); + let second = attach(config(dir.path(), OFFLINE)).unwrap(); + assert!(Arc::ptr_eq(&first.store(), &second.store())); + assert!(first.close().wait(WAIT)); + assert!(second.close().wait(WAIT)); +} + +#[test] +fn a_different_server_for_an_open_data_dir_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let open = attach(config(dir.path(), OFFLINE)).unwrap(); + assert!(matches!( + attach(config(dir.path(), "ws://127.0.0.1:10")), + Err(OpenError::ConfigMismatch(_)) + )); + let mut atomic_lists = config(dir.path(), OFFLINE); + atomic_lists.list_merge.default = ListMergePolicy::Atomic; + assert!(matches!( + attach(atomic_lists), + Err(OpenError::ConfigMismatch(_)) + )); + assert!(open.close().wait(WAIT)); +} + +#[test] +fn the_last_close_stops_the_engine_and_a_new_attach_starts_another() { + let dir = tempfile::tempdir().unwrap(); + let first = attach(config(dir.path(), OFFLINE)).unwrap(); + let first_instance = first.store().instance_id(); + assert!(first.close().wait(WAIT)); + let second = attach(config(dir.path(), OFFLINE)).unwrap(); + assert_ne!(second.store().instance_id(), first_instance, "a new engine"); + let readers = second.block_on(count( + &second.store(), + "SELECT COUNT(*) FROM change_log_readers", + )); + assert_eq!( + readers, 1, + "the stopped engine removed its change-log reader" + ); + assert!(second.close().wait(WAIT)); +} + +#[test] +fn closing_one_of_two_handles_keeps_the_engine_running() { + let dir = tempfile::tempdir().unwrap(); + let staying = attach(config(dir.path(), OFFLINE)).unwrap(); + let leaving = attach(config(dir.path(), OFFLINE)).unwrap(); + assert!(leaving.close().wait(WAIT), "nothing to wait for"); + assert_ne!(staying.state().connection, ConnectionView::Stopped); + staying + .block_on(staying.store().create_document(None, json!({"n": 1}))) + .unwrap(); + assert!(staying.close().wait(WAIT)); +} + +#[test] +fn a_late_handle_is_seeded_with_the_connection_it_joined() { + let (_server_runtime, server) = live_server(); + let dir = tempfile::tempdir().unwrap(); + sign_in(dir.path(), "k1"); + let first = attach(config(dir.path(), &server.url)).unwrap(); + let seen = events_until(&first, "SyncCompleted", |events| { + events.contains(&HostEvent::SyncCompleted) + }); + assert_eq!( + seen.iter() + .filter(|event| **event == HostEvent::ConnectionSucceeded) + .count(), + 1 + ); + let late = attach(config(dir.path(), &server.url)).unwrap(); + assert_eq!( + late.take_events(), + vec![ + HostEvent::ConnectionSucceeded, + HostEvent::SyncStarted, + HostEvent::SyncCompleted + ] + ); + std::thread::sleep(Duration::from_millis(300)); + assert!(late.take_events().is_empty(), "nothing is delivered twice"); + assert!(first.close().wait(WAIT)); + assert!(late.close().wait(WAIT)); +} + +#[test] +fn a_write_reaches_every_handle_once_as_local() { + let dir = tempfile::tempdir().unwrap(); + let writer = attach(config(dir.path(), OFFLINE)).unwrap(); + let reader = attach(config(dir.path(), OFFLINE)).unwrap(); + let doc_id = writer + .block_on(writer.store().create_document(None, json!({"title": "T"}))) + .unwrap(); + writer.notify_outbox(); + let is_change = |event: &HostEvent| matches!(event, HostEvent::DocumentChanged { document, .. } if document.id == doc_id); + for handle in [&writer, &reader] { + let mut seen = events_until(handle, "the change", |events| events.iter().any(is_change)); + std::thread::sleep(Duration::from_millis(600)); + seen.extend(handle.take_events()); + let changes: Vec<&HostEvent> = seen.iter().filter(|&event| is_change(event)).collect(); + assert_eq!(changes.len(), 1, "one event per document per pass"); + assert!(matches!( + changes[0], + HostEvent::DocumentChanged { origin: Origin::Local, document } if document.visibility == "private" + )); + } + assert!(writer.close().wait(WAIT)); + assert!(reader.close().wait(WAIT)); +} + +#[test] +fn credentials_stored_after_attach_bring_the_engine_online() { + let (_server_runtime, server) = live_server(); + let dir = tempfile::tempdir().unwrap(); + let handle = attach(config(dir.path(), &server.url)).unwrap(); + wait_until("halted as not enrolled", || halted_not_enrolled(&handle)); + sign_in(dir.path(), "k1"); + assert_eq!(credentials_changed(dir.path()), 1); + wait_until("live", || handle.state().sync == SyncView::Live); + assert_eq!(server.join_keys(), vec!["k1".to_string()]); + assert!(handle.close().wait(WAIT)); +} + +#[test] +fn signing_out_halts_the_engine_and_it_never_joins_again() { + let (_server_runtime, server) = live_server(); + let dir = tempfile::tempdir().unwrap(); + sign_in(dir.path(), "k1"); + let handle = attach(config(dir.path(), &server.url)).unwrap(); + wait_until("live", || handle.state().sync == SyncView::Live); + secret_store::clear(dir.path()).unwrap(); + assert_eq!(credentials_changed(dir.path()), 1); + wait_until("halted as not enrolled", || halted_not_enrolled(&handle)); + handle.reconnect(); + wait_until("one more dial, halted before its join", || { + server.stats.upgrades() == 2 && halted_not_enrolled(&handle) + }); + assert_eq!(server.join_keys(), vec!["k1".to_string()]); + assert!(handle.close().wait(WAIT)); +} + +#[test] +fn a_database_from_a_newer_build_is_refused_as_newer_schema() { + let dir = tempfile::tempdir().unwrap(); + assert!(attach(config(dir.path(), OFFLINE)) + .unwrap() + .close() + .wait(WAIT)); + let path = dir.path().join("replicant.sqlite3"); + Runtime::new().unwrap().block_on(async { + let store = Store::open(&path).await.unwrap(); + exec( + &store, + "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) \ + VALUES (99, 'from a newer build', 1, x'00', 0)", + ) + .await; + store.close().await; + }); + assert!(matches!( + attach(config(dir.path(), OFFLINE)), + Err(OpenError::NewerSchema) + )); +} + +#[test] +fn repeated_attach_and_close_cycles_finish_within_the_bound() { + let dir = tempfile::tempdir().unwrap(); + for cycle in 0..20 { + let started = Instant::now(); + let handle = attach(config(dir.path(), OFFLINE)).unwrap(); + assert!( + handle.close().wait(Duration::from_secs(3)), + "cycle {cycle} still stopping after {:?}", + started.elapsed() + ); + } +} + +#[test] +fn the_first_handle_hears_the_engines_first_events() { + let dir = tempfile::tempdir().unwrap(); + // `attach` returns only after the fan-out has published the engine's first event. + AFTER_FANOUT_SPAWN.set(Some(Box::new(|fanout: &Mutex<FanOut>| { + wait_until("the first published event", || lock(fanout).published > 0) + }))); + let handle = attach(config(dir.path(), OFFLINE)).unwrap(); + let first_events = handle.take_events(); + assert!( + matches!(first_events.first(), Some(HostEvent::SyncError { code, fatal: true, .. }) if code == "not_enrolled"), + "{first_events:?}" + ); + assert!(handle.close().wait(WAIT)); +} + +#[test] +fn credentials_for_one_data_dir_never_wait_for_an_engine_starting_on_another() { + use sqlx::sqlite::SqliteConnectOptions; + use sqlx::{ConnectOptions, Connection}; + + let signed_in_dir = tempfile::tempdir().unwrap(); + let starting_dir = tempfile::tempdir().unwrap(); + assert!(attach(config(starting_dir.path(), OFFLINE)) + .unwrap() + .close() + .wait(WAIT)); + let starting_key = std::fs::canonicalize(starting_dir.path()) + .unwrap() + .join("replicant.sqlite3"); + let lock_runtime = Runtime::new().unwrap(); + let mut write_lock = lock_runtime.block_on(async { + let mut connection = SqliteConnectOptions::new() + .filename(&starting_key) + .connect() + .await + .unwrap(); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut connection) + .await + .unwrap(); + connection + }); + let starting = std::thread::spawn({ + let config = config(starting_dir.path(), OFFLINE); + move || attach(config).map(Handle::close) + }); + wait_until("the other engine is starting", || { + lock(®ISTRY) + .get(&starting_key) + .is_some_and(|slot| slot.0.try_lock().is_err()) + }); + + let signed_in = attach(config(signed_in_dir.path(), OFFLINE)).unwrap(); + sign_in(signed_in_dir.path(), "k1"); + let told = std::thread::spawn({ + let dir = signed_in_dir.path().to_path_buf(); + move || credentials_changed(&dir) + }); + wait_until("credentials_changed returns", || told.is_finished()); + assert_eq!(told.join().unwrap(), 1); + assert!( + !starting.is_finished(), + "the other engine was still starting" + ); + + lock_runtime.block_on(async { + sqlx::query("ROLLBACK") + .execute(&mut write_lock) + .await + .unwrap(); + write_lock.close().await.unwrap(); + }); + assert!(starting.join().unwrap().unwrap().wait(WAIT)); + assert!(signed_in.close().wait(WAIT)); +} + +#[test] +fn a_full_policy_is_refused_as_config_even_with_an_engine_open() { + let dir = tempfile::tempdir().unwrap(); + let open = attach(config(dir.path(), OFFLINE)).unwrap(); + let mut full = config(dir.path(), OFFLINE); + full.list_merge.default = ListMergePolicy::Full; + assert!(matches!(attach(full), Err(OpenError::Config(_)))); + let mut nested = config(dir.path(), OFFLINE); + nested.database_file = "../replicant.sqlite3".into(); + assert!(matches!(attach(nested), Err(OpenError::Config(_)))); + assert!(open.close().wait(WAIT)); +} + +#[test] +fn the_first_join_announces_the_adopted_identity() { + let (_runtime, server) = live_server(); + let dir = tempfile::tempdir().unwrap(); + sign_in(dir.path(), "k1"); + let handle = attach(config(dir.path(), &server.url)).unwrap(); + events_until(&handle, "IdentityAdopted", |seen| { + seen.contains(&HostEvent::IdentityAdopted { user_id: ME }) + }); + assert_eq!(handle.block_on(handle.store().user_id()).unwrap(), ME); + assert!(handle.close().wait(WAIT)); +} + +#[test] +fn a_fresh_engine_on_an_adopted_dir_announces_no_identity() { + let (_runtime, server) = live_server(); + let dir = tempfile::tempdir().unwrap(); + sign_in(dir.path(), "k1"); + let handle = attach(config(dir.path(), &server.url)).unwrap(); + events_until(&handle, "IdentityAdopted then SyncCompleted", |seen| { + seen.contains(&HostEvent::IdentityAdopted { user_id: ME }) + && seen.contains(&HostEvent::SyncCompleted) + }); + assert!(handle.close().wait(WAIT)); + let again = attach(config(dir.path(), &server.url)).unwrap(); + let mut seen = events_until(&again, "SyncCompleted", |seen| { + seen.contains(&HostEvent::SyncCompleted) + }); + std::thread::sleep(Duration::from_millis(300)); + seen.extend(again.take_events()); + assert!( + !seen + .iter() + .any(|event| matches!(event, HostEvent::IdentityAdopted { .. })), + "{seen:?}" + ); + assert!(again.close().wait(WAIT)); +} + +#[test] +fn the_join_email_comes_from_the_stored_credentials_before_the_fallback() { + let dir = tempfile::tempdir().unwrap(); + let with_fallback = credential_loader(dir.path().to_path_buf(), Some("fallback@x.io".into())); + let without_fallback = credential_loader(dir.path().to_path_buf(), None); + assert!(with_fallback().unwrap().is_none(), "nothing stored"); + sign_in(dir.path(), "k1"); + assert_eq!(with_fallback().unwrap().unwrap().email, "fallback@x.io"); + assert!( + without_fallback().unwrap().is_none(), + "no email anywhere: cannot sign a join" + ); + secret_store::store( + dir.path(), + &Credentials { + api_key: "k2".into(), + secret: "rps_test".into(), + user_id: ME, + email: Some("stored@x.io".into()), + }, + ) + .unwrap(); + assert_eq!(with_fallback().unwrap().unwrap().email, "stored@x.io"); + assert_eq!(without_fallback().unwrap().unwrap().api_key, "k2"); +} + +#[test] +fn damaged_credentials_read_as_signed_out_and_unreadable_ones_as_an_error() { + let dir = tempfile::tempdir().unwrap(); + let loader = credential_loader(dir.path().to_path_buf(), Some("a@b.c".into())); + let credentials = dir.path().join("credentials.enc"); + sign_in(dir.path(), "k1"); + std::fs::write(&credentials, b"short").unwrap(); + assert!(loader().unwrap().is_none(), "truncated"); + std::fs::write(&credentials, [7u8; 40]).unwrap(); + assert!(loader().unwrap().is_none(), "undecryptable"); + sign_in(dir.path(), "k1"); + std::fs::remove_file(dir.path().join("key.bin")).unwrap(); + assert!(loader().unwrap().is_none(), "key missing"); + std::fs::write(dir.path().join("key.bin"), [1u8; 5]).unwrap(); + assert!(loader().unwrap().is_none(), "key the wrong size"); + + std::fs::remove_file(dir.path().join("key.bin")).unwrap(); + std::fs::remove_file(&credentials).unwrap(); + sign_in(dir.path(), "k1"); + std::fs::remove_file(&credentials).unwrap(); + // Stands in for any read error that may pass, such as a locked file. + std::fs::create_dir(&credentials).unwrap(); + assert!(loader().is_err(), "unreadable right now is not signed out"); +} + +#[test] +fn damaged_credentials_at_attach_halt_until_a_sign_in_the_recheck_reads() { + let (_server_runtime, server) = live_server(); + let dir = tempfile::tempdir().unwrap(); + sign_in(dir.path(), "k1"); + // Credentials that no longer decrypt with the stored key, as after corruption. + std::fs::write(dir.path().join("credentials.enc"), [7u8; 40]).unwrap(); + let handle = attach(config(dir.path(), &server.url)).unwrap(); + assert!(halted_not_enrolled(&handle), "{:?}", handle.state()); + sign_in(dir.path(), "k2"); + wait_until("live without a credentials_changed", || { + handle.state().sync == SyncView::Live + }); + assert_eq!(server.join_keys(), vec!["k2".to_string()]); + assert!(handle.close().wait(WAIT)); +} + +#[test] +fn credentials_unreadable_right_now_keep_the_engine_dialling() { + let (_server_runtime, server) = live_server(); + let dir = tempfile::tempdir().unwrap(); + sign_in(dir.path(), "k1"); + let credentials = dir.path().join("credentials.enc"); + std::fs::remove_file(&credentials).unwrap(); + std::fs::create_dir(&credentials).unwrap(); + let handle = attach(config(dir.path(), &server.url)).unwrap(); + wait_until("a second dial abandoned before its join", || { + server.stats.upgrades() >= 2 + }); + assert!(!halted_not_enrolled(&handle), "{:?}", handle.state()); + assert!(server.join_keys().is_empty()); + std::fs::remove_dir(&credentials).unwrap(); + sign_in(dir.path(), "k1"); + wait_until("live", || handle.state().sync == SyncView::Live); + assert_eq!(server.join_keys(), vec!["k1".to_string()]); + assert!(handle.close().wait(WAIT)); +} diff --git a/replicant-client/src/lib.rs b/replicant-client/src/lib.rs index 9429589..191e49e 100644 --- a/replicant-client/src/lib.rs +++ b/replicant-client/src/lib.rs @@ -1,339 +1,12 @@ -pub mod client; -pub mod database; pub mod driver; pub mod engine; pub mod enrollment; pub mod error_code; pub mod events; -pub mod offline_queue; -pub mod queries; +pub mod ffi; +pub mod host; pub mod secret_store; pub mod store; pub mod transport; -pub mod websocket; - -// C FFI module -pub mod ffi; -// C FFI test functions (debug builds only) -#[cfg(debug_assertions)] -pub mod ffi_test; - -pub use client::Client; -pub use database::ClientDatabase; pub use error_code::{is_credential_rejection, ReplicantErrorCode}; -pub use websocket::{error_code_for_join_reject, WebSocketClient}; - -#[cfg(test)] -mod tests { - use super::*; - use replicant_core::models::Document; - use serde_json::json; - use sqlx::Row; - use uuid::Uuid; - - #[tokio::test] - async fn test_client_database_operations() { - // Create in-memory SQLite database - let db = ClientDatabase::new(":memory:").await.unwrap(); - - // Create schema manually for in-memory database - sqlx::query( - r#" - CREATE TABLE user_config ( - user_id TEXT PRIMARY KEY, - server_url TEXT NOT NULL, - last_sync_at TIMESTAMP - ); - - CREATE TABLE documents ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - content JSON NOT NULL, - sync_revision INTEGER NOT NULL DEFAULT 1, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP, - local_changes JSON, - sync_status TEXT DEFAULT 'synced', - title TEXT, - author_name TEXT, - visibility TEXT, - provenance JSON, - CHECK (sync_status IN ('synced', 'pending', 'conflict')) - ); - "#, - ) - .execute(&db.pool) - .await - .unwrap(); - - // Insert test user - let user_id = Uuid::new_v4(); - sqlx::query("INSERT INTO user_config (user_id, server_url) VALUES (?1, ?2)") - .bind(user_id.to_string()) - .bind("ws://localhost:8080/ws") - .execute(&db.pool) - .await - .unwrap(); - - // Test get_user_id - let retrieved_user_id = db.get_user_id().await.unwrap(); - assert_eq!(retrieved_user_id, user_id); - - // Create a test document - let content = json!({ - "title": "Test Document", - "text": "Hello, World!", - "tags": ["test"] - }); - let doc = Document { - id: Uuid::new_v4(), - user_id: Some(user_id), - content: content.clone(), - content_hash: None, - title: None, - sync_revision: 1, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }; - - // Save document - db.save_document(&doc).await.unwrap(); - - // Retrieve document - let loaded_doc = db.get_document(&doc.id).await.unwrap(); - assert_eq!(loaded_doc.id, doc.id); - // Title is now part of content JSON, so just compare the content - assert_eq!(loaded_doc.content, doc.content); - } - - #[test] - fn test_offline_queue_message_extraction() { - use crate::offline_queue::{extract_document_id, operation_type}; - use replicant_core::protocol::ClientMessage; - - let doc_id = Uuid::new_v4(); - - // Test create message - let test_content = json!({"title": "Test"}); - let create_msg = ClientMessage::CreateDocument { - document: Document { - id: doc_id, - user_id: Some(Uuid::new_v4()), - content: test_content.clone(), - sync_revision: 1, - content_hash: None, - title: None, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }, - }; - - assert_eq!(extract_document_id(&create_msg), Some(doc_id)); - assert_eq!(operation_type(&create_msg), "create"); - - // Test delete message - let delete_msg = ClientMessage::DeleteDocument { - document_id: doc_id, - }; - - assert_eq!(extract_document_id(&delete_msg), Some(doc_id)); - assert_eq!(operation_type(&delete_msg), "delete"); - } - - #[tokio::test] - async fn test_delete_document() { - // Create in-memory SQLite database - let db = ClientDatabase::new(":memory:").await.unwrap(); - - // Create schema manually for in-memory database - sqlx::query( - r#" - CREATE TABLE user_config ( - user_id TEXT PRIMARY KEY, - server_url TEXT NOT NULL, - last_sync_at TIMESTAMP - ); - - CREATE TABLE documents ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - content JSON NOT NULL, - sync_revision INTEGER NOT NULL DEFAULT 1, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP, - local_changes JSON, - sync_status TEXT DEFAULT 'synced', - title TEXT, - author_name TEXT, - visibility TEXT, - provenance JSON, - CHECK (sync_status IN ('synced', 'pending', 'conflict')) - ); - "#, - ) - .execute(&db.pool) - .await - .unwrap(); - - // Insert test user - let user_id = Uuid::new_v4(); - sqlx::query("INSERT INTO user_config (user_id, server_url) VALUES (?1, ?2)") - .bind(user_id.to_string()) - .bind("ws://localhost:8080/ws") - .execute(&db.pool) - .await - .unwrap(); - - // Create a test document - let content = json!({ - "title": "Test Document", - "text": "Hello, World!" - }); - let doc = Document { - id: Uuid::new_v4(), - user_id: Some(user_id), - content: content.clone(), - content_hash: None, - title: None, - sync_revision: 1, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }; - - // Save document - db.save_document(&doc).await.unwrap(); - - // Delete document - db.delete_document(&doc.id).await.unwrap(); - - // Try to retrieve deleted document - it should still exist but with deleted_at set - let loaded_doc = db.get_document(&doc.id).await.unwrap(); - assert_eq!(loaded_doc.id, doc.id); - - // Check that deleted_at is set - let row = sqlx::query("SELECT deleted_at FROM documents WHERE id = ?") - .bind(doc.id.to_string()) - .fetch_one(&db.pool) - .await - .unwrap(); - - let deleted_at: Option<String> = row.try_get("deleted_at").unwrap(); - assert!(deleted_at.is_some()); - } - - #[tokio::test] - async fn test_create_document_with_specific_id() { - let db = ClientDatabase::new(":memory:").await.unwrap(); - - sqlx::query( - r#" - CREATE TABLE user_config ( - user_id TEXT PRIMARY KEY, - server_url TEXT NOT NULL, - last_sync_at TIMESTAMP - ); - - CREATE TABLE documents ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - content JSON NOT NULL, - sync_revision INTEGER NOT NULL DEFAULT 1, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP, - local_changes JSON, - sync_status TEXT DEFAULT 'synced', - title TEXT, - author_name TEXT, - visibility TEXT, - provenance JSON, - CHECK (sync_status IN ('synced', 'pending', 'conflict')) - ); - "#, - ) - .execute(&db.pool) - .await - .unwrap(); - - let user_id = Uuid::new_v4(); - sqlx::query("INSERT INTO user_config (user_id, server_url) VALUES (?1, ?2)") - .bind(user_id.to_string()) - .bind("ws://localhost:8080/ws") - .execute(&db.pool) - .await - .unwrap(); - - // Create document with a specific UUID - let specific_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); - let content = json!({"title": "Original", "text": "First version"}); - let doc = Document { - id: specific_id, - user_id: Some(user_id), - content: content.clone(), - content_hash: None, - title: None, - sync_revision: 1, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }; - - db.save_document(&doc).await.unwrap(); - - // Verify document was saved with the specific ID - let loaded = db.get_document(&specific_id).await.unwrap(); - assert_eq!(loaded.id, specific_id); - assert_eq!(loaded.content["title"], "Original"); - - // Create another document with same ID (upsert behavior) - let updated_content = json!({"title": "Updated", "text": "Second version"}); - let doc2 = Document { - id: specific_id, - user_id: Some(user_id), - content: updated_content.clone(), - content_hash: None, - title: None, - sync_revision: 2, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }; - - db.save_document(&doc2).await.unwrap(); - - // Verify upsert updated the document - let loaded2 = db.get_document(&specific_id).await.unwrap(); - assert_eq!(loaded2.id, specific_id); - assert_eq!(loaded2.content["title"], "Updated"); - assert_eq!(loaded2.sync_revision, 2); - - // Verify there's only one document with this ID - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents WHERE id = ?") - .bind(specific_id.to_string()) - .fetch_one(&db.pool) - .await - .unwrap(); - assert_eq!(count, 1); - } -} diff --git a/replicant-client/src/offline_queue.rs b/replicant-client/src/offline_queue.rs deleted file mode 100644 index 8fd40c3..0000000 --- a/replicant-client/src/offline_queue.rs +++ /dev/null @@ -1,91 +0,0 @@ -use crate::queries::Queries; -use replicant_core::{protocol::ClientMessage, SyncResult}; -use sqlx::{Row, SqlitePool}; -use uuid::Uuid; - -pub struct OfflineQueue { - pool: SqlitePool, -} - -impl OfflineQueue { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } - - pub async fn enqueue(&self, message: ClientMessage) -> SyncResult<()> { - let message_json = serde_json::to_string(&message)?; - - sqlx::query(Queries::INSERT_SYNC_QUEUE) - .bind(extract_document_id(&message).map(|id| id.to_string())) - .bind(operation_type(&message)) - .bind(message_json) - .execute(&self.pool) - .await?; - - Ok(()) - } - - pub async fn process_queue<F, Fut>(&self, mut send_fn: F) -> SyncResult<()> - where - F: FnMut(ClientMessage) -> Fut, - Fut: std::future::Future<Output = SyncResult<()>>, - { - let rows = sqlx::query(Queries::GET_SYNC_QUEUE) - .fetch_all(&self.pool) - .await?; - - for row in rows { - let id: i64 = row.get("id"); - let patch: Option<String> = row.get("patch"); - let _retry_count: i64 = row.get("retry_count"); - - // Rows this queue wrote always carry a serialized ClientMessage. - // `create` rows written by the document path carry no patch at all, - // and are uploaded from the document itself, not from here. - let Some(patch) = patch else { - continue; - }; - let message: ClientMessage = serde_json::from_str(&patch)?; - - // Simple retry logic without backoff crate due to closure limitations - let result = send_fn(message.clone()).await; - - match result { - Ok(_) => { - // Remove from queue - sqlx::query(Queries::DELETE_FROM_QUEUE) - .bind(id) - .execute(&self.pool) - .await?; - } - Err(_) => { - // Increment retry count - sqlx::query(Queries::INCREMENT_RETRY_COUNT) - .bind(id) - .execute(&self.pool) - .await?; - } - } - } - - Ok(()) - } -} - -pub fn extract_document_id(message: &ClientMessage) -> Option<Uuid> { - match message { - ClientMessage::CreateDocument { document } => Some(document.id), - ClientMessage::UpdateDocument { patch } => Some(patch.document_id), - ClientMessage::DeleteDocument { document_id, .. } => Some(*document_id), - _ => None, - } -} - -pub fn operation_type(message: &ClientMessage) -> &'static str { - match message { - ClientMessage::CreateDocument { .. } => "create", - ClientMessage::UpdateDocument { .. } => "update", - ClientMessage::DeleteDocument { .. } => "delete", - _ => "other", - } -} diff --git a/replicant-client/src/queries.rs b/replicant-client/src/queries.rs deleted file mode 100644 index 6255c90..0000000 --- a/replicant-client/src/queries.rs +++ /dev/null @@ -1,259 +0,0 @@ -use chrono::{DateTime, Utc}; -use replicant_core::{ - models::{Document, SyncStatus}, - SyncResult, -}; -use sqlx::{sqlite::SqliteRow, Row, SqlitePool}; -use uuid::Uuid; - -/// Type alias for document parameters tuple -pub type DocumentParams = ( - String, // id - Option<String>, // user_id - String, // content - i64, // sync_revision - String, // created_at - String, // updated_at - Option<String>, // deleted_at - String, // sync_status - String, // title - Option<String>, // author_name - Option<String>, // visibility - Option<String>, // provenance -); - -/// SQL queries for client database operations -pub struct Queries; - -impl Queries { - // User config queries - pub const GET_USER_ID: &'static str = "SELECT user_id FROM user_config LIMIT 1"; - - pub const GET_CLIENT_ID: &'static str = "SELECT client_id FROM user_config LIMIT 1"; - - pub const GET_USER_AND_CLIENT_ID: &'static str = - "SELECT user_id, client_id FROM user_config LIMIT 1"; - - pub const INSERT_USER_CONFIG: &'static str = - "INSERT INTO user_config (user_id, client_id, server_url) VALUES (?1, ?2, ?3)"; - - pub const UPDATE_LAST_SYNC: &'static str = - "UPDATE user_config SET last_sync_at = ?1 WHERE user_id = ?2"; - - pub const RESTAMP_DOCUMENTS_USER_ID: &'static str = - "UPDATE documents SET user_id = ?1 WHERE user_id = ?2"; - - pub const ADOPT_USER_CONFIG_IDENTITY: &'static str = - "UPDATE user_config SET user_id = ?1, identity_adopted = 1 WHERE user_id = ?2"; - - // Document queries - pub const GET_DOCUMENT: &'static str = r#" - SELECT id, user_id, content, sync_revision, - created_at, updated_at, deleted_at, title, - author_name, visibility, provenance - FROM documents - WHERE id = ?1 - "#; - - pub const UPSERT_DOCUMENT: &'static str = r#" - INSERT INTO documents ( - id, user_id, content, sync_revision, - created_at, updated_at, deleted_at, sync_status, title, - author_name, visibility, provenance - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) - ON CONFLICT(id) DO UPDATE SET - content = excluded.content, - sync_revision = excluded.sync_revision, - updated_at = excluded.updated_at, - deleted_at = excluded.deleted_at, - sync_status = excluded.sync_status, - title = excluded.title, - author_name = excluded.author_name, - visibility = excluded.visibility, - provenance = excluded.provenance - "#; - - pub const LIST_USER_DOCUMENTS: &'static str = r#" - SELECT id, user_id, content, sync_revision, - created_at, updated_at, deleted_at, title, - author_name, visibility, provenance - FROM documents - WHERE user_id = ?1 AND deleted_at IS NULL - ORDER BY updated_at DESC - "#; - - pub const GET_PENDING_DOCUMENTS: &'static str = r#" - SELECT id, user_id, content, sync_revision, - created_at, updated_at, deleted_at, title, - author_name, visibility, provenance - FROM documents - WHERE sync_status = ? - ORDER BY updated_at ASC - "#; - - pub const MARK_DOCUMENT_SYNCED: &'static str = r#" - UPDATE documents - SET sync_status = ? - WHERE id = ? - "#; - - pub const UPDATE_SYNC_STATUS: &'static str = - "UPDATE documents SET sync_status = ?2 WHERE id = ?1"; - - pub const COUNT_BY_SYNC_STATUS: &'static str = - "SELECT COUNT(*) as count FROM documents WHERE sync_status = ?1"; - - // Sync queue queries - pub const INSERT_SYNC_QUEUE: &'static str = r#" - INSERT INTO sync_queue (document_id, operation_type, patch) - VALUES (?1, ?2, ?3) - "#; - - pub const GET_SYNC_QUEUE: &'static str = r#" - SELECT id, document_id, operation_type, patch, retry_count - FROM sync_queue - ORDER BY created_at ASC - LIMIT 100 - "#; - - pub const DELETE_FROM_QUEUE: &'static str = "DELETE FROM sync_queue WHERE id = ?1"; - - pub const INCREMENT_RETRY_COUNT: &'static str = - "UPDATE sync_queue SET retry_count = retry_count + 1 WHERE id = ?1"; - - // FTS (Full-Text Search) queries - pub const HAS_SEARCH_CONFIG: &'static str = - "SELECT EXISTS(SELECT 1 FROM search_config LIMIT 1)"; - - pub const CLEAR_SEARCH_CONFIG: &'static str = "DELETE FROM search_config"; - - pub const INSERT_SEARCH_PATH: &'static str = "INSERT INTO search_config (json_path) VALUES (?)"; - - pub const DELETE_FTS_ENTRY: &'static str = "DELETE FROM documents_fts WHERE document_id = ?"; - - pub const UPDATE_FTS_ENTRY: &'static str = r#" - INSERT INTO documents_fts (document_id, title, body) - SELECT - d.id, - COALESCE(d.title, ''), - COALESCE( - (SELECT GROUP_CONCAT(json_extract(d.content, sc.json_path), ' ') - FROM search_config sc - WHERE json_extract(d.content, sc.json_path) IS NOT NULL), - '' - ) - FROM documents d - WHERE d.id = ? AND d.deleted_at IS NULL - "#; - - pub const REBUILD_FTS_INDEX: &'static str = r#" - INSERT INTO documents_fts (document_id, title, body) - SELECT - d.id, - COALESCE(d.title, ''), - COALESCE( - (SELECT GROUP_CONCAT(json_extract(d.content, sc.json_path), ' ') - FROM search_config sc - WHERE json_extract(d.content, sc.json_path) IS NOT NULL), - '' - ) - FROM documents d - WHERE d.deleted_at IS NULL - "#; - - pub const CLEAR_FTS_INDEX: &'static str = "DELETE FROM documents_fts"; - - pub const SEARCH_DOCUMENTS: &'static str = r#" - SELECT d.id, d.user_id, d.content, d.sync_revision, - d.created_at, d.updated_at, d.deleted_at, d.title, - d.author_name, d.visibility, d.provenance - FROM documents d - JOIN documents_fts fts ON d.id = fts.document_id - WHERE d.deleted_at IS NULL - AND documents_fts MATCH ? - ORDER BY rank - LIMIT ? - "#; -} - -/// Helper functions for common database operations -pub struct DbHelpers; - -impl DbHelpers { - /// Parse a document from a database row - pub fn parse_document(row: &SqliteRow) -> SyncResult<Document> { - let id: String = row.get("id"); - let user_id: Option<String> = row.get("user_id"); - let content: String = row.get("content"); - let sync_revision: i64 = row - .try_get::<i64, _>("sync_revision") - .or_else(|_| row.try_get::<i32, _>("sync_revision").map(|v| v as i64))?; - let created_at: String = row.get("created_at"); - let updated_at: String = row.get("updated_at"); - let deleted_at: Option<String> = row.get("deleted_at"); - let title: Option<String> = row.try_get("title").ok(); - let author_name: Option<String> = row.try_get("author_name").ok(); - let visibility: Option<String> = row.try_get("visibility").ok(); - let provenance_str: Option<String> = row.try_get("provenance").ok(); - let provenance: Option<serde_json::Value> = - provenance_str.and_then(|s| serde_json::from_str(&s).ok()); - - Ok(Document { - id: Uuid::parse_str(&id)?, - user_id: user_id.map(|s| Uuid::parse_str(&s)).transpose()?, - content: serde_json::from_str(&content)?, - sync_revision, - content_hash: None, // Not stored in client database - title, - created_at: DateTime::parse_from_rfc3339(&created_at)?.with_timezone(&Utc), - updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc), - deleted_at: deleted_at - .and_then(|dt| DateTime::parse_from_rfc3339(&dt).ok()) - .map(|dt| dt.with_timezone(&Utc)), - author_name, - visibility, - provenance, - }) - } - - /// Prepare document values for database insertion - pub fn document_to_params( - doc: &Document, - sync_status: Option<SyncStatus>, - ) -> SyncResult<DocumentParams> { - let status = sync_status.unwrap_or(SyncStatus::Pending).to_string(); - let title = doc.title.clone().unwrap_or_else(|| { - // Fallback to extracting from content if not set - doc.content - .get("title") - .and_then(|v| v.as_str()) - .map(|s| s.chars().take(128).collect::<String>()) - .unwrap_or_else(|| doc.created_at.format("%Y-%m-%d|%H:%M:%S%.3f").to_string()) - }); - - Ok(( - doc.id.to_string(), - doc.user_id.map(|id| id.to_string()), - serde_json::to_string(&doc.content)?, - doc.sync_revision, - doc.created_at.to_rfc3339(), - doc.updated_at.to_rfc3339(), - doc.deleted_at.map(|dt| dt.to_rfc3339()), - status, - title, - doc.author_name.clone(), - doc.visibility.clone(), - doc.provenance.as_ref().map(|v| v.to_string()), - )) - } - - /// Get count of documents by sync status - pub async fn count_by_status(pool: &SqlitePool, status: SyncStatus) -> SyncResult<i64> { - let row = sqlx::query(Queries::COUNT_BY_SYNC_STATUS) - .bind(status.to_string()) - .fetch_one(pool) - .await?; - - Ok(row.try_get("count")?) - } -} diff --git a/replicant-client/src/secret_store.rs b/replicant-client/src/secret_store.rs index af2542a..7805aae 100644 --- a/replicant-client/src/secret_store.rs +++ b/replicant-client/src/secret_store.rs @@ -6,7 +6,7 @@ use chacha20poly1305::aead::{Aead, KeyInit, OsRng}; use chacha20poly1305::{ChaCha20Poly1305, Nonce}; use rand::RngCore; use std::io::{self, Error, ErrorKind}; -use std::path::Path; +use std::path::{Path, PathBuf}; const KEY_FILE: &str = "key.bin"; const CRED_FILE: &str = "credentials.enc"; @@ -16,6 +16,9 @@ pub struct Credentials { pub api_key: String, pub secret: String, pub user_id: uuid::Uuid, + /// Signs the join. Credentials stored by 0.6 have none; the engine's configured email is used. + #[serde(default)] + pub email: Option<String>, } /// Reads the at-rest key if present. Never creates one — callers that must @@ -39,28 +42,86 @@ fn load_or_create_key(dir: &Path) -> io::Result<[u8; 32]> { let mut key = [0u8; 32]; OsRng.fill_bytes(&mut key); std::fs::create_dir_all(dir)?; - write_private(&dir.join(KEY_FILE), &key)?; - Ok(key) + let key_path = dir.join(KEY_FILE); + let temp = write_temp(&key_path, &key)?; + #[cfg(test)] + if let Some(hook) = tests::BEFORE_KEY_LINK.take() { + hook(); + } + // `hard_link` fails if the key exists, like O_EXCL, but never exposes a partial key. + let linked = std::fs::hard_link(&temp, &key_path); + let _ = std::fs::remove_file(&temp); + match linked { + Ok(()) => { + sync_dir(dir); + Ok(key) + } + Err(error) if error.kind() == ErrorKind::AlreadyExists => read_key(dir)? + .ok_or_else(|| Error::new(ErrorKind::NotFound, "key removed while being created")), + Err(error) => Err(error), + } } -#[cfg(unix)] -fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { +/// Writes `bytes` to a new private file next to `path` and returns its path. +fn write_temp(path: &Path, bytes: &[u8]) -> io::Result<PathBuf> { use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - let mut f = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(path)?; - f.write_all(bytes) + let mut name = path.as_os_str().to_owned(); + name.push(format!( + ".{}.{:016x}.tmp", + std::process::id(), + OsRng.next_u64() + )); + let temp = PathBuf::from(name); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + // TODO(hardening): wrap with DPAPI on Windows. For now rely on the + // user-profile directory ACL. + let written = options.open(&temp).and_then(|mut file| { + file.write_all(bytes)?; + file.sync_all() + }); + match written { + Ok(()) => Ok(temp), + Err(error) => { + let _ = std::fs::remove_file(&temp); + Err(error) + } + } } -#[cfg(not(unix))] +/// Replaces `path` whole: a reader sees the old file or the new one, never part of one. fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { - // TODO(hardening): wrap with DPAPI on Windows. For now rely on the - // user-profile directory ACL. - std::fs::write(path, bytes) + let temp = write_temp(path, bytes)?; + std::fs::rename(&temp, path).inspect_err(|_| { + let _ = std::fs::remove_file(&temp); + })?; + if let Some(dir) = path.parent() { + sync_dir(dir); + } + Ok(()) +} + +/// Makes a rename or link durable; best effort, since the file itself is already written. +fn sync_dir(dir: &Path) { + #[cfg(unix)] + if let Err(error) = std::fs::File::open(dir).and_then(|dir| dir.sync_all()) { + tracing::warn!(%error, "could not sync the credentials directory"); + } + #[cfg(not(unix))] + let _ = dir; +} + +/// Checks that `store` can write in `dir`: creates it and the key, then writes and removes a +/// probe file. +pub fn prepare(dir: &Path) -> io::Result<()> { + std::fs::create_dir_all(dir)?; + load_or_create_key(dir)?; + std::fs::remove_file(write_temp(&dir.join(CRED_FILE), &[])?) } pub fn store(dir: &Path, creds: &Credentials) -> io::Result<()> { @@ -87,10 +148,7 @@ pub fn load(dir: &Path) -> io::Result<Option<Credentials>> { } // Never mint a key on the read path: an existing credentials file // without a key is a broken/tampered state, not a "first run". - let key = match read_key(dir)? { - Some(key) => key, - None => return Ok(None), - }; + let key = read_key(dir)?.ok_or_else(|| Error::new(ErrorKind::NotFound, "key missing"))?; let cipher = ChaCha20Poly1305::new((&key).into()); let bytes = std::fs::read(&path)?; @@ -102,11 +160,17 @@ pub fn load(dir: &Path) -> io::Result<Option<Credentials>> { .decrypt(Nonce::from_slice(nonce_bytes), ciphertext) .map_err(|_| Error::new(ErrorKind::InvalidData, "decrypt failed"))?; - let creds = - serde_json::from_slice(&plaintext).map_err(|e| Error::new(ErrorKind::InvalidData, e))?; + let creds = serde_json::from_slice(&plaintext) + .map_err(|_| Error::new(ErrorKind::InvalidData, "credentials unparseable"))?; Ok(Some(creds)) } +/// True for a [`load`] error no retry can fix: the key is missing or the wrong size, or the +/// credentials are truncated, do not decrypt or do not parse. Other errors may pass. +pub fn is_damaged(error: &io::Error) -> bool { + matches!(error.kind(), ErrorKind::NotFound | ErrorKind::InvalidData) +} + pub fn clear(dir: &Path) -> io::Result<()> { let cred_path = dir.join(CRED_FILE); if cred_path.exists() { @@ -133,6 +197,7 @@ mod tests { api_key: "rpa_x".into(), secret: "rps_y".into(), user_id, + email: None, }; store(dir.path(), &creds).unwrap(); @@ -154,6 +219,7 @@ mod tests { api_key: "rpa_x".into(), secret: "rps_y".into(), user_id: uuid::Uuid::new_v4(), + email: None, }, ) .unwrap(); @@ -177,6 +243,27 @@ mod tests { assert!(!dir.path().join(CRED_FILE).exists()); } + #[test] + fn credentials_without_their_key_are_unreadable_not_absent() { + let dir = tempfile::tempdir().unwrap(); + store( + dir.path(), + &Credentials { + api_key: "a".into(), + secret: "b".into(), + user_id: uuid::Uuid::new_v4(), + email: None, + }, + ) + .unwrap(); + std::fs::remove_file(dir.path().join(KEY_FILE)).unwrap(); + assert!(is_damaged(&load(dir.path()).unwrap_err())); + assert!( + !dir.path().join(KEY_FILE).exists(), + "load() must never mint a key" + ); + } + #[test] fn tampered_ciphertext_fails_to_decrypt() { let dir = tempfile::tempdir().unwrap(); @@ -186,6 +273,7 @@ mod tests { api_key: "a".into(), secret: "b".into(), user_id: uuid::Uuid::new_v4(), + email: None, }, ) .unwrap(); @@ -194,6 +282,69 @@ mod tests { let last = bytes.len() - 1; bytes[last] ^= 0xFF; std::fs::write(&cred_path, bytes).unwrap(); - assert!(load(dir.path()).is_err()); + assert!(is_damaged(&load(dir.path()).unwrap_err())); + } + + #[test] + fn credentials_stored_without_an_email_still_load() { + let stored = serde_json::json!({ + "api_key": "rpa_k", + "secret": "rps_s", + "user_id": uuid::Uuid::from_u128(1) + }); + let credentials: Credentials = serde_json::from_value(stored).unwrap(); + assert_eq!(credentials.email, None); + } + + fn creds(api_key: &str) -> Credentials { + Credentials { + api_key: api_key.into(), + secret: "rps_s".into(), + user_id: uuid::Uuid::from_u128(1), + email: Some("a@x.io".into()), + } + } + + #[test] + fn a_store_racing_a_load_is_never_read_as_signed_out_or_unreadable() { + let dir = tempfile::tempdir().unwrap(); + store(dir.path(), &creds("k0")).unwrap(); + let path = dir.path().to_path_buf(); + let writer = std::thread::spawn(move || { + for n in 0..300 { + store(&path, &creds(&format!("k{n}"))).unwrap(); + } + }); + while !writer.is_finished() { + let loaded = load(dir.path()); + assert!(matches!(loaded, Ok(Some(_))), "a reader saw {loaded:?}"); + } + writer.join().unwrap(); + } + + thread_local! { + pub(super) static BEFORE_KEY_LINK: std::cell::Cell<Option<Box<dyn FnOnce()>>> = + const { std::cell::Cell::new(None) }; + } + + #[test] + fn two_racing_first_stores_leave_readable_credentials() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().to_path_buf(); + // The other store runs whole after this one found no key and before it installs its own. + BEFORE_KEY_LINK.set(Some(Box::new(move || { + std::thread::spawn(move || store(&path, &creds("b")).unwrap()) + .join() + .unwrap(); + }))); + let key = load_or_create_key(dir.path()).unwrap(); + assert_eq!( + load(dir.path()).unwrap().unwrap().api_key, + "b", + "the winner's key was not replaced" + ); + assert_eq!(read_key(dir.path()).unwrap(), Some(key)); + store(dir.path(), &creds("a")).unwrap(); + assert_eq!(load(dir.path()).unwrap().unwrap().api_key, "a"); } } diff --git a/replicant-client/src/store/change_log.rs b/replicant-client/src/store/change_log.rs index b24907e..193296a 100644 --- a/replicant-client/src/store/change_log.rs +++ b/replicant-client/src/store/change_log.rs @@ -216,13 +216,10 @@ mod tests { let mut reader = ChangeLogReader::open(&t.store, T0).await.unwrap(); let mine = t .store - .create_document(ME, None, json!({"a": 1})) - .await - .unwrap(); - let theirs = other - .create_document(ME, None, json!({"b": 1})) + .create_document(None, json!({"a": 1})) .await .unwrap(); + let theirs = other.create_document(None, json!({"b": 1})).await.unwrap(); let from_server = Uuid::from_u128(0xD1); let push = upsert_change( SCOPE_OWN, @@ -262,18 +259,18 @@ mod tests { let mut reader = ChangeLogReader::open(&t.store, T0).await.unwrap(); let doc_id = t .store - .create_document(ME, None, json!({"n": 0})) + .create_document(None, json!({"n": 0})) .await .unwrap(); t.store - .update_document(ME, doc_id, json!({"n": 1})) + .update_document(doc_id, json!({"n": 1})) .await .unwrap(); t.store - .update_document(ME, doc_id, json!({"n": 2})) + .update_document(doc_id, json!({"n": 2})) .await .unwrap(); - t.store.delete_document(ME, doc_id).await.unwrap(); + t.store.delete_document(doc_id).await.unwrap(); assert_eq!( reader.read(T0).await.unwrap(), LogRead::Docs(vec![DocChange { @@ -313,7 +310,7 @@ mod tests { let mut reader = ChangeLogReader::open(&t.store, T0).await.unwrap(); let good_id = t .store - .create_document(ME, None, json!({"a": 1})) + .create_document(None, json!({"a": 1})) .await .unwrap(); // An undecodable row after the good one: `read` must fail without losing `good_id`. @@ -354,10 +351,7 @@ mod tests { let mut reader = ChangeLogReader::open(&t.store, T0).await.unwrap(); assert_eq!(reader.read(T0).await.unwrap(), LogRead::Docs(vec![])); - let doc_id = other - .create_document(ME, None, json!({"x": 1})) - .await - .unwrap(); + let doc_id = other.create_document(None, json!({"x": 1})).await.unwrap(); assert_eq!( reader.read(T0).await.unwrap(), LogRead::Docs(vec![DocChange { @@ -375,7 +369,7 @@ mod tests { let mut suspended = ChangeLogReader::open(&t.store, T0).await.unwrap(); let mut live = ChangeLogReader::open(&other, T0).await.unwrap(); t.store - .create_document(ME, None, json!({"a": 1})) + .create_document(None, json!({"a": 1})) .await .unwrap(); @@ -406,10 +400,7 @@ mod tests { .await .unwrap(); - let doc_id = other - .create_document(ME, None, json!({"a": 1})) - .await - .unwrap(); + let doc_id = other.create_document(None, json!({"a": 1})).await.unwrap(); // now_unix is far enough past `heartbeat_at` that `read` attempts the heartbeat save, // which fails because the table is gone; the doc change must still come back. @@ -455,7 +446,7 @@ mod tests { let other = open_again(&t.path()).await; let closing = ChangeLogReader::open(&t.store, T0).await.unwrap(); let mut live = ChangeLogReader::open(&other, T0).await.unwrap(); - t.store.create_document(ME, None, json!({})).await.unwrap(); + t.store.create_document(None, json!({})).await.unwrap(); closing.close().await.unwrap(); live.read(T0 + 10).await.unwrap(); @@ -469,7 +460,7 @@ mod tests { let mut reader = ChangeLogReader::open(&t.store, T0).await.unwrap(); for i in 1..=10 { t.store - .create_document(ME, None, json!({"i": i})) + .create_document(None, json!({"i": i})) .await .unwrap(); reader.read(T0 + i).await.unwrap(); @@ -493,10 +484,7 @@ mod tests { assert_eq!(idle.read(now).await.unwrap(), LogRead::Docs(vec![])); } - let doc_id = other - .create_document(ME, None, json!({"a": 1})) - .await - .unwrap(); + let doc_id = other.create_document(None, json!({"a": 1})).await.unwrap(); active.read(now + 1).await.unwrap(); assert_eq!( diff --git a/replicant-client/src/store/docs.rs b/replicant-client/src/store/docs.rs index f57fb08..1cbaefa 100644 --- a/replicant-client/src/store/docs.rs +++ b/replicant-client/src/store/docs.rs @@ -4,13 +4,13 @@ use serde_json::Value; use sqlx::{Row, SqliteConnection}; use uuid::Uuid; -use super::{now_rfc3339, now_unix, DocNotice, StoreError, StoreResult}; +use super::reads::{DELETE_FTS_ENTRY, HAS_SEARCH_CONFIG, UPDATE_FTS_ENTRY}; +use super::{now_rfc3339, now_unix, DocNotice, KeptCopy, StoreError, StoreResult}; use crate::engine::doc::{ DocOp, DocSnapshot, Membership, OutboxRow, RecoverReason, RowKind, Shadow, }; use crate::engine::hash::content_hash; use crate::engine::types::DocEnvelope; -use crate::queries::Queries; const TITLE_MAX_CHARS: usize = 128; @@ -72,7 +72,13 @@ pub(crate) async fn load_snapshot( snap.shadow = Some(Shadow { content: serde_json::from_str(&server_content)?, hash, - seq: row.try_get::<Option<i64>, _>("server_seq")?.unwrap_or(0), + seq: row + .try_get::<Option<i64>, _>("server_seq")? + .ok_or_else(|| { + StoreError::Corrupt(format!( + "document {doc_id}: server_content without server_seq" + )) + })?, }); } } @@ -124,9 +130,11 @@ pub(crate) async fn apply_ops( snap: &DocSnapshot, ops: &[DocOp], envelope: Option<&DocEnvelope>, + title_pointer: Option<&str>, ) -> StoreResult<Vec<DocNotice>> { let id = snap.doc_id.to_string(); let mut notices = Vec::new(); + let mut kept: Option<KeptCopy> = None; for op in ops { match op { DocOp::DeleteRows(mutation_ids) => { @@ -184,7 +192,7 @@ pub(crate) async fn apply_ops( .await?; } DocOp::Recover { content, reason } => { - sqlx::query( + let inserted = sqlx::query( "INSERT INTO recovered (doc_id, content, reason, recovered_at) VALUES (?, ?, ?, ?)", ) .bind(&id) @@ -193,9 +201,13 @@ pub(crate) async fn apply_ops( .bind(now_unix()) .execute(&mut *conn) .await?; + kept = Some(KeptCopy { + recovered_id: inserted.last_insert_rowid(), + reason: reason_str(*reason).to_string(), + }); } DocOp::RecoverFields { content, fields } => { - sqlx::query( + let inserted = sqlx::query( "INSERT INTO recovered (doc_id, content, reason, recovered_at, fields) \ VALUES (?, ?, 'field_conflict', ?, ?)", ) @@ -205,19 +217,25 @@ pub(crate) async fn apply_ops( .bind(serde_json::to_string(fields)?) .execute(&mut *conn) .await?; + kept = Some(KeptCopy { + recovered_id: inserted.last_insert_rowid(), + reason: "field_conflict".to_string(), + }); } DocOp::Emit(event) => notices.push(DocNotice { doc_id: snap.doc_id, event: event.clone(), + kept: kept.take(), }), DocOp::SetShadow(_) | DocOp::SetContent(_) | DocOp::SetMeta { .. } - | DocOp::HardDelete => {} + | DocOp::HardDelete + | DocOp::Undelete => {} } } let after = snap.project(ops); - write_document(&mut *conn, snap, &after, envelope).await?; + write_document(&mut *conn, snap, &after, envelope, title_pointer).await?; if visible_change(snap, &after) { append_change_log(&mut *conn, writer, snap.doc_id, !after.exists).await?; } @@ -229,6 +247,7 @@ async fn write_document( before: &DocSnapshot, after: &DocSnapshot, envelope: Option<&DocEnvelope>, + title_pointer: Option<&str>, ) -> StoreResult<()> { let id = before.doc_id.to_string(); if !after.exists { @@ -254,7 +273,7 @@ async fn write_document( server_seq, read_only, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT(id) DO UPDATE SET user_id = excluded.user_id, content = excluded.content, \ - hash = excluded.hash, title = COALESCE(excluded.title, documents.title), \ + hash = excluded.hash, title = excluded.title, \ server_content = excluded.server_content, server_hash = excluded.server_hash, \ server_seq = excluded.server_seq, read_only = excluded.read_only, \ updated_at = CASE WHEN documents.content = excluded.content \ @@ -264,7 +283,7 @@ async fn write_document( .bind(after.owner_id.map(|owner| owner.to_string())) .bind(after.content.to_string()) .bind(content_hash(&after.content)) - .bind(title_of(&after.content, envelope)) + .bind(title_of(&after.content, title_pointer)) .bind(shadow.map(|s| s.content.to_string())) .bind(shadow.map(|s| s.hash.clone())) .bind(shadow.map(|s| s.seq)) @@ -277,6 +296,13 @@ async fn write_document( refresh_search(&mut *conn, before.doc_id).await?; } } + if before.soft_deleted && !after.soft_deleted { + sqlx::query("UPDATE documents SET deleted_at = NULL WHERE id = ?") + .bind(&id) + .execute(&mut *conn) + .await?; + refresh_search(&mut *conn, before.doc_id).await?; + } // The envelope's version became the shadow: keep its provenance metadata. let applied = envelope.filter(|env| { before.shadow != after.shadow && after.shadow.as_ref().is_some_and(|s| s.seq == env.seq) @@ -300,7 +326,8 @@ fn visible_change(before: &DocSnapshot, after: &DocSnapshot) -> bool { || (after.exists && (before.content != after.content || before.owner_id != after.owner_id - || before.read_only != after.read_only)) + || before.read_only != after.read_only + || before.soft_deleted != after.soft_deleted)) } pub(crate) async fn insert_marker( @@ -354,29 +381,39 @@ pub(crate) async fn append_change_log( /// Keeps the v1 full-text index in step when search paths are configured. pub(crate) async fn refresh_search(conn: &mut SqliteConnection, doc_id: Uuid) -> StoreResult<()> { - let configured: i64 = sqlx::query_scalar(Queries::HAS_SEARCH_CONFIG) + let configured: i64 = sqlx::query_scalar(HAS_SEARCH_CONFIG) .fetch_one(&mut *conn) .await?; if configured == 0 { return Ok(()); } - sqlx::query(Queries::DELETE_FTS_ENTRY) + sqlx::query(DELETE_FTS_ENTRY) .bind(doc_id.to_string()) .execute(&mut *conn) .await?; - sqlx::query(Queries::UPDATE_FTS_ENTRY) + sqlx::query(UPDATE_FTS_ENTRY) .bind(doc_id.to_string()) .execute(&mut *conn) .await?; Ok(()) } -pub(crate) fn title_of(content: &Value, envelope: Option<&DocEnvelope>) -> Option<String> { +/// The string at `title_pointer` in `content`, cut to `TITLE_MAX_CHARS`; `None` without a +/// pointer or when the value there is missing or not a string. +pub(crate) fn title_of(content: &Value, title_pointer: Option<&str>) -> Option<String> { content - .get("title") + .pointer(title_pointer?) .and_then(Value::as_str) .map(|title| title.chars().take(TITLE_MAX_CHARS).collect()) - .or_else(|| envelope.and_then(|env| env.title.clone())) +} + +/// RFC 6901: empty, or `/`-separated tokens where `~` only starts `~0` or `~1`. +pub fn is_json_pointer(text: &str) -> bool { + (text.is_empty() || text.starts_with('/')) + && text + .split('~') + .skip(1) + .all(|rest| rest.starts_with('0') || rest.starts_with('1')) } fn row_kind_str(kind: RowKind) -> &'static str { @@ -402,6 +439,9 @@ fn reason_str(reason: RecoverReason) -> &'static str { RecoverReason::Conflict => "conflict", RecoverReason::BecamePublication => "became_publication", RecoverReason::CreateRejected => "create_rejected", + RecoverReason::DeleteSuperseded => "delete_superseded", + RecoverReason::DeleteRefused => "delete_refused", + RecoverReason::DeletePublication => "delete_publication", } } @@ -411,7 +451,7 @@ mod tests { use super::*; use crate::engine::doc::change_fixtures::{env, upsert, with_upload}; - use crate::engine::doc::fixtures::{synced, DOC}; + use crate::engine::doc::fixtures::{synced, APPEND, DOC}; use crate::engine::doc::{apply_change, DocEvent}; use crate::engine::types::{SCOPE_CURATED, SCOPE_OWN}; use crate::store::test_support::*; @@ -424,6 +464,91 @@ mod tests { assert_eq!(snapshot(&t.store, DOC).await, synced(content, 3)); } + #[tokio::test] + async fn a_kept_copy_is_named_in_the_notice_that_reports_it() { + let t = temp_store().await; + seed_synced(&t.store, DOC, "own", Some(ME), json!({"a": 1}), 1).await; + let notices = apply(&t.store, DOC, |_| { + vec![ + DocOp::Emit(DocEvent::SyncError { + code: "validation".into(), + }), + DocOp::Recover { + content: json!({"a": 2}), + reason: RecoverReason::DeleteWins, + }, + DocOp::Emit(DocEvent::ConflictDetected), + ] + }) + .await; + assert_eq!( + notices[0].kept, None, + "no copy was written before this notice" + ); + let kept = notices[1] + .kept + .clone() + .expect("the copy written just before it"); + assert_eq!(kept.reason, "delete_wins"); + assert_eq!( + t.store.list_recovered().await.unwrap()[0].id, + kept.recovered_id + ); + } + + #[tokio::test] + async fn a_kept_copy_attaches_to_the_next_notice_only() { + let t = temp_store().await; + seed_synced(&t.store, DOC, "own", Some(ME), json!({"a": 1}), 1).await; + let notices = apply(&t.store, DOC, |_| { + vec![ + DocOp::Recover { + content: json!({"a": 2}), + reason: RecoverReason::DeleteWins, + }, + DocOp::Emit(DocEvent::ConflictDetected), + DocOp::Emit(DocEvent::ConflictDetected), + ] + }) + .await; + assert!(notices[0].kept.is_some()); + assert_eq!(notices[1].kept, None); + } + + #[tokio::test] + async fn superseded_and_refused_deletes_name_their_kept_copy() { + let t = temp_store().await; + seed_synced(&t.store, DOC, "own", Some(ME), json!({"a": 1}), 1).await; + let notices = apply(&t.store, DOC, |_| { + vec![ + DocOp::Recover { + content: json!({"a": 2}), + reason: RecoverReason::DeleteSuperseded, + }, + DocOp::Emit(DocEvent::DeleteSuperseded), + DocOp::Recover { + content: json!({"a": 3}), + reason: RecoverReason::DeleteRefused, + }, + DocOp::Emit(DocEvent::SyncError { + code: "forbidden".into(), + }), + ] + }) + .await; + let reasons: Vec<Option<String>> = notices + .iter() + .map(|notice| notice.kept.as_ref().map(|kept| kept.reason.clone())) + .collect(); + assert_eq!( + reasons, + vec![ + Some("delete_superseded".to_string()), + Some("delete_refused".to_string()) + ] + ); + } + #[tokio::test] async fn recover_keeps_local_content_in_recovered() { let t = temp_store().await; @@ -444,13 +569,20 @@ mod tests { let mut page_echo = with_upload(upsert(SCOPE_OWN, json!({}), 2), uploaded); page_echo.doc = Some(env(json!({"a": 1, "mine": true, "theirs": true}), 4)); - let notices = apply(&t.store, DOC, |snap| apply_change(snap, &page_echo, ME)).await; + let notices = apply(&t.store, DOC, |snap| { + apply_change(snap, &page_echo, ME, &APPEND) + }) + .await; assert_eq!( notices, vec![DocNotice { doc_id: DOC, - event: DocEvent::ConflictDetected + event: DocEvent::ConflictDetected, + kept: Some(KeptCopy { + recovered_id: 1, + reason: "conflict".into() + }) }] ); let recovered: Vec<(String, String)> = @@ -470,6 +602,24 @@ mod tests { assert!(after.rows.is_empty()); } + #[tokio::test] + async fn a_refused_delete_copy_is_listed_as_delete_refused() { + let t = temp_store().await; + seed_synced(&t.store, DOC, SCOPE_OWN, Some(ME), json!({"a": 1}), 1).await; + apply(&t.store, DOC, |_| { + vec![DocOp::Recover { + content: json!({"a": 2}), + reason: RecoverReason::DeleteRefused, + }] + }) + .await; + + let copies = t.store.list_recovered().await.unwrap(); + assert_eq!(copies.len(), 1); + assert_eq!(copies[0].reason, "delete_refused"); + assert_eq!(copies[0].content, json!({"a": 2})); + } + #[tokio::test] async fn hard_delete_keeps_membership_rows() { let t = temp_store().await; @@ -595,15 +745,33 @@ mod tests { } #[tokio::test] - async fn title_hash_and_envelope_metadata_follow_the_applied_version() { + async fn undelete_clears_the_soft_delete_and_logs_an_upsert() { let t = temp_store().await; + seed_synced(&t.store, DOC, SCOPE_OWN, Some(ME), json!({"a": 1}), 1).await; + t.store.delete_document(DOC).await.unwrap(); + apply(&t.store, DOC, |_| vec![DocOp::DropAllRows, DocOp::Undelete]).await; + let after = snapshot(&t.store, DOC).await; + assert!(after.exists && !after.soft_deleted); + assert!(after.rows.is_empty()); + let last_kind: String = + sqlx::query_scalar("SELECT kind FROM change_log ORDER BY local_seq DESC LIMIT 1") + .fetch_one(&t.store.pool) + .await + .unwrap(); + assert_eq!(last_kind, "upsert", "the host is told the document is back"); + } + + #[tokio::test] + async fn title_hash_and_envelope_metadata_follow_the_applied_version() { + let mut t = temp_store().await; + t.store.title_pointer = Some("/title".into()); let mut doc = envelope(DOC, None, json!({"title": "Just Intonation"}), 5); doc.author_id = Some(Uuid::from_u128(0xB)); doc.source_doc_id = Some(Uuid::from_u128(0x5)); let change = upsert_change(SCOPE_CURATED, doc.clone()); apply_with_envelope(&t.store, DOC, Some(&doc), |snap| { - apply_change(snap, &change, ME) + apply_change(snap, &change, ME, &APPEND) }) .await; @@ -619,4 +787,77 @@ mod tests { assert_eq!(author, Uuid::from_u128(0xB).to_string()); assert_eq!(source, Uuid::from_u128(0x5).to_string()); } + + #[tokio::test] + async fn the_server_s_title_is_ignored_and_titles_follow_only_the_pointer() { + let mut t = temp_store().await; + t.store.title_pointer = Some("/name".into()); + let title = |store: &crate::store::Store| { + let pool = store.pool.clone(); + async move { + sqlx::query_scalar::<_, Option<String>>("SELECT title FROM documents WHERE id = ?") + .bind(DOC.to_string()) + .fetch_one(&pool) + .await + .unwrap() + } + }; + for (seq, content, expected) in [ + (5, json!({"name": "First"}), Some("First".to_string())), + (6, json!({"body": "no name here"}), None), + ] { + let mut doc = envelope(DOC, None, content, seq); + doc.title = Some("From the server".into()); + let change = upsert_change(SCOPE_CURATED, doc.clone()); + apply_with_envelope(&t.store, DOC, Some(&doc), |snap| { + apply_change(snap, &change, ME, &APPEND) + }) + .await; + assert_eq!(title(&t.store).await, expected, "seq {seq}"); + } + } + + #[test] + fn title_of_reads_a_string_at_the_pointer_and_nothing_else() { + let content = json!({"title": "T", "meta": {"name": "N", "n": 3}, "a/b": "slash"}); + let long = json!({"title": "x".repeat(200)}); + assert_eq!(title_of(&content, None), None); + assert_eq!(title_of(&content, Some("")), None, "the whole document"); + assert_eq!(title_of(&content, Some("/meta/name")).as_deref(), Some("N")); + assert_eq!(title_of(&content, Some("/meta/n")), None); + assert_eq!(title_of(&content, Some("/missing")), None); + assert_eq!(title_of(&content, Some("/a~1b")).as_deref(), Some("slash")); + assert_eq!( + title_of(&long, Some("/title")).map(|title| title.chars().count()), + Some(TITLE_MAX_CHARS) + ); + } + + #[test] + fn json_pointers_follow_rfc_6901() { + for valid in ["", "/", "/title", "/a/0/b", "/a~0b", "/a~1b"] { + assert!(is_json_pointer(valid), "{valid}"); + } + for invalid in ["title", "a/b", "/a~", "/a~2", "~0"] { + assert!(!is_json_pointer(invalid), "{invalid}"); + } + } + + #[tokio::test] + async fn server_content_without_a_server_seq_is_corrupt() { + let t = temp_store().await; + exec( + &t.store, + &format!( + "INSERT INTO documents (id, user_id, content, hash, server_content, server_hash, \ + created_at, updated_at) VALUES ('{DOC}', '{ME}', '{{}}', 'h', '{{}}', 'h', 't', 't')" + ), + ) + .await; + let mut tx = t.store.begin().await.unwrap(); + assert!(matches!( + load_snapshot(&mut tx, DOC).await, + Err(StoreError::Corrupt(_)) + )); + } } diff --git a/replicant-client/src/store/feed.rs b/replicant-client/src/store/feed.rs index dc04e8c..151d17b 100644 --- a/replicant-client/src/store/feed.rs +++ b/replicant-client/src/store/feed.rs @@ -42,8 +42,18 @@ impl Store { let mut notices = Vec::new(); for change in changes { let snap = load_snapshot(&mut tx, change.doc_id).await?; - let ops = apply_change(&snap, change, me); - notices.extend(apply_ops(&mut tx, &writer, &snap, &ops, change.doc.as_ref()).await?); + let ops = apply_change(&snap, change, me, &self.list_merge); + notices.extend( + apply_ops( + &mut tx, + &writer, + &snap, + &ops, + change.doc.as_ref(), + self.title_pointer.as_deref(), + ) + .await?, + ); } advance_cursor(&mut tx, scope, new_cursor).await?; tx.commit().await?; @@ -63,8 +73,18 @@ impl Store { let mut notices = Vec::new(); for doc in docs { let snap = load_snapshot(&mut tx, doc.doc_id).await?; - let ops = apply_snapshot_doc(&snap, scope, doc, me); - notices.extend(apply_ops(&mut tx, &writer, &snap, &ops, Some(doc)).await?); + let ops = apply_snapshot_doc(&snap, scope, doc, me, &self.list_merge); + notices.extend( + apply_ops( + &mut tx, + &writer, + &snap, + &ops, + Some(doc), + self.title_pointer.as_deref(), + ) + .await?, + ); } tx.commit().await?; Ok(notices) @@ -95,7 +115,15 @@ impl Store { } let snap = load_snapshot(&mut tx, doc_id).await?; let ops = sweep_doc(&snap, scope, snapshot_seq); - apply_ops(&mut tx, &writer, &snap, &ops, None).await?; + apply_ops( + &mut tx, + &writer, + &snap, + &ops, + None, + self.title_pointer.as_deref(), + ) + .await?; } advance_cursor(&mut tx, scope, snapshot_seq).await?; tx.commit().await?; @@ -251,7 +279,7 @@ mod tests { .await; let local = json!({"items": ["a", "b"]}); t.store - .update_document(ME, doc(1), local.clone()) + .update_document(doc(1), local.clone()) .await .unwrap(); // The append went out and its reply was lost. @@ -269,7 +297,11 @@ mod tests { notices, vec![crate::store::DocNotice { doc_id: doc(1), - event: crate::engine::doc::DocEvent::ConflictDetected + event: crate::engine::doc::DocEvent::ConflictDetected, + kept: Some(crate::store::KeptCopy { + recovered_id: 1, + reason: "conflict".into() + }) }] ); let recovered: Vec<(String, String)> = @@ -302,7 +334,7 @@ mod tests { ) .await; t.store - .update_document(ME, doc(1), json!({"a": 2, "b": 1})) + .update_document(doc(1), json!({"a": 2, "b": 1})) .await .unwrap(); // Months later: another device changed `b`; the resync delivers it as a snapshot. @@ -351,7 +383,7 @@ mod tests { ) .await; t.store - .update_document(ME, doc(3), json!({"pending": 2})) + .update_document(doc(3), json!({"pending": 2})) .await .unwrap(); seed_synced( @@ -470,7 +502,7 @@ mod tests { tokio::join!(t.store.apply_snapshot_page(ME, SCOPE_OWN, &docs), async { tokio::time::sleep(std::time::Duration::from_millis(5)).await; let began = std::time::Instant::now(); - let created = other.create_document(ME, None, json!({"n": 1})).await; + let created = other.create_document(None, json!({"n": 1})).await; (created, began.elapsed()) }); page.unwrap(); @@ -486,4 +518,95 @@ mod tests { "a host write in another process waited {waited:?} behind a snapshot page" ); } + + #[tokio::test] + async fn a_pending_delete_on_a_document_that_became_a_publication_leaves_it_visible() { + let t = temp_store().await; + seed_synced( + &t.store, + doc(1), + SCOPE_OWN, + Some(ME), + json!({"title": "Mine"}), + 3, + ) + .await; + t.store.delete_document(doc(1)).await.unwrap(); + let mut publication = envelope(doc(1), Some(ME), json!({"title": "Mine"}), 4); + publication.read_only = true; + t.store + .apply_changes( + ME, + SCOPE_CURATED, + &[upsert_change(SCOPE_CURATED, publication)], + 4, + ) + .await + .unwrap(); + let visible = format!( + "SELECT COUNT(*) FROM documents WHERE id = '{}' AND deleted_at IS NULL AND read_only = 1", + doc(1) + ); + assert_eq!(count(&t.store, &visible).await, 1); + assert_eq!(count(&t.store, "SELECT COUNT(*) FROM outbox").await, 0); + assert_eq!( + count(&t.store, "SELECT COUNT(*) FROM recovered").await, + 0, + "deleted without an edit: nothing kept" + ); + let last_kind: String = + sqlx::query_scalar("SELECT kind FROM change_log ORDER BY local_seq DESC LIMIT 1") + .fetch_one(&t.store.pool) + .await + .unwrap(); + assert_eq!(last_kind, "upsert", "the host is told it is back"); + } + + #[tokio::test] + async fn an_edited_pending_delete_on_a_document_that_became_a_publication_names_its_kept_copy() + { + let t = temp_store().await; + seed_synced( + &t.store, + doc(1), + SCOPE_OWN, + Some(ME), + json!({"title": "Mine"}), + 3, + ) + .await; + let edited = json!({"title": "Mine", "n": 2}); + t.store + .update_document(doc(1), edited.clone()) + .await + .unwrap(); + t.store.delete_document(doc(1)).await.unwrap(); + let mut publication = envelope(doc(1), Some(ME), json!({"title": "Mine"}), 4); + publication.read_only = true; + let notices = t + .store + .apply_changes( + ME, + SCOPE_CURATED, + &[upsert_change(SCOPE_CURATED, publication)], + 4, + ) + .await + .unwrap(); + assert_eq!( + notices, + vec![crate::store::DocNotice { + doc_id: doc(1), + event: crate::engine::doc::DocEvent::SyncError { + code: "became_publication".into() + }, + kept: Some(crate::store::KeptCopy { + recovered_id: 1, + reason: "delete_publication".into() + }) + }] + ); + let kept = t.store.list_recovered().await.unwrap(); + assert_eq!(kept[0].content, edited); + } } diff --git a/replicant-client/src/store/mod.rs b/replicant-client/src/store/mod.rs index e9e6f25..47d0ad4 100644 --- a/replicant-client/src/store/mod.rs +++ b/replicant-client/src/store/mod.rs @@ -3,19 +3,23 @@ use std::path::Path; use std::time::Duration; +use serde_json::Value; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; -use sqlx::{Row, Sqlite, SqlitePool, Transaction}; +use sqlx::{Row, Sqlite, SqliteConnection, SqlitePool, Transaction}; use uuid::Uuid; use docs::{LogOrigin, Writer}; use crate::engine::doc::DocEvent; +use crate::engine::list_merge::ListMergeConfig; use crate::engine::types::{SCOPE_CURATED, SCOPE_OWN}; -use crate::queries::Queries; const BUSY_TIMEOUT: Duration = Duration::from_secs(5); const POOL_SIZE: u32 = 4; const TOMBSTONE_RETENTION_SECS: i64 = 90 * 24 * 3600; +const RESTAMP_DOCUMENTS_USER_ID: &str = "UPDATE documents SET user_id = ?1 WHERE user_id = ?2"; +const ADOPT_USER_CONFIG_IDENTITY: &str = + "UPDATE user_config SET user_id = ?1, identity_adopted = 1 WHERE user_id = ?2"; #[derive(Debug, thiserror::Error)] pub enum StoreError { @@ -41,15 +45,57 @@ pub enum StoreError { NilServerUserId, #[error("no field-conflict copy with id {0}")] NoFieldConflict(i64), + #[error("no kept copy with id {0}")] + NoKeptCopy(i64), + #[error("document {0} is gone; restore its kept copy as a new document")] + DocumentGone(Uuid), + #[error("malformed search query: {0}")] + BadSearchQuery(String), + /// Migrating v1 sync data failed; the v1 tables are untouched and a backup sits next to + /// the database. + #[error("v1 data migration failed: {0}")] + MigrationFailed(String), } pub type StoreResult<T> = Result<T, StoreError>; +impl StoreError { + /// A newer build migrated this database: it has a migration this build does not know. + pub fn is_newer_schema(&self) -> bool { + matches!( + self, + StoreError::Migrate(sqlx::migrate::MigrateError::VersionMissing(_)) + ) + } + + pub fn is_migration_failed(&self) -> bool { + matches!(self, StoreError::MigrationFailed(_)) + } + + /// SQLITE_BUSY: another connection held the lock past the busy timeout. + pub fn is_busy(&self) -> bool { + // sqlx reports the extended code, whose low byte is the primary code. + matches!(self, StoreError::Db(sqlx::Error::Database(db)) + if db.code().and_then(|code| code.parse::<i32>().ok()).is_some_and(|code| code & 0xFF == 5)) + } +} + +/// The kept copy a rule wrote just before its notice (`Store::list_recovered`). +#[derive(Debug, Clone, PartialEq)] +pub struct KeptCopy { + pub recovered_id: i64, + /// `conflict`, `field_conflict`, `delete_wins`, `became_publication`, `create_rejected`, + /// `delete_superseded`, `delete_refused` or `delete_publication`. `unmigratable` copies are + /// written by the v1 migration without a notice. + pub reason: String, +} + /// An event a rule asked for (`DocOp::Emit`), for the driver to hand to the host. #[derive(Debug, Clone, PartialEq)] pub struct DocNotice { pub doc_id: Uuid, pub event: DocEvent, + pub kept: Option<KeptCopy>, } #[derive(Debug, Clone, PartialEq)] @@ -69,6 +115,11 @@ pub struct Store { pool: SqlitePool, options: SqliteConnectOptions, instance_id: Uuid, + /// How the per-document rules merge lists; the engine sets it from its config. + pub(crate) list_merge: ListMergeConfig, + /// The JSON Pointer titles are read from (`None`: no titles); the engine sets it from its + /// config and then calls `apply_title_pointer`. + pub(crate) title_pointer: Option<String>, } impl Store { @@ -82,7 +133,7 @@ impl Store { .max_connections(POOL_SIZE) .connect_with(options.clone()) .await?; - if let Err(error) = prepare(&pool).await { + if let Err(error) = prepare(&pool, path).await { pool.close().await; return Err(error); } @@ -90,6 +141,8 @@ impl Store { pool, options, instance_id: Uuid::new_v4(), + list_merge: ListMergeConfig::default(), + title_pointer: None, }) } @@ -111,10 +164,87 @@ impl Store { } pub async fn user_id(&self) -> StoreResult<Uuid> { - let user_id: Option<String> = sqlx::query_scalar("SELECT user_id FROM user_config LIMIT 1") - .fetch_optional(&self.pool) + read_user_id(&mut *self.pool.acquire().await?).await + } + + /// Gives a new data dir a provisional identity (the server's id is adopted at the first + /// join) and returns the data dir's client id. + pub async fn ensure_user_config(&self, server_url: &str) -> StoreResult<Uuid> { + let mut tx = self.begin().await?; + let existing: Option<String> = + sqlx::query_scalar("SELECT client_id FROM user_config LIMIT 1") + .fetch_optional(&mut *tx) + .await?; + let client_id = match existing { + Some(client_id) => Uuid::parse_str(&client_id)?, + None => { + let client_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO user_config (user_id, client_id, server_url, identity_adopted) \ + VALUES (?, ?, ?, 0)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(client_id.to_string()) + .bind(server_url) + .execute(&mut *tx) + .await?; + client_id + } + }; + tx.commit().await?; + Ok(client_id) + } + + /// The title `content` has under this store's title pointer. + pub fn title_of(&self, content: &Value) -> Option<String> { + docs::title_of(content, self.title_pointer.as_deref()) + } + + /// When the titles were derived from another pointer (or never recorded), recomputes every + /// document's title and the search index from `title_pointer`, and records it. Returns + /// whether it recomputed. + pub async fn apply_title_pointer(&self) -> StoreResult<bool> { + let pointer = self.title_pointer.as_deref().unwrap_or(""); + let mut tx = self.begin().await?; + let recorded: Option<String> = + sqlx::query_scalar("SELECT title_pointer FROM user_config LIMIT 1") + .fetch_optional(&mut *tx) + .await? + .flatten(); + if recorded.as_deref() == Some(pointer) { + return Ok(false); + } + let rows = sqlx::query("SELECT id, content FROM documents") + .fetch_all(&mut *tx) + .await?; + for row in rows { + let id: String = row.try_get("id")?; + let title = serde_json::from_str::<Value>(&row.try_get::<String, _>("content")?) + .ok() + .and_then(|content| self.title_of(&content)); + sqlx::query("UPDATE documents SET title = ? WHERE id = ?") + .bind(title) + .bind(id) + .execute(&mut *tx) + .await?; + } + let searchable: i64 = sqlx::query_scalar(reads::HAS_SEARCH_CONFIG) + .fetch_one(&mut *tx) .await?; - Ok(Uuid::parse_str(&user_id.ok_or(StoreError::NoUserConfig)?)?) + if searchable != 0 { + sqlx::query("DELETE FROM documents_fts") + .execute(&mut *tx) + .await?; + sqlx::query(reads::REBUILD_FTS_INDEX) + .execute(&mut *tx) + .await?; + } + sqlx::query("UPDATE user_config SET title_pointer = ?") + .bind(pointer) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(true) } /// The join's user id against this data dir: adopt it if none was ever adopted. @@ -131,7 +261,7 @@ impl Store { let adopted = row.try_get::<i64, _>("identity_adopted")? != 0; let check = if local == server_user_id { if !adopted { - sqlx::query(Queries::ADOPT_USER_CONFIG_IDENTITY) + sqlx::query(ADOPT_USER_CONFIG_IDENTITY) .bind(server_user_id.to_string()) .bind(local.to_string()) .execute(&mut *tx) @@ -139,12 +269,12 @@ impl Store { } IdentityCheck::Matches } else if !adopted { - sqlx::query(Queries::RESTAMP_DOCUMENTS_USER_ID) + sqlx::query(RESTAMP_DOCUMENTS_USER_ID) .bind(server_user_id.to_string()) .bind(local.to_string()) .execute(&mut *tx) .await?; - sqlx::query(Queries::ADOPT_USER_CONFIG_IDENTITY) + sqlx::query(ADOPT_USER_CONFIG_IDENTITY) .bind(server_user_id.to_string()) .bind(local.to_string()) .execute(&mut *tx) @@ -164,8 +294,10 @@ impl Store { } } -async fn prepare(pool: &SqlitePool) -> StoreResult<()> { +async fn prepare(pool: &SqlitePool, path: &Path) -> StoreResult<()> { + v1_data::back_up_v1_database(pool, path).await?; sqlx::migrate!("./migrations").run(pool).await?; + v1_data::migrate_v1_data(pool).await?; sqlx::query("INSERT OR IGNORE INTO subscriptions (scope) VALUES (?), (?)") .bind(SCOPE_OWN) .bind(SCOPE_CURATED) @@ -188,13 +320,26 @@ pub(crate) fn now_rfc3339() -> String { chrono::Utc::now().to_rfc3339() } +/// The data dir's user. Writes read it inside their own transaction: another process's first +/// join may have re-stamped it since this process last looked. +pub(crate) async fn read_user_id(conn: &mut SqliteConnection) -> StoreResult<Uuid> { + let user_id: Option<String> = sqlx::query_scalar("SELECT user_id FROM user_config LIMIT 1") + .fetch_optional(&mut *conn) + .await?; + Ok(Uuid::parse_str(&user_id.ok_or(StoreError::NoUserConfig)?)?) +} + pub mod change_log; mod docs; mod feed; +mod reads; mod recovered; mod uploads; +mod v1_data; mod writes; +pub use docs::is_json_pointer; +pub use reads::{ParkedDocument, StoredDocument}; pub use recovered::RecoveredCopy; #[cfg(test)] @@ -204,7 +349,46 @@ pub(crate) mod test_support; mod tests { use super::test_support::*; use super::*; - use serde_json::json; + + #[tokio::test] + async fn ensure_user_config_creates_one_provisional_row_and_keeps_it() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(&dir.path().join("fresh.sqlite3")) + .await + .unwrap(); + let first = store.ensure_user_config("ws://one").await.unwrap(); + let again = store.ensure_user_config("ws://two").await.unwrap(); + assert_eq!(first, again, "one client id per data dir"); + assert_eq!(count(&store, "SELECT COUNT(*) FROM user_config").await, 1); + assert_eq!( + count( + &store, + "SELECT COUNT(*) FROM user_config WHERE identity_adopted = 0" + ) + .await, + 1, + "the server's id is adopted at the first join" + ); + store.user_id().await.unwrap(); + store.close().await; + } + + #[tokio::test] + async fn a_database_from_a_newer_build_is_reported_as_newer_schema() { + let t = temp_store().await; + exec( + &t.store, + "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) \ + VALUES (99, 'from a newer build', 1, x'00', 0)", + ) + .await; + t.store.close().await; + let error = Store::open(&t.path()) + .await + .err() + .expect("a newer schema must not open"); + assert!(error.is_newer_schema(), "{error}"); + } #[tokio::test] async fn open_applies_the_v2_schema_in_wal_mode() { @@ -242,32 +426,6 @@ mod tests { ); } - #[tokio::test] - async fn v1_database_still_reads_and_writes_after_013() { - let t = temp_store().await; - let url = format!("sqlite://{}?mode=rwc", t.path().display()); - let v1 = crate::database::ClientDatabase::new(&url).await.unwrap(); - v1.run_migrations().await.unwrap(); - let now = chrono::Utc::now(); - let doc = replicant_core::models::Document { - id: Uuid::new_v4(), - user_id: Some(ME), - content: json!({"title": "v1"}), - sync_revision: 1, - content_hash: None, - title: Some("v1".into()), - created_at: now, - updated_at: now, - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }; - v1.save_document(&doc).await.unwrap(); - assert_eq!(v1.get_document(&doc.id).await.unwrap().content, doc.content); - v1.close().await; - } - #[tokio::test] async fn first_join_adopts_the_server_identity_then_later_ids_drift() { let t = temp_store().await; diff --git a/replicant-client/src/store/reads.rs b/replicant-client/src/store/reads.rs new file mode 100644 index 0000000..a6938f7 --- /dev/null +++ b/replicant-client/src/store/reads.rs @@ -0,0 +1,582 @@ +//! What the host reads: documents as the host sees them (a pending delete hides one), counts, +//! and full-text search over the configured JSON paths. + +use serde::Serialize; +use serde_json::Value; +use sqlx::sqlite::SqliteRow; +use sqlx::Row; +use uuid::Uuid; + +use super::uploads::PENDING_DOC_IDS; +use super::{Store, StoreError, StoreResult}; +use crate::engine::types::SCOPE_CURATED; + +pub(crate) const HAS_SEARCH_CONFIG: &str = "SELECT EXISTS(SELECT 1 FROM search_config LIMIT 1)"; +pub(crate) const DELETE_FTS_ENTRY: &str = "DELETE FROM documents_fts WHERE document_id = ?"; +pub(crate) const UPDATE_FTS_ENTRY: &str = "INSERT INTO documents_fts (document_id, title, body) \ + SELECT d.id, COALESCE(d.title, ''), COALESCE((SELECT GROUP_CONCAT(json_extract(d.content, \ + sc.json_path), ' ') FROM search_config sc WHERE json_extract(d.content, sc.json_path) IS NOT NULL), '') \ + FROM documents d WHERE d.id = ? AND d.deleted_at IS NULL"; +pub(crate) const REBUILD_FTS_INDEX: &str = "INSERT INTO documents_fts (document_id, title, body) \ + SELECT d.id, COALESCE(d.title, ''), COALESCE((SELECT GROUP_CONCAT(json_extract(d.content, \ + sc.json_path), ' ') FROM search_config sc WHERE json_extract(d.content, sc.json_path) IS NOT NULL), '') \ + FROM documents d WHERE d.deleted_at IS NULL"; + +/// A document as the host sees it. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct StoredDocument { + pub id: Uuid, + /// The owner; `None` for a legacy document that has none. + pub owner_id: Option<Uuid>, + pub author_id: Option<Uuid>, + pub title: Option<String>, + pub content: Value, + pub read_only: bool, + /// `public` for a curated or read-only document, `private` otherwise. + pub visibility: &'static str, + pub source_doc_id: Option<Uuid>, + pub derived_from: Option<Uuid>, + pub created_at: String, + pub updated_at: String, +} + +/// A document that stopped uploading until its next local edit. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ParkedDocument { + pub doc_id: Uuid, + pub code: String, +} + +/// Visible documents (no pending delete), after `join`, narrowed by `filter` (`AND …`, may be empty). +fn select_documents(join: &str, filter: &str) -> String { + format!( + "SELECT d.id, d.user_id, d.author_id, d.title, d.content, d.read_only, d.source_doc_id, \ + d.derived_from, d.created_at, d.updated_at, (d.read_only = 1 OR EXISTS (SELECT 1 FROM \ + doc_scopes s WHERE s.doc_id = d.id AND s.scope = '{SCOPE_CURATED}' AND s.member = 1)) \ + AS public FROM documents d {join} WHERE d.deleted_at IS NULL {filter}" + ) +} + +fn parse_document(row: &SqliteRow) -> StoreResult<StoredDocument> { + let uuid = |column: &str| -> StoreResult<Option<Uuid>> { + Ok(row + .try_get::<Option<String>, _>(column)? + .map(|text| Uuid::parse_str(&text)) + .transpose()?) + }; + Ok(StoredDocument { + id: Uuid::parse_str(&row.try_get::<String, _>("id")?)?, + owner_id: uuid("user_id")?, + author_id: uuid("author_id")?, + title: row.try_get("title")?, + content: serde_json::from_str(&row.try_get::<String, _>("content")?)?, + read_only: row.try_get::<i64, _>("read_only")? != 0, + visibility: if row.try_get::<i64, _>("public")? != 0 { + "public" + } else { + "private" + }, + source_doc_id: uuid("source_doc_id")?, + derived_from: uuid("derived_from")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + }) +} + +impl Store { + pub async fn get_document(&self, doc_id: Uuid) -> StoreResult<Option<StoredDocument>> { + let row = sqlx::query(&select_documents("", "AND d.id = ?")) + .bind(doc_id.to_string()) + .fetch_optional(&self.pool) + .await?; + row.as_ref().map(parse_document).transpose() + } + + pub async fn list_documents(&self) -> StoreResult<Vec<StoredDocument>> { + let rows = sqlx::query(&select_documents("", "ORDER BY d.id")) + .fetch_all(&self.pool) + .await?; + rows.iter().map(parse_document).collect() + } + + /// `include_deleted` adds documents whose delete is not yet sent. + pub async fn document_ids(&self, include_deleted: bool) -> StoreResult<Vec<Uuid>> { + let sql = if include_deleted { + "SELECT id FROM documents ORDER BY id" + } else { + "SELECT id FROM documents WHERE deleted_at IS NULL ORDER BY id" + }; + let ids: Vec<String> = sqlx::query_scalar(sql).fetch_all(&self.pool).await?; + ids.iter().map(|id| Ok(Uuid::parse_str(id)?)).collect() + } + + pub async fn count_documents(&self) -> StoreResult<u64> { + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM documents WHERE deleted_at IS NULL") + .fetch_one(&self.pool) + .await?; + Ok(count as u64) + } + + /// Documents the uploader will send. A parked document waits for the user's next edit + /// (`list_parked`). + pub async fn count_pending_sync(&self) -> StoreResult<u64> { + let count: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM ({PENDING_DOC_IDS})")) + .fetch_one(&self.pool) + .await?; + Ok(count as u64) + } + + /// Documents that stopped uploading until their next local edit, with the code of their + /// newest parked row (`validation`, `forbidden`, `too_large`, `diverged`). + pub async fn list_parked(&self) -> StoreResult<Vec<ParkedDocument>> { + let rows: Vec<(String, String)> = sqlx::query_as(&format!( + "SELECT p.doc_id, (SELECT n.parked_error FROM outbox n WHERE n.doc_id = p.doc_id \ + AND n.parked_error IS NOT NULL ORDER BY n.mutation_id DESC LIMIT 1) \ + FROM (SELECT DISTINCT doc_id FROM outbox WHERE parked_error IS NOT NULL) p \ + WHERE p.doc_id NOT IN ({PENDING_DOC_IDS}) ORDER BY p.doc_id" + )) + .fetch_all(&self.pool) + .await?; + rows.into_iter() + .map(|(doc_id, code)| { + Ok(ParkedDocument { + doc_id: Uuid::parse_str(&doc_id)?, + code, + }) + }) + .collect() + } + + /// Replaces the indexed JSON paths and rebuilds the index, in one transaction. + pub async fn configure_search(&self, json_paths: &[String]) -> StoreResult<()> { + let mut tx = self.begin().await?; + sqlx::query("DELETE FROM search_config") + .execute(&mut *tx) + .await?; + for path in json_paths { + sqlx::query("INSERT INTO search_config (json_path) VALUES (?)") + .bind(path) + .execute(&mut *tx) + .await?; + } + sqlx::query("DELETE FROM documents_fts") + .execute(&mut *tx) + .await?; + sqlx::query(REBUILD_FTS_INDEX).execute(&mut *tx).await?; + tx.commit().await?; + Ok(()) + } + + pub async fn rebuild_search_index(&self) -> StoreResult<()> { + let mut tx = self.begin().await?; + sqlx::query("DELETE FROM documents_fts") + .execute(&mut *tx) + .await?; + sqlx::query(REBUILD_FTS_INDEX).execute(&mut *tx).await?; + tx.commit().await?; + Ok(()) + } + + /// FTS5 query syntax: terms, `prefix*`, `"a phrase"`, `AND`/`OR`, `title:word`. A query + /// FTS5 cannot parse is `BadSearchQuery`. + pub async fn search_documents( + &self, + query: &str, + limit: u32, + ) -> StoreResult<Vec<StoredDocument>> { + let rows = sqlx::query(&select_documents( + "JOIN documents_fts f ON f.document_id = d.id", + "AND f.documents_fts MATCH ? ORDER BY f.rank LIMIT ?", + )) + .bind(query) + .bind(i64::from(limit)) + .fetch_all(&self.pool) + .await + .map_err(|error| match &error { + // Plain SQLITE_ERROR: the statement is fixed, so only the MATCH text can cause it. + sqlx::Error::Database(db) if db.code().as_deref() == Some("1") => { + StoreError::BadSearchQuery(db.message().to_string()) + } + _ => StoreError::Db(error), + })?; + rows.iter().map(parse_document).collect() + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use uuid::Uuid; + + use crate::engine::machine::BuildOutcome; + use crate::engine::types::{ServerError, SCOPE_CURATED, SCOPE_OWN}; + use crate::store::test_support::*; + use crate::store::{ParkedDocument, Store, StoreError}; + + fn doc(n: u128) -> Uuid { + Uuid::from_u128(0xD000 + n) + } + + #[tokio::test] + async fn get_document_returns_the_host_view_and_hides_a_pending_delete() { + let mut t = temp_store().await; + t.store.title_pointer = Some("/title".into()); + let doc_id = t + .store + .create_document(None, json!({"title": "Mine", "n": 1})) + .await + .unwrap(); + let read = t + .store + .get_document(doc_id) + .await + .unwrap() + .expect("created"); + assert_eq!(read.id, doc_id); + assert_eq!(read.owner_id, Some(ME)); + assert_eq!(read.title.as_deref(), Some("Mine")); + assert_eq!(read.content, json!({"title": "Mine", "n": 1})); + assert!(!read.read_only); + assert_eq!(read.visibility, "private"); + assert_eq!(t.store.list_documents().await.unwrap(), vec![read]); + t.store.delete_document(doc_id).await.unwrap(); + assert_eq!(t.store.get_document(doc_id).await.unwrap(), None); + assert!(t.store.list_documents().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn curated_and_read_only_documents_are_public() { + let t = temp_store().await; + seed_synced(&t.store, doc(1), SCOPE_CURATED, None, json!({"n": 1}), 3).await; + seed_synced(&t.store, doc(2), SCOPE_OWN, Some(ME), json!({"n": 2}), 4).await; + exec( + &t.store, + &format!("UPDATE documents SET read_only = 1 WHERE id = '{}'", doc(2)), + ) + .await; + seed_synced(&t.store, doc(3), SCOPE_OWN, Some(ME), json!({"n": 3}), 5).await; + for (n, expected) in [(1, "public"), (2, "public"), (3, "private")] { + let read = t.store.get_document(doc(n)).await.unwrap().unwrap(); + assert_eq!(read.visibility, expected, "doc {n}"); + } + } + + #[tokio::test] + async fn document_ids_can_include_pending_deletes() { + let t = temp_store().await; + let kept = t.store.create_document(None, json!({})).await.unwrap(); + let deleted = t.store.create_document(None, json!({})).await.unwrap(); + t.store.delete_document(deleted).await.unwrap(); + assert_eq!(t.store.document_ids(false).await.unwrap(), vec![kept]); + let mut all = t.store.document_ids(true).await.unwrap(); + all.sort(); + let mut expected = vec![kept, deleted]; + expected.sort(); + assert_eq!(all, expected); + } + + #[tokio::test] + async fn counts_hide_pending_deletes_and_pending_sync_skips_parked_documents() { + let t = temp_store().await; + seed_synced(&t.store, doc(8), SCOPE_OWN, Some(ME), json!({}), 1).await; + let first = doc(8); + let parked = t.store.create_document(None, json!({})).await.unwrap(); + seed_synced(&t.store, doc(9), SCOPE_OWN, Some(ME), json!({}), 1).await; + exec( + &t.store, + &format!("UPDATE outbox SET parked_error = 'validation' WHERE doc_id = '{parked}'"), + ) + .await; + assert_eq!(t.store.count_documents().await.unwrap(), 3); + assert_eq!( + t.store.count_pending_sync().await.unwrap(), + 0, + "a parked document waits for an edit, not for sync; a synced one does not count" + ); + t.store.delete_document(first).await.unwrap(); + assert_eq!(t.store.count_documents().await.unwrap(), 2); + assert_eq!( + t.store.count_pending_sync().await.unwrap(), + 1, + "its delete is pending" + ); + } + + #[tokio::test] + async fn list_parked_names_each_parked_document_and_its_code() { + let t = temp_store().await; + let parked = t.store.create_document(None, json!({})).await.unwrap(); + t.store.create_document(None, json!({})).await.unwrap(); + exec( + &t.store, + &format!("UPDATE outbox SET parked_error = 'too_large' WHERE doc_id = '{parked}'"), + ) + .await; + assert_eq!( + t.store.list_parked().await.unwrap(), + vec![ParkedDocument { + doc_id: parked, + code: "too_large".into() + }] + ); + } + + /// Sends the document's queued rows and has the server refuse them as invalid. + async fn refuse_upload(t: &TempStore, doc_id: Uuid) { + let BuildOutcome::Send { inflight, .. } = t.store.build_upload(ME, doc_id).await.unwrap() + else { + panic!("expected an upload"); + }; + t.store + .settle_upload( + ME, + doc_id, + &inflight, + &Err(ServerError::new("validation")), + 0, + 0, + ) + .await + .unwrap(); + } + + async fn parked_ids(store: &Store) -> Vec<Uuid> { + let parked = store.list_parked().await.unwrap(); + parked.into_iter().map(|parked| parked.doc_id).collect() + } + + #[tokio::test] + async fn an_edit_made_during_a_refused_upload_leaves_the_document_parked_not_pending() { + let t = temp_store().await; + seed_synced(&t.store, doc(1), SCOPE_OWN, Some(ME), json!({"n": 0}), 1).await; + t.store + .update_document(doc(1), json!({"n": 1})) + .await + .unwrap(); + let BuildOutcome::Send { inflight, .. } = t.store.build_upload(ME, doc(1)).await.unwrap() + else { + panic!("expected an upload"); + }; + t.store + .update_document(doc(1), json!({"n": 2})) + .await + .unwrap(); + t.store + .settle_upload( + ME, + doc(1), + &inflight, + &Err(ServerError::new("validation")), + 0, + 0, + ) + .await + .unwrap(); + assert!(t.store.load_pending().await.unwrap().is_empty()); + assert_eq!(t.store.count_pending_sync().await.unwrap(), 0); + assert_eq!(parked_ids(&t.store).await, vec![doc(1)]); + } + + #[tokio::test] + async fn a_delete_after_a_refused_upload_is_pending_and_the_document_stays_hidden() { + let t = temp_store().await; + seed_synced(&t.store, doc(1), SCOPE_OWN, Some(ME), json!({"n": 0}), 1).await; + t.store + .update_document(doc(1), json!({"n": 1})) + .await + .unwrap(); + let BuildOutcome::Send { inflight, .. } = t.store.build_upload(ME, doc(1)).await.unwrap() + else { + panic!("expected an upload"); + }; + t.store.delete_document(doc(1)).await.unwrap(); + t.store + .settle_upload( + ME, + doc(1), + &inflight, + &Err(ServerError::new("validation")), + 0, + 0, + ) + .await + .unwrap(); + assert_eq!(t.store.load_pending().await.unwrap(), vec![doc(1)]); + assert_eq!(t.store.count_pending_sync().await.unwrap(), 1); + assert!(parked_ids(&t.store).await.is_empty()); + assert_eq!(t.store.get_document(doc(1)).await.unwrap(), None); + } + + #[tokio::test] + async fn every_document_with_outbox_rows_is_exactly_one_of_pending_or_parked() { + let t = temp_store().await; + for n in 1..=5 { + seed_synced(&t.store, doc(n), SCOPE_OWN, Some(ME), json!({"n": 0}), 1).await; + t.store + .update_document(doc(n), json!({"n": 1})) + .await + .unwrap(); + } + // 1: queued. 2: refused. 3: edited during a refused upload. 4: refused then deleted. + refuse_upload(&t, doc(2)).await; + let BuildOutcome::Send { inflight, .. } = t.store.build_upload(ME, doc(3)).await.unwrap() + else { + panic!("expected an upload"); + }; + t.store + .update_document(doc(3), json!({"n": 2})) + .await + .unwrap(); + t.store + .settle_upload( + ME, + doc(3), + &inflight, + &Err(ServerError::new("forbidden")), + 0, + 0, + ) + .await + .unwrap(); + refuse_upload(&t, doc(4)).await; + t.store.delete_document(doc(4)).await.unwrap(); + // 5: refused, deleted, and that delete parked too. + refuse_upload(&t, doc(5)).await; + t.store.delete_document(doc(5)).await.unwrap(); + exec( + &t.store, + &format!( + "UPDATE outbox SET parked_error = 'forbidden' WHERE doc_id = '{}'", + doc(5) + ), + ) + .await; + + let pending = t.store.load_pending().await.unwrap(); + let parked = parked_ids(&t.store).await; + assert!(pending.iter().all(|id| !parked.contains(id))); + let mut union: Vec<Uuid> = pending.iter().chain(&parked).copied().collect(); + union.sort(); + let with_rows: Vec<String> = + sqlx::query_scalar("SELECT DISTINCT doc_id FROM outbox ORDER BY doc_id") + .fetch_all(&t.store.pool) + .await + .unwrap(); + assert_eq!( + union.iter().map(Uuid::to_string).collect::<Vec<_>>(), + with_rows + ); + assert_eq!( + t.store.count_pending_sync().await.unwrap(), + pending.len() as u64 + ); + assert_eq!(pending, vec![doc(1), doc(4)]); + } + + #[tokio::test] + async fn list_parked_reports_the_code_of_the_newest_parked_row() { + let t = temp_store().await; + let doc_id = t.store.create_document(None, json!({})).await.unwrap(); + exec( + &t.store, + &format!( + "UPDATE outbox SET parked_error = 'too_large' WHERE doc_id = '{doc_id}'; \ + INSERT INTO outbox (mutation_id, doc_id, kind, created_at, parked_error) \ + VALUES ('00000000-0000-0000-0000-000000000000', '{doc_id}', 'update', 0, 'validation')" + ), + ) + .await; + let code = t.store.list_parked().await.unwrap()[0].code.clone(); + let newest: String = + sqlx::query_scalar("SELECT parked_error FROM outbox ORDER BY mutation_id DESC LIMIT 1") + .fetch_one(&t.store.pool) + .await + .unwrap(); + assert_eq!(code, newest); + } + + #[tokio::test] + async fn search_finds_configured_paths_and_skips_pending_deletes() { + let t = temp_store().await; + t.store + .configure_search(&["$.body".to_string()]) + .await + .unwrap(); + let music = t + .store + .create_document(None, json!({"title": "Alpha", "body": "music theory"})) + .await + .unwrap(); + t.store + .create_document(None, json!({"title": "Beta", "body": "cooking"})) + .await + .unwrap(); + let ids = |found: Vec<crate::store::StoredDocument>| { + found + .into_iter() + .map(|document| document.id) + .collect::<Vec<_>>() + }; + assert_eq!( + ids(t.store.search_documents("music", 10).await.unwrap()), + vec![music] + ); + t.store.rebuild_search_index().await.unwrap(); + assert_eq!( + ids(t.store.search_documents("music", 10).await.unwrap()), + vec![music] + ); + t.store.delete_document(music).await.unwrap(); + assert!(t + .store + .search_documents("music", 10) + .await + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn search_returns_the_best_match_first_when_limited() { + let t = temp_store().await; + t.store + .configure_search(&["$.body".to_string()]) + .await + .unwrap(); + let weak = Uuid::from_u128(0x1); + let strong = Uuid::from_u128(0xFFFF); + t.store + .create_document( + Some(weak), + json!({"body": "one mention of tuning among many other unrelated words here"}), + ) + .await + .unwrap(); + t.store + .create_document(Some(strong), json!({"body": "tuning tuning tuning"})) + .await + .unwrap(); + let found = t.store.search_documents("tuning", 1).await.unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].id, strong); + } + + #[tokio::test] + async fn a_query_fts5_cannot_parse_is_a_bad_search_query() { + let t = temp_store().await; + t.store + .configure_search(&["$.body".to_string()]) + .await + .unwrap(); + for query in ["\"open", "a AND", "nosuch:word", "*", ""] { + assert!( + matches!( + t.store.search_documents(query, 10).await, + Err(StoreError::BadSearchQuery(_)) + ), + "{query:?}" + ); + } + assert!(t.store.search_documents("tun*", 10).await.is_ok()); + } +} diff --git a/replicant-client/src/store/recovered.rs b/replicant-client/src/store/recovered.rs index 64c58fb..6688f0e 100644 --- a/replicant-client/src/store/recovered.rs +++ b/replicant-client/src/store/recovered.rs @@ -1,7 +1,9 @@ //! Local content the sync rules set aside (`DocOp::Recover`), kept until the user dismisses it. use serde_json::Value; +use sqlx::sqlite::SqliteRow; use sqlx::Row; +use tracing::warn; use uuid::Uuid; use super::{Store, StoreResult}; @@ -13,7 +15,8 @@ pub struct RecoveredCopy { pub doc_id: Uuid, /// The full local content when it was set aside. pub content: Value, - /// `conflict`, `field_conflict`, `delete_wins`, `became_publication` or `create_rejected`. + /// `conflict`, `field_conflict`, `delete_wins`, `became_publication`, `create_rejected`, + /// `delete_superseded`, `delete_refused`, `delete_publication` or `unmigratable`. pub reason: String, /// Unix seconds. pub recovered_at: i64, @@ -21,29 +24,40 @@ pub struct RecoveredCopy { pub fields: Option<Vec<FieldConflict>>, } +fn parse_copy(row: &SqliteRow) -> StoreResult<RecoveredCopy> { + Ok(RecoveredCopy { + id: row.try_get("id")?, + doc_id: Uuid::parse_str(&row.try_get::<String, _>("doc_id")?)?, + content: serde_json::from_str(&row.try_get::<String, _>("content")?)?, + reason: row.try_get("reason")?, + recovered_at: row.try_get("recovered_at")?, + fields: row + .try_get::<Option<String>, _>("fields")? + .map(|fields| serde_json::from_str(&fields)) + .transpose()?, + }) +} + impl Store { - /// Every kept copy, newest first. + /// Every kept copy, newest first. A row that cannot be read is left out (and logged), so one + /// bad row never hides the others. pub async fn list_recovered(&self) -> StoreResult<Vec<RecoveredCopy>> { let rows = sqlx::query( "SELECT id, doc_id, content, reason, recovered_at, fields FROM recovered ORDER BY id DESC", ) .fetch_all(&self.pool) .await?; - rows.iter() - .map(|row| -> StoreResult<RecoveredCopy> { - Ok(RecoveredCopy { - id: row.try_get("id")?, - doc_id: Uuid::parse_str(&row.try_get::<String, _>("doc_id")?)?, - content: serde_json::from_str(&row.try_get::<String, _>("content")?)?, - reason: row.try_get("reason")?, - recovered_at: row.try_get("recovered_at")?, - fields: row - .try_get::<Option<String>, _>("fields")? - .map(|fields| serde_json::from_str(&fields)) - .transpose()?, - }) + Ok(rows + .iter() + .filter_map(|row| match parse_copy(row) { + Ok(copy) => Some(copy), + Err(error) => { + let id = row.try_get::<i64, _>("id").ok(); + warn!(?id, %error, "skipping an unreadable kept copy"); + None + } }) - .collect() + .collect()) } /// Deletes one kept copy; false when no copy has that id. @@ -92,6 +106,29 @@ mod tests { ); } + #[tokio::test] + async fn an_unreadable_kept_copy_is_left_out_and_the_others_are_listed() { + let t = temp_store().await; + keep(&t.store, Uuid::from_u128(1), r#"{"v":1}"#, 100).await; + keep(&t.store, Uuid::from_u128(2), "not json", 200).await; + exec( + &t.store, + "INSERT INTO recovered (doc_id, content, reason, recovered_at) \ + VALUES ('not-a-uuid', '{}', 'conflict', 300)", + ) + .await; + keep(&t.store, Uuid::from_u128(4), r#"{"v":4}"#, 400).await; + let doc_ids: Vec<Uuid> = t + .store + .list_recovered() + .await + .unwrap() + .iter() + .map(|copy| copy.doc_id) + .collect(); + assert_eq!(doc_ids, vec![Uuid::from_u128(4), Uuid::from_u128(1)]); + } + #[tokio::test] async fn dismiss_recovered_deletes_exactly_one_row() { let t = temp_store().await; diff --git a/replicant-client/src/store/test_support.rs b/replicant-client/src/store/test_support.rs index b730465..98e0438 100644 --- a/replicant-client/src/store/test_support.rs +++ b/replicant-client/src/store/test_support.rs @@ -3,6 +3,8 @@ use std::path::PathBuf; use serde_json::Value; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::SqlitePool; use tempfile::TempDir; use uuid::Uuid; @@ -88,6 +90,7 @@ pub(crate) async fn apply_with_envelope( &snap, &ops, envelope, + store.title_pointer.as_deref(), ) .await .unwrap(); @@ -181,3 +184,79 @@ pub(crate) fn delete_change(scope: &str, doc_id: Uuid, seq: Seq) -> Change { upload_id: None, } } + +/// A data dir at the last v1 schema (migration 012), for migration 015 tests. Write v1 rows +/// through the returned pool, close it, then `Store::open` the path. +pub(crate) async fn v012_db() -> (TempDir, PathBuf, SqlitePool) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(DB_FILE); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with( + SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true), + ) + .await + .unwrap(); + let mut migrator = sqlx::migrate!("./migrations"); + migrator.migrations = std::borrow::Cow::Owned( + migrator + .iter() + .filter(|migration| migration.version <= 12) + .cloned() + .collect(), + ); + migrator.run(&pool).await.unwrap(); + (dir, path, pool) +} + +pub(crate) async fn v1_user(pool: &SqlitePool, user_id: Uuid, adopted: bool) { + sqlx::query( + "INSERT INTO user_config (user_id, client_id, server_url, identity_adopted) \ + VALUES (?, ?, 'ws://v1', ?)", + ) + .bind(user_id.to_string()) + .bind(Uuid::new_v4().to_string()) + .bind(adopted as i64) + .execute(pool) + .await + .unwrap(); +} + +/// A v1 `documents` row, with `id` stored exactly as given. +pub(crate) async fn v1_doc( + pool: &SqlitePool, + id: &str, + owner: Option<Uuid>, + content: Value, + status: &str, + deleted_at: Option<&str>, +) { + sqlx::query( + "INSERT INTO documents (id, user_id, content, sync_revision, created_at, updated_at, \ + deleted_at, sync_status, title) \ + VALUES (?, ?, ?, 1, '2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00', ?, ?, 'v1')", + ) + .bind(id) + .bind(owner.map(|owner| owner.to_string())) + .bind(content.to_string()) + .bind(deleted_at) + .bind(status) + .execute(pool) + .await + .unwrap(); +} + +pub(crate) async fn v1_queue_row(pool: &SqlitePool, doc_id: &str, kind: &str, base: Option<Value>) { + sqlx::query( + "INSERT INTO sync_queue (document_id, operation_type, patch, base_content, created_at) \ + VALUES (?, ?, '[]', ?, '2026-01-02 03:04:05')", + ) + .bind(doc_id) + .bind(kind) + .bind(base.map(|base| base.to_string())) + .execute(pool) + .await + .unwrap(); +} diff --git a/replicant-client/src/store/uploads.rs b/replicant-client/src/store/uploads.rs index d0ce29c..81e2cda 100644 --- a/replicant-client/src/store/uploads.rs +++ b/replicant-client/src/store/uploads.rs @@ -5,18 +5,23 @@ use uuid::Uuid; use super::docs::{apply_ops, load_snapshot, LogOrigin}; use super::{DocNotice, Store, StoreError, StoreResult}; use crate::engine::doc::{self as rules, DocOp, DocSnapshot, InFlight}; +use crate::engine::doc_upload::reply_diverged; use crate::engine::machine::{BuildOutcome, SettleOutcome}; use crate::engine::types::{DocEnvelope, Seq, ServerError}; +/// The documents the uploader will send. Every other document with outbox rows is parked. +pub(super) const PENDING_DOC_IDS: &str = "SELECT doc_id FROM outbox GROUP BY doc_id \ + HAVING SUM(parked_error IS NOT NULL) = 0 OR (SELECT kind = 'delete' AND parked_error IS NULL \ + FROM outbox last WHERE last.doc_id = outbox.doc_id ORDER BY mutation_id DESC LIMIT 1)"; + impl Store { - /// `Effect::LoadPending`: documents with outbox rows and none parked, oldest row first. + /// `Effect::LoadPending`: documents with outbox rows and none parked (or an unparked delete + /// as the last row), oldest row first. pub async fn load_pending(&self) -> StoreResult<Vec<Uuid>> { - let ids: Vec<String> = sqlx::query_scalar( - "SELECT doc_id FROM outbox GROUP BY doc_id \ - HAVING SUM(parked_error IS NOT NULL) = 0 ORDER BY MIN(mutation_id)", - ) - .fetch_all(&self.pool) - .await?; + let ids: Vec<String> = + sqlx::query_scalar(&format!("{PENDING_DOC_IDS} ORDER BY MIN(mutation_id)")) + .fetch_all(&self.pool) + .await?; ids.iter() .map(|id| Uuid::parse_str(id).map_err(StoreError::from)) .collect() @@ -34,7 +39,15 @@ impl Store { rules::BuildResult::NeedsServerCopy => BuildOutcome::NeedsServerCopy, rules::BuildResult::Nothing => BuildOutcome::Nothing, rules::BuildResult::SettleLocally(ops) => { - apply_ops(&mut tx, &self.writer(LogOrigin::Server), &snap, &ops, None).await?; + apply_ops( + &mut tx, + &self.writer(LogOrigin::Server), + &snap, + &ops, + None, + self.title_pointer.as_deref(), + ) + .await?; BuildOutcome::SettledLocally { rows_remain: !snap.project(&ops).rows.is_empty(), } @@ -65,15 +78,41 @@ impl Store { inflight: &InFlight, reply: &Result<DocEnvelope, ServerError>, mismatch_attempts: u32, + divergent_replies: u32, ) -> StoreResult<(SettleOutcome, Vec<DocNotice>)> { let mut tx = self.begin().await?; let snap = load_snapshot(&mut tx, doc_id).await?; - let settled = match rules::settle(&snap, inflight, reply, me, mismatch_attempts) { + let diverged = reply_diverged(&snap, inflight, reply); + if diverged { + tracing::warn!(%doc_id, "the server's copy differs from what was uploaded"); + } + let settled = match rules::settle( + &snap, + inflight, + reply, + me, + mismatch_attempts, + divergent_replies, + ) { rules::SettleResult::Ops(ops) => { let writer = self.writer(LogOrigin::Server); - let notices = apply_ops(&mut tx, &writer, &snap, &ops, reply.as_ref().ok()).await?; + let notices = apply_ops( + &mut tx, + &writer, + &snap, + &ops, + reply.as_ref().ok(), + self.title_pointer.as_deref(), + ) + .await?; let rows_remain = !snap.project(&ops).rows.is_empty(); - (SettleOutcome::Done { rows_remain }, notices) + ( + SettleOutcome::Done { + rows_remain, + diverged, + }, + notices, + ) } rules::SettleResult::FetchServerCopy => (SettleOutcome::FetchServerCopy, Vec::new()), rules::SettleResult::Retry { after_ms, mismatch } => { @@ -92,7 +131,7 @@ impl Store { doc: Option<&DocEnvelope>, ) -> StoreResult<Vec<DocNotice>> { self.apply_rule(doc_id, doc, |snap| match doc { - Some(doc) => rules::apply_server_copy(snap, doc, me), + Some(doc) => rules::apply_server_copy(snap, doc, me, &self.list_merge), None => rules::apply_server_missing(snap), }) .await @@ -123,6 +162,7 @@ impl Store { &snap, &ops, envelope, + self.title_pointer.as_deref(), ) .await?; tx.commit().await?; @@ -139,6 +179,7 @@ mod tests { use crate::engine::doc::{DocEvent, RowKind}; use crate::engine::types::{Upload, UploadKind, SCOPE_OWN}; use crate::store::test_support::*; + use crate::store::KeptCopy; async fn sent(store: &Store, doc_id: Uuid) -> (Upload, InFlight) { match store.build_upload(ME, doc_id).await.unwrap() { @@ -162,7 +203,7 @@ mod tests { async fn lost_create_of_an_integral_float_adopts_the_servers_copy() { let t = temp_store().await; t.store - .create_document(ME, Some(DOC), json!({"n": 1200.0})) + .create_document(Some(DOC), json!({"n": 1200.0})) .await .unwrap(); let (_, inflight) = sent(&t.store, DOC).await; @@ -187,17 +228,17 @@ mod tests { let t = temp_store().await; let first = t .store - .create_document(ME, None, json!({"a": 1})) + .create_document(None, json!({"a": 1})) .await .unwrap(); let parked = t .store - .create_document(ME, None, json!({"b": 1})) + .create_document(None, json!({"b": 1})) .await .unwrap(); let last = t .store - .create_document(ME, None, json!({"c": 1})) + .create_document(None, json!({"c": 1})) .await .unwrap(); sqlx::query("UPDATE outbox SET parked_error = 'validation' WHERE doc_id = ?") @@ -208,16 +249,45 @@ mod tests { assert_eq!(t.store.load_pending().await.unwrap(), vec![first, last]); } + #[tokio::test] + async fn load_pending_lists_a_parked_doc_whose_last_row_is_a_delete() { + let t = temp_store().await; + let doc_id = t + .store + .create_document(None, json!({"a": 1})) + .await + .unwrap(); + sqlx::query("UPDATE outbox SET parked_error = 'validation'") + .execute(&t.store.pool) + .await + .unwrap(); + assert!(t.store.load_pending().await.unwrap().is_empty()); + sqlx::query( + "INSERT INTO outbox (mutation_id, doc_id, kind, created_at) \ + VALUES ('ffffffff-ffff-ffff-ffff-ffffffffffff', ?, 'delete', 0)", + ) + .bind(doc_id.to_string()) + .execute(&t.store.pool) + .await + .unwrap(); + assert_eq!(t.store.load_pending().await.unwrap(), vec![doc_id]); + sqlx::query("UPDATE outbox SET parked_error = 'forbidden'") + .execute(&t.store.pool) + .await + .unwrap(); + assert!(t.store.load_pending().await.unwrap().is_empty()); + } + #[tokio::test] async fn mark_sent_marks_only_the_rows_the_upload_covers() { let t = temp_store().await; let doc_id = t .store - .create_document(ME, None, json!({"n": 1})) + .create_document(None, json!({"n": 1})) .await .unwrap(); t.store - .update_document(ME, doc_id, json!({"n": 2})) + .update_document(doc_id, json!({"n": 2})) .await .unwrap(); let rows: Vec<String> = sqlx::query_scalar( @@ -244,25 +314,25 @@ mod tests { async fn ack_deletes_only_covered_rows_and_keeps_the_server_hash_verbatim() { let t = temp_store().await; seed_synced(&t.store, DOC, SCOPE_OWN, Some(ME), json!({"n": 0}), 1).await; - t.store - .update_document(ME, DOC, json!({"n": 1})) - .await - .unwrap(); + t.store.update_document(DOC, json!({"n": 1})).await.unwrap(); let (_, inflight) = sent(&t.store, DOC).await; - t.store - .update_document(ME, DOC, json!({"n": 2})) - .await - .unwrap(); + t.store.update_document(DOC, json!({"n": 2})).await.unwrap(); let mut reply = envelope(DOC, Some(ME), json!({"n": 1}), 2); reply.hash = "server-hash-2".into(); let (outcome, notices) = t .store - .settle_upload(ME, DOC, &inflight, &Ok(reply), 0) + .settle_upload(ME, DOC, &inflight, &Ok(reply), 0, 0) .await .unwrap(); - assert_eq!(outcome, SettleOutcome::Done { rows_remain: true }); + assert_eq!( + outcome, + SettleOutcome::Done { + rows_remain: true, + diverged: false + } + ); assert!(notices.is_empty()); let after = snapshot(&t.store, DOC).await; assert_eq!(after.rows.len(), 1); @@ -277,10 +347,7 @@ mod tests { async fn settling_the_same_reply_twice_is_a_noop_the_second_time() { let t = temp_store().await; seed_synced(&t.store, DOC, SCOPE_OWN, Some(ME), json!({"n": 0}), 1).await; - t.store - .update_document(ME, DOC, json!({"n": 1})) - .await - .unwrap(); + t.store.update_document(DOC, json!({"n": 1})).await.unwrap(); let (_, inflight) = sent(&t.store, DOC).await; let mut reply = envelope(DOC, Some(ME), json!({"n": 1}), 2); @@ -288,21 +355,33 @@ mod tests { let (first_outcome, _) = t .store - .settle_upload(ME, DOC, &inflight, &Ok(reply.clone()), 0) + .settle_upload(ME, DOC, &inflight, &Ok(reply.clone()), 0, 0) .await .unwrap(); - assert_eq!(first_outcome, SettleOutcome::Done { rows_remain: false }); + assert_eq!( + first_outcome, + SettleOutcome::Done { + rows_remain: false, + diverged: false + } + ); let before = snapshot(&t.store, DOC).await; // Two processes (or an ack retried after a dropped ack) settling the same upload_id // with the same reply: the second settle must be a no-op, not a second write. let (second_outcome, second_notices) = t .store - .settle_upload(ME, DOC, &inflight, &Ok(reply), 0) + .settle_upload(ME, DOC, &inflight, &Ok(reply), 0, 0) .await .unwrap(); - assert_eq!(second_outcome, SettleOutcome::Done { rows_remain: false }); + assert_eq!( + second_outcome, + SettleOutcome::Done { + rows_remain: false, + diverged: false + } + ); assert!(second_notices.is_empty()); let after = snapshot(&t.store, DOC).await; assert_eq!(after.shadow, before.shadow); @@ -314,7 +393,7 @@ mod tests { let t = temp_store().await; let doc_id = t .store - .create_document(ME, None, json!({"title": "Offline"})) + .create_document(None, json!({"title": "Offline"})) .await .unwrap(); let first = t.store.build_upload(ME, doc_id).await.unwrap(); @@ -327,10 +406,7 @@ mod tests { async fn empty_diff_settles_locally() { let t = temp_store().await; seed_synced(&t.store, DOC, SCOPE_OWN, Some(ME), json!({"n": 0}), 1).await; - t.store - .update_document(ME, DOC, json!({"n": 0})) - .await - .unwrap(); + t.store.update_document(DOC, json!({"n": 0})).await.unwrap(); assert_eq!( t.store.build_upload(ME, DOC).await.unwrap(), BuildOutcome::SettledLocally { rows_remain: false } @@ -342,16 +418,10 @@ mod tests { async fn unsent_edit_whose_row_was_lost_gets_a_new_marker() { let t = temp_store().await; seed_synced(&t.store, DOC, SCOPE_OWN, Some(ME), json!({"n": 0}), 1).await; - t.store - .update_document(ME, DOC, json!({"n": 1})) - .await - .unwrap(); + t.store.update_document(DOC, json!({"n": 1})).await.unwrap(); let (_, inflight) = sent(&t.store, DOC).await; let other = open_again(&t.path()).await; - other - .update_document(ME, DOC, json!({"n": 2})) - .await - .unwrap(); + other.update_document(DOC, json!({"n": 2})).await.unwrap(); // The other process settled rows up to an echo's upload_id; UUIDv7 order across // processes is approximate, so its own unsent row went too. sqlx::query("DELETE FROM outbox WHERE doc_id = ?") @@ -363,11 +433,17 @@ mod tests { let reply = envelope(DOC, Some(ME), json!({"n": 1}), 2); let (outcome, _) = t .store - .settle_upload(ME, DOC, &inflight, &Ok(reply.clone()), 0) + .settle_upload(ME, DOC, &inflight, &Ok(reply.clone()), 0, 0) .await .unwrap(); - assert_eq!(outcome, SettleOutcome::Done { rows_remain: true }); + assert_eq!( + outcome, + SettleOutcome::Done { + rows_remain: true, + diverged: false + } + ); let (upload, _) = sent(&t.store, DOC).await; assert_eq!(upload.base_hash, Some(reply.hash)); assert_eq!( @@ -380,19 +456,22 @@ mod tests { async fn hash_drift_does_not_reinsert_a_marker() { let t = temp_store().await; seed_synced(&t.store, DOC, SCOPE_OWN, Some(ME), json!({"f": 1}), 1).await; - t.store - .update_document(ME, DOC, json!({"f": 2})) - .await - .unwrap(); + t.store.update_document(DOC, json!({"f": 2})).await.unwrap(); let (_, inflight) = sent(&t.store, DOC).await; let mut reply = envelope(DOC, Some(ME), json!({"f": 2}), 2); reply.hash = "jsonb-normalised-hash".into(); let (outcome, _) = t .store - .settle_upload(ME, DOC, &inflight, &Ok(reply), 0) + .settle_upload(ME, DOC, &inflight, &Ok(reply), 0, 0) .await .unwrap(); - assert_eq!(outcome, SettleOutcome::Done { rows_remain: false }); + assert_eq!( + outcome, + SettleOutcome::Done { + rows_remain: false, + diverged: false + } + ); assert!(snapshot(&t.store, DOC).await.rows.is_empty()); assert!(t.store.load_pending().await.unwrap().is_empty()); } @@ -402,10 +481,7 @@ mod tests { let t = temp_store().await; seed_synced(&t.store, DOC, SCOPE_OWN, Some(ME), json!({"n": 0}), 1).await; let local = json!({"n": 1, "mine": true}); - t.store - .update_document(ME, DOC, local.clone()) - .await - .unwrap(); + t.store.update_document(DOC, local.clone()).await.unwrap(); let (_, inflight) = sent(&t.store, DOC).await; let mut mismatch = ServerError::new("hash_mismatch"); mismatch.current_hash = Some("elsewhere".into()); @@ -413,16 +489,26 @@ mod tests { let (outcome, notices) = t .store - .settle_upload(ME, DOC, &inflight, &Err(mismatch), 3) + .settle_upload(ME, DOC, &inflight, &Err(mismatch), 3, 0) .await .unwrap(); - assert_eq!(outcome, SettleOutcome::Done { rows_remain: false }); + assert_eq!( + outcome, + SettleOutcome::Done { + rows_remain: false, + diverged: false + } + ); assert_eq!( notices, vec![DocNotice { doc_id: DOC, - event: DocEvent::ConflictDetected + event: DocEvent::ConflictDetected, + kept: Some(KeptCopy { + recovered_id: 1, + reason: "conflict".into() + }) }] ); assert_eq!(recovered(&t).await, vec![(local, "conflict".to_string())]); @@ -433,27 +519,38 @@ mod tests { async fn validation_error_leaves_the_sent_mark_and_a_later_snapshot_still_conflicts() { let t = temp_store().await; seed_synced(&t.store, DOC, SCOPE_OWN, Some(ME), json!({"n": 0}), 1).await; - t.store - .update_document(ME, DOC, json!({"n": 1})) - .await - .unwrap(); + t.store.update_document(DOC, json!({"n": 1})).await.unwrap(); let (_, inflight) = sent(&t.store, DOC).await; t.store.mark_sent(DOC, inflight.upload_id).await.unwrap(); let (outcome, notices) = t .store - .settle_upload(ME, DOC, &inflight, &Err(ServerError::new("validation")), 0) + .settle_upload( + ME, + DOC, + &inflight, + &Err(ServerError::new("validation")), + 0, + 0, + ) .await .unwrap(); - assert_eq!(outcome, SettleOutcome::Done { rows_remain: true }); + assert_eq!( + outcome, + SettleOutcome::Done { + rows_remain: true, + diverged: false + } + ); assert_eq!( notices, vec![DocNotice { doc_id: DOC, event: DocEvent::SyncError { code: "validation".into() - } + }, + kept: None }] ); // An error reply does not clear the mark: an earlier send of the same rows may have @@ -478,7 +575,11 @@ mod tests { notices, vec![DocNotice { doc_id: DOC, - event: DocEvent::ConflictDetected + event: DocEvent::ConflictDetected, + kept: Some(KeptCopy { + recovered_id: 1, + reason: "conflict".into() + }) }] ); } @@ -487,10 +588,7 @@ mod tests { async fn server_deleted_takes_delete_wins_path() { let t = temp_store().await; seed_synced(&t.store, DOC, SCOPE_OWN, Some(ME), json!({"n": 0}), 1).await; - t.store - .update_document(ME, DOC, json!({"n": 5})) - .await - .unwrap(); + t.store.update_document(DOC, json!({"n": 5})).await.unwrap(); let notices = t.store.apply_server_deleted(DOC, 7).await.unwrap(); @@ -498,7 +596,11 @@ mod tests { notices, vec![DocNotice { doc_id: DOC, - event: DocEvent::ConflictDetected + event: DocEvent::ConflictDetected, + kept: Some(KeptCopy { + recovered_id: 1, + reason: "delete_wins".into() + }) }] ); assert_eq!( @@ -561,7 +663,11 @@ mod tests { notices, vec![DocNotice { doc_id: DOC, - event: DocEvent::ConflictDetected + event: DocEvent::ConflictDetected, + kept: Some(KeptCopy { + recovered_id: 1, + reason: "conflict".into() + }) }] ); assert_eq!( @@ -573,4 +679,65 @@ mod tests { assert!(after.rows.is_empty()); assert_eq!(after.shadow.as_ref().unwrap().hash, server.hash); } + + #[tokio::test] + async fn a_refused_resend_keeps_its_mark_so_a_snapshot_cannot_resurrect_a_removed_element() { + let t = temp_store().await; + seed_synced( + &t.store, + DOC, + SCOPE_OWN, + Some(ME), + json!({"items": ["a"], "n": 0}), + 1, + ) + .await; + // The first upload appends x and lands, but its reply and echo are never seen. + t.store + .update_document(DOC, json!({"items": ["a", "x"], "n": 0})) + .await + .unwrap(); + let (_, first) = sent(&t.store, DOC).await; + t.store.mark_sent(DOC, first.upload_id).await.unwrap(); + // A later edit; the resend covers both rows on the stale base and is refused. + let local = json!({"items": ["a", "x"], "n": 1}); + t.store.update_document(DOC, local.clone()).await.unwrap(); + let (_, second) = sent(&t.store, DOC).await; + t.store.mark_sent(DOC, second.upload_id).await.unwrap(); + let refused = ServerError { + current_hash: Some("landed".into()), + current_seq: Some(4), + ..ServerError::new("hash_mismatch") + }; + t.store + .settle_upload(ME, DOC, &second, &Err(refused), 0, 0) + .await + .unwrap(); + + // Another device removed x; a full resync snapshot arrives. + let server = envelope(DOC, Some(ME), json!({"items": ["a"], "n": 0}), 6); + let notices = t + .store + .apply_snapshot_page(ME, SCOPE_OWN, std::slice::from_ref(&server)) + .await + .unwrap(); + + assert_eq!( + notices, + vec![DocNotice { + doc_id: DOC, + event: DocEvent::ConflictDetected, + kept: Some(KeptCopy { + recovered_id: 1, + reason: "conflict".into() + }) + }] + ); + assert_eq!(recovered(&t).await, vec![(local, "conflict".to_string())]); + assert_eq!( + snapshot(&t.store, DOC).await.content, + json!({"items": ["a"], "n": 0}), + "x is not silently brought back" + ); + } } diff --git a/replicant-client/src/store/v1_data.rs b/replicant-client/src/store/v1_data.rs new file mode 100644 index 0000000..25417a9 --- /dev/null +++ b/replicant-client/src/store/v1_data.rs @@ -0,0 +1,1817 @@ +//! Migration 015: v1 sync data into the v2 shadow and outbox model. SQLite has no SHA-256 and +//! no UUIDv7, so this runs in Rust after the SQL migrations, in one transaction. The v1 +//! `sync_queue` table is its gate: the step drops it, so it runs once per data dir. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use sqlx::{Row, SqliteConnection, SqlitePool}; +use tracing::{info, warn}; +use uuid::Uuid; + +use super::{now_unix, StoreError, StoreResult}; +use crate::engine::hash::{canonicalise_numbers, content_hash}; +use crate::engine::types::{SCOPE_CURATED, SCOPE_OWN}; + +#[derive(Default)] +pub(crate) struct Counts { + synced: usize, + hard_deleted: usize, + pending: usize, + pending_without_base: usize, + rejected_creates: usize, + unmigratable: usize, + kept_aside: usize, + dropped_queue_rows: usize, + markers: u64, +} + +struct QueueRow { + kind: &'static str, + base: Option<Value>, + created_at: Option<i64>, +} + +pub(crate) async fn migrate_v1_data(pool: &SqlitePool) -> StoreResult<Counts> { + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + if !holds_v1_data(&mut *tx).await? { + tx.commit().await?; + return Ok(Counts::default()); + } + let counts = migrate(&mut tx).await.map_err(migration_failed)?; + tx.commit() + .await + .map_err(|error| migration_failed(error.into()))?; + info!( + synced = counts.synced, + hard_deleted = counts.hard_deleted, + pending = counts.pending, + rejected_creates = counts.rejected_creates, + unmigratable = counts.unmigratable, + kept_aside = counts.kept_aside, + dropped_queue_rows = counts.dropped_queue_rows, + markers = counts.markers, + "migrated v1 sync data" + ); + if counts.dropped_queue_rows > 0 { + warn!( + rows = counts.dropped_queue_rows, + "v1 queue rows dropped: their documents are missing, set aside, or kept aside" + ); + } + if counts.kept_aside > 0 { + warn!( + documents = counts.kept_aside, + "v1 local work that cannot be uploaded: each document's content is kept aside" + ); + } + if counts.pending_without_base > 0 { + warn!( + documents = counts.pending_without_base, + "v1 edits with no known base: each is kept aside if the server's copy differs" + ); + } + Ok(counts) +} + +async fn migrate(conn: &mut SqliteConnection) -> StoreResult<Counts> { + let me = sqlx::query_scalar::<_, String>("SELECT user_id FROM user_config LIMIT 1") + .fetch_optional(&mut *conn) + .await? + .map(|user_id| Uuid::parse_str(&user_id)) + .transpose()?; + // Without the account there is no telling whose documents are pending edits, and + // treating them all as someone else's would let the curated sweep remove them. + if me.is_none() + && sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM documents") + .fetch_one(&mut *conn) + .await? + > 0 + { + return Err(StoreError::MigrationFailed( + "the v1 database holds documents but no user_config row".into(), + )); + } + + let mut queue: HashMap<Uuid, Vec<QueueRow>> = HashMap::new(); + let queued = sqlx::query( + "SELECT document_id, operation_type, base_content, \ + CAST(strftime('%s', created_at) AS INTEGER) AS created_unix FROM sync_queue ORDER BY id", + ) + .fetch_all(&mut *conn) + .await?; + let mut counts = Counts::default(); + for row in queued { + let Ok(doc_id) = Uuid::parse_str(&row.try_get::<String, _>("document_id")?) else { + counts.dropped_queue_rows += 1; + continue; + }; + let base_content: Option<String> = row.try_get("base_content")?; + // An unknown operation or an unreadable base still leaves an edit to send, with no base. + let (kind, base_content) = match row.try_get::<String, _>("operation_type")?.as_str() { + "create" => ("create", base_content), + "update" => ("update", base_content), + "delete" => ("delete", base_content), + _ => ("update", None), + }; + queue.entry(doc_id).or_default().push(QueueRow { + kind, + base: base_content.and_then(|base| serde_json::from_str(&base).ok()), + created_at: row.try_get("created_unix")?, + }); + } + // The queue references documents, so it goes before any document is hard-deleted. + sqlx::query("DROP TABLE sync_queue") + .execute(&mut *conn) + .await?; + + let documents = sqlx::query( + "SELECT id, user_id, content, sync_status, deleted_at, visibility, \ + CAST(strftime('%s', deleted_at) AS INTEGER) AS deleted_unix FROM documents ORDER BY rowid", + ) + .fetch_all(&mut *conn) + .await?; + let migrated_at_ms = u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap_or(0); + let now = now_unix(); + for document in documents { + let stored_id: String = document.try_get("id")?; + let raw_content: String = document.try_get("content")?; + let owner = match document.try_get::<Option<String>, _>("user_id")? { + None => Some(None), + Some(owner) => Uuid::parse_str(&owner).ok().map(Some), + }; + let (Some(doc_id), Some(owner), Some(mut content)) = ( + Uuid::parse_str(&stored_id).ok(), + owner, + serde_json::from_str::<Value>(&raw_content).ok(), + ) else { + set_aside_unmigratable(&mut *conn, &stored_id, &raw_content, &mut counts).await?; + continue; + }; + let id = doc_id.to_string(); + // Two v1 ids that differ only in case: the one already in canonical form (else the + // first renamed) keeps the id. + if stored_id != id && document_exists(&mut *conn, &id).await? { + set_aside_unmigratable(&mut *conn, &stored_id, &raw_content, &mut counts).await?; + continue; + } + if stored_id != id { + sqlx::query("UPDATE documents SET id = ? WHERE id = ?") + .bind(&id) + .bind(&stored_id) + .execute(&mut *conn) + .await?; + sqlx::query("UPDATE documents_fts SET document_id = ? WHERE document_id = ?") + .bind(&id) + .bind(&stored_id) + .execute(&mut *conn) + .await?; + } + canonicalise_numbers(&mut content); + let status = document + .try_get::<Option<String>, _>("sync_status")? + .unwrap_or_default(); + let deleted = document + .try_get::<Option<String>, _>("deleted_at")? + .is_some(); + let public = document + .try_get::<Option<String>, _>("visibility")? + .as_deref() + == Some("public"); + let rows = queue.remove(&doc_id).unwrap_or_default(); + let mine = owner.is_some() && owner == me; + let has_create = rows.iter().any(|row| row.kind == "create"); + let pending = mine && status == "pending"; + let rejected_create = mine && status == "conflict" && has_create; + // Only the owner's pending work becomes uploads. Anything else queued, and content only + // this device holds on a document that is not ours, is kept aside. Only a local delete + // (always `pending` in v1) is the user's own choice; a delete from elsewhere is `synced`. + if !pending && !rejected_create { + counts.dropped_queue_rows += rows.len(); + let local_only = !mine && (status == "pending" || (status == "conflict" && has_create)); + if !(deleted && status == "pending") && (local_only || !rows.is_empty()) { + keep_aside(&mut *conn, &id, &content, "unmigratable").await?; + counts.kept_aside += 1; + } + } + + if deleted && !pending { + let deleted_at = document + .try_get::<Option<i64>, _>("deleted_unix")? + .unwrap_or(now); + hard_delete(&mut *conn, &id, deleted_at).await?; + counts.hard_deleted += 1; + continue; + } + + let (shadow, markers) = if pending { + counts.pending += 1; + // With a shadow the first upload would be an update of a document the server + // may never have had. + let shadow = if has_create { + None + } else { + rows.iter() + .rev() + .find_map(|row| row.base.clone()) + .map(|mut base| { + canonicalise_numbers(&mut base); + base + }) + }; + // Edits and deletes with no known base; a plain v1 delete has no queue row at all. + if shadow.is_none() && !has_create && (deleted || !rows.is_empty()) { + counts.pending_without_base += 1; + } + let mut markers: Vec<_> = rows + .iter() + .map(|row| (row.kind, row.created_at.unwrap_or(now))) + .collect(); + if deleted { + markers.push(("delete", now)); + } else if markers.is_empty() { + markers.push(("create", now)); + } + (shadow, markers) + } else if rejected_create { + // The v2 engine's end state for a refused create: content kept, document gone. + counts.rejected_creates += 1; + keep_aside(&mut *conn, &id, &content, "create_rejected").await?; + remove_document(&mut *conn, &id).await?; + continue; + } else { + counts.synced += 1; + (Some(content.clone()), Vec::new()) + }; + + // Documents of no or another account start read-only, as their snapshot would make them. + sqlx::query( + "UPDATE documents SET content = ?, hash = ?, server_content = ?, server_hash = ?, \ + server_seq = ?, read_only = ? WHERE id = ?", + ) + .bind(content.to_string()) + .bind(content_hash(&content)) + .bind(shadow.as_ref().map(Value::to_string)) + .bind(shadow.as_ref().map(content_hash)) + .bind(shadow.as_ref().map(|_| 0_i64)) + .bind(!mine) + .bind(&id) + .execute(&mut *conn) + .await?; + for (kind, created_at) in markers { + counts.markers += 1; + sqlx::query( + "INSERT INTO outbox (mutation_id, doc_id, kind, created_at) VALUES (?, ?, ?, ?)", + ) + .bind(migrated_mutation_id(migrated_at_ms, counts.markers).to_string()) + .bind(&id) + .bind(kind) + .bind(created_at) + .execute(&mut *conn) + .await?; + } + // The own snapshot does not list publications: the author's public document is also + // curated, or own's sweep would remove it until the curated page brings it back. + let scopes: &[&str] = match (mine, public) { + (true, true) => &[SCOPE_OWN, SCOPE_CURATED], + (true, false) => &[SCOPE_OWN], + (false, _) => &[SCOPE_CURATED], + }; + for scope in scopes { + sqlx::query( + "INSERT OR IGNORE INTO doc_scopes (doc_id, scope, member, seq) VALUES (?, ?, 1, 0)", + ) + .bind(&id) + .bind(scope) + .execute(&mut *conn) + .await?; + } + } + + // What is left belongs to documents that are missing or were set aside. + counts.dropped_queue_rows += queue.values().map(Vec::len).sum::<usize>(); + + sqlx::query("DROP TABLE IF EXISTS change_events") + .execute(&mut *conn) + .await?; + sqlx::query("DROP TABLE IF EXISTS sync_state") + .execute(&mut *conn) + .await?; + sqlx::query("ALTER TABLE documents DROP COLUMN local_changes") + .execute(&mut *conn) + .await?; + Ok(counts) +} + +async fn hard_delete(conn: &mut SqliteConnection, id: &str, deleted_at: i64) -> StoreResult<()> { + remove_document(&mut *conn, id).await?; + sqlx::query( + "INSERT OR IGNORE INTO tombstones (doc_id, server_seq, deleted_at) VALUES (?, 0, ?)", + ) + .bind(id) + .bind(deleted_at) + .execute(&mut *conn) + .await?; + Ok(()) +} + +/// Deletes the row and its search entry; no tombstone, so a server copy can come back. +async fn remove_document(conn: &mut SqliteConnection, id: &str) -> StoreResult<()> { + sqlx::query("DELETE FROM documents WHERE id = ?") + .bind(id) + .execute(&mut *conn) + .await?; + sqlx::query(super::reads::DELETE_FTS_ENTRY) + .bind(id) + .execute(&mut *conn) + .await?; + Ok(()) +} + +async fn keep_aside( + conn: &mut SqliteConnection, + doc_id: &str, + content: &Value, + reason: &str, +) -> StoreResult<()> { + sqlx::query( + "INSERT INTO recovered (doc_id, content, reason, recovered_at) VALUES (?, ?, ?, ?)", + ) + .bind(doc_id) + .bind(content.to_string()) + .bind(reason) + .bind(now_unix()) + .execute(&mut *conn) + .await?; + Ok(()) +} + +/// A v1 row 015 cannot read: its content is kept (as a JSON string when it is not JSON) under +/// a readable id, and the row goes. +async fn set_aside_unmigratable( + conn: &mut SqliteConnection, + stored_id: &str, + raw_content: &str, + counts: &mut Counts, +) -> StoreResult<()> { + let content = serde_json::from_str::<Value>(raw_content) + .unwrap_or_else(|_| Value::String(raw_content.to_string())); + let doc_id = Uuid::parse_str(stored_id).unwrap_or_else(|_| Uuid::new_v4()); + warn!(v1_id = stored_id, kept_as = %doc_id, "v1 document could not be migrated; kept aside"); + keep_aside(&mut *conn, &doc_id.to_string(), &content, "unmigratable").await?; + remove_document(&mut *conn, stored_id).await?; + counts.unmigratable += 1; + Ok(()) +} + +async fn document_exists(conn: &mut SqliteConnection, id: &str) -> StoreResult<bool> { + let found: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents WHERE id = ?") + .bind(id) + .fetch_one(&mut *conn) + .await?; + Ok(found > 0) +} + +/// SQLITE_BUSY stays as it is, so `retry_open` retries it; anything else is this migration's +/// failure, which retrying cannot fix. +fn migration_failed(error: StoreError) -> StoreError { + if error.is_busy() || error.is_migration_failed() { + error + } else { + StoreError::MigrationFailed(error.to_string()) + } +} + +async fn holds_v1_data<'c>(conn: impl sqlx::SqliteExecutor<'c>) -> StoreResult<bool> { + let v1_tables: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_queue'", + ) + .fetch_one(conn) + .await?; + Ok(v1_tables > 0) +} + +/// Copies a data dir that still holds v1 sync data to `<db path>.v1-backup` before 015 changes +/// it for good. The copy is written under a temporary name and renamed, so a backup that exists +/// is complete. Once a backup exists, another is written only for a clean v1 file (one restored +/// from a backup and used by 0.6 again), and beside the old one (`backup_path`), never over it. +pub(crate) async fn back_up_v1_database(pool: &SqlitePool, db_path: &Path) -> StoreResult<()> { + if !holds_v1_data(pool).await? { + return Ok(()); + } + // Holding the write lock serialises backups across processes, so a temporary file found + // here was left by a crash. + let mut lock = pool.begin_with("BEGIN IMMEDIATE").await?; + // Another process may have migrated the file, or backed it up, while this one waited for + // the lock. + if !holds_v1_data(&mut *lock).await? + || (any_backup_exists(db_path)? && !is_clean_v1(&mut lock).await?) + { + lock.rollback().await?; + return Ok(()); + } + let backup = backup_path(db_path, now_unix()); + let partial = sibling(db_path, ".v1-backup.tmp"); + if let Err(error) = write_backup(pool, &partial, &backup).await { + let _ = std::fs::remove_file(&partial); + lock.rollback().await?; + return Err(error); + } + // Marks the file as backed up. The mark is written after the copy, so a backup restored + // over the file is unmarked. + let marked = match sqlx::query(&format!("PRAGMA user_version = {V1_BACKED_UP}")) + .execute(&mut *lock) + .await + { + Ok(_) => lock.commit().await, + Err(error) => Err(error), + }; + if let Err(error) = marked { + warn!(%error, "could not mark the v1 database as backed up"); + } + Ok(()) +} + +async fn write_backup(pool: &SqlitePool, partial: &Path, backup: &Path) -> StoreResult<()> { + match std::fs::remove_file(partial) { + Ok(()) => warn!(path = %partial.display(), "removed a partial v1 backup"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(backup_failed(error)), + } + // A second pooled connection: VACUUM cannot run inside the lock's transaction. + sqlx::query("VACUUM INTO ?") + .bind(partial.to_string_lossy().into_owned()) + .execute(pool) + .await + .map_err(|error| match StoreError::from(error) { + busy if busy.is_busy() => busy, + error => backup_failed(error), + })?; + std::fs::File::open(partial) + .and_then(|file| file.sync_all()) + .map_err(backup_failed)?; + std::fs::rename(partial, backup).map_err(backup_failed)?; + if let Some(dir) = backup.parent() { + // Best effort: makes the rename durable where directories can be synced. + let _ = std::fs::File::open(dir).and_then(|dir| dir.sync_all()); + } + Ok(()) +} + +/// The first migration only 2.0 applies; a file that records it has been migrated since its +/// backup, even if 015 has not finished. +const FIRST_V2_MIGRATION: i64 = 13; +/// `PRAGMA user_version` of a v1 file that has been backed up. v1 never sets it. +const V1_BACKED_UP: i64 = 0x5631_424b; + +/// A v1 file that was neither backed up nor touched by 2.0: one restored from a backup. +async fn is_clean_v1(conn: &mut SqliteConnection) -> StoreResult<bool> { + let user_version: i64 = sqlx::query_scalar("PRAGMA user_version") + .fetch_one(&mut *conn) + .await?; + if user_version == V1_BACKED_UP { + return Ok(false); + } + let has_ledger: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations'", + ) + .fetch_one(&mut *conn) + .await?; + if has_ledger == 0 { + return Ok(true); + } + let v2_applied: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations WHERE version >= ?") + .bind(FIRST_V2_MIGRATION) + .fetch_one(&mut *conn) + .await?; + Ok(v2_applied == 0) +} + +/// Whether `<db path>.v1-backup` or a `.v1-backup-<suffix>` beside it exists; a partial +/// `.v1-backup.tmp` does not count. +fn any_backup_exists(db_path: &Path) -> StoreResult<bool> { + let (Some(dir), Some(name)) = (db_path.parent(), db_path.file_name()) else { + return Ok(false); + }; + let first = format!("{}.v1-backup", name.to_string_lossy()); + let stamped = format!("{first}-"); + for entry in std::fs::read_dir(dir).map_err(backup_failed)? { + let entry_name = entry.map_err(backup_failed)?.file_name(); + let entry_name = entry_name.to_string_lossy(); + if entry_name == first || entry_name.starts_with(&stamped) { + return Ok(true); + } + } + Ok(false) +} + +fn backup_failed(error: impl std::fmt::Display) -> StoreError { + StoreError::MigrationFailed(format!("backup before migrating: {error}")) +} + +/// The first free name of `<db path>.v1-backup`, `.v1-backup-<unix seconds>`, +/// `.v1-backup-<unix seconds>-2`, … Chosen under the write lock, so no other backup races it. +fn backup_path(db_path: &Path, now: i64) -> PathBuf { + let first = sibling(db_path, ".v1-backup"); + if !first.exists() { + return first; + } + let stamped = format!(".v1-backup-{now}"); + let mut candidate = sibling(db_path, &stamped); + let mut n = 2; + while candidate.exists() { + candidate = sibling(db_path, &format!("{stamped}-{n}")); + n += 1; + } + candidate +} + +fn sibling(db_path: &Path, suffix: &str) -> PathBuf { + let mut path = db_path.as_os_str().to_owned(); + path.push(suffix); + PathBuf::from(path) +} + +/// UUIDv7 at `unix_ms` with `counter` in the low bits: strictly increasing in migration +/// order; `insert_marker` keeps every later id of a document above them. +fn migrated_mutation_id(unix_ms: u64, counter: u64) -> Uuid { + let timestamp = u128::from(unix_ms & 0xFFFF_FFFF_FFFF) << 80; + let version = 0x7_u128 << 76; + let variant = 0b10_u128 << 62; + Uuid::from_u128(timestamp | version | variant | u128::from(counter & 0x3FFF_FFFF_FFFF_FFFF)) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use serde_json::{json, Value}; + use sqlx::sqlite::SqliteConnectOptions; + use sqlx::SqlitePool; + use uuid::Uuid; + + use crate::engine::doc::DocEvent; + use crate::engine::doc_upload::{build_upload, BuildResult}; + use crate::engine::hash::content_hash; + use crate::engine::types::{UploadKind, SCOPE_CURATED, SCOPE_OWN}; + use crate::store::test_support::*; + use crate::store::{now_unix, Store}; + + use super::{back_up_v1_database, backup_path}; + + const OTHER: Uuid = Uuid::from_u128(0xB); + + fn doc(n: u128) -> Uuid { + Uuid::from_u128(0xD000 + n) + } + + fn id(n: u128) -> String { + doc(n).to_string() + } + + /// Opens the v1 file with the v2 store, which applies 013 and 014 and runs 015. + async fn migrated(pool: SqlitePool, path: &Path) -> Store { + pool.close().await; + Store::open(path).await.unwrap() + } + + async fn outbox(store: &Store, doc_id: Uuid) -> Vec<(String, Option<String>)> { + sqlx::query_as( + "SELECT kind, parked_error FROM outbox WHERE doc_id = ? ORDER BY mutation_id", + ) + .bind(doc_id.to_string()) + .fetch_all(&store.pool) + .await + .unwrap() + } + + fn kinds(rows: &[&str]) -> Vec<(String, Option<String>)> { + rows.iter().map(|kind| (kind.to_string(), None)).collect() + } + + /// A migrated pending v1 update of doc(1); keep the dir alive as long as the store. + async fn pending_update(content: Value, base: Option<Value>) -> (tempfile::TempDir, Store) { + let (dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), content, "pending", None).await; + v1_queue_row(&pool, &id(1), "update", base).await; + (dir, migrated(pool, &path).await) + } + + #[tokio::test] + async fn synced_documents_get_a_seq_0_shadow_equal_to_their_content_and_no_markers() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "synced", None).await; + let store = migrated(pool, &path).await; + let snap = snapshot(&store, doc(1)).await; + let shadow = snap.shadow.expect("a synced document has a shadow"); + assert_eq!((shadow.content.clone(), shadow.seq), (json!({"n": 1}), 0)); + assert_eq!(shadow.hash, content_hash(&json!({"n": 1}))); + assert!(snap.rows.is_empty()); + assert!(kept_copies(&store).await.is_empty()); + store.close().await; + } + + #[tokio::test] + async fn synced_deleted_documents_are_hard_deleted_and_tombstoned_at_seq_0() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + let yesterday = now_unix() - 86_400; + let deleted_at = chrono::DateTime::from_timestamp(yesterday, 0) + .unwrap() + .to_rfc3339(); + v1_doc( + &pool, + &id(1), + Some(ME), + json!({"n": 1}), + "synced", + Some(&deleted_at), + ) + .await; + let store = migrated(pool, &path).await; + let snap = snapshot(&store, doc(1)).await; + assert!(!snap.exists, "a v2 soft delete would mean an unsent delete"); + assert_eq!(snap.tombstone_seq, Some(0)); + assert!(snap.rows.is_empty()); + let tombstoned_at: i64 = + sqlx::query_scalar("SELECT deleted_at FROM tombstones WHERE doc_id = ?") + .bind(id(1)) + .fetch_one(&store.pool) + .await + .unwrap(); + assert_eq!(tombstoned_at, yesterday, "the v1 deletion time"); + assert!(kept_copies(&store).await.is_empty(), "the user deleted it"); + store.close().await; + } + + #[tokio::test] + async fn a_pending_create_gets_no_shadow_even_when_a_row_has_base_content() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 2}), "pending", None).await; + v1_queue_row(&pool, &id(1), "create", None).await; + v1_queue_row(&pool, &id(1), "update", Some(json!({"n": 1}))).await; + let store = migrated(pool, &path).await; + assert!( + snapshot(&store, doc(1)).await.shadow.is_none(), + "a shadow would upload an update of a document the server may never have had" + ); + assert_eq!(outbox(&store, doc(1)).await, kinds(&["create", "update"])); + store.close().await; + } + + #[tokio::test] + async fn a_pending_update_with_a_base_gets_that_base_as_a_seq_0_shadow() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 3}), "pending", None).await; + v1_queue_row(&pool, &id(1), "update", Some(json!({"n": 0}))).await; + v1_queue_row(&pool, &id(1), "update", Some(json!({"n": 1}))).await; + let store = migrated(pool, &path).await; + let shadow = snapshot(&store, doc(1)) + .await + .shadow + .expect("based on v1 base_content"); + assert_eq!(shadow.content, json!({"n": 1}), "the newest row's base"); + assert_eq!(shadow.hash, content_hash(&json!({"n": 1}))); + assert_eq!( + shadow.seq, 0, + "seq 0 is the migrated-shadow exemption (rule e)" + ); + assert_eq!(outbox(&store, doc(1)).await, kinds(&["update", "update"])); + store.close().await; + } + + #[tokio::test] + async fn a_pending_update_without_a_base_gets_no_shadow() { + let (_dir, store) = pending_update(json!({"n": 2}), None).await; + assert!(snapshot(&store, doc(1)).await.shadow.is_none()); + assert_eq!(outbox(&store, doc(1)).await, kinds(&["update"])); + store.close().await; + } + + #[tokio::test] + async fn a_pending_document_with_no_rows_gets_one_create() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "pending", None).await; + let store = migrated(pool, &path).await; + assert!(snapshot(&store, doc(1)).await.shadow.is_none()); + assert_eq!(outbox(&store, doc(1)).await, kinds(&["create"])); + store.close().await; + } + + #[tokio::test] + async fn a_pending_deleted_document_gets_its_rows_then_a_delete() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + let deleted_at = chrono::Utc::now().to_rfc3339(); + v1_doc( + &pool, + &id(1), + Some(ME), + json!({"n": 1}), + "pending", + Some(&deleted_at), + ) + .await; + v1_queue_row(&pool, &id(1), "update", Some(json!({"n": 0}))).await; + let store = migrated(pool, &path).await; + let snap = snapshot(&store, doc(1)).await; + assert!( + snap.exists && snap.soft_deleted, + "the delete is still to be sent" + ); + assert_eq!(outbox(&store, doc(1)).await, kinds(&["update", "delete"])); + store.close().await; + } + + #[tokio::test] + async fn a_settled_conflict_is_synced_and_a_rejected_create_is_kept_aside() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "conflict", None).await; + v1_doc(&pool, &id(2), Some(ME), json!({"n": 2}), "conflict", None).await; + v1_queue_row(&pool, &id(2), "create", None).await; + let store = migrated(pool, &path).await; + let settled = snapshot(&store, doc(1)).await; + assert_eq!(settled.shadow.map(|s| s.content), Some(json!({"n": 1}))); + assert!(settled.rows.is_empty()); + let rejected = snapshot(&store, doc(2)).await; + assert!(!rejected.exists && rejected.tombstone_seq.is_none()); + assert!(outbox(&store, doc(2)).await.is_empty()); + let kept = store.list_recovered().await.unwrap(); + assert_eq!(kept.len(), 1); + assert_eq!( + (kept[0].doc_id, kept[0].reason.as_str()), + (doc(2), "create_rejected") + ); + assert_eq!(kept[0].content, json!({"n": 2})); + store.close().await; + } + + #[tokio::test] + async fn mutation_ids_are_v7_lowercase_hyphenated_and_increase_in_queue_order() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 2}), "pending", None).await; + v1_queue_row(&pool, &id(1), "create", None).await; + v1_queue_row(&pool, &id(1), "update", None).await; + v1_queue_row(&pool, &id(1), "update", None).await; + let store = migrated(pool, &path).await; + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT mutation_id, kind FROM outbox WHERE doc_id = ? ORDER BY mutation_id", + ) + .bind(id(1)) + .fetch_all(&store.pool) + .await + .unwrap(); + let parsed: Vec<Uuid> = rows + .iter() + .map(|(mutation_id, _)| Uuid::parse_str(mutation_id).unwrap()) + .collect(); + assert!(parsed + .iter() + .all(|mutation_id| mutation_id.get_version_num() == 7)); + for ((text, _), mutation_id) in rows.iter().zip(&parsed) { + assert_eq!( + *text, + mutation_id.to_string(), + "stored lowercase hyphenated" + ); + } + let mut numeric = parsed.clone(); + numeric.sort(); + assert_eq!(numeric, parsed, "text order is id order"); + let queue_order: Vec<&str> = rows.iter().map(|(_, kind)| kind.as_str()).collect(); + assert_eq!(queue_order, vec!["create", "update", "update"]); + + store + .update_document(doc(1), json!({"n": 3})) + .await + .unwrap(); + let newest: String = sqlx::query_scalar( + "SELECT mutation_id FROM outbox WHERE doc_id = ? ORDER BY mutation_id DESC LIMIT 1", + ) + .bind(id(1)) + .fetch_one(&store.pool) + .await + .unwrap(); + assert!( + newest > rows.last().unwrap().0, + "a later edit sorts after every migrated row" + ); + store.close().await; + } + + #[tokio::test] + async fn every_document_is_placed_in_own_or_curated_at_seq_0() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({}), "synced", None).await; + v1_doc(&pool, &id(2), Some(OTHER), json!({}), "synced", None).await; + v1_doc(&pool, &id(3), None, json!({}), "synced", None).await; + let store = migrated(pool, &path).await; + let scopes: Vec<(String, String, i64, i64)> = + sqlx::query_as("SELECT doc_id, scope, member, seq FROM doc_scopes ORDER BY doc_id") + .fetch_all(&store.pool) + .await + .unwrap(); + assert_eq!( + scopes, + vec![ + (id(1), "own".to_string(), 1, 0), + (id(2), "collection:curated".to_string(), 1, 0), + (id(3), "collection:curated".to_string(), 1, 0), + ] + ); + let read_only: Vec<String> = + sqlx::query_scalar("SELECT id FROM documents WHERE read_only = 1 ORDER BY id") + .fetch_all(&store.pool) + .await + .unwrap(); + assert_eq!( + read_only, + vec![id(2), id(3)], + "only our documents are writable" + ); + assert_eq!( + count( + &store, + "SELECT COUNT(*) FROM subscriptions WHERE cursor = 0" + ) + .await, + 2 + ); + store.close().await; + } + + #[tokio::test] + async fn the_v1_tables_are_gone_and_a_second_open_changes_nothing() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "pending", None).await; + v1_queue_row(&pool, &id(1), "update", Some(json!({"n": 0}))).await; + let store = migrated(pool, &path).await; + assert_eq!( + count( + &store, + "SELECT COUNT(*) FROM sqlite_master \ + WHERE name IN ('sync_queue', 'change_events', 'sync_state')" + ) + .await, + 0 + ); + assert_eq!( + count( + &store, + "SELECT COUNT(*) FROM pragma_table_info('documents') WHERE name = 'local_changes'" + ) + .await, + 0 + ); + store.close().await; + let reopened = Store::open(&path).await.unwrap(); + assert_eq!(count(&reopened, "SELECT COUNT(*) FROM outbox").await, 1); + reopened.close().await; + } + + #[tokio::test] + async fn uppercase_v1_document_ids_are_normalised() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + let upper = id(1).to_uppercase(); + v1_doc(&pool, &upper, Some(ME), json!({"n": 1}), "pending", None).await; + v1_queue_row(&pool, &upper, "update", Some(json!({"n": 0}))).await; + let store = migrated(pool, &path).await; + let snap = snapshot(&store, doc(1)).await; + assert!(snap.exists); + assert_eq!(snap.rows.len(), 1); + let still_upper = format!("SELECT COUNT(*) FROM documents WHERE id = '{upper}'"); + assert_eq!(count(&store, &still_upper).await, 0); + store.close().await; + } + + #[tokio::test] + async fn a_migrated_update_with_no_base_is_kept_aside_and_reported_on_its_first_differing_envelope( + ) { + let (_dir, store) = pending_update(json!({"n": 2}), None).await; + let server = envelope(doc(1), Some(ME), json!({"n": 5}), 5); + let notices = store + .apply_changes(ME, SCOPE_OWN, &[upsert_change(SCOPE_OWN, server)], 5) + .await + .unwrap(); + assert!( + notices + .iter() + .any(|notice| notice.doc_id == doc(1) && notice.event == DocEvent::ConflictDetected), + "never silent: the host is told" + ); + let kept: Vec<(String, String)> = + sqlx::query_as("SELECT content, reason FROM recovered WHERE doc_id = ?") + .bind(id(1)) + .fetch_all(&store.pool) + .await + .unwrap(); + assert_eq!( + kept, + vec![(json!({"n": 2}).to_string(), "conflict".to_string())] + ); + assert_eq!(snapshot(&store, doc(1)).await.content, json!({"n": 5})); + store.close().await; + } + + #[tokio::test] + async fn a_migrated_update_with_no_base_adopts_an_envelope_with_equal_content() { + let (_dir, store) = pending_update(json!({"n": 2}), None).await; + let server = envelope(doc(1), Some(ME), json!({"n": 2}), 5); + let notices = store + .apply_changes(ME, SCOPE_OWN, &[upsert_change(SCOPE_OWN, server)], 5) + .await + .unwrap(); + assert!(notices.is_empty()); + let snap = snapshot(&store, doc(1)).await; + assert_eq!(snap.shadow.map(|s| s.seq), Some(5)); + assert!(snap.rows.is_empty(), "nothing was really pending"); + assert_eq!(count(&store, "SELECT COUNT(*) FROM recovered").await, 0); + store.close().await; + } + + #[tokio::test] + async fn a_migrated_update_with_a_base_rebases_onto_the_first_envelope() { + let (_dir, store) = + pending_update(json!({"a": 2, "b": 1}), Some(json!({"a": 1, "b": 1}))).await; + let server = envelope(doc(1), Some(ME), json!({"a": 1, "b": 3}), 5); + let notices = store + .apply_changes(ME, SCOPE_OWN, &[upsert_change(SCOPE_OWN, server)], 5) + .await + .unwrap(); + assert!(notices.is_empty()); + let snap = snapshot(&store, doc(1)).await; + assert_eq!( + snap.content, + json!({"a": 2, "b": 3}), + "local edit kept, server edit merged" + ); + assert_eq!(snap.rows.len(), 1, "the local edit is still to be uploaded"); + assert_eq!(count(&store, "SELECT COUNT(*) FROM recovered").await, 0); + store.close().await; + } + + #[tokio::test] + async fn migrated_content_and_shadows_hold_canonical_numbers() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc( + &pool, + &id(1), + Some(ME), + json!({"n": 1200.0, "x": 1.5}), + "synced", + None, + ) + .await; + v1_doc( + &pool, + &id(2), + Some(ME), + json!({"n": 1300.0}), + "pending", + None, + ) + .await; + v1_queue_row(&pool, &id(2), "update", Some(json!({"n": 1100.0}))).await; + let store = migrated(pool, &path).await; + let synced = snapshot(&store, doc(1)).await; + assert_eq!( + synced.content, + json!({"n": 1200, "x": 1.5}), + "1200.0 is stored as 1200" + ); + assert_eq!(synced.shadow.unwrap().content, json!({"n": 1200, "x": 1.5})); + let stored: String = sqlx::query_scalar("SELECT content FROM documents WHERE id = ?") + .bind(id(1)) + .fetch_one(&store.pool) + .await + .unwrap(); + assert_eq!( + stored, r#"{"n":1200,"x":1.5}"#, + "written back, not only read canonically" + ); + let pending = snapshot(&store, doc(2)).await; + assert_eq!(pending.content, json!({"n": 1300})); + let base = pending.shadow.expect("based on v1 base_content"); + assert_eq!(base.content, json!({"n": 1100})); + assert_eq!(base.hash, content_hash(&json!({"n": 1100}))); + store.close().await; + } + + /// A migrated pending delete of doc(1): content {"n": 1}, one v1 update row with `base`. + async fn pending_delete(base: Option<Value>) -> (tempfile::TempDir, Store) { + let (dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + let deleted_at = chrono::Utc::now().to_rfc3339(); + v1_doc( + &pool, + &id(1), + Some(ME), + json!({"n": 1}), + "pending", + Some(&deleted_at), + ) + .await; + v1_queue_row(&pool, &id(1), "update", base).await; + (dir, migrated(pool, &path).await) + } + + #[tokio::test] + async fn a_migrated_pending_delete_with_a_base_is_sent_on_that_base() { + let (_dir, store) = pending_delete(Some(json!({"n": 0}))).await; + match build_upload(&snapshot(&store, doc(1)).await, ME) { + BuildResult::Send { upload, .. } => { + assert_eq!(upload.kind, UploadKind::Delete); + assert_eq!(upload.base_hash, Some(content_hash(&json!({"n": 0})))); + } + other => panic!("expected a conditional delete, got {other:?}"), + } + store.close().await; + } + + #[tokio::test] + async fn a_migrated_pending_delete_without_a_base_fetches_the_server_copy_first() { + let (_dir, store) = pending_delete(None).await; + assert_eq!( + build_upload(&snapshot(&store, doc(1)).await, ME), + BuildResult::NeedsServerCopy + ); + store.close().await; + } + + #[tokio::test] + async fn a_plain_v1_offline_delete_with_no_queue_row_fetches_the_server_copy_first() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + let deleted_at = chrono::Utc::now().to_rfc3339(); + v1_doc( + &pool, + &id(1), + Some(ME), + json!({"n": 1}), + "pending", + Some(&deleted_at), + ) + .await; + let store = migrated(pool, &path).await; + assert_eq!(outbox(&store, doc(1)).await, kinds(&["delete"])); + assert_eq!( + build_upload(&snapshot(&store, doc(1)).await, ME), + BuildResult::NeedsServerCopy + ); + store.close().await; + } + + #[tokio::test] + async fn a_migrated_create_then_delete_is_decided_by_the_first_snapshot_that_holds_it() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + let deleted_at = chrono::Utc::now().to_rfc3339(); + v1_doc( + &pool, + &id(1), + Some(ME), + json!({"n": 1}), + "pending", + Some(&deleted_at), + ) + .await; + v1_queue_row(&pool, &id(1), "create", None).await; + let store = migrated(pool, &path).await; + let server = envelope(doc(1), Some(ME), json!({"n": 7}), 5); + let notices = store + .apply_snapshot_page(ME, SCOPE_OWN, std::slice::from_ref(&server)) + .await + .unwrap(); + assert!(notices + .iter() + .any(|notice| notice.event == DocEvent::DeleteSuperseded)); + let snap = snapshot(&store, doc(1)).await; + assert!(snap.exists && !snap.soft_deleted && snap.rows.is_empty()); + assert_eq!( + snap.content, + json!({"n": 7}), + "another device's edit survives" + ); + store.close().await; + } + + #[tokio::test] + async fn a_migrated_pending_delete_without_a_base_is_superseded_by_different_server_content() { + let (_dir, store) = pending_delete(None).await; + let server = envelope(doc(1), Some(ME), json!({"n": 5}), 5); + let notices = store + .apply_snapshot_page(ME, SCOPE_OWN, std::slice::from_ref(&server)) + .await + .unwrap(); + assert!(notices + .iter() + .any(|notice| notice.doc_id == doc(1) && notice.event == DocEvent::DeleteSuperseded)); + let snap = snapshot(&store, doc(1)).await; + assert!( + snap.exists && !snap.soft_deleted && snap.rows.is_empty(), + "the document is back" + ); + assert_eq!(snap.content, json!({"n": 5})); + let kept: Vec<(String, String)> = + sqlx::query_as("SELECT content, reason FROM recovered WHERE doc_id = ?") + .bind(id(1)) + .fetch_all(&store.pool) + .await + .unwrap(); + assert_eq!( + kept, + vec![(json!({"n": 1}).to_string(), "delete_superseded".to_string())], + "with no base, a superseded delete always keeps the local content" + ); + store.close().await; + } + + fn backup_of(path: &Path) -> PathBuf { + PathBuf::from(format!("{}.v1-backup", path.display())) + } + + async fn raw_pool(path: &Path) -> SqlitePool { + SqlitePool::connect_with(SqliteConnectOptions::new().filename(path)) + .await + .unwrap() + } + + async fn has_table(pool: &SqlitePool, name: &str) -> bool { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sqlite_master WHERE name = ?") + .bind(name) + .fetch_one(pool) + .await + .unwrap() + > 0 + } + + #[tokio::test] + async fn a_v1_database_is_backed_up_before_it_is_migrated() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "synced", None).await; + let store = migrated(pool, &path).await; + store.close().await; + let backup = raw_pool(&backup_of(&path)).await; + assert!( + has_table(&backup, "sync_queue").await, + "the copy is the v1 file" + ); + let documents: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents") + .fetch_one(&backup) + .await + .unwrap(); + assert_eq!(documents, 1); + backup.close().await; + let written = std::fs::metadata(backup_of(&path)) + .unwrap() + .modified() + .unwrap(); + Store::open(&path).await.unwrap().close().await; + assert_eq!( + std::fs::metadata(backup_of(&path)) + .unwrap() + .modified() + .unwrap(), + written, + "a later open leaves the backup alone" + ); + } + + #[tokio::test] + async fn a_failed_v1_migration_is_reported_and_leaves_the_v1_data_in_place() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc( + &pool, + &id(1), + Some(ME), + json!({}), + "synced", + Some("2026-01-03T00:00:00+00:00"), + ) + .await; + sqlx::query( + "CREATE TRIGGER refuse BEFORE DELETE ON documents BEGIN SELECT RAISE(ABORT, 'refused'); END", + ) + .execute(&pool) + .await + .unwrap(); + pool.close().await; + let error = Store::open(&path).await.err().expect("015 must fail"); + assert!(error.is_migration_failed(), "{error}"); + let raw = raw_pool(&path).await; + assert!(has_table(&raw, "sync_queue").await, "015 rolled back"); + raw.close().await; + assert!(backup_of(&path).exists()); + } + + #[tokio::test] + async fn v1_documents_with_no_user_config_fail_the_migration_and_leave_the_v1_data_in_place() { + let (_dir, path, pool) = v012_db().await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "pending", None).await; + pool.close().await; + let error = Store::open(&path).await.err().expect("015 must fail"); + assert!(error.is_migration_failed(), "{error}"); + let raw = raw_pool(&path).await; + assert!(has_table(&raw, "sync_queue").await, "015 rolled back"); + raw.close().await; + assert!(backup_of(&path).exists()); + } + + #[tokio::test] + async fn an_empty_v1_database_with_no_user_config_still_migrates() { + let (_dir, path, pool) = v012_db().await; + let store = migrated(pool, &path).await; + store.close().await; + let raw = raw_pool(&path).await; + assert!(!has_table(&raw, "sync_queue").await); + raw.close().await; + } + + /// Runs the v1 migration on `pool`'s file and returns what it counted. + async fn migrated_with_counts(pool: SqlitePool, path: &Path) -> (Store, super::Counts) { + pool.close().await; + let raw = raw_pool(path).await; + sqlx::migrate!("./migrations").run(&raw).await.unwrap(); + let counts = super::migrate_v1_data(&raw).await.unwrap(); + raw.close().await; + (Store::open(path).await.unwrap(), counts) + } + + #[tokio::test] + async fn queue_rows_of_missing_or_unmigratable_documents_are_counted_as_dropped() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "pending", None).await; + v1_queue_row(&pool, &id(1), "update", None).await; + // A document whose content is not JSON is set aside, and so is its queue row. + v1_doc(&pool, &id(2), Some(ME), json!("x"), "pending", None).await; + sqlx::query("UPDATE documents SET content = 'not json' WHERE id = ?") + .bind(id(2)) + .execute(&pool) + .await + .unwrap(); + v1_queue_row(&pool, &id(2), "update", None).await; + // No document 3 exists. + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&pool) + .await + .unwrap(); + v1_queue_row(&pool, &id(3), "update", None).await; + let (store, counts) = migrated_with_counts(pool, &path).await; + store.close().await; + assert_eq!(counts.dropped_queue_rows, 2); + } + + fn partial_backup_of(path: &Path) -> PathBuf { + PathBuf::from(format!("{}.v1-backup.tmp", path.display())) + } + + async fn backed_up_content(path: &Path, doc_id: Uuid) -> String { + let backup = raw_pool(&backup_of(path)).await; + assert!( + has_table(&backup, "sync_queue").await, + "the copy is the v1 file" + ); + let content = sqlx::query_scalar("SELECT content FROM documents WHERE id = ?") + .bind(doc_id.to_string()) + .fetch_one(&backup) + .await + .unwrap(); + backup.close().await; + content + } + + #[tokio::test] + async fn a_partial_backup_left_by_a_crash_is_replaced_by_a_complete_one() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "synced", None).await; + std::fs::write(partial_backup_of(&path), b"half a database").unwrap(); + migrated(pool, &path).await.close().await; + assert!(!partial_backup_of(&path).exists()); + assert_eq!( + backed_up_content(&path, doc(1)).await, + json!({"n": 1}).to_string() + ); + } + + fn backups_of(path: &Path) -> Vec<PathBuf> { + let prefix = format!("{}.v1-backup", path.file_name().unwrap().to_string_lossy()); + let mut found: Vec<PathBuf> = std::fs::read_dir(path.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|p| { + let name = p.file_name().unwrap().to_string_lossy().into_owned(); + name == prefix || name.starts_with(&format!("{prefix}-")) + }) + .collect(); + found.sort(); + found + } + + #[tokio::test] + async fn a_migration_that_keeps_failing_after_the_backup_leaves_exactly_one_backup() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc( + &pool, + &id(1), + Some(ME), + json!({"n": 1}), + "synced", + Some("2026-01-03T00:00:00+00:00"), + ) + .await; + sqlx::query( + "CREATE TRIGGER refuse BEFORE DELETE ON documents BEGIN SELECT RAISE(ABORT, 'refused'); END", + ) + .execute(&pool) + .await + .unwrap(); + pool.close().await; + for _ in 0..3 { + assert!(Store::open(&path) + .await + .err() + .unwrap() + .is_migration_failed()); + } + let first = std::fs::read(backup_of(&path)).unwrap(); + + let raw = raw_pool(&path).await; + sqlx::query("DROP TRIGGER refuse") + .execute(&raw) + .await + .unwrap(); + raw.close().await; + Store::open(&path).await.unwrap().close().await; + assert_eq!(backups_of(&path), vec![backup_of(&path)]); + assert_eq!( + std::fs::read(backup_of(&path)).unwrap(), + first, + "the retries leave the first backup byte for byte" + ); + } + + #[tokio::test] + async fn a_restored_v1_database_used_by_0_6_again_gets_a_new_backup_beside_the_old_one() { + let (_dir, migrated_path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "synced", None).await; + migrated(pool, &migrated_path).await.close().await; + let first = std::fs::read(backup_of(&migrated_path)).unwrap(); + + // The user restores the backup and runs 0.6, which edits the document. A fresh dir + // holding the same two files: a closed pool's handle may still touch the old one. + let restored_dir = tempfile::tempdir().unwrap(); + let path = restored_dir.path().join(migrated_path.file_name().unwrap()); + std::fs::write(backup_of(&path), &first).unwrap(); + std::fs::write(&path, &first).unwrap(); + let raw = raw_pool(&path).await; + sqlx::query("UPDATE documents SET content = '{\"n\":2}'") + .execute(&raw) + .await + .unwrap(); + raw.close().await; + Store::open(&path).await.unwrap().close().await; + + let backups = backups_of(&path); + assert_eq!(backups.len(), 2, "{backups:?}"); + assert_eq!(std::fs::read(backup_of(&path)).unwrap(), first); + let second = raw_pool(&backups[1]).await; + let content: String = sqlx::query_scalar("SELECT content FROM documents") + .fetch_one(&second) + .await + .unwrap(); + second.close().await; + assert_eq!(content, json!({"n": 2}).to_string()); + } + + #[test] + fn a_backup_name_already_taken_gets_the_next_free_suffix() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("lib.sqlite3"); + let named = |suffix: &str| dir.path().join(format!("lib.sqlite3{suffix}")); + assert_eq!(backup_path(&db, 7), named(".v1-backup")); + std::fs::write(named(".v1-backup"), b"a").unwrap(); + assert_eq!(backup_path(&db, 7), named(".v1-backup-7")); + std::fs::write(named(".v1-backup-7"), b"b").unwrap(); + assert_eq!(backup_path(&db, 7), named(".v1-backup-7-2")); + std::fs::write(named(".v1-backup-7-2"), b"c").unwrap(); + assert_eq!(backup_path(&db, 7), named(".v1-backup-7-3")); + } + + #[tokio::test] + async fn a_backup_blocked_by_another_writer_is_busy_and_succeeds_once_it_lets_go() { + use sqlx::sqlite::SqlitePoolOptions; + use sqlx::ConnectOptions; + + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "synced", None).await; + pool.close().await; + let options = SqliteConnectOptions::new() + .filename(&path) + .busy_timeout(std::time::Duration::from_millis(1)); + let mut writer = options.connect().await.unwrap(); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut writer) + .await + .unwrap(); + let opener = SqlitePoolOptions::new() + .max_connections(2) + .connect_with(options) + .await + .unwrap(); + + let error = back_up_v1_database(&opener, &path).await.unwrap_err(); + assert!(error.is_busy(), "retry_open retries it: {error}"); + assert!(!backup_of(&path).exists()); + assert!(!partial_backup_of(&path).exists()); + + sqlx::query("ROLLBACK").execute(&mut writer).await.unwrap(); + back_up_v1_database(&opener, &path).await.unwrap(); + opener.close().await; + assert_eq!( + backed_up_content(&path, doc(1)).await, + json!({"n": 1}).to_string() + ); + } + + #[tokio::test] + async fn a_backup_that_waited_for_the_lock_does_not_copy_a_migrated_file() { + use sqlx::sqlite::SqlitePoolOptions; + use sqlx::ConnectOptions; + + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + pool.close().await; + let options = SqliteConnectOptions::new() + .filename(&path) + .busy_timeout(std::time::Duration::from_secs(10)); + let mut other = options.connect().await.unwrap(); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut other) + .await + .unwrap(); + let opener = SqlitePoolOptions::new() + .max_connections(2) + .connect_with(options) + .await + .unwrap(); + let backup_path = path.clone(); + let backup = tokio::spawn(async move { + let result = back_up_v1_database(&opener, &backup_path).await; + opener.close().await; + result + }); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + // Another process migrates the file while the backup waits for the lock. + sqlx::query("DROP TABLE sync_queue") + .execute(&mut other) + .await + .unwrap(); + sqlx::query("COMMIT").execute(&mut other).await.unwrap(); + backup.await.unwrap().unwrap(); + assert!(!backup_of(&path).exists()); + assert!(!partial_backup_of(&path).exists()); + } + + #[tokio::test] + async fn unreadable_v1_documents_are_kept_aside_and_the_rest_migrate() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc( + &pool, + "not-a-uuid", + Some(ME), + json!({"a": 1}), + "synced", + None, + ) + .await; + v1_doc(&pool, &id(2), Some(ME), json!({}), "synced", None).await; + sqlx::query("UPDATE documents SET content = 'not json' WHERE id = ?") + .bind(id(2)) + .execute(&pool) + .await + .unwrap(); + v1_doc(&pool, &id(3), Some(ME), json!({"n": 3}), "synced", None).await; + let store = migrated(pool, &path).await; + assert!(snapshot(&store, doc(3)).await.exists); + assert_eq!(count(&store, "SELECT COUNT(*) FROM documents").await, 1); + let mut kept: Vec<(Value, String)> = store + .list_recovered() + .await + .unwrap() + .into_iter() + .map(|copy| (copy.content, copy.reason)) + .collect(); + kept.sort_by_key(|(content, _)| content.to_string()); + assert_eq!( + kept, + vec![ + (json!("not json"), "unmigratable".to_string()), + (json!({"a": 1}), "unmigratable".to_string()), + ] + ); + store.close().await; + } + + #[tokio::test] + async fn a_case_colliding_v1_id_keeps_the_canonical_row_and_sets_the_other_aside() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc( + &pool, + &id(1).to_uppercase(), + Some(ME), + json!({"n": 2}), + "synced", + None, + ) + .await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "synced", None).await; + let store = migrated(pool, &path).await; + assert_eq!(snapshot(&store, doc(1)).await.content, json!({"n": 1})); + let kept = store.list_recovered().await.unwrap(); + assert_eq!(kept.len(), 1); + assert_eq!( + (kept[0].content.clone(), kept[0].reason.as_str()), + (json!({"n": 2}), "unmigratable") + ); + store.close().await; + } + + #[tokio::test] + async fn an_unknown_queue_operation_becomes_an_edit_with_no_base() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "pending", None).await; + // v1's CHECK allows only create/update/delete; a damaged file may hold anything. + sqlx::query("PRAGMA ignore_check_constraints = ON") + .execute(&pool) + .await + .unwrap(); + v1_queue_row(&pool, &id(1), "patch", Some(json!({"n": 0}))).await; + v1_doc(&pool, &id(2), Some(ME), json!({"n": 2}), "pending", None).await; + v1_queue_row(&pool, &id(2), "update", None).await; + sqlx::query("UPDATE sync_queue SET base_content = 'not json' WHERE document_id = ?") + .bind(id(2)) + .execute(&pool) + .await + .unwrap(); + let store = migrated(pool, &path).await; + for n in [1, 2] { + assert_eq!(outbox(&store, doc(n)).await, kinds(&["update"])); + assert!(snapshot(&store, doc(n)).await.shadow.is_none()); + } + store.close().await; + } + + #[tokio::test] + async fn an_authors_public_document_is_also_placed_in_curated() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "synced", None).await; + sqlx::query("UPDATE documents SET visibility = 'public' WHERE id = ?") + .bind(id(1)) + .execute(&pool) + .await + .unwrap(); + let store = migrated(pool, &path).await; + let mut scopes: Vec<String> = snapshot(&store, doc(1)) + .await + .memberships + .into_iter() + .map(|membership| membership.scope) + .collect(); + scopes.sort(); + assert_eq!( + scopes, + vec![SCOPE_CURATED.to_string(), SCOPE_OWN.to_string()] + ); + store.close().await; + } + + async fn kept_copies(store: &Store) -> Vec<(Uuid, Value, String)> { + store + .list_recovered() + .await + .unwrap() + .into_iter() + .map(|copy| (copy.doc_id, copy.content, copy.reason)) + .collect() + } + + #[tokio::test] + async fn a_pending_edit_on_another_accounts_document_is_kept_aside_and_it_migrates_as_synced() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(OTHER), json!({"n": 1}), "pending", None).await; + v1_queue_row(&pool, &id(1), "update", None).await; + let store = migrated(pool, &path).await; + let snap = snapshot(&store, doc(1)).await; + assert!(snap.rows.is_empty()); + assert!(snap.read_only, "not ours to edit"); + assert_eq!(snap.shadow.map(|s| s.content), Some(json!({"n": 1}))); + assert_eq!( + kept_copies(&store).await, + vec![(doc(1), json!({"n": 1}), "unmigratable".to_string())] + ); + store.close().await; + } + + /// A pending v1 edit of doc(1) with no owner, as v1's `resync_document` inserts a document it + /// did not have. + async fn unowned_pending_edit() -> (tempfile::TempDir, Store) { + let (dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), None, json!({"n": 2}), "pending", None).await; + v1_queue_row(&pool, &id(1), "update", Some(json!({"n": 1}))).await; + (dir, migrated(pool, &path).await) + } + + #[tokio::test] + async fn a_pending_edit_on_an_unowned_document_is_kept_before_the_curated_sweep() { + let (_dir, store) = unowned_pending_edit().await; + assert!(snapshot(&store, doc(1)).await.read_only); + store.finish_snapshot(SCOPE_CURATED, &[], 1).await.unwrap(); + assert!(!snapshot(&store, doc(1)).await.exists); + assert_eq!( + kept_copies(&store).await, + vec![(doc(1), json!({"n": 2}), "unmigratable".to_string())] + ); + store.close().await; + } + + #[tokio::test] + async fn a_pending_edit_on_an_unowned_document_is_kept_when_a_snapshot_replaces_it() { + let (_dir, store) = unowned_pending_edit().await; + let server = envelope(doc(1), Some(ME), json!({"n": 1}), 5); + store + .apply_snapshot_page(ME, SCOPE_OWN, std::slice::from_ref(&server)) + .await + .unwrap(); + assert_eq!(snapshot(&store, doc(1)).await.content, json!({"n": 1})); + assert_eq!( + kept_copies(&store).await, + vec![(doc(1), json!({"n": 2}), "unmigratable".to_string())] + ); + store.close().await; + } + + #[tokio::test] + async fn the_identity_adoption_state_is_carried_over() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, false).await; + let store = migrated(pool, &path).await; + assert_eq!(store.user_id().await.unwrap(), ME); + assert_eq!( + count( + &store, + "SELECT COUNT(*) FROM user_config WHERE identity_adopted = 0" + ) + .await, + 1, + "the first join still adopts or checks it" + ); + store.close().await; + } + + fn kept(n: u128, content: Value) -> Vec<(Uuid, Value, String)> { + vec![(doc(n), content, "unmigratable".to_string())] + } + + #[tokio::test] + async fn a_conflict_with_a_queued_update_keeps_its_content_aside_and_drops_the_rows() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 2}), "conflict", None).await; + v1_queue_row(&pool, &id(1), "update", Some(json!({"n": 1}))).await; + v1_queue_row(&pool, &id(1), "update", None).await; + let (store, counts) = migrated_with_counts(pool, &path).await; + assert!(outbox(&store, doc(1)).await.is_empty()); + assert_eq!(kept_copies(&store).await, kept(1, json!({"n": 2}))); + assert_eq!(counts.dropped_queue_rows, 2); + store.close().await; + } + + #[tokio::test] + async fn a_refused_create_on_another_accounts_document_keeps_its_content_aside() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc( + &pool, + &id(1), + Some(OTHER), + json!({"n": 3}), + "conflict", + None, + ) + .await; + v1_queue_row(&pool, &id(1), "create", None).await; + let (store, counts) = migrated_with_counts(pool, &path).await; + let snap = snapshot(&store, doc(1)).await; + assert!(snap.read_only && snap.rows.is_empty()); + assert_eq!(kept_copies(&store).await, kept(1, json!({"n": 3}))); + assert_eq!(counts.dropped_queue_rows, 1); + store.close().await; + } + + #[tokio::test] + async fn a_synced_document_with_queue_rows_keeps_its_content_aside_and_drops_the_rows() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 4}), "synced", None).await; + v1_queue_row(&pool, &id(1), "update", None).await; + let (store, counts) = migrated_with_counts(pool, &path).await; + let snap = snapshot(&store, doc(1)).await; + assert!(snap.rows.is_empty()); + assert_eq!(snap.shadow.map(|s| s.content), Some(json!({"n": 4}))); + assert_eq!(kept_copies(&store).await, kept(1, json!({"n": 4}))); + assert_eq!(counts.dropped_queue_rows, 1); + store.close().await; + } + + #[tokio::test] + async fn a_pending_delete_on_another_accounts_document_keeps_no_copy() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc( + &pool, + &id(1), + Some(OTHER), + json!({"n": 5}), + "pending", + Some("2026-01-03T00:00:00+00:00"), + ) + .await; + v1_queue_row(&pool, &id(1), "delete", None).await; + let (store, counts) = migrated_with_counts(pool, &path).await; + assert!(!snapshot(&store, doc(1)).await.exists); + assert!(kept_copies(&store).await.is_empty()); + assert_eq!(counts.dropped_queue_rows, 1); + store.close().await; + } + + #[tokio::test] + async fn a_document_deleted_elsewhere_keeps_its_unsent_edit_aside() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc( + &pool, + &id(1), + Some(ME), + json!({"n": 2}), + "synced", + Some("2026-01-03T00:00:00+00:00"), + ) + .await; + v1_queue_row(&pool, &id(1), "update", Some(json!({"n": 1}))).await; + let (store, counts) = migrated_with_counts(pool, &path).await; + assert!(!snapshot(&store, doc(1)).await.exists); + assert_eq!(kept_copies(&store).await, kept(1, json!({"n": 2}))); + assert_eq!(counts.dropped_queue_rows, 1); + store.close().await; + } + + #[tokio::test] + async fn a_second_opener_before_013_adds_no_backup_copy() { + let (_dir, path, pool) = v012_db().await; + v1_user(&pool, ME, true).await; + v1_doc(&pool, &id(1), Some(ME), json!({"n": 1}), "synced", None).await; + pool.close().await; + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(2) + .connect_with(SqliteConnectOptions::new().filename(&path)) + .await + .unwrap(); + back_up_v1_database(&pool, &path).await.unwrap(); + // A second opener that got the lock before the first applied 013. + back_up_v1_database(&pool, &path).await.unwrap(); + pool.close().await; + assert_eq!(backups_of(&path), vec![backup_of(&path)]); + let copy = raw_pool(&backup_of(&path)).await; + let user_version: i64 = sqlx::query_scalar("PRAGMA user_version") + .fetch_one(©) + .await + .unwrap(); + assert_eq!(user_version, 0, "backup is unmarked"); + } +} diff --git a/replicant-client/src/store/writes.rs b/replicant-client/src/store/writes.rs index 66e7753..f45f79b 100644 --- a/replicant-client/src/store/writes.rs +++ b/replicant-client/src/store/writes.rs @@ -7,73 +7,87 @@ use uuid::Uuid; use super::docs::{ append_change_log, insert_marker, load_snapshot, refresh_search, title_of, LogOrigin, }; -use super::{now_rfc3339, Store, StoreError, StoreResult}; +use super::{now_rfc3339, read_user_id, Store, StoreError, StoreResult}; use crate::engine::doc::{DocSnapshot, FieldConflict, RowKind}; use crate::engine::hash::{canonicalise_numbers, content_hash}; impl Store { - /// Creates a document owned by `me`, returning its id. - pub async fn create_document( + /// Creates a document owned by this data dir's user, returning its id. + pub async fn create_document(&self, doc_id: Option<Uuid>, content: Value) -> StoreResult<Uuid> { + let doc_id = doc_id.unwrap_or_else(Uuid::new_v4); + let mut tx = self.begin().await?; + self.create_in(&mut tx, doc_id, content).await?; + tx.commit().await?; + Ok(doc_id) + } + + async fn create_in( &self, - me: Uuid, - doc_id: Option<Uuid>, + conn: &mut SqliteConnection, + doc_id: Uuid, mut content: Value, - ) -> StoreResult<Uuid> { + ) -> StoreResult<()> { canonicalise_numbers(&mut content); - let doc_id = doc_id.unwrap_or_else(Uuid::new_v4); - let mut tx = self.begin().await?; - let snap = load_snapshot(&mut tx, doc_id).await?; + let content = &content; + let snap = load_snapshot(&mut *conn, doc_id).await?; if snap.exists || snap.tombstone_seq.is_some() { return Err(StoreError::AlreadyExists(doc_id)); } + let owner = read_user_id(&mut *conn).await?; let now = now_rfc3339(); sqlx::query( "INSERT INTO documents (id, user_id, content, hash, title, read_only, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, 0, ?, ?)", ) .bind(doc_id.to_string()) - .bind(me.to_string()) + .bind(owner.to_string()) .bind(content.to_string()) - .bind(content_hash(&content)) - .bind(title_of(&content, None)) + .bind(content_hash(content)) + .bind(title_of(content, self.title_pointer.as_deref())) .bind(&now) .bind(&now) - .execute(&mut *tx) + .execute(&mut *conn) .await?; - insert_marker(&mut tx, doc_id, RowKind::Create).await?; - append_change_log(&mut tx, &self.writer(LogOrigin::Local), doc_id, false).await?; - refresh_search(&mut tx, doc_id).await?; - tx.commit().await?; - Ok(doc_id) + insert_marker(&mut *conn, doc_id, RowKind::Create).await?; + append_change_log(&mut *conn, &self.writer(LogOrigin::Local), doc_id, false).await?; + refresh_search(&mut *conn, doc_id).await?; + Ok(()) } - pub async fn update_document(&self, me: Uuid, doc_id: Uuid, content: Value) -> StoreResult<()> { + pub async fn update_document(&self, doc_id: Uuid, content: Value) -> StoreResult<()> { let mut tx = self.begin().await?; - self.update_in(&mut tx, me, doc_id, content).await?; + self.update_in(&mut tx, doc_id, content).await?; tx.commit().await?; Ok(()) } /// Writes a field conflict's kept local values back at their paths as a local edit and /// deletes the kept copy, in one transaction. Other paths keep the server's values. - pub async fn restore_fields(&self, me: Uuid, recovered_id: i64) -> StoreResult<()> { + pub async fn restore_fields(&self, recovered_id: i64) -> StoreResult<()> { let mut tx = self.begin().await?; - let kept: Option<(String, Option<String>)> = sqlx::query_as( - "SELECT doc_id, fields FROM recovered WHERE id = ? AND reason = 'field_conflict'", - ) - .bind(recovered_id) - .fetch_optional(&mut *tx) - .await?; - let Some((doc_id, Some(fields))) = kept else { + let kept: Option<(String, Option<String>, String)> = + sqlx::query_as("SELECT doc_id, fields, reason FROM recovered WHERE id = ?") + .bind(recovered_id) + .fetch_optional(&mut *tx) + .await?; + let Some((doc_id, fields, reason)) = kept else { + return Err(StoreError::NoKeptCopy(recovered_id)); + }; + let (Some(fields), "field_conflict") = (fields, reason.as_str()) else { return Err(StoreError::NoFieldConflict(recovered_id)); }; let doc_id = Uuid::parse_str(&doc_id)?; let fields: Vec<FieldConflict> = serde_json::from_str(&fields)?; - let mut content = load_snapshot(&mut tx, doc_id).await?.content; + // Checked before put_field: a deleted document would otherwise report Corrupt. + let snap = load_snapshot(&mut tx, doc_id).await?; + if !snap.exists || snap.soft_deleted { + return Err(StoreError::DocumentGone(doc_id)); + } + let mut content = snap.content; for field in &fields { put_field(&mut content, field)?; } - self.update_in(&mut tx, me, doc_id, content).await?; + self.update_in(&mut tx, doc_id, content).await?; sqlx::query("DELETE FROM recovered WHERE id = ?") .bind(recovered_id) .execute(&mut *tx) @@ -82,23 +96,44 @@ impl Store { Ok(()) } + /// Re-creates a kept copy's full content as a new document (its old id may be tombstoned on + /// the server) and deletes the copy, in one transaction. A field copy holds the whole local + /// document too, so this is also the way out when its document is gone or read-only. + pub async fn restore_document(&self, recovered_id: i64) -> StoreResult<Uuid> { + let mut tx = self.begin().await?; + let content: Option<String> = + sqlx::query_scalar("SELECT content FROM recovered WHERE id = ?") + .bind(recovered_id) + .fetch_optional(&mut *tx) + .await?; + let content: Value = + serde_json::from_str(&content.ok_or(StoreError::NoKeptCopy(recovered_id))?)?; + let doc_id = Uuid::new_v4(); + self.create_in(&mut tx, doc_id, content).await?; + sqlx::query("DELETE FROM recovered WHERE id = ?") + .bind(recovered_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(doc_id) + } + async fn update_in( &self, conn: &mut SqliteConnection, - me: Uuid, doc_id: Uuid, mut content: Value, ) -> StoreResult<()> { canonicalise_numbers(&mut content); let snap = load_snapshot(&mut *conn, doc_id).await?; - check_writable(&snap, me)?; + check_writable(&snap, read_user_id(&mut *conn).await?)?; sqlx::query( - "UPDATE documents SET content = ?, hash = ?, title = COALESCE(?, title), updated_at = ? \ + "UPDATE documents SET content = ?, hash = ?, title = ?, updated_at = ? \ WHERE id = ?", ) .bind(content.to_string()) .bind(content_hash(&content)) - .bind(title_of(&content, None)) + .bind(title_of(&content, self.title_pointer.as_deref())) .bind(now_rfc3339()) .bind(doc_id.to_string()) .execute(&mut *conn) @@ -111,10 +146,10 @@ impl Store { } /// Soft delete: the shadow stays so the delete upload can be built. - pub async fn delete_document(&self, me: Uuid, doc_id: Uuid) -> StoreResult<()> { + pub async fn delete_document(&self, doc_id: Uuid) -> StoreResult<()> { let mut tx = self.begin().await?; let snap = load_snapshot(&mut tx, doc_id).await?; - check_writable(&snap, me)?; + check_writable(&snap, read_user_id(&mut tx).await?)?; let now = now_rfc3339(); sqlx::query("UPDATE documents SET deleted_at = ?, updated_at = ? WHERE id = ?") .bind(&now) @@ -206,13 +241,48 @@ mod tests { use uuid::Uuid; use super::*; - use crate::database::ClientDatabase; use crate::engine::doc::DocOp; use crate::engine::types::{SCOPE_CURATED, SCOPE_OWN}; use crate::store::test_support::*; const OTHER: Uuid = Uuid::from_u128(0xB); + #[tokio::test] + async fn a_write_after_another_process_adopts_the_identity_is_stamped_with_the_adopted_id() { + let t = temp_store().await; + let provisional = Uuid::from_u128(0x1); + let server = Uuid::from_u128(0x2); + seed_user(&t.store, provisional, false).await; + let before = t + .store + .create_document(None, json!({"n": 1})) + .await + .unwrap(); + let other_process = open_again(&t.path()).await; + other_process.check_identity(server).await.unwrap(); + let after = t + .store + .create_document(None, json!({"n": 2})) + .await + .unwrap(); + assert_eq!( + snapshot(&t.store, before).await.owner_id, + Some(server), + "re-stamped by the other process's join" + ); + assert_eq!( + snapshot(&t.store, after).await.owner_id, + Some(server), + "stamped with the adopted id, not the one this process saw first" + ); + t.store + .update_document(after, json!({"n": 3})) + .await + .unwrap(); + t.store.delete_document(before).await.unwrap(); + other_process.close().await; + } + fn doc(n: u128) -> Uuid { Uuid::from_u128(0xD000 + n) } @@ -222,7 +292,7 @@ mod tests { let t = temp_store().await; let doc_id = t .store - .create_document(ME, None, json!({"n": 1200.0, "x": 1.5, "list": [2.0]})) + .create_document(None, json!({"n": 1200.0, "x": 1.5, "list": [2.0]})) .await .unwrap(); let canonical = json!({"n": 1200, "x": 1.5, "list": [2]}); @@ -234,18 +304,45 @@ mod tests { .unwrap(); assert_eq!(hash, content_hash(&canonical)); t.store - .update_document(ME, doc_id, json!({"n": 1300.0})) + .update_document(doc_id, json!({"n": 1300.0})) .await .unwrap(); assert_eq!(snapshot(&t.store, doc_id).await.content, json!({"n": 1300})); } + #[tokio::test] + async fn a_local_edit_sets_the_title_from_the_pointer_and_clears_it_when_the_value_goes() { + let mut t = temp_store().await; + t.store.title_pointer = Some("/name".into()); + let doc_id = t + .store + .create_document(None, json!({"name": "Scale"})) + .await + .unwrap(); + let title = |store: &Store| { + let pool = store.pool.clone(); + async move { + sqlx::query_scalar::<_, Option<String>>("SELECT title FROM documents WHERE id = ?") + .bind(doc_id.to_string()) + .fetch_one(&pool) + .await + .unwrap() + } + }; + assert_eq!(title(&t.store).await.as_deref(), Some("Scale")); + t.store + .update_document(doc_id, json!({"name": 7})) + .await + .unwrap(); + assert_eq!(title(&t.store).await, None); + } + #[tokio::test] async fn create_writes_document_marker_and_log_row_together() { let t = temp_store().await; let doc_id = t .store - .create_document(ME, None, json!({"title": "New"})) + .create_document(None, json!({"title": "New"})) .await .unwrap(); let snap = snapshot(&t.store, doc_id).await; @@ -274,18 +371,18 @@ mod tests { async fn create_rejects_existing_soft_deleted_or_tombstoned_ids() { let t = temp_store().await; t.store - .create_document(ME, Some(doc(1)), json!({})) + .create_document(Some(doc(1)), json!({})) .await .unwrap(); - let again = t.store.create_document(ME, Some(doc(1)), json!({})).await; + let again = t.store.create_document(Some(doc(1)), json!({})).await; assert!(matches!(again, Err(StoreError::AlreadyExists(id)) if id == doc(1))); - t.store.delete_document(ME, doc(1)).await.unwrap(); - let after_delete = t.store.create_document(ME, Some(doc(1)), json!({})).await; + t.store.delete_document(doc(1)).await.unwrap(); + let after_delete = t.store.create_document(Some(doc(1)), json!({})).await; assert!(matches!(after_delete, Err(StoreError::AlreadyExists(_)))); apply(&t.store, doc(2), |_| vec![DocOp::RecordTombstone(5)]).await; - let tombstoned = t.store.create_document(ME, Some(doc(2)), json!({})).await; + let tombstoned = t.store.create_document(Some(doc(2)), json!({})).await; assert!(matches!(tombstoned, Err(StoreError::AlreadyExists(id)) if id == doc(2))); } @@ -293,7 +390,7 @@ mod tests { async fn update_and_delete_reject_foreign_read_only_and_missing_documents() { let t = temp_store().await; seed_synced(&t.store, doc(1), SCOPE_OWN, Some(OTHER), json!({}), 1).await; - let foreign = t.store.update_document(ME, doc(1), json!({"x": 1})).await; + let foreign = t.store.update_document(doc(1), json!({"x": 1})).await; assert!(matches!(foreign, Err(StoreError::NotWritable(id)) if id == doc(1))); seed_synced(&t.store, doc(2), SCOPE_CURATED, Some(ME), json!({}), 1).await; @@ -305,22 +402,22 @@ mod tests { }) .await; assert!(matches!( - t.store.update_document(ME, doc(2), json!({"x": 1})).await, + t.store.update_document(doc(2), json!({"x": 1})).await, Err(StoreError::NotWritable(_)) )); assert!(matches!( - t.store.delete_document(ME, doc(2)).await, + t.store.delete_document(doc(2)).await, Err(StoreError::NotWritable(_)) )); assert!(matches!( - t.store.update_document(ME, doc(3), json!({})).await, + t.store.update_document(doc(3), json!({})).await, Err(StoreError::NotFound(id)) if id == doc(3) )); - let created = t.store.create_document(ME, None, json!({})).await.unwrap(); - t.store.delete_document(ME, created).await.unwrap(); + let created = t.store.create_document(None, json!({})).await.unwrap(); + t.store.delete_document(created).await.unwrap(); assert!(matches!( - t.store.delete_document(ME, created).await, + t.store.delete_document(created).await, Err(StoreError::NotFound(_)) )); } @@ -330,7 +427,7 @@ mod tests { let t = temp_store().await; seed_synced(&t.store, doc(1), SCOPE_OWN, Some(ME), json!({"n": 0}), 1).await; t.store - .update_document(ME, doc(1), json!({"n": 1})) + .update_document(doc(1), json!({"n": 1})) .await .unwrap(); apply(&t.store, doc(1), |snap| { @@ -343,7 +440,7 @@ mod tests { assert!(snapshot(&t.store, doc(1)).await.rows[0].parked); t.store - .update_document(ME, doc(1), json!({"n": 2})) + .update_document(doc(1), json!({"n": 2})) .await .unwrap(); let rows = snapshot(&t.store, doc(1)).await.rows; @@ -376,7 +473,7 @@ mod tests { plant_marker(&t.store, doc(1), planted).await; t.store - .update_document(ME, doc(1), json!({"n": 1})) + .update_document(doc(1), json!({"n": 1})) .await .unwrap(); @@ -385,7 +482,7 @@ mod tests { assert!(newest.mutation_id > planted); assert_eq!(newest.kind, RowKind::Update); - t.store.delete_document(ME, doc(1)).await.unwrap(); + t.store.delete_document(doc(1)).await.unwrap(); let rows = snapshot(&t.store, doc(1)).await.rows; let newest = rows.last().unwrap(); assert!(newest.mutation_id > planted); @@ -401,7 +498,7 @@ mod tests { let other = open_again(&t.path()).await; other - .update_document(ME, doc(1), json!({"n": 1})) + .update_document(doc(1), json!({"n": 1})) .await .unwrap(); @@ -415,7 +512,7 @@ mod tests { async fn delete_is_soft_and_keeps_the_shadow() { let t = temp_store().await; seed_synced(&t.store, doc(1), SCOPE_OWN, Some(ME), json!({"n": 1}), 3).await; - t.store.delete_document(ME, doc(1)).await.unwrap(); + t.store.delete_document(doc(1)).await.unwrap(); let snap = snapshot(&t.store, doc(1)).await; assert!(snap.exists); assert!(snap.soft_deleted); @@ -429,36 +526,17 @@ mod tests { assert_eq!(last, "delete"); } - #[tokio::test] - async fn v1_reader_parses_v2_written_documents() { - let t = temp_store().await; - let doc_id = t - .store - .create_document(ME, None, json!({"title": "From v2"})) - .await - .unwrap(); - let v1 = ClientDatabase::new(&format!("sqlite://{}?mode=rwc", t.path().display())) - .await - .unwrap(); - let read = v1.get_document(&doc_id).await.unwrap(); - assert_eq!(read.content, json!({"title": "From v2"})); - assert_eq!(read.title.as_deref(), Some("From v2")); - t.store.delete_document(ME, doc_id).await.unwrap(); - assert!(v1.get_document(&doc_id).await.unwrap().deleted_at.is_some()); - v1.close().await; - } - #[tokio::test] async fn rapid_edits_keep_marker_order() { let t = temp_store().await; let doc_id = t .store - .create_document(ME, None, json!({"n": 0})) + .create_document(None, json!({"n": 0})) .await .unwrap(); for n in 1..=200 { t.store - .update_document(ME, doc_id, json!({"n": n})) + .update_document(doc_id, json!({"n": n})) .await .unwrap(); } @@ -484,13 +562,13 @@ mod tests { let other = open_again(&t.path()).await; let doc_id = t .store - .create_document(ME, None, json!({"n": 0})) + .create_document(None, json!({"n": 0})) .await .unwrap(); let mine = async { for n in 0..50 { t.store - .update_document(ME, doc_id, json!({"a": n})) + .update_document(doc_id, json!({"a": n})) .await .unwrap(); } @@ -498,7 +576,7 @@ mod tests { let theirs = async { for n in 0..50 { other - .update_document(ME, doc_id, json!({"b": n})) + .update_document(doc_id, json!({"b": n})) .await .unwrap(); } @@ -513,10 +591,7 @@ mod tests { t.store.close().await; for n in 0..20 { let store = open_again(&t.path()).await; - store - .create_document(ME, None, json!({"n": n})) - .await - .unwrap(); + store.create_document(None, json!({"n": n})).await.unwrap(); store.close().await; } let reopened = open_again(&t.path()).await; @@ -534,7 +609,7 @@ mod tests { 1, ) .await; - t.store.update_document(ME, doc(1), local).await.unwrap(); + t.store.update_document(doc(1), local).await.unwrap(); let change = upsert_change(SCOPE_OWN, envelope(doc(1), Some(ME), theirs, 2)); t.store .apply_changes(ME, SCOPE_OWN, &[change], 2) @@ -564,7 +639,11 @@ mod tests { doc_id: doc(1), event: crate::engine::doc::DocEvent::FieldConflict { paths: vec!["/s".into()] - } + }, + kept: Some(crate::store::KeptCopy { + recovered_id: 1, + reason: "field_conflict".into() + }) }] ); let fields: String = sqlx::query_scalar("SELECT fields FROM recovered WHERE doc_id = ?") @@ -593,7 +672,7 @@ mod tests { .await; let rows_before = count(&t.store, "SELECT COUNT(*) FROM outbox").await; t.store - .restore_fields(ME, kept_copy_id(&t).await) + .restore_fields(kept_copy_id(&t).await) .await .unwrap(); assert_eq!( @@ -612,12 +691,51 @@ mod tests { let t = temp_store().await; collide(&t, json!({"k": 1}), json!({"s": "theirs", "k": 1})).await; t.store - .restore_fields(ME, kept_copy_id(&t).await) + .restore_fields(kept_copy_id(&t).await) .await .unwrap(); assert_eq!(snapshot(&t.store, doc(1)).await.content, json!({"k": 1})); } + #[tokio::test] + async fn restoring_a_whole_list_conflict_restores_the_list_exactly() { + let t = temp_store().await; + let scale = json!({"pitches": [0, 200, 400, 500, 700, 900]}); + seed_synced(&t.store, doc(1), SCOPE_OWN, Some(ME), scale, 1).await; + let mine = json!({"pitches": [0, 200, 500, 700, 900]}); + t.store.update_document(doc(1), mine.clone()).await.unwrap(); + let theirs = json!({"pitches": [0, 200, 400, 500, 700]}); + let change = upsert_change(SCOPE_OWN, envelope(doc(1), Some(ME), theirs.clone(), 2)); + let notices = t + .store + .apply_changes(ME, SCOPE_OWN, &[change], 2) + .await + .unwrap(); + assert_eq!( + notices, + vec![crate::store::DocNotice { + doc_id: doc(1), + event: crate::engine::doc::DocEvent::FieldConflict { + paths: vec!["/pitches".into()] + }, + kept: Some(crate::store::KeptCopy { + recovered_id: 1, + reason: "field_conflict".into() + }) + }] + ); + assert_eq!(snapshot(&t.store, doc(1)).await.content, theirs); + t.store + .restore_fields(kept_copy_id(&t).await) + .await + .unwrap(); + assert_eq!( + snapshot(&t.store, doc(1)).await.content, + mine, + "the whole local list, exactly" + ); + } + #[tokio::test] async fn restore_fields_refuses_a_whole_document_copy() { let t = temp_store().await; @@ -635,8 +753,135 @@ mod tests { .await .unwrap(); assert!(matches!( - t.store.restore_fields(ME, id).await, + t.store.restore_fields(id).await, Err(StoreError::NoFieldConflict(refused)) if refused == id )); + assert!(matches!( + t.store.restore_fields(id + 1).await, + Err(StoreError::NoKeptCopy(missing)) if missing == id + 1 + )); + } + + async fn keep_whole(store: &Store, content: &str) -> i64 { + exec( + store, + &format!( + "INSERT INTO recovered (doc_id, content, reason, recovered_at) \ + VALUES ('{}', '{content}', 'delete_wins', 1)", + doc(9) + ), + ) + .await; + store.list_recovered().await.unwrap()[0].id + } + + #[tokio::test] + async fn restore_document_creates_a_new_document_and_removes_the_copy() { + let t = temp_store().await; + let recovered_id = keep_whole(&t.store, r#"{"title":"Mine"}"#).await; + let restored = t.store.restore_document(recovered_id).await.unwrap(); + assert_ne!( + restored, + doc(9), + "a new id: the old one may be tombstoned on the server" + ); + let snap = snapshot(&t.store, restored).await; + assert_eq!(snap.content, json!({"title": "Mine"})); + assert_eq!(snap.owner_id, Some(ME)); + assert_eq!( + snap.rows.iter().map(|row| row.kind).collect::<Vec<_>>(), + vec![RowKind::Create] + ); + assert!(t.store.list_recovered().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn restore_document_takes_a_field_copy_whole_and_refuses_an_unknown_id() { + let t = temp_store().await; + exec( + &t.store, + &format!( + "INSERT INTO recovered (doc_id, content, reason, recovered_at, fields) \ + VALUES ('{}', '{{\"s\":\"mine\"}}', 'field_conflict', 1, '[]')", + doc(1) + ), + ) + .await; + assert!(matches!( + t.store.restore_document(999).await, + Err(StoreError::NoKeptCopy(999)) + )); + let field_copy = t.store.list_recovered().await.unwrap()[0].id; + let restored = t.store.restore_document(field_copy).await.unwrap(); + assert_eq!( + snapshot(&t.store, restored).await.content, + json!({"s": "mine"}) + ); + assert!(t.store.list_recovered().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn restoring_fields_of_a_deleted_document_reports_it_gone_and_keeps_the_copy() { + let t = temp_store().await; + exec( + &t.store, + &format!( + "INSERT INTO recovered (doc_id, content, reason, recovered_at, fields) \ + VALUES ('{}', '{{}}', 'field_conflict', 1, \ + '[{{\"path\":\"/s\",\"local_value\":\"mine\",\"local_removed\":false}}]')", + doc(1) + ), + ) + .await; + let recovered_id = t.store.list_recovered().await.unwrap()[0].id; + assert!(matches!( + t.store.restore_fields(recovered_id).await, + Err(StoreError::DocumentGone(id)) if id == doc(1) + )); + assert_eq!(t.store.list_recovered().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn restore_fields_records_a_local_change_and_unparks_the_rows() { + let t = temp_store().await; + let doc_id = t + .store + .create_document(None, json!({"s": "theirs", "k": 1})) + .await + .unwrap(); + exec( + &t.store, + &format!("UPDATE outbox SET parked_error = 'validation' WHERE doc_id = '{doc_id}'"), + ) + .await; + exec( + &t.store, + &format!( + "INSERT INTO recovered (doc_id, content, reason, recovered_at, fields) \ + VALUES ('{doc_id}', '{{}}', 'field_conflict', 1, \ + '[{{\"path\":\"/s\",\"local_value\":\"mine\",\"local_removed\":false}}]')" + ), + ) + .await; + let recovered_id = t.store.list_recovered().await.unwrap()[0].id; + t.store.restore_fields(recovered_id).await.unwrap(); + assert_eq!( + snapshot(&t.store, doc_id).await.content, + json!({"s": "mine", "k": 1}) + ); + assert_eq!( + count( + &t.store, + "SELECT COUNT(*) FROM outbox WHERE parked_error IS NOT NULL" + ) + .await, + 0 + ); + let last: (String, String) = + sqlx::query_as("SELECT doc_id, origin FROM change_log ORDER BY local_seq DESC LIMIT 1") + .fetch_one(&t.store.pool) + .await + .unwrap(); + assert_eq!(last, (doc_id.to_string(), "local".to_string())); } } diff --git a/replicant-client/src/transport/wire.rs b/replicant-client/src/transport/wire.rs index baaec71..2be0725 100644 --- a/replicant-client/src/transport/wire.rs +++ b/replicant-client/src/transport/wire.rs @@ -114,6 +114,11 @@ pub fn socket_url(server_url: &str, client_id: Uuid) -> Result<String, String> { if !matches!(url.scheme(), "ws" | "wss") { return Err(format!("unsupported server url scheme: {server_url}")); } + if url.query().is_some() || url.fragment().is_some() { + return Err(format!( + "server url must not carry a query string or fragment: {server_url}" + )); + } if !url.path().ends_with("/socket/websocket") { let path = format!("{}/socket/websocket", url.path().trim_end_matches('/')); url.set_path(&path); @@ -154,6 +159,13 @@ mod tests { use crate::engine::types::ServerError; use serde_json::json; + #[test] + fn socket_url_refuses_a_query_string_or_fragment() { + let id = Uuid::from_u128(1); + assert!(socket_url("wss://sync.example.com/?vsn=1.0.0", id).is_err()); + assert!(socket_url("wss://sync.example.com/#top", id).is_err()); + } + #[test] fn join_signature_matches_the_server_hmac_scheme() { let auth = JoinAuth { diff --git a/replicant-client/src/websocket.rs b/replicant-client/src/websocket.rs deleted file mode 100644 index f40884e..0000000 --- a/replicant-client/src/websocket.rs +++ /dev/null @@ -1,1057 +0,0 @@ -use crate::error_code::{error_code_for_reason, ReplicantErrorCode}; -use crate::events::EventDispatcher; -use hmac::{Hmac, Mac}; -use phoenix_channels_client::{ - CallError, Channel, ChannelJoinError, ChannelStatus, Event, Payload, Socket, StatusesError, - Topic, -}; -use replicant_core::{ - errors::ClientError, - models::{Document, DocumentPatch, ServerDocumentPatch}, - protocol::{ChangeEvent, ChangeEventType, ClientMessage, ErrorCode, ServerMessage}, - SyncResult, -}; -use serde_json::{json, Value}; -use sha2::Sha256; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::mpsc; -use url::Url; -use uuid::Uuid; - -type HmacSha256 = Hmac<Sha256>; - -const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -const JOIN_TIMEOUT: Duration = Duration::from_secs(10); -const CALL_TIMEOUT: Duration = Duration::from_secs(30); - -#[derive(Clone)] -pub struct WebSocketClient { - channel: Arc<Channel>, - _public_channel: Arc<Channel>, - tx: mpsc::Sender<ServerMessage>, -} - -pub struct WebSocketReceiver { - rx: mpsc::Receiver<ServerMessage>, -} - -impl WebSocketClient { - pub async fn connect( - server_url: &str, - email: &str, - client_id: Uuid, - user_id: Uuid, - api_key: &str, - api_secret: &str, - event_dispatcher: Option<Arc<EventDispatcher>>, - is_connected: Arc<AtomicBool>, - ) -> SyncResult<(Self, WebSocketReceiver)> { - Self::connect_with_hmac( - server_url, - email, - client_id, - user_id, - api_key, - api_secret, - event_dispatcher, - is_connected, - ) - .await - } - - pub async fn connect_with_hmac( - server_url: &str, - email: &str, - client_id: Uuid, - user_id: Uuid, - api_key: &str, - api_secret: &str, - event_dispatcher: Option<Arc<EventDispatcher>>, - is_connected: Arc<AtomicBool>, - ) -> SyncResult<(Self, WebSocketReceiver)> { - let ws_url = Self::to_websocket_url(server_url)?; - - if let Some(ref d) = event_dispatcher { - d.emit_connection_attempted(&ws_url); - } - - // Connect socket - let url = Url::parse(&ws_url).map_err(|e| ws_err(format!("Invalid URL: {}", e)))?; - let socket = Socket::spawn(url, None, None) - .await - .map_err(|e| ws_err(format!("Socket spawn failed: {:?}", e)))?; - - socket.connect(CONNECT_TIMEOUT).await.map_err(|e| { - if let Some(ref d) = event_dispatcher { - d.emit_sync_error( - ReplicantErrorCode::ConnectionFailed, - &format!("Connection failed: {:?}", e), - ); - } - ws_err(format!("Connect failed: {:?}", e)) - })?; - - // Join channel with HMAC auth; the topic and payload both carry the - // caller's (already-adopted) user id. - let timestamp = chrono::Utc::now().timestamp(); - let signature = Self::create_hmac_signature(api_secret, timestamp, email, api_key, ""); - let join_payload = Self::build_join_payload(email, api_key, &signature, timestamp, user_id); - - // Join per-user channel - let channel = socket - .channel( - Topic::from_string(format!("sync:user:{}", user_id)), - Some(to_payload(&join_payload)?), - ) - .await - .map_err(|e| ws_err(format!("Channel create failed: {:?}", e)))?; - - let join_reply = channel.join(JOIN_TIMEOUT).await.map_err(|e| { - if let Some(ref d) = event_dispatcher { - d.emit_sync_error( - error_code_for_join_reject(&e), - &format!("Join failed: {:?}", e), - ); - } - ws_err(format!("Join failed: {:?}", e)) - })?; - - // Identity drift check: every join reply names the credential's user - // id. A mismatch means the local identity diverged from the account — - // refuse to sync rather than silently re-stamp anything. - let reply_value = payload_to_value(&join_reply).unwrap_or_else(|| json!({})); - if let Err(msg) = Self::verify_reply_identity(user_id, &reply_value) { - if let Some(ref d) = event_dispatcher { - d.emit_sync_error(ReplicantErrorCode::IdentityDrift, &msg); - } - let _ = channel.leave().await; - return Err(ws_err(msg)); - } - - // Join public channel for public document events - let public_channel = socket - .channel( - Topic::from_string("sync:public".to_string()), - Some(to_payload(&join_payload)?), - ) - .await - .map_err(|e| ws_err(format!("Public channel create failed: {:?}", e)))?; - - public_channel.join(JOIN_TIMEOUT).await.map_err(|e| { - if let Some(ref d) = event_dispatcher { - d.emit_sync_error( - error_code_for_join_reject(&e), - &format!("Public channel join failed: {:?}", e), - ); - } - ws_err(format!("Public channel join failed: {:?}", e)) - })?; - - is_connected.store(true, Ordering::Relaxed); - - let (tx, rx) = mpsc::channel::<ServerMessage>(100); - Self::setup_broadcast_handlers(&channel, tx.clone(), is_connected.clone()); - Self::setup_broadcast_handlers(&public_channel, tx.clone(), is_connected.clone()); - - // The phoenix client freezes the join payload (and its timestamp) at - // channel creation and replays it on every auto-rejoin; once the stamp - // is older than the server's clock-skew window every rejoin is rejected - // forever. A channel-level rejoin never flips is_connected, so watch the - // channel status and hand off to our own reconnect loop (which mints a - // fresh timestamp) when the library gets stuck rejoining. - Self::setup_status_watcher(&channel, is_connected.clone()); - Self::setup_status_watcher(&public_channel, is_connected); - - // Emit auth success - let _ = tx - .send(ServerMessage::AuthSuccess { - session_id: Uuid::new_v4(), - client_id, - }) - .await; - - Ok(( - Self { - channel, - _public_channel: public_channel, - tx, - }, - WebSocketReceiver { rx }, - )) - } - - /// Build the channel join payload; always carries the adopted user id. - pub(crate) fn build_join_payload( - email: &str, - api_key: &str, - signature: &str, - timestamp: i64, - user_id: Uuid, - ) -> Value { - json!({ - "email": email, - "api_key": api_key, - "signature": signature, - "timestamp": timestamp, - "user_id": user_id.to_string(), - }) - } - - /// `Ok` when the join reply's `user_id` (if present) matches ours; `Err` - /// with a description when it differs or cannot be parsed. A reply naming - /// a different user means local identity diverged from the account — the - /// caller must refuse to sync, never silently re-stamp. - pub(crate) fn verify_reply_identity(local: Uuid, reply: &Value) -> Result<(), String> { - match reply.get("user_id").and_then(|v| v.as_str()) { - None => Ok(()), - Some(s) => match Uuid::parse_str(s) { - Ok(server_id) if server_id == local => Ok(()), - Ok(server_id) => Err(format!( - "identity drift: server reports user {} but local identity is {}; \ - refusing to sync", - server_id, local - )), - Err(_) => Err(format!( - "identity drift: server sent unparseable user_id {:?}; refusing to sync", - s - )), - }, - } - } - - /// Whether a channel status means the phoenix client is stuck rejoining - /// with its frozen (stale-timestamp) payload and our reconnect loop should - /// take over. - fn should_reconnect_on_status(status: &ChannelStatus) -> bool { - matches!(status, ChannelStatus::WaitingToRejoin { .. }) - } - - fn setup_status_watcher(channel: &Arc<Channel>, is_connected: Arc<AtomicBool>) { - let statuses = channel.statuses(); - - tokio::spawn(async move { - loop { - match statuses.status().await { - Ok(status) => { - if Self::should_reconnect_on_status(&status) { - is_connected.store(false, Ordering::Relaxed); - } - } - // Only a closed status channel ends the watch; a lagged - // receiver just skips ahead to the next status. - Err(StatusesError::NoMoreStatuses) => break, - Err(_) => continue, - } - } - }); - } - - fn to_websocket_url(server_url: &str) -> SyncResult<String> { - let url = match server_url { - s if s.starts_with("http://") => s.replace("http://", "ws://"), - s if s.starts_with("https://") => s.replace("https://", "wss://"), - s if s.starts_with("ws://") || s.starts_with("wss://") => s.to_string(), - _ => return Err(ws_err(format!("Invalid URL scheme: {}", server_url))), - }; - - Ok(if url.contains("/socket/websocket") { - url - } else { - format!("{}/socket/websocket", url.trim_end_matches('/')) - }) - } - - fn setup_broadcast_handlers( - channel: &Arc<Channel>, - tx: mpsc::Sender<ServerMessage>, - is_connected: Arc<AtomicBool>, - ) { - let events = channel.events(); - let tx_clone = tx; - let is_connected_clone = is_connected; - - tokio::spawn(async move { - loop { - match events.event().await { - Ok(event_payload) => { - let event_name = event_payload.event.to_string(); - let payload_json = payload_to_value(&event_payload.payload); - - match event_name.as_str() { - "document_created" => { - if let Some(doc) = payload_json.as_ref().and_then(json_to_document) - { - let _ = tx_clone - .send(ServerMessage::DocumentCreated { document: doc }) - .await; - } - } - "document_updated" => { - match payload_json.as_ref().and_then(json_to_patch) { - Some(patch) => { - let _ = tx_clone - .send(ServerMessage::DocumentUpdated { patch }) - .await; - } - None => { - tracing::warn!( - "dropping malformed document_updated payload: {:?}", - payload_json - ); - } - } - } - "document_deleted" => { - if let Some(id) = payload_json - .as_ref() - .and_then(|j| j.get("id")?.as_str()) - .and_then(|s| Uuid::parse_str(s).ok()) - { - let _ = tx_clone - .send(ServerMessage::DocumentDeleted { document_id: id }) - .await; - } - } - "phx_close" => { - is_connected_clone.store(false, Ordering::Relaxed); - let _ = tx_clone - .send(ServerMessage::Error { - code: ErrorCode::ServerError, - message: "Channel closed".to_string(), - }) - .await; - } - _ => {} - } - } - Err(_) => break, - } - } - }); - } - - pub async fn send(&self, message: ClientMessage) -> SyncResult<()> { - match message { - ClientMessage::Authenticate { .. } => Ok(()), // Handled in join - ClientMessage::CreateDocument { document } => self.create_document(document).await, - ClientMessage::UpdateDocument { patch } => self.update_document(patch).await, - ClientMessage::DeleteDocument { document_id } => { - self.delete_document(document_id).await - } - ClientMessage::RequestFullSync => self.request_full_sync().await, - ClientMessage::RequestSync { .. } => self.request_full_sync().await, - ClientMessage::GetChangesSince { - last_sequence, - limit, - } => self.get_changes_since(last_sequence, limit).await, - ClientMessage::AckChanges { .. } => Ok(()), - ClientMessage::GetDocument { id } => self.get_document(id).await, - ClientMessage::Ping => { - let _ = self.tx.send(ServerMessage::Pong).await; - Ok(()) - } - } - } - - async fn create_document(&self, document: Document) -> SyncResult<()> { - let payload = json!({"id": document.id.to_string(), "content": document.content}); - let resp = self.call("create_document", &payload).await; - - let (success, error, author_name, visibility, provenance) = match &resp { - Ok(j) => ( - j.get("id").is_some(), - None, - j.get("author_name") - .and_then(|v| v.as_str()) - .map(String::from), - j.get("visibility") - .and_then(|v| v.as_str()) - .map(String::from), - j.get("provenance").filter(|v| !v.is_null()).cloned(), - ), - Err(e) => (false, Some(format!("{:?}", e)), None, None, None), - }; - - let _ = self - .tx - .send(ServerMessage::DocumentCreatedResponse { - document_id: document.id, - success, - error, - author_name, - visibility, - provenance, - }) - .await; - Ok(()) - } - - async fn update_document(&self, patch: DocumentPatch) -> SyncResult<()> { - let payload = json!({ - "id": patch.document_id.to_string(), - "patch": patch.patch, - "content_hash": patch.content_hash - }); - - // Bypass the `call` helper (which stringifies CallError) so a - // `hash_mismatch` rejection keeps the server state it carries. Without - // it the client cannot rebase and the local edit is silently lost. - let encoded = match to_payload(&payload) { - Ok(p) => p, - Err(e) => { - let _ = self - .tx - .send(update_rejected( - patch.document_id, - format!("Update failed to encode request: {:?}", e), - Value::Null, - )) - .await; - return Ok(()); - } - }; - - let result = self - .channel - .call( - Event::from_string("update_document".to_string()), - encoded, - CALL_TIMEOUT, - ) - .await; - - let message = match result { - Ok(reply) => { - let revision = payload_to_value(&reply) - .and_then(|j| j.get("sync_revision").and_then(|v| v.as_i64())); - match revision { - Some(revision) => ServerMessage::DocumentUpdatedResponse { - document_id: patch.document_id, - success: true, - error: None, - sync_revision: Some(revision), - reason: None, - current_revision: None, - current_content: None, - current_hash: None, - }, - None => update_rejected( - patch.document_id, - "Malformed update_document response".to_string(), - Value::Null, - ), - } - } - Err(CallError::Reply { reply }) => { - let body = payload_to_value(&reply).unwrap_or(Value::Null); - let reason = body - .get("reason") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - update_rejected( - patch.document_id, - format!("Update rejected: {}", reason), - body, - ) - } - Err(e) => update_rejected( - patch.document_id, - format!("Update failed: {:?}", e), - Value::Null, - ), - }; - - let _ = self.tx.send(message).await; - Ok(()) - } - - async fn delete_document(&self, document_id: Uuid) -> SyncResult<()> { - let payload = json!({"id": document_id.to_string()}); - let resp = self.call("delete_document", &payload).await; - - let (success, error) = match &resp { - Ok(_) => (true, None), - Err(e) => (false, Some(format!("{:?}", e))), - }; - - let _ = self - .tx - .send(ServerMessage::DocumentDeletedResponse { - document_id, - success, - error, - }) - .await; - Ok(()) - } - - async fn request_full_sync(&self) -> SyncResult<()> { - let resp = self.call("request_full_sync", &json!({})).await; - - match resp { - Ok(j) => { - if let Some(docs) = j.get("documents").and_then(|v| v.as_array()) { - for doc_json in docs { - if let Some(document) = json_to_document(doc_json) { - let _ = self.tx.send(ServerMessage::SyncDocument { document }).await; - } - } - } - let synced_count = j - .get("documents") - .and_then(|v| v.as_array()) - .map(|a| a.len()) - .unwrap_or(0); - let _ = self - .tx - .send(ServerMessage::SyncComplete { synced_count }) - .await; - } - Err(e) => { - let _ = self - .tx - .send(ServerMessage::Error { - code: ErrorCode::ServerError, - message: format!("Full sync failed: {:?}", e), - }) - .await; - } - } - Ok(()) - } - - async fn get_document(&self, id: Uuid) -> SyncResult<()> { - let msg = self.fetch_document(id).await; - let _ = self.tx.send(msg).await; - Ok(()) - } - - /// Fetch a full document and return the resulting message to the caller - /// instead of routing it through the inbound channel. Resync needs the - /// reply inline: routing it back through `tx` would lose the association - /// with the document being resynced (`ServerMessage::Error` carries no id). - pub async fn fetch_document(&self, id: Uuid) -> ServerMessage { - // Bypass the `call` helper (which stringifies CallError) so a - // `{"reason": "not_found"}` reply can be distinguished from a - // transient transport/timeout failure. - let payload = json!({"id": id.to_string()}); - let encoded = match to_payload(&payload) { - Ok(p) => p, - Err(e) => { - return ServerMessage::Error { - code: ErrorCode::ServerError, - message: format!("Get document failed to encode request: {:?}", e), - } - } - }; - - let result = self - .channel - .call( - Event::from_string("get_document".to_string()), - encoded, - CALL_TIMEOUT, - ) - .await; - - match result { - Ok(reply) => match payload_to_value(&reply) { - Some(j) => match json_to_get_document_response(&j, id) { - Ok(msg) => msg, - Err(parse_err) => ServerMessage::Error { - code: ErrorCode::ServerError, - message: format!("Malformed get_document response: {}", parse_err), - }, - }, - None => ServerMessage::Error { - code: ErrorCode::ServerError, - message: "Invalid get_document response payload".to_string(), - }, - }, - Err(CallError::Reply { reply: payload }) => { - let reason = payload_to_value(&payload) - .and_then(|v| v.get("reason").and_then(|r| r.as_str().map(String::from))); - match reason.as_deref() { - Some("not_found") => ServerMessage::Error { - code: ErrorCode::DocumentNotFound, - message: "Document not found".to_string(), - }, - _ => ServerMessage::Error { - code: ErrorCode::ServerError, - message: format!("Get document failed: server error reply {:?}", reason), - }, - } - } - Err(e) => ServerMessage::Error { - code: ErrorCode::ServerError, - message: format!("Get document failed: {:?}", e), - }, - } - } - - async fn get_changes_since(&self, last_sequence: u64, limit: Option<u32>) -> SyncResult<()> { - let mut payload = json!({"last_sequence": last_sequence}); - if let Some(l) = limit { - payload["limit"] = json!(l); - } - - let resp = self.call("get_changes_since", &payload).await; - - match resp { - Ok(j) => { - let events = j - .get("events") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(json_to_change_event).collect()) - .unwrap_or_default(); - let latest_sequence = j - .get("latest_sequence") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - let _ = self - .tx - .send(ServerMessage::Changes { - events, - latest_sequence, - has_more: false, - }) - .await; - } - Err(e) => { - let _ = self - .tx - .send(ServerMessage::Error { - code: ErrorCode::ServerError, - message: format!("Get changes failed: {:?}", e), - }) - .await; - } - } - Ok(()) - } - - async fn call(&self, event: &str, payload: &Value) -> Result<Value, String> { - self.channel - .call( - Event::from_string(event.to_string()), - to_payload(payload).map_err(|e| format!("{:?}", e))?, - CALL_TIMEOUT, - ) - .await - .map_err(|e| format!("{:?}", e)) - .and_then(|p| payload_to_value(&p).ok_or_else(|| "Invalid response".to_string())) - } - - fn create_hmac_signature( - secret: &str, - timestamp: i64, - email: &str, - api_key: &str, - body: &str, - ) -> String { - let mut mac = - HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key size"); - mac.update(format!("{}.{}.{}.{}", timestamp, email, api_key, body).as_bytes()); - hex::encode(mac.finalize().into_bytes()) - } -} - -impl WebSocketReceiver { - pub async fn receive(&mut self) -> SyncResult<Option<ServerMessage>> { - Ok(self.rx.recv().await) - } - - pub async fn forward_to(mut self, tx: mpsc::Sender<ServerMessage>) -> SyncResult<()> { - tracing::info!("CLIENT: WebSocket receiver forwarder started"); - while let Some(msg) = self.receive().await? { - tracing::info!( - "CLIENT: Received message: {:?}", - std::mem::discriminant(&msg) - ); - if tx.send(msg).await.is_err() { - tracing::error!("CLIENT: Failed to forward message"); - break; - } - } - tracing::warn!("CLIENT: WebSocket receiver forwarder terminated"); - Ok(()) - } -} - -// Helper functions -fn ws_err(msg: String) -> replicant_core::errors::SyncError { - ClientError::WebSocket(msg).into() -} - -fn to_payload(v: &Value) -> SyncResult<Payload> { - Payload::json_from_serialized(v.to_string()) - .map_err(|e| ws_err(format!("Payload error: {:?}", e))) -} - -/// A failed update ack, carrying whatever the server's error reply held. -/// -/// `body` is the raw rejection payload; for `hash_mismatch` it also carries the -/// server's current revision, content and hash, which the client rebases onto. -/// Every field is optional so other reasons — and future ones — still parse. -fn update_rejected(document_id: Uuid, error: String, body: Value) -> ServerMessage { - ServerMessage::DocumentUpdatedResponse { - document_id, - success: false, - error: Some(error), - sync_revision: None, - reason: body - .get("reason") - .and_then(|v| v.as_str()) - .map(String::from), - current_revision: body.get("current_revision").and_then(|v| v.as_i64()), - current_content: body - .get("current_content") - .filter(|v| !v.is_null()) - .cloned(), - current_hash: body - .get("current_hash") - .and_then(|v| v.as_str()) - .map(String::from), - } -} - -fn payload_to_value(p: &Payload) -> Option<Value> { - match p { - Payload::JSONPayload { json } => Some(Value::from(json.clone())), - Payload::Binary { .. } => None, - } -} - -/// Derive a structured [`ReplicantErrorCode`] from a phoenix channel-join error. -/// -/// A server rejection carries a JSON payload `{"reason": "<atom>"}` (see -/// `replicant_server` `Sync.Channel`); the reason is mapped through -/// [`error_code_for_reason`]. A rejection with no `reason` field is `Unknown`. -/// Join timeouts map to [`ReplicantErrorCode::Timeout`] and every other -/// transport/socket failure to [`ReplicantErrorCode::ConnectionFailed`]. -pub fn error_code_for_join_reject(err: &ChannelJoinError) -> ReplicantErrorCode { - match err { - ChannelJoinError::Rejected { rejection } => payload_to_value(rejection) - .as_ref() - .and_then(|v| v.get("reason")) - .and_then(|r| r.as_str()) - .map(error_code_for_reason) - .unwrap_or(ReplicantErrorCode::Unknown), - ChannelJoinError::Timeout => ReplicantErrorCode::Timeout, - _ => ReplicantErrorCode::ConnectionFailed, - } -} - -fn json_to_document(j: &Value) -> Option<Document> { - // The server always carries ownership in the sync envelope: a string - // user_id (owned doc) or null (public doc). A payload missing the key - // entirely is malformed — reject it rather than guess an owner. - let Some(uid_value) = j.get("user_id") else { - tracing::warn!( - "dropping document payload without ownership envelope (id: {:?})", - j.get("id") - ); - return None; - }; - let user_id = match uid_value { - Value::Null => None, - uid_value => Some(Uuid::parse_str(uid_value.as_str()?).ok()?), - }; - Some(Document { - id: Uuid::parse_str(j.get("id")?.as_str()?).ok()?, - user_id, - content: j.get("content")?.clone(), - sync_revision: j.get("sync_revision")?.as_i64()?, - content_hash: j - .get("content_hash") - .and_then(|v| v.as_str()) - .map(String::from), - title: j.get("title").and_then(|v| v.as_str()).map(String::from), - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: j - .get("author_name") - .and_then(|v| v.as_str()) - .map(String::from), - visibility: j - .get("visibility") - .and_then(|v| v.as_str()) - .map(String::from), - provenance: j.get("provenance").filter(|v| !v.is_null()).cloned(), - }) -} - -fn json_to_patch(j: &Value) -> Option<ServerDocumentPatch> { - let patch_value = j.get("patch")?; - let patch: json_patch::Patch = serde_json::from_value(patch_value.clone()).ok()?; - let id_str = j - .get("document_id") - .or_else(|| j.get("id")) - .and_then(|v| v.as_str())?; - Some(ServerDocumentPatch { - document_id: Uuid::parse_str(id_str).ok()?, - patch, - sync_revision: j.get("sync_revision")?.as_i64()?, - content_hash: j.get("content_hash")?.as_str().map(String::from)?, - }) -} - -/// Parse a `get_document` success reply into `ServerMessage::GetDocumentResponse`. -/// -/// `sync_revision` and `content_hash` are new fields with no legacy-compat -/// requirement: a reply missing either is malformed and must be rejected -/// rather than silently defaulted, since a defaulted revision would corrupt -/// downstream continuity checks. `id` falls back to the requested id (the -/// server always echoes it, but the caller already knows it either way). -fn json_to_get_document_response(j: &Value, requested_id: Uuid) -> Result<ServerMessage, String> { - let id = j - .get("id") - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok()) - .unwrap_or(requested_id); - let content = j.get("content").cloned().unwrap_or(Value::Null); - let sync_revision = j - .get("sync_revision") - .and_then(|v| v.as_i64()) - .ok_or("missing or invalid sync_revision")?; - let content_hash = j - .get("content_hash") - .and_then(|v| v.as_str()) - .ok_or("missing or invalid content_hash")? - .to_string(); - let deleted = j.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false); - - Ok(ServerMessage::GetDocumentResponse { - id, - content, - sync_revision, - content_hash, - deleted, - }) -} - -fn json_to_change_event(j: &Value) -> Option<ChangeEvent> { - Some(ChangeEvent { - sequence: j.get("sequence")?.as_u64()?, - document_id: Uuid::parse_str(j.get("id")?.as_str()?).ok()?, - user_id: Uuid::nil(), - event_type: match j.get("event_type")?.as_str()? { - "create" => ChangeEventType::Create, - "update" => ChangeEventType::Update, - "delete" => ChangeEventType::Delete, - _ => return None, - }, - forward_patch: j.get("forward_patch").cloned(), - reverse_patch: j.get("reverse_patch").cloned(), - created_at: j - .get("server_timestamp") - .and_then(|v| v.as_str()) - .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) - .map(|dt| dt.with_timezone(&chrono::Utc)) - .unwrap_or_else(chrono::Utc::now), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn json_to_patch_reads_sync_revision_and_content_hash() { - let j = serde_json::json!({ - "id": "71b2b712-7878-56ee-8323-43809b8198a5", - "patch": [{"op": "replace", "path": "/title", "value": "T"}], - "sync_revision": 7, - "content_hash": "abc123" - }); - let patch = json_to_patch(&j).unwrap(); - assert_eq!( - patch.document_id.to_string(), - "71b2b712-7878-56ee-8323-43809b8198a5" - ); - assert_eq!(patch.sync_revision, 7); - assert_eq!(patch.content_hash, "abc123"); - } - - #[test] - fn json_to_patch_missing_sync_revision_is_none() { - let j = serde_json::json!({ - "id": "71b2b712-7878-56ee-8323-43809b8198a5", - "patch": [{"op": "replace", "path": "/title", "value": "T"}], - "content_hash": "abc123" - }); - assert!(json_to_patch(&j).is_none()); - } - - #[test] - fn json_to_patch_missing_content_hash_is_none() { - let j = serde_json::json!({ - "id": "71b2b712-7878-56ee-8323-43809b8198a5", - "patch": [{"op": "replace", "path": "/title", "value": "T"}], - "sync_revision": 7 - }); - assert!(json_to_patch(&j).is_none()); - } - - #[test] - fn json_to_get_document_response_tolerates_unknown_fields() { - let requested_id = Uuid::new_v4(); - let j = serde_json::json!({ - "id": requested_id.to_string(), - "content": {"title": "T"}, - "sync_revision": 5, - "content_hash": "abc123", - "deleted": false, - "future_field": {"nested": "data"} - }); - let msg = json_to_get_document_response(&j, requested_id).unwrap(); - match msg { - ServerMessage::GetDocumentResponse { - id, - sync_revision, - content_hash, - deleted, - .. - } => { - assert_eq!(id, requested_id); - assert_eq!(sync_revision, 5); - assert_eq!(content_hash, "abc123"); - assert!(!deleted); - } - other => panic!("expected GetDocumentResponse, got {:?}", other), - } - } - - #[test] - fn json_to_get_document_response_missing_sync_revision_is_error() { - let requested_id = Uuid::new_v4(); - let j = serde_json::json!({ - "id": requested_id.to_string(), - "content": {}, - "content_hash": "abc123", - "deleted": false - }); - assert!(json_to_get_document_response(&j, requested_id).is_err()); - } - - #[test] - fn json_to_get_document_response_missing_content_hash_is_error() { - let requested_id = Uuid::new_v4(); - let j = serde_json::json!({ - "id": requested_id.to_string(), - "content": {}, - "sync_revision": 5, - "deleted": false - }); - assert!(json_to_get_document_response(&j, requested_id).is_err()); - } - - #[test] - fn json_to_document_reads_attribution() { - let j = serde_json::json!({ - "id": "71b2b712-7878-56ee-8323-43809b8198a5", - "user_id": "71b2b712-7878-56ee-8323-43809b8198a5", - "content": {"title": "T"}, - "sync_revision": 3, - "content_hash": "abc", - "author_name": "Sevish", - "visibility": "public", - "provenance": {"copied_from": "x"} - }); - let doc = json_to_document(&j).unwrap(); - assert_eq!(doc.author_name.as_deref(), Some("Sevish")); - assert_eq!(doc.visibility.as_deref(), Some("public")); - assert!(doc.provenance.is_some()); - } - - #[test] - fn json_to_document_tolerates_missing_attribution() { - let j = serde_json::json!({ - "id": "71b2b712-7878-56ee-8323-43809b8198a5", - "user_id": null, - "content": {"title": "T"}, - "sync_revision": 1 - }); - let doc = json_to_document(&j).unwrap(); - assert_eq!(doc.user_id, None, "null user_id means a public document"); - assert_eq!(doc.author_name, None); - assert_eq!(doc.visibility, None); - } - - #[test] - fn json_to_document_rejects_payload_without_user_id_key() { - let j = serde_json::json!({ - "id": "71b2b712-7878-56ee-8323-43809b8198a5", - "content": {"title": "T"}, - "sync_revision": 1 - }); - assert!( - json_to_document(&j).is_none(), - "ownership must come from the envelope, never be guessed" - ); - } - - #[test] - fn join_payload_always_includes_user_id() { - let uid = Uuid::new_v4(); - let payload = WebSocketClient::build_join_payload("a@b.com", "key", "sig", 123, uid); - assert_eq!(payload["user_id"], uid.to_string()); - assert_eq!(payload["email"], "a@b.com"); - } - - #[test] - fn reply_identity_matching_id_is_ok() { - let local = Uuid::new_v4(); - let reply = serde_json::json!({ "user_id": local.to_string() }); - assert!(WebSocketClient::verify_reply_identity(local, &reply).is_ok()); - } - - #[test] - fn reply_identity_mismatch_is_drift_error() { - let local = Uuid::new_v4(); - let other = Uuid::new_v4(); - let reply = serde_json::json!({ "user_id": other.to_string() }); - assert!(WebSocketClient::verify_reply_identity(local, &reply).is_err()); - } - - #[test] - fn reply_identity_absent_is_tolerated() { - let local = Uuid::new_v4(); - let reply = serde_json::json!({ "email": "a@b.com" }); - assert!(WebSocketClient::verify_reply_identity(local, &reply).is_ok()); - } - - #[test] - fn reply_identity_garbage_is_drift_error() { - let local = Uuid::new_v4(); - let reply = serde_json::json!({ "user_id": "not-a-uuid" }); - assert!(WebSocketClient::verify_reply_identity(local, &reply).is_err()); - } - - #[test] - fn should_reconnect_on_waiting_to_rejoin() { - let status = ChannelStatus::WaitingToRejoin { - until: std::time::SystemTime::now(), - }; - assert!(WebSocketClient::should_reconnect_on_status(&status)); - } - - #[test] - fn should_not_reconnect_on_steady_statuses() { - for status in [ - ChannelStatus::Joined, - ChannelStatus::Joining, - ChannelStatus::WaitingToJoin, - ChannelStatus::WaitingForSocketToConnect, - ChannelStatus::Leaving, - ChannelStatus::Left, - ] { - assert!(!WebSocketClient::should_reconnect_on_status(&status)); - } - } -} diff --git a/replicant-client/tests/common.rs b/replicant-client/tests/common.rs deleted file mode 100644 index 18acf73..0000000 --- a/replicant-client/tests/common.rs +++ /dev/null @@ -1,49 +0,0 @@ -use chrono::Utc; -use replicant_client::ClientDatabase; -use replicant_core::models::Document; -use uuid::Uuid; - -/// Creates a new in-memory test sqlite database and runs migrations. -#[allow(dead_code)] -pub async fn setup_test_db() -> ClientDatabase { - let db_url = "sqlite::memory:"; - let db = ClientDatabase::new(db_url).await.unwrap(); - db.run_migrations().await.unwrap(); - db -} - -/// Creates a sample document for a given user. -#[allow(dead_code)] -pub fn make_document(user_id: Uuid, title: &str, text: &str, sync_revision: i64) -> Document { - let content = serde_json::json!({ - "title": title, - "text": text - }); - - Document { - id: Uuid::new_v4(), - user_id: Some(user_id), - content: content.clone(), - sync_revision, - content_hash: None, - title: None, // Will be extracted when saved to database - created_at: Utc::now(), - updated_at: Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - } -} - -/// Helper to get the sync_status for a document. -#[allow(dead_code)] -pub async fn get_sync_status(db: &ClientDatabase, doc_id: Uuid) -> String { - use sqlx::Row; - sqlx::query("SELECT sync_status FROM documents WHERE id = ?") - .bind(doc_id.to_string()) - .fetch_one(&db.pool) - .await - .unwrap() - .get::<String, _>(0) -} diff --git a/replicant-client/tests/constructor_identity_tests.rs b/replicant-client/tests/constructor_identity_tests.rs deleted file mode 100644 index 6d8333a..0000000 --- a/replicant-client/tests/constructor_identity_tests.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Constructor-time identity adoption: the canonical user id (from stored -//! credentials) is adopted during `Client` construction, before any WebSocket -//! work and before the handle is returned — so adoption can never race live -//! document creation. - -use replicant_client::Client; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; -use uuid::Uuid; - -/// Unreachable server: connect fails fast, keeping tests offline. Only used -/// by tests that don't need to prove "zero connection attempts" (a dead -/// address fails identically whether or not the sync gate ran). -const DEAD_SERVER: &str = "http://127.0.0.1:1"; - -fn temp_db_url() -> String { - let path = std::env::temp_dir().join(format!("replicant-ctor-test-{}.sqlite3", Uuid::new_v4())); - format!("sqlite://{}?mode=rwc", path.display()) -} - -/// Binds a local listener and counts accepted connections, so tests can -/// assert "no connection attempt was made" instead of relying on a dead -/// address (which fails identically whether or not the gate ran). Returns -/// the reachable server URL and the shared counter. -async fn counting_listener() -> (String, Arc<AtomicUsize>) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind local listener"); - let addr = listener.local_addr().expect("local addr"); - let count = Arc::new(AtomicUsize::new(0)); - let count_clone = Arc::clone(&count); - tokio::spawn(async move { - loop { - match listener.accept().await { - Ok((socket, _)) => { - count_clone.fetch_add(1, Ordering::SeqCst); - drop(socket); - } - Err(_) => break, - } - } - }); - (format!("http://{}", addr), count) -} - -async fn open_offline(db_url: &str, canonical: Option<Uuid>) -> replicant_core::SyncResult<Client> { - Client::new(db_url, DEAD_SERVER, "ctor@test.com", "", "", canonical).await -} - -#[tokio::test] -async fn constructor_adopts_canonical_id_before_returning() { - let db_url = temp_db_url(); - - // First run: offline, no canonical id → provisional identity. - let client = open_offline(&db_url, None).await.unwrap(); - let provisional = client.user_id(); - let doc = client - .create_document(serde_json::json!({"title": "offline tuning"})) - .await - .unwrap(); - assert_eq!(doc.user_id, Some(provisional)); - drop(client); - - // Second run: credentials arrived with a canonical id → constructor adopts. - let canonical = Uuid::new_v4(); - let client = open_offline(&db_url, Some(canonical)).await.unwrap(); - assert_eq!(client.user_id(), canonical); - - let docs = client.get_all_documents().await.unwrap(); - let restamped = docs.iter().find(|d| d.id == doc.id).unwrap(); - assert_eq!( - restamped.user_id, - Some(canonical), - "offline-created document must be re-stamped to the canonical id" - ); -} - -#[tokio::test] -async fn constructor_rejects_account_switch() { - let db_url = temp_db_url(); - let canonical = Uuid::new_v4(); - - let client = open_offline(&db_url, Some(canonical)).await.unwrap(); - assert_eq!(client.user_id(), canonical); - drop(client); - - // A different canonical id on an already-adopted install is an account - // switch — refuse rather than re-stamp another account's documents. - let other_account = Uuid::new_v4(); - let result = open_offline(&db_url, Some(other_account)).await; - assert!(result.is_err(), "account switch must be rejected"); -} - -#[tokio::test] -async fn constructor_same_id_after_adoption_is_noop() { - let db_url = temp_db_url(); - let canonical = Uuid::new_v4(); - - let client = open_offline(&db_url, Some(canonical)).await.unwrap(); - drop(client); - - // Same canonical id again (e.g. credential rotation): proceed normally. - let client = open_offline(&db_url, Some(canonical)).await.unwrap(); - assert_eq!(client.user_id(), canonical); -} - -/// Empty credentials must yield a fully usable local-only client: no -/// connection attempt against the sync server, and full CRUD over the local -/// DB. The 10s timeout is a suite-hang safety net only — it does not prove -/// the client dropped cleanly (there is no `Drop` impl on `Client` and any -/// spawned task is detached, so a hung reconnect task would not fail this -/// wrapper). What actually falsifies a regressed sync gate is the counting -/// listener below: if `sync_enabled` were wrongly true, the constructor's own -/// connect attempt would register on the counter within milliseconds. -#[tokio::test] -async fn open_without_credentials_is_local_only_and_usable() { - let (server_url, connections) = counting_listener().await; - - let result = tokio::time::timeout(std::time::Duration::from_secs(10), async { - let db_url = temp_db_url(); - let client = Client::new(&db_url, &server_url, "offline@test.com", "", "", None) - .await - .unwrap(); - - assert!(!client.is_connected(), "no credentials must mean offline"); - - let doc = client - .create_document(serde_json::json!({"title": "offline-only"})) - .await - .unwrap(); - - let docs = client.get_all_documents().await.unwrap(); - assert!(docs.iter().any(|d| d.id == doc.id)); - - client - .update_document(doc.id, serde_json::json!({"title": "offline-only-edited"})) - .await - .unwrap(); - let docs = client.get_all_documents().await.unwrap(); - let updated = docs.iter().find(|d| d.id == doc.id).unwrap(); - assert_eq!(updated.content["title"], "offline-only-edited"); - - client.delete_document(doc.id).await.unwrap(); - let docs = client.get_all_documents().await.unwrap(); - assert!(!docs.iter().any(|d| d.id == doc.id)); - - assert!( - !client.is_connected(), - "still offline after local CRUD activity" - ); - - drop(client); - }) - .await; - - assert!( - result.is_ok(), - "test did not complete within the 10s suite-hang safety timeout" - ); - - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - assert_eq!( - connections.load(Ordering::SeqCst), - 0, - "no connection attempt must be made against the sync server without credentials" - ); -} - -/// Reviewer-flagged gap (Task-3 decision row 5): credentials are present but -/// the identity has never been adopted (canonical_user_id = None, never -/// adopted before). Sync must stay disabled — no connection attempt at all — -/// even though api_key/api_secret are non-empty. Uses a real local listener -/// (not the dead-server address) so a wrongly-attempted connection would -/// actually register instead of merely failing to connect either way. -#[tokio::test] -async fn open_with_credentials_but_unadopted_identity_stays_local() { - let (server_url, connections) = counting_listener().await; - - let result = tokio::time::timeout(std::time::Duration::from_secs(10), async { - let db_url = temp_db_url(); - let client = Client::new( - &db_url, - &server_url, - "unadopted@test.com", - "some-api-key", - "some-api-secret", - None, - ) - .await - .unwrap(); - - assert!( - !client.is_connected(), - "unadopted identity must not connect even with credentials present" - ); - - let doc = client - .create_document(serde_json::json!({"title": "unadopted-but-local"})) - .await - .unwrap(); - let docs = client.get_all_documents().await.unwrap(); - assert!(docs.iter().any(|d| d.id == doc.id)); - - // Give a hypothetical reconnect loop time to spin and misbehave; it - // must not, since sync_enabled requires an adopted identity. - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - assert!( - !client.is_connected(), - "reconnection loop must not have connected/spammed while identity is unadopted" - ); - - drop(client); - }) - .await; - - assert!( - result.is_ok(), - "test did not complete within the 10s suite-hang safety timeout" - ); - - assert_eq!( - connections.load(Ordering::SeqCst), - 0, - "no connection attempt must be made against the sync server while identity is unadopted" - ); -} diff --git a/replicant-client/tests/document_lifecycle_tests.rs b/replicant-client/tests/document_lifecycle_tests.rs deleted file mode 100644 index 5742bb7..0000000 --- a/replicant-client/tests/document_lifecycle_tests.rs +++ /dev/null @@ -1,298 +0,0 @@ -//! # Document Lifecycle Tests -//! -//! Tests for the core document lifecycle: creation, retrieval, and deletion. -//! These tests verify that documents transition through sync states correctly. -//! -//! Tests cover: -//! - New local documents start as pending -//! - Pending documents can be queried -//! - Delete operations mark documents as pending until synced - -mod common; - -use common::*; -use replicant_core::models::Document; -use uuid::Uuid; - -/// Verifies that newly created local documents start with "pending" sync status. -/// This is critical for offline-first functionality - documents created while -/// offline must be tracked for later sync. -#[tokio::test] -async fn test_new_local_documents_are_pending() { - let db = setup_test_db().await; - let user_id = Uuid::new_v4(); - let doc = make_document(user_id, "Local Document", "Local content", 1); - - // Save without specifying status (should default to pending) - db.save_document(&doc).await.unwrap(); - - let sync_status = get_sync_status(&db, doc.id).await; - assert_eq!( - sync_status, "pending", - "New local documents should be marked as pending" - ); -} - -/// Verifies that the get_pending_documents query correctly filters and retrieves -/// only documents with pending sync status. -#[tokio::test] -async fn test_pending_documents_can_be_retrieved() { - let db = setup_test_db().await; - let user_id = Uuid::new_v4(); - let mut doc_ids = Vec::new(); - - // Create mix of pending and synced documents - for i in 0..5 { - let doc = make_document( - user_id, - &format!("Document {}", i), - &format!("Content {}", i), - 1, - ); - - doc_ids.push(doc.id); - - // Save every other document as synced, rest as pending - if i % 2 == 0 { - db.save_document(&doc).await.unwrap(); - db.mark_synced(&doc.id).await.unwrap(); - } else { - db.save_document(&doc).await.unwrap(); // defaults to pending - } - } - - // Get pending documents - let pending_docs = db.get_pending_documents().await.unwrap(); - - // Should have 2 pending documents (indices 1, 3) - assert_eq!(pending_docs.len(), 2, "Should have 2 pending documents"); - - // Verify the pending ones are the odd indices - assert!( - pending_docs.iter().any(|p| p.id == doc_ids[1]), - "Document 1 should be pending" - ); - assert!( - pending_docs.iter().any(|p| p.id == doc_ids[3]), - "Document 3 should be pending" - ); - assert!( - !pending_docs.iter().any(|p| p.id == doc_ids[0]), - "Document 0 should not be pending" - ); - assert!( - !pending_docs.iter().any(|p| p.id == doc_ids[2]), - "Document 2 should not be pending" - ); - assert!( - !pending_docs.iter().any(|p| p.id == doc_ids[4]), - "Document 4 should not be pending" - ); -} - -/// Verifies the complete deletion workflow: -/// 1. Document starts synced -/// 2. Delete operation marks it pending -/// 3. After server confirmation, it's marked synced again -/// -/// This ensures deletes are tracked for sync just like creates and updates. -#[tokio::test] -async fn test_delete_marks_document_as_pending_then_synced() { - let db = setup_test_db().await; - let user_id = Uuid::new_v4(); - let doc = make_document(user_id, "Test Document", "Test content", 1); - - // Create and sync a document - db.save_document(&doc).await.unwrap(); - db.mark_synced(&doc.id).await.unwrap(); - - // Verify it's synced - let sync_status = get_sync_status(&db, doc.id).await; - assert_eq!(sync_status, "synced", "Document should start as synced"); - - // Delete the document - db.delete_document(&doc.id).await.unwrap(); - - // Verify deletion marks it as pending - let sync_status = get_sync_status(&db, doc.id).await; - assert_eq!( - sync_status, "pending", - "Deleted document should be marked as pending sync" - ); - - // Simulate server confirmation - db.mark_synced(&doc.id).await.unwrap(); - - // Verify it's now synced - let sync_status = get_sync_status(&db, doc.id).await; - assert_eq!( - sync_status, "synced", - "Deleted document should be marked as synced after confirmation" - ); -} - -#[tokio::test] -async fn test_count_documents() { - let db = setup_test_db().await; - let user_id = Uuid::new_v4(); - let doc = make_document(user_id, "Test Document", "Test content", 1); - let doc1 = make_document(user_id, "Test Document 1", "Test content 1", 1); - let doc2 = make_document(user_id, "Test Document 2", "Test content 2", 1); - // Create and sync a document - db.save_document(&doc).await.unwrap(); - db.mark_synced(&doc.id).await.unwrap(); - assert_eq!(db.count_documents().await.unwrap(), 1); - db.save_document(&doc1).await.unwrap(); - assert_eq!(db.count_documents().await.unwrap(), 2); - db.save_document(&doc2).await.unwrap(); - assert_eq!(db.count_documents().await.unwrap(), 3); -} - -#[tokio::test] -async fn test_count_documents_excludes_deleted() { - let db = setup_test_db().await; - let user_id = Uuid::new_v4(); - - // Create 3 documents - let doc1 = make_document(user_id, "Document 1", "Content 1", 1); - let doc2 = make_document(user_id, "Document 2", "Content 2", 1); - let doc3 = make_document(user_id, "Document 3", "Content 3", 1); - - db.save_document(&doc1).await.unwrap(); - db.save_document(&doc2).await.unwrap(); - db.save_document(&doc3).await.unwrap(); - - assert_eq!(db.count_documents().await.unwrap(), 3); - - // Delete one document - db.delete_document(&doc2.id).await.unwrap(); - assert_eq!( - db.count_documents().await.unwrap(), - 2, - "Deleted documents should not be included in count" - ); - - // Delete another - db.delete_document(&doc1.id).await.unwrap(); - assert_eq!( - db.count_documents().await.unwrap(), - 1, - "Count should decrease as documents are deleted" - ); - - // Delete the last one - db.delete_document(&doc3.id).await.unwrap(); - assert_eq!( - db.count_documents().await.unwrap(), - 0, - "Count should be 0 when all documents are deleted" - ); -} - -#[tokio::test] -async fn test_get_all_document_ids_include_deleted() { - let db = setup_test_db().await; - let user_id = Uuid::new_v4(); - - // Create 3 documents - let doc1 = make_document(user_id, "Document 1", "Content 1", 1); - let doc2 = make_document(user_id, "Document 2", "Content 2", 1); - let doc3 = make_document(user_id, "Document 3", "Content 3", 1); - - db.save_document(&doc1).await.unwrap(); - db.save_document(&doc2).await.unwrap(); - db.save_document(&doc3).await.unwrap(); - - // Delete one document - db.delete_document(&doc2.id).await.unwrap(); - - let live_ids = db.get_all_document_ids(false).await.unwrap(); - assert_eq!( - live_ids.len(), - 2, - "Live ids should exclude the deleted document" - ); - assert!(live_ids.contains(&doc1.id)); - assert!(live_ids.contains(&doc3.id)); - assert!( - !live_ids.contains(&doc2.id), - "Deleted document should not be included when include_deleted is false" - ); - - let all_ids = db.get_all_document_ids(true).await.unwrap(); - assert_eq!( - all_ids.len(), - 3, - "All ids should include the deleted document" - ); - assert!( - all_ids.contains(&doc2.id), - "Deleted document should be included when include_deleted is true" - ); -} - -#[tokio::test] -async fn test_attribution_persists_across_save_and_load() { - let db = setup_test_db().await; - let mut doc = make_document(Uuid::new_v4(), "Attributed", "body", 1); - doc.author_name = Some("Robert Rich".to_string()); - doc.visibility = Some("public".to_string()); - doc.provenance = Some(serde_json::json!({"copied_from": "abc"})); - - db.save_document(&doc).await.unwrap(); - let loaded = db.get_document(&doc.id).await.unwrap(); - - assert_eq!(loaded.author_name.as_deref(), Some("Robert Rich")); - assert_eq!(loaded.visibility.as_deref(), Some("public")); - assert_eq!(loaded.provenance, doc.provenance); -} - -/// Verifies that the title field is properly extracted from document content -/// when documents are saved to the client database. -#[tokio::test] -async fn test_title_extraction() { - let db = setup_test_db().await; - let user_id = Uuid::new_v4(); - - // Test 1: Document with title in content - let doc_with_title = make_document(user_id, "My Document", "Test content", 1); - db.save_document(&doc_with_title).await.unwrap(); - - let retrieved = db.get_document(&doc_with_title.id).await.unwrap(); - assert_eq!(retrieved.title, Some("My Document".to_string())); - - // Test 2: Document without title (should use datetime fallback) - let content_no_title = serde_json::json!({"text": "No title content"}); - let doc_no_title = Document { - id: Uuid::new_v4(), - user_id: Some(user_id), - content: content_no_title, - sync_revision: 1, - content_hash: None, - title: None, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }; - - db.save_document(&doc_no_title).await.unwrap(); - - let retrieved = db.get_document(&doc_no_title.id).await.unwrap(); - assert!(retrieved.title.is_some()); - let title = retrieved.title.unwrap(); - // Should have datetime format: YYYY-MM-DD|HH:MM:SS.mmm - assert!(title.contains('|'), "Title should contain pipe separator"); - assert!(title.contains('-'), "Title should contain date separator"); - - // Test 3: Very long title (should be truncated to 128 chars) - let long_title = "a".repeat(200); - let doc_long_title = make_document(user_id, &long_title, "Long title content", 1); - db.save_document(&doc_long_title).await.unwrap(); - - let retrieved = db.get_document(&doc_long_title.id).await.unwrap(); - assert_eq!(retrieved.title.as_ref().unwrap().len(), 128); - assert_eq!(retrieved.title, Some("a".repeat(128))); -} diff --git a/replicant-client/tests/ffi_events/mod.rs b/replicant-client/tests/ffi_events/mod.rs deleted file mode 100644 index a7c12ab..0000000 --- a/replicant-client/tests/ffi_events/mod.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! Engine setup and event capture shared by the FFI test suites. - -use replicant_client::events::EventType; -use replicant_client::ffi::{ - replicant_create, replicant_destroy_and_wait, replicant_process_events, - replicant_register_connection_callback, replicant_register_error_callback, - replicant_register_sync_callback, Replicant, SyncResult, -}; -use std::ffi::{c_char, c_void, CString}; -use std::sync::Mutex; -use std::time::{Duration, Instant}; - -/// Every sync, connection and error event an engine delivers, in order. -#[derive(Default)] -pub struct EventLog(Mutex<Vec<EventType>>); - -impl EventLog { - pub fn events(&self) -> Vec<EventType> { - self.0.lock().unwrap().clone() - } - - pub fn count(&self, event_type: EventType) -> usize { - self.events().iter().filter(|e| **e == event_type).count() - } - - pub fn position(&self, event_type: EventType) -> Option<usize> { - self.events().iter().position(|e| *e == event_type) - } - - fn push(context: *mut c_void, event_type: EventType) { - let log = unsafe { &*(context as *const EventLog) }; - log.0.lock().unwrap().push(event_type); - } -} - -extern "C" fn log_sync_event(event_type: EventType, _count: u64, context: *mut c_void) { - EventLog::push(context, event_type); -} - -extern "C" fn log_connection_event( - event_type: EventType, - _connected: bool, - _attempt: u32, - context: *mut c_void, -) { - EventLog::push(context, event_type); -} - -extern "C" fn log_error_event( - event_type: EventType, - _code: i32, - _message: *const c_char, - context: *mut c_void, -) { - EventLog::push(context, event_type); -} - -/// Creates an engine on the sqlite file `db_path`. A `user_id` gives it an -/// adopted identity, so with an API key it syncs with `server_url`. -pub unsafe fn create_engine( - db_path: &str, - server_url: &str, - email: &str, - api_key: &str, - api_secret: &str, - user_id: Option<&str>, -) -> *mut Replicant { - remove_db(db_path); - let db_url = CString::new(format!("sqlite:{}?mode=rwc", db_path)).unwrap(); - let server_url = CString::new(server_url).unwrap(); - let email = CString::new(email).unwrap(); - let api_key = CString::new(api_key).unwrap(); - let api_secret = CString::new(api_secret).unwrap(); - let user_id = user_id.map(|id| CString::new(id).unwrap()); - - replicant_create( - db_url.as_ptr(), - server_url.as_ptr(), - email.as_ptr(), - api_key.as_ptr(), - api_secret.as_ptr(), - user_id.as_ref().map_or(std::ptr::null(), |id| id.as_ptr()), - ) -} - -pub unsafe fn register_event_log(engine: *mut Replicant, log: &EventLog) { - let context = log as *const EventLog as *mut c_void; - assert_eq!( - replicant_register_sync_callback(engine, log_sync_event, context), - SyncResult::Success - ); - assert_eq!( - replicant_register_connection_callback(engine, log_connection_event, context), - SyncResult::Success - ); - assert_eq!( - replicant_register_error_callback(engine, log_error_event, context), - SyncResult::Success - ); -} - -/// Pumps events until `done` holds or `timeout` passes; returns whether `done` held. -pub unsafe fn pump_events_until( - engine: *mut Replicant, - timeout: Duration, - done: impl Fn() -> bool, -) -> bool { - let deadline = Instant::now() + timeout; - loop { - replicant_process_events(engine, std::ptr::null_mut()); - if done() { - return true; - } - if Instant::now() >= deadline { - return false; - } - std::thread::sleep(Duration::from_millis(20)); - } -} - -/// Destroys the engine, waits for its teardown, then deletes its database files. -pub unsafe fn destroy_and_remove_db(engine: *mut Replicant, db_path: &str) { - assert!( - replicant_destroy_and_wait(engine, 30_000), - "engine teardown did not finish" - ); - remove_db(db_path); -} - -fn remove_db(db_path: &str) { - for path in [ - db_path.to_string(), - format!("{}-wal", db_path), - format!("{}-shm", db_path), - ] { - let _ = std::fs::remove_file(path); - } -} diff --git a/replicant-client/tests/ffi_integration_tests.rs b/replicant-client/tests/ffi_integration_tests.rs deleted file mode 100644 index 08870ae..0000000 --- a/replicant-client/tests/ffi_integration_tests.rs +++ /dev/null @@ -1,1062 +0,0 @@ -//! Integration tests for FFI event callbacks -//! -//! These tests verify that the FFI interface works correctly and that -//! callbacks are properly invoked when events occur. - -use std::ffi::{c_char, c_void, CStr, CString}; -use std::ptr; -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use replicant_client::events::{EventOrigin, EventType}; -use replicant_client::ffi::{ - replicant_count_documents, replicant_count_pending_sync, replicant_create_document, - replicant_destroy, replicant_get_all_documents, replicant_get_document, replicant_get_user_id, - replicant_is_connected, replicant_process_events, replicant_register_connection_callback, - replicant_register_document_callback, replicant_register_error_callback, - replicant_register_sync_callback, replicant_string_free, replicant_update_document, Replicant, - SyncResult, -}; - -mod ffi_events; -use ffi_events::{ - create_engine, destroy_and_remove_db, pump_events_until, register_event_log, EventLog, -}; - -#[cfg(debug_assertions)] -use replicant_client::ffi_test::{replicant_emit_test_event, replicant_emit_test_event_burst}; - -/// Test data structure for capturing document callback invocations -#[derive(Debug, Default)] -struct DocumentCapture { - call_count: AtomicUsize, - last_event_type: Mutex<Option<EventType>>, - last_document_id: Mutex<Option<String>>, - last_title: Mutex<Option<String>>, - last_content: Mutex<Option<String>>, - last_user_id: Mutex<Option<String>>, - last_author_name: Mutex<Option<String>>, - last_visibility: Mutex<Option<String>>, - last_origin: Mutex<Option<EventOrigin>>, -} - -impl DocumentCapture { - fn new() -> Self { - Self::default() - } - - fn reset(&self) { - self.call_count.store(0, Ordering::SeqCst); - *self.last_event_type.lock().unwrap() = None; - *self.last_document_id.lock().unwrap() = None; - *self.last_title.lock().unwrap() = None; - *self.last_content.lock().unwrap() = None; - *self.last_user_id.lock().unwrap() = None; - *self.last_author_name.lock().unwrap() = None; - *self.last_visibility.lock().unwrap() = None; - *self.last_origin.lock().unwrap() = None; - } -} - -/// Test data structure for capturing sync callback invocations -#[derive(Debug, Default)] -struct SyncCapture { - call_count: AtomicUsize, - last_event_type: Mutex<Option<EventType>>, - last_document_count: Mutex<u64>, -} - -impl SyncCapture { - fn new() -> Self { - Self::default() - } -} - -/// Test data structure for capturing error callback invocations -#[derive(Debug, Default)] -struct ErrorCapture { - call_count: AtomicUsize, - last_error: Mutex<Option<String>>, -} - -impl ErrorCapture { - fn new() -> Self { - Self::default() - } -} - -/// Test data structure for capturing connection callback invocations -#[derive(Debug, Default)] -struct ConnectionCapture { - call_count: AtomicUsize, - last_event_type: Mutex<Option<EventType>>, - last_connected: Mutex<bool>, - last_attempt: Mutex<u32>, -} - -impl ConnectionCapture { - fn new() -> Self { - Self::default() - } -} - -extern "C" fn document_capture_callback( - event_type: EventType, - document_id: *const c_char, - title: *const c_char, - content: *const c_char, - user_id: *const c_char, - author_name: *const c_char, - visibility: *const c_char, - origin: EventOrigin, - context: *mut c_void, -) { - let capture = unsafe { &*(context as *const DocumentCapture) }; - *capture.last_origin.lock().unwrap() = Some(origin); - - capture.call_count.fetch_add(1, Ordering::SeqCst); - *capture.last_event_type.lock().unwrap() = Some(event_type); - - if !document_id.is_null() { - let doc_id = unsafe { CStr::from_ptr(document_id).to_string_lossy().to_string() }; - *capture.last_document_id.lock().unwrap() = Some(doc_id); - } - - if !title.is_null() { - let title_str = unsafe { CStr::from_ptr(title).to_string_lossy().to_string() }; - *capture.last_title.lock().unwrap() = Some(title_str); - } - - if !content.is_null() { - let content_str = unsafe { CStr::from_ptr(content).to_string_lossy().to_string() }; - *capture.last_content.lock().unwrap() = Some(content_str); - } - - if !user_id.is_null() { - let user_id_str = unsafe { CStr::from_ptr(user_id).to_string_lossy().to_string() }; - *capture.last_user_id.lock().unwrap() = Some(user_id_str); - } - - if !author_name.is_null() { - let author_name_str = unsafe { CStr::from_ptr(author_name).to_string_lossy().to_string() }; - *capture.last_author_name.lock().unwrap() = Some(author_name_str); - } - - if !visibility.is_null() { - let visibility_str = unsafe { CStr::from_ptr(visibility).to_string_lossy().to_string() }; - *capture.last_visibility.lock().unwrap() = Some(visibility_str); - } -} - -extern "C" fn sync_capture_callback( - event_type: EventType, - document_count: u64, - context: *mut c_void, -) { - let capture = unsafe { &*(context as *const SyncCapture) }; - capture.call_count.fetch_add(1, Ordering::SeqCst); - *capture.last_event_type.lock().unwrap() = Some(event_type); - *capture.last_document_count.lock().unwrap() = document_count; -} - -extern "C" fn error_capture_callback( - _event_type: EventType, - _error_code: i32, - error: *const c_char, - context: *mut c_void, -) { - let capture = unsafe { &*(context as *const ErrorCapture) }; - capture.call_count.fetch_add(1, Ordering::SeqCst); - - if !error.is_null() { - let error_str = unsafe { CStr::from_ptr(error).to_string_lossy().to_string() }; - *capture.last_error.lock().unwrap() = Some(error_str); - } -} - -extern "C" fn connection_capture_callback( - event_type: EventType, - connected: bool, - attempt_number: u32, - context: *mut c_void, -) { - let capture = unsafe { &*(context as *const ConnectionCapture) }; - capture.call_count.fetch_add(1, Ordering::SeqCst); - *capture.last_event_type.lock().unwrap() = Some(event_type); - *capture.last_connected.lock().unwrap() = connected; - *capture.last_attempt.lock().unwrap() = attempt_number; -} - -static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); - -/// A unique database path for one engine in this test process. -fn unique_db_path() -> String { - let unique_id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); - format!( - "/tmp/sync_client_test_{}_{}.db", - std::process::id(), - unique_id - ) -} - -/// Creates an engine on a fresh database; see `ffi_events::create_engine`. -unsafe fn create_test_engine_with( - db_path: &str, - server_url: &str, - user_id: Option<&str>, -) -> *mut Replicant { - create_engine( - db_path, - server_url, - "test-user@example.com", - "rpa_test_api_key_example_12345", - "rps_test_api_secret_example_67890", - user_id, - ) -} - -/// Creates a local-only engine (no identity, so it never connects). -unsafe fn create_test_engine() -> *mut Replicant { - create_test_engine_with(&unique_db_path(), "ws://localhost:8080/ws", None) -} - -#[test] -fn test_ffi_callback_registration() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - let capture = DocumentCapture::new(); - - // Register callback for all document events - let result = replicant_register_document_callback( - engine, - document_capture_callback, - &capture as *const DocumentCapture as *mut c_void, - -1, // All document events - ); - - assert_eq!(result, SyncResult::Success); - - replicant_destroy(engine); - } -} - -#[test] -fn test_ffi_callback_with_document_creation() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - let capture = DocumentCapture::new(); - - // Register callback for document events - let result = replicant_register_document_callback( - engine, - document_capture_callback, - &capture as *const DocumentCapture as *mut c_void, - 0, // DocumentCreated - ); - assert_eq!(result, SyncResult::Success); - - // Create a document (this should trigger the callback in offline mode) - let content = - CString::new(r#"{"title":"Test Document","content":"test data","type":"note"}"#) - .unwrap(); - let mut doc_id = [0u8; 37]; // UUID string + null terminator - - let create_result = - replicant_create_document(engine, content.as_ptr(), doc_id.as_mut_ptr() as *mut i8); - - assert_eq!(create_result, SyncResult::Success); - - // Process events to trigger callbacks - replicant_process_events(engine, ptr::null_mut()); - - // Verify callback was called - assert_eq!(capture.call_count.load(Ordering::SeqCst), 1); - assert_eq!( - *capture.last_event_type.lock().unwrap(), - Some(EventType::DocumentCreated) - ); - - let captured_title = capture.last_title.lock().unwrap(); - assert_eq!(captured_title.as_ref().unwrap(), "Test Document"); - - let captured_content = capture.last_content.lock().unwrap(); - assert!(captured_content.is_some()); - assert!(captured_content.as_ref().unwrap().contains("test data")); - - // Documents created offline are always assigned an owner user_id, so this - // must arrive on the callback. author_name/visibility are not seeded by - // replicant_create_document, so they merely need to arrive without crashing. - let captured_user_id = capture.last_user_id.lock().unwrap(); - assert!(captured_user_id.is_some()); - - // The host created this document itself, so the event must say so; - // otherwise a consumer cannot tell it apart from a remote change. - assert_eq!( - *capture.last_origin.lock().unwrap(), - Some(EventOrigin::Local) - ); - - // Verify replicant_get_user_id returns the same engine-frozen user UUID - let mut out_user_id: *mut c_char = ptr::null_mut(); - let get_user_id_result = replicant_get_user_id(engine, &mut out_user_id); - assert_eq!(get_user_id_result, SyncResult::Success); - assert!(!out_user_id.is_null()); - let engine_user_id = CStr::from_ptr(out_user_id).to_string_lossy().to_string(); - assert_eq!(engine_user_id, captured_user_id.as_ref().unwrap().clone()); - replicant_string_free(out_user_id); - - replicant_destroy(engine); - } -} - -#[test] -fn test_ffi_callback_filtering() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - let created_capture = DocumentCapture::new(); - let updated_capture = DocumentCapture::new(); - - // Register callback only for created events - let result1 = replicant_register_document_callback( - engine, - document_capture_callback, - &created_capture as *const DocumentCapture as *mut c_void, - 0, // DocumentCreated - ); - assert_eq!(result1, SyncResult::Success); - - // Register callback only for updated events - let result2 = replicant_register_document_callback( - engine, - document_capture_callback, - &updated_capture as *const DocumentCapture as *mut c_void, - 1, // DocumentUpdated - ); - assert_eq!(result2, SyncResult::Success); - - #[cfg(debug_assertions)] - { - // Test with debug event emission - replicant_emit_test_event(engine, 0); // DocumentCreated - replicant_process_events(engine, ptr::null_mut()); - - assert_eq!(created_capture.call_count.load(Ordering::SeqCst), 1); - assert_eq!(updated_capture.call_count.load(Ordering::SeqCst), 0); - - replicant_emit_test_event(engine, 1); // DocumentUpdated - replicant_process_events(engine, ptr::null_mut()); - - assert_eq!(created_capture.call_count.load(Ordering::SeqCst), 1); - assert_eq!(updated_capture.call_count.load(Ordering::SeqCst), 1); - - replicant_emit_test_event(engine, 2); // DocumentDeleted (neither should trigger) - replicant_process_events(engine, ptr::null_mut()); - - assert_eq!(created_capture.call_count.load(Ordering::SeqCst), 1); - assert_eq!(updated_capture.call_count.load(Ordering::SeqCst), 1); - } - - replicant_destroy(engine); - } -} - -#[test] -fn test_ffi_multiple_callbacks() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - let capture1 = SyncCapture::new(); - let capture2 = SyncCapture::new(); - let capture3 = SyncCapture::new(); - - // Register three callbacks for sync events - replicant_register_sync_callback( - engine, - sync_capture_callback, - &capture1 as *const SyncCapture as *mut c_void, - ); - - replicant_register_sync_callback( - engine, - sync_capture_callback, - &capture2 as *const SyncCapture as *mut c_void, - ); - - replicant_register_sync_callback( - engine, - sync_capture_callback, - &capture3 as *const SyncCapture as *mut c_void, - ); - - #[cfg(debug_assertions)] - { - // Emit a test event - replicant_emit_test_event(engine, 3); // SyncStarted - replicant_process_events(engine, ptr::null_mut()); - - // All three should have been called - assert_eq!(capture1.call_count.load(Ordering::SeqCst), 1); - assert_eq!(capture2.call_count.load(Ordering::SeqCst), 1); - assert_eq!(capture3.call_count.load(Ordering::SeqCst), 1); - - assert_eq!( - *capture1.last_event_type.lock().unwrap(), - Some(EventType::SyncStarted) - ); - assert_eq!( - *capture2.last_event_type.lock().unwrap(), - Some(EventType::SyncStarted) - ); - assert_eq!( - *capture3.last_event_type.lock().unwrap(), - Some(EventType::SyncStarted) - ); - } - - replicant_destroy(engine); - } -} - -#[test] -#[cfg(debug_assertions)] -fn test_ffi_all_event_types() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - let doc_capture = DocumentCapture::new(); - let sync_capture = SyncCapture::new(); - let error_capture = ErrorCapture::new(); - let conn_capture = ConnectionCapture::new(); - - // Register for all event types - replicant_register_document_callback( - engine, - document_capture_callback, - &doc_capture as *const DocumentCapture as *mut c_void, - -1, - ); - replicant_register_sync_callback( - engine, - sync_capture_callback, - &sync_capture as *const SyncCapture as *mut c_void, - ); - replicant_register_error_callback( - engine, - error_capture_callback, - &error_capture as *const ErrorCapture as *mut c_void, - ); - replicant_register_connection_callback( - engine, - connection_capture_callback, - &conn_capture as *const ConnectionCapture as *mut c_void, - ); - - // Test document events (0-2) - for event_type in 0..3 { - doc_capture.reset(); - replicant_emit_test_event(engine, event_type); - replicant_process_events(engine, ptr::null_mut()); - assert_eq!( - doc_capture.call_count.load(Ordering::SeqCst), - 1, - "Document event type {} was not emitted", - event_type - ); - } - - // Test sync events (3-4) - replicant_emit_test_event(engine, 3); // SyncStarted - replicant_process_events(engine, ptr::null_mut()); - assert_eq!(sync_capture.call_count.load(Ordering::SeqCst), 1); - - replicant_emit_test_event(engine, 4); // SyncCompleted - replicant_process_events(engine, ptr::null_mut()); - assert_eq!(sync_capture.call_count.load(Ordering::SeqCst), 2); - - // Test error event (5) - replicant_emit_test_event(engine, 5); // SyncError - replicant_process_events(engine, ptr::null_mut()); - assert_eq!(error_capture.call_count.load(Ordering::SeqCst), 1); - - // Test connection events (7-9) - replicant_emit_test_event(engine, 7); // ConnectionLost - replicant_process_events(engine, ptr::null_mut()); - assert_eq!(conn_capture.call_count.load(Ordering::SeqCst), 1); - - replicant_emit_test_event(engine, 8); // ConnectionAttempted - replicant_process_events(engine, ptr::null_mut()); - assert_eq!(conn_capture.call_count.load(Ordering::SeqCst), 2); - - replicant_emit_test_event(engine, 9); // ConnectionSucceeded - assert!(pump_events_until(engine, Duration::from_secs(1), || { - conn_capture.call_count.load(Ordering::SeqCst) == 3 - })); - - replicant_destroy(engine); - } -} - -#[test] -#[cfg(debug_assertions)] -fn test_ffi_event_data_integrity() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - let sync_capture = SyncCapture::new(); - let error_capture = ErrorCapture::new(); - let conn_capture = ConnectionCapture::new(); - - replicant_register_sync_callback( - engine, - sync_capture_callback, - &sync_capture as *const SyncCapture as *mut c_void, - ); - replicant_register_error_callback( - engine, - error_capture_callback, - &error_capture as *const ErrorCapture as *mut c_void, - ); - replicant_register_connection_callback( - engine, - connection_capture_callback, - &conn_capture as *const ConnectionCapture as *mut c_void, - ); - - // Test sync completed event (has document count) - replicant_emit_test_event(engine, 4); // SyncCompleted - replicant_process_events(engine, ptr::null_mut()); - - assert_eq!(sync_capture.call_count.load(Ordering::SeqCst), 1); - assert_eq!( - *sync_capture.last_event_type.lock().unwrap(), - Some(EventType::SyncCompleted) - ); - assert_eq!(*sync_capture.last_document_count.lock().unwrap(), 5); // Test uses fixed value of 5 - - // Test sync error event (has error message) - replicant_emit_test_event(engine, 5); // SyncError - replicant_process_events(engine, ptr::null_mut()); - - assert_eq!(error_capture.call_count.load(Ordering::SeqCst), 1); - let error_msg = error_capture.last_error.lock().unwrap(); - assert!(error_msg.is_some()); - assert!(error_msg.as_ref().unwrap().contains("Test error message")); - - // Test connection event - replicant_emit_test_event(engine, 7); // ConnectionLost - replicant_process_events(engine, ptr::null_mut()); - - assert_eq!(conn_capture.call_count.load(Ordering::SeqCst), 1); - assert_eq!( - *conn_capture.last_event_type.lock().unwrap(), - Some(EventType::ConnectionLost) - ); - - replicant_destroy(engine); - } -} - -#[test] -#[cfg(debug_assertions)] -fn test_ffi_event_burst() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - let capture = DocumentCapture::new(); - - replicant_register_document_callback( - engine, - document_capture_callback, - &capture as *const DocumentCapture as *mut c_void, - 0, // Only DocumentCreated events - ); - - // Emit a burst of 5 events - replicant_emit_test_event_burst(engine, 5); - replicant_process_events(engine, ptr::null_mut()); - - // Should have received all 5 events - assert_eq!(capture.call_count.load(Ordering::SeqCst), 5); - assert_eq!( - *capture.last_event_type.lock().unwrap(), - Some(EventType::DocumentCreated) - ); - - // The last event should have sequence 4 in the title - let last_title = capture.last_title.lock().unwrap(); - assert!(last_title.is_some()); - assert!(last_title.as_ref().unwrap().contains("4")); // Sequence is 0-indexed - - replicant_destroy(engine); - } -} - -#[test] -fn test_ffi_null_pointer_safety() { - unsafe { - // Test that null pointers are handled safely - - // Test null engine for document callback - let result = replicant_register_document_callback( - ptr::null_mut(), - document_capture_callback, - ptr::null_mut(), - -1, - ); - assert_eq!(result, SyncResult::ErrorInvalidInput); - - // Test with valid engine but invalid filter - let engine = create_test_engine(); - if !engine.is_null() { - let result = replicant_register_document_callback( - engine, - document_capture_callback, - ptr::null_mut(), - 999, // Invalid event type - ); - assert_eq!(result, SyncResult::ErrorInvalidInput); - - replicant_destroy(engine); - } - } -} - -#[test] -fn test_ffi_engine_lifecycle() { - unsafe { - // Test creating and destroying multiple engines - for _ in 0..3 { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - let capture = DocumentCapture::new(); - - let result = replicant_register_document_callback( - engine, - document_capture_callback, - &capture as *const DocumentCapture as *mut c_void, - -1, - ); - assert_eq!(result, SyncResult::Success); - - replicant_destroy(engine); - // After destruction, the engine pointer should not be used - } - } -} - -#[test] -fn test_ffi_callback_thread_safety() { - use std::thread; - use std::time::Duration; - - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - let capture = Arc::new(SyncCapture::new()); - let capture_clone = capture.clone(); - - // Register callback - replicant_register_sync_callback( - engine, - sync_capture_callback, - Arc::as_ptr(&capture_clone) as *mut c_void, - ); - - #[cfg(debug_assertions)] - { - // Emit events from multiple threads (simulated stress test) - let engine_ptr = engine as usize; // Store as usize for thread safety - - let handles: Vec<_> = (0..3) - .map(|_i| { - let _capture = capture.clone(); - thread::spawn(move || { - let engine = engine_ptr as *mut Replicant; - for _ in 0..2 { - replicant_emit_test_event(engine, 3); // SyncStarted - thread::sleep(Duration::from_millis(1)); - } - }) - }) - .collect(); - - // Wait for all threads - for handle in handles { - handle.join().unwrap(); - } - - // Process all queued events - replicant_process_events(engine, ptr::null_mut()); - - // Should have received 6 events total (3 threads × 2 events each) - assert_eq!(capture.call_count.load(Ordering::SeqCst), 6); - } - - replicant_destroy(engine); - } -} - -// ============================================================================= -// Tests for Read APIs (get_document, get_all_documents, count_documents, etc.) -// ============================================================================= - -#[test] -fn test_ffi_get_document() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - // Create a document first - let content = - CString::new(r#"{"title":"Test Get Document","data":"some content"}"#).unwrap(); - let mut doc_id_buf = [0u8; 37]; - - let create_result = - replicant_create_document(engine, content.as_ptr(), doc_id_buf.as_mut_ptr() as *mut i8); - assert_eq!(create_result, SyncResult::Success); - - let doc_id = CStr::from_ptr(doc_id_buf.as_ptr() as *const i8) - .to_string_lossy() - .to_string(); - - // Now retrieve the document - let doc_id_cstr = CString::new(doc_id.clone()).unwrap(); - let mut out_content: *mut i8 = ptr::null_mut(); - - let get_result = replicant_get_document(engine, doc_id_cstr.as_ptr(), &mut out_content); - - assert_eq!(get_result, SyncResult::Success); - assert!(!out_content.is_null()); - - let content_str = CStr::from_ptr(out_content).to_string_lossy().to_string(); - assert!(content_str.contains("Test Get Document")); - assert!(content_str.contains("some content")); - assert!(content_str.contains(&doc_id)); // Should include the document ID - - // Free the returned string - replicant_string_free(out_content); - - replicant_destroy(engine); - } -} - -#[test] -fn test_ffi_get_document_not_found() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - // Try to get a non-existent document - let fake_id = CString::new("00000000-0000-0000-0000-000000000000").unwrap(); - let mut out_content: *mut i8 = ptr::null_mut(); - - let result = replicant_get_document(engine, fake_id.as_ptr(), &mut out_content); - - assert_eq!(result, SyncResult::ErrorInvalidInput); - - replicant_destroy(engine); - } -} - -#[test] -fn test_ffi_get_all_documents_empty() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - let mut out_documents: *mut i8 = ptr::null_mut(); - - let result = replicant_get_all_documents(engine, &mut out_documents); - - assert_eq!(result, SyncResult::Success); - assert!(!out_documents.is_null()); - - let docs_str = CStr::from_ptr(out_documents).to_string_lossy().to_string(); - assert_eq!(docs_str, "[]"); // Empty array - - replicant_string_free(out_documents); - replicant_destroy(engine); - } -} - -#[test] -fn test_ffi_get_all_documents_with_data() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - // Create multiple documents - let mut doc_ids: Vec<String> = Vec::new(); - for i in 0..3 { - let content = - CString::new(format!(r#"{{"title":"Document {}","index":{}}}"#, i, i)).unwrap(); - let mut doc_id_buf = [0u8; 37]; - - let result = replicant_create_document( - engine, - content.as_ptr(), - doc_id_buf.as_mut_ptr() as *mut i8, - ); - assert_eq!(result, SyncResult::Success); - - let doc_id = CStr::from_ptr(doc_id_buf.as_ptr() as *const i8) - .to_string_lossy() - .to_string(); - doc_ids.push(doc_id); - } - - // Get all documents - let mut out_documents: *mut i8 = ptr::null_mut(); - let result = replicant_get_all_documents(engine, &mut out_documents); - - assert_eq!(result, SyncResult::Success); - assert!(!out_documents.is_null()); - - let docs_str = CStr::from_ptr(out_documents).to_string_lossy().to_string(); - - // Verify all document IDs are present - for doc_id in &doc_ids { - assert!( - docs_str.contains(doc_id), - "Document {} not found in result", - doc_id - ); - } - - // Verify it's a JSON array with 3 documents - assert!(docs_str.starts_with('[')); - assert!(docs_str.ends_with(']')); - assert!(docs_str.contains("Document 0")); - assert!(docs_str.contains("Document 1")); - assert!(docs_str.contains("Document 2")); - - replicant_string_free(out_documents); - replicant_destroy(engine); - } -} - -#[test] -fn test_ffi_count_documents() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - // Initially should be 0 - let mut count: u64 = 999; - let result = replicant_count_documents(engine, &mut count); - assert_eq!(result, SyncResult::Success); - assert_eq!(count, 0); - - // Create some documents - for i in 0..5 { - let content = CString::new(format!(r#"{{"title":"Doc {}"}}"#, i)).unwrap(); - let mut doc_id_buf = [0u8; 37]; - let create_result = replicant_create_document( - engine, - content.as_ptr(), - doc_id_buf.as_mut_ptr() as *mut i8, - ); - assert_eq!(create_result, SyncResult::Success); - } - - // Count should now be 5 - let result = replicant_count_documents(engine, &mut count); - assert_eq!(result, SyncResult::Success); - assert_eq!(count, 5); - - replicant_destroy(engine); - } -} - -#[test] -fn test_ffi_is_connected() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - // Should be disconnected (no server running) - let connected = replicant_is_connected(engine); - assert!(!connected); - - replicant_destroy(engine); - } -} - -#[test] -fn test_ffi_count_pending_sync() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - // Initially should have no pending syncs - let mut pending_count: u64 = 999; - let result = replicant_count_pending_sync(engine, &mut pending_count); - assert_eq!(result, SyncResult::Success); - assert_eq!(pending_count, 0); - - // Create a document (should be pending sync since we're offline) - let content = CString::new(r#"{"title":"Pending Doc"}"#).unwrap(); - let mut doc_id_buf = [0u8; 37]; - let create_result = - replicant_create_document(engine, content.as_ptr(), doc_id_buf.as_mut_ptr() as *mut i8); - assert_eq!(create_result, SyncResult::Success); - - // Should now have 1 pending sync - let result = replicant_count_pending_sync(engine, &mut pending_count); - assert_eq!(result, SyncResult::Success); - assert_eq!(pending_count, 1); - - replicant_destroy(engine); - } -} - -#[test] -fn test_ffi_read_api_null_safety() { - unsafe { - // Test null engine pointer for all read APIs - let mut out_content: *mut i8 = ptr::null_mut(); - let mut out_count: u64 = 0; - let fake_id = CString::new("00000000-0000-0000-0000-000000000000").unwrap(); - - // get_document with null engine - let result = replicant_get_document(ptr::null_mut(), fake_id.as_ptr(), &mut out_content); - assert_eq!(result, SyncResult::ErrorInvalidInput); - - // get_all_documents with null engine - let result = replicant_get_all_documents(ptr::null_mut(), &mut out_content); - assert_eq!(result, SyncResult::ErrorInvalidInput); - - // count_documents with null engine - let result = replicant_count_documents(ptr::null_mut(), &mut out_count); - assert_eq!(result, SyncResult::ErrorInvalidInput); - - // count_pending_sync with null engine - let result = replicant_count_pending_sync(ptr::null_mut(), &mut out_count); - assert_eq!(result, SyncResult::ErrorInvalidInput); - - // is_connected with null engine should return false - let connected = replicant_is_connected(ptr::null_mut()); - assert!(!connected); - } -} - -#[test] -fn test_ffi_update_and_get_document() { - unsafe { - let engine = create_test_engine(); - assert!(!engine.is_null(), "Failed to create sync engine"); - - // Create a document - let content = CString::new(r#"{"title":"Original Title","value":1}"#).unwrap(); - let mut doc_id_buf = [0u8; 37]; - - let create_result = - replicant_create_document(engine, content.as_ptr(), doc_id_buf.as_mut_ptr() as *mut i8); - assert_eq!(create_result, SyncResult::Success); - - let doc_id = CStr::from_ptr(doc_id_buf.as_ptr() as *const i8) - .to_string_lossy() - .to_string(); - let doc_id_cstr = CString::new(doc_id.clone()).unwrap(); - - // Update the document - let updated_content = CString::new(r#"{"title":"Updated Title","value":42}"#).unwrap(); - let update_result = - replicant_update_document(engine, doc_id_cstr.as_ptr(), updated_content.as_ptr()); - assert_eq!(update_result, SyncResult::Success); - - // Retrieve and verify the content field has updated values - let mut out_content: *mut i8 = ptr::null_mut(); - let get_result = replicant_get_document(engine, doc_id_cstr.as_ptr(), &mut out_content); - assert_eq!(get_result, SyncResult::Success); - - let content_str = CStr::from_ptr(out_content).to_string_lossy().to_string(); - let doc: serde_json::Value = serde_json::from_str(&content_str).unwrap(); - - let doc_content = &doc["content"]; - assert_eq!(doc_content["title"], "Updated Title"); - assert_eq!(doc_content["value"], 42); - - replicant_string_free(out_content); - replicant_destroy(engine); - } -} - -#[test] -fn test_ffi_offline_startup_reports_no_connection_or_sync() { - unsafe { - // Nothing listens on port 1, so every connect is refused at once. - let db_path = unique_db_path(); - let user_id = uuid::Uuid::new_v4().to_string(); - let engine = create_test_engine_with( - &db_path, - "ws://127.0.0.1:1/socket/websocket", - Some(&user_id), - ); - assert!(!engine.is_null(), "Failed to create sync engine"); - - let log = EventLog::default(); - register_event_log(engine, &log); - - // The start-up connect fails, then the reconnect monitor's first try does. - let retried = pump_events_until(engine, Duration::from_secs(10), || { - log.count(EventType::SyncError) >= 2 - }); - assert!(retried, "both connects should fail: {:?}", log.events()); - pump_events_until(engine, Duration::from_millis(200), || false); - - assert_eq!( - log.position(EventType::ConnectionSucceeded), - None, - "{:?}", - log.events() - ); - assert_eq!( - log.position(EventType::SyncCompleted), - None, - "{:?}", - log.events() - ); - assert!(!replicant_is_connected(engine)); - - destroy_and_remove_db(engine, &db_path); - } -} - -#[test] -fn test_ffi_local_only_startup_reports_no_connection_or_sync() { - unsafe { - let db_path = unique_db_path(); - let engine = create_test_engine_with(&db_path, "ws://localhost:8080/ws", None); - assert!(!engine.is_null(), "Failed to create sync engine"); - - let log = EventLog::default(); - register_event_log(engine, &log); - - // A local-only start has no event marking its end, so watch a short window. - pump_events_until(engine, Duration::from_millis(500), || false); - - assert_eq!( - log.count(EventType::ConnectionSucceeded), - 0, - "{:?}", - log.events() - ); - assert_eq!(log.count(EventType::SyncCompleted), 0, "{:?}", log.events()); - - destroy_and_remove_db(engine, &db_path); - } -} diff --git a/replicant-client/tests/ffi_lifecycle_tests.rs b/replicant-client/tests/ffi_lifecycle_tests.rs new file mode 100644 index 0000000..7d701b3 --- /dev/null +++ b/replicant-client/tests/ffi_lifecycle_tests.rs @@ -0,0 +1,267 @@ +//! Plugin-host lifecycle through the C API: destroy never blocks the caller, and a finished +//! teardown leaves no thread behind. Serial: the thread counts are process-wide. + +use std::ffi::{c_char, CString}; +use std::path::Path; +use std::ptr; +use std::time::{Duration, Instant}; + +use replicant_client::ffi::*; +use replicant_client::{host, secret_store}; +use serial_test::serial; + +/// Live thread count of this process; `None` (said loudly) where it cannot be probed. +#[cfg(windows)] +fn live_thread_count() -> Option<usize> { + let pid = std::process::id(); + let out = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + &format!("(Get-Process -Id {pid}).Threads.Count"), + ]) + .output(); + + let out = match out { + Ok(out) => out, + Err(e) => { + eprintln!( + "!! THREAD-COUNT ASSERTIONS SKIPPED: could not run the probe \ + (powershell: {e}) — the thread-lifetime guarantee is NOT checked \ + on this run" + ); + return None; + } + }; + + let stdout = String::from_utf8_lossy(&out.stdout); + match stdout.trim().parse() { + Ok(count) => Some(count), + Err(e) => { + eprintln!( + "!! THREAD-COUNT ASSERTIONS SKIPPED: the probe returned {:?} ({e}), \ + stderr {:?} — the thread-lifetime guarantee is NOT checked on this run", + stdout.trim(), + String::from_utf8_lossy(&out.stderr) + ); + None + } + } +} + +#[cfg(target_os = "linux")] +fn live_thread_count() -> Option<usize> { + match std::fs::read_dir("/proc/self/task") { + Ok(entries) => Some(entries.count()), + Err(e) => { + eprintln!( + "!! THREAD-COUNT ASSERTIONS SKIPPED: could not read /proc/self/task \ + ({e}) — the thread-lifetime guarantee is NOT checked on this run" + ); + None + } + } +} + +#[cfg(not(any(windows, target_os = "linux")))] +fn live_thread_count() -> Option<usize> { + // No cheap dependency-free thread enumeration here; the timing assertions + // still run, the thread-count ones are skipped. + None +} + +/// Waits for the process's thread count to stop moving (two equal samples), then returns it. +fn quiesce_threads() -> Option<usize> { + let mut last = live_thread_count()?; + let deadline = Instant::now() + Duration::from_secs(15); + loop { + std::thread::sleep(Duration::from_millis(100)); + let now = live_thread_count()?; + if now == last || Instant::now() >= deadline { + return Some(now); + } + last = now; + } +} + +/// A TCP listener that accepts connections and never answers, so a WebSocket handshake stalls. +/// The listener thread outlives the test; take thread baselines after calling this. +fn stalled_server() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + std::thread::Builder::new() + .name("stalled-server".to_string()) + .spawn(move || { + // Hold every accepted connection open, answering nothing. + let mut held = Vec::new(); + for stream in listener.incoming() { + match stream { + Ok(stream) => held.push(stream), + Err(_) => break, + } + } + }) + .expect("spawn"); + port +} + +fn c(text: &str) -> CString { + CString::new(text).unwrap() +} + +fn open(dir: &Path, server_url: &str) -> *mut Replicant { + let strings = [ + c(dir.to_str().unwrap()), + c("replicant.sqlite3"), + c(server_url), + c("a@b.c"), + c("Test Host"), + c("1.0"), + ]; + let config = ReplicantConfig { + struct_size: std::mem::size_of::<ReplicantConfig>() as u32, + data_dir: strings[0].as_ptr(), + database_file: strings[1].as_ptr(), + server_url: strings[2].as_ptr(), + email: strings[3].as_ptr(), + host_app: strings[4].as_ptr(), + host_version: strings[5].as_ptr(), + list_merge: 0, + list_merge_rules_json: ptr::null(), + title_pointer: ptr::null(), + }; + let mut handle = ptr::null_mut(); + assert_eq!( + unsafe { replicant_create(&config, &mut handle) }, + SyncResult::Success + ); + handle +} + +fn sign_in(dir: &Path) { + secret_store::store( + dir, + &secret_store::Credentials { + api_key: "rpa_k".into(), + secret: "rps_s".into(), + user_id: uuid::Uuid::new_v4(), + email: Some("a@b.c".into()), + }, + ) + .unwrap(); + host::credentials_changed(dir); +} + +#[test] +#[serial] +fn create_destroy_cycles_leave_no_threads_behind() { + let baseline = quiesce_threads(); + let dir = tempfile::tempdir().unwrap(); + for cycle in 0..20 { + let handle = open(dir.path(), "ws://127.0.0.1:9"); + assert!( + unsafe { replicant_destroy_and_wait(handle, 5_000) }, + "cycle {cycle}: the teardown did not finish" + ); + } + match (baseline, live_thread_count()) { + (Some(baseline), Some(now)) => assert!( + now <= baseline, + "{now} threads alive after 20 cycles; baseline {baseline}" + ), + _ => eprintln!("thread enumeration unavailable here; only the waits were checked"), + } +} + +#[test] +#[serial] +fn destroy_returns_at_once_while_the_engine_dials_a_silent_server() { + let port = stalled_server(); + let dir = tempfile::tempdir().unwrap(); + sign_in(dir.path()); + let handle = open(dir.path(), &format!("ws://127.0.0.1:{port}")); + std::thread::sleep(Duration::from_millis(300)); + let started = Instant::now(); + unsafe { replicant_destroy(handle) }; + assert!( + started.elapsed() < Duration::from_secs(1), + "destroy blocked for {:?}", + started.elapsed() + ); + // Let the detached teardown finish before the next test takes a thread baseline. + std::thread::sleep(Duration::from_secs(3)); +} + +#[test] +#[serial] +fn destroy_and_wait_finishes_within_the_join_bound_while_dialling() { + let port = stalled_server(); + let dir = tempfile::tempdir().unwrap(); + sign_in(dir.path()); + let handle = open(dir.path(), &format!("ws://127.0.0.1:{port}")); + std::thread::sleep(Duration::from_millis(300)); + let started = Instant::now(); + assert!(unsafe { replicant_destroy_and_wait(handle, 10_000) }); + assert!( + started.elapsed() < Duration::from_secs(4), + "the teardown waited on the network: {:?}", + started.elapsed() + ); +} + +#[test] +#[serial] +fn destroy_of_one_of_two_handles_leaves_the_other_working() { + let dir = tempfile::tempdir().unwrap(); + let staying = open(dir.path(), "ws://127.0.0.1:9"); + let leaving = open(dir.path(), "ws://127.0.0.1:9"); + let started = Instant::now(); + unsafe { replicant_destroy(leaving) }; + assert!(started.elapsed() < Duration::from_secs(1)); + let content = c("{}"); + let mut id = [0 as c_char; 37]; + assert_eq!( + unsafe { replicant_create_document(staying, content.as_ptr(), id.as_mut_ptr()) }, + SyncResult::Success + ); + assert!(unsafe { replicant_destroy_and_wait(staying, 10_000) }); +} + +/// Opens a one-connection pool on `path` and takes SQLite's write lock with `BEGIN IMMEDIATE`. +async fn hold_write_lock(path: &Path) -> sqlx::pool::PoolConnection<sqlx::Sqlite> { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect_with(sqlx::sqlite::SqliteConnectOptions::new().filename(path)) + .await + .unwrap(); + let mut connection = pool.acquire().await.unwrap(); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *connection) + .await + .unwrap(); + connection +} + +async fn release(mut connection: sqlx::pool::PoolConnection<sqlx::Sqlite>) { + sqlx::query("ROLLBACK") + .execute(&mut *connection) + .await + .unwrap(); +} + +#[test] +#[serial] +fn destroy_returns_at_once_while_another_connection_holds_the_write_lock() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path(), "ws://127.0.0.1:9"); + let runtime = tokio::runtime::Runtime::new().unwrap(); + // A second connection takes SQLite's write lock and keeps it while the handle goes. + let lock_holder = runtime.block_on(hold_write_lock(&dir.path().join("replicant.sqlite3"))); + let started = Instant::now(); + unsafe { replicant_destroy(handle) }; + let elapsed = started.elapsed(); + // Released before the assert: a pooled connection dropped while panicking aborts the binary. + runtime.block_on(release(lock_holder)); + assert!(elapsed < Duration::from_secs(1), "{elapsed:?}"); +} diff --git a/replicant-client/tests/ffi_tests.rs b/replicant-client/tests/ffi_tests.rs new file mode 100644 index 0000000..9b07835 --- /dev/null +++ b/replicant-client/tests/ffi_tests.rs @@ -0,0 +1,1578 @@ +//! The C API end to end, offline: nothing here needs a server. + +use std::ffi::{c_char, c_void, CStr, CString}; +use std::path::Path; +use std::ptr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use replicant_client::events::{EventOrigin, EventType}; +use replicant_client::ffi::*; +use replicant_client::secret_store; +use serde_json::{json, Value}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePool}; +use uuid::Uuid; + +const OFFLINE: &str = "ws://127.0.0.1:9"; +const DB_FILE: &str = "replicant.sqlite3"; + +type Log = Mutex<Vec<String>>; + +fn c(text: &str) -> CString { + CString::new(text).unwrap() +} + +fn read(value: *const c_char) -> String { + if value.is_null() { + "null".into() + } else { + unsafe { CStr::from_ptr(value) } + .to_string_lossy() + .into_owned() + } +} + +fn context(log: &Log) -> *mut c_void { + log as *const Log as *mut c_void +} + +fn log(context: *mut c_void) -> &'static Log { + unsafe { &*(context as *const Log) } +} + +extern "C" fn on_document( + event_type: EventType, + document_id: *const c_char, + title: *const c_char, + _content: *const c_char, + _owner_id: *const c_char, + _author_id: *const c_char, + visibility: *const c_char, + read_only: bool, + origin: EventOrigin, + context: *mut c_void, +) { + log(context).lock().unwrap().push(format!( + "{event_type:?} {} {} {} {read_only} {origin:?}", + read(document_id), + read(title), + read(visibility) + )); +} + +extern "C" fn on_error( + event_type: EventType, + error_code: i32, + error: *const c_char, + document_id: *const c_char, + scope: *const c_char, + fatal: bool, + recovered_id: i64, + context: *mut c_void, +) { + log(context).lock().unwrap().push(format!( + "{event_type:?} {error_code} {} {} {} {fatal} {recovered_id}", + read(error), + read(document_id), + read(scope) + )); +} + +fn create(dir: &Path, server_url: &str) -> (SyncResult, *mut Replicant) { + create_with_lists(dir, server_url, 0, None) +} + +fn create_with_lists( + dir: &Path, + server_url: &str, + list_merge: i32, + rules: Option<&str>, +) -> (SyncResult, *mut Replicant) { + let rules = rules.map(c); + create_with_config(dir, server_url, |config| { + config.list_merge = list_merge; + config.list_merge_rules_json = rules.as_ref().map_or(ptr::null(), |rules| rules.as_ptr()); + }) +} + +/// `create` with a config the test can change before the call. +fn create_with( + dir: &Path, + change: impl FnOnce(&mut ReplicantConfig), +) -> (SyncResult, *mut Replicant) { + create_with_config(dir, OFFLINE, change) +} + +fn create_with_config( + dir: &Path, + server_url: &str, + change: impl FnOnce(&mut ReplicantConfig), +) -> (SyncResult, *mut Replicant) { + let strings = [ + c(dir.to_str().unwrap()), + c(DB_FILE), + c(server_url), + c("a@b.c"), + c("Test Host"), + c("1.0"), + c("/title"), + ]; + let mut config = ReplicantConfig { + struct_size: std::mem::size_of::<ReplicantConfig>() as u32, + data_dir: strings[0].as_ptr(), + database_file: strings[1].as_ptr(), + server_url: strings[2].as_ptr(), + email: strings[3].as_ptr(), + host_app: strings[4].as_ptr(), + host_version: strings[5].as_ptr(), + list_merge: 0, + list_merge_rules_json: ptr::null(), + title_pointer: strings[6].as_ptr(), + }; + change(&mut config); + let mut handle = ptr::null_mut(); + let result = unsafe { replicant_create(&config, &mut handle) }; + (result, handle) +} + +fn open(dir: &Path) -> *mut Replicant { + let (result, handle) = create(dir, OFFLINE); + assert_eq!(result, SyncResult::Success); + handle +} + +fn close(handle: *mut Replicant) { + assert!(unsafe { replicant_destroy_and_wait(handle, 10_000) }); +} + +fn take_string(out: *mut c_char) -> String { + let text = read(out); + unsafe { replicant_string_free(out) }; + text +} + +fn create_doc(handle: *mut Replicant, json: &str) -> String { + let content = c(json); + let mut id = [0 as c_char; 37]; + assert_eq!( + unsafe { replicant_create_document(handle, content.as_ptr(), id.as_mut_ptr()) }, + SyncResult::Success + ); + read(id.as_ptr()) +} + +fn update(handle: *mut Replicant, id: &str, json: &str) -> SyncResult { + unsafe { replicant_update_document(handle, c(id).as_ptr(), c(json).as_ptr()) } +} + +fn get_doc(handle: *mut Replicant, id: &str) -> Result<Value, SyncResult> { + let mut out = ptr::null_mut(); + match unsafe { replicant_get_document(handle, c(id).as_ptr(), &mut out) } { + SyncResult::Success => Ok(serde_json::from_str(&take_string(out)).unwrap()), + failed => Err(failed), + } +} + +fn json_out(call: impl FnOnce(*mut *mut c_char) -> SyncResult) -> Value { + let mut out = ptr::null_mut(); + assert_eq!(call(&mut out), SyncResult::Success); + serde_json::from_str(&take_string(out)).unwrap() +} + +fn state(handle: *mut Replicant) -> ReplicantState { + let mut state = ReplicantState { + struct_size: std::mem::size_of::<ReplicantState>() as u32, + connection: ReplicantConnection::Idle, + sync: ReplicantSync::Idle, + halt_reason: ReplicantHaltReason::None, + }; + assert_eq!( + unsafe { replicant_get_state(handle, &mut state) }, + SyncResult::Success + ); + state +} + +fn halted_not_enrolled(handle: *mut Replicant) -> bool { + let state = state(handle); + state.connection == ReplicantConnection::Halted + && state.halt_reason == ReplicantHaltReason::NotEnrolled +} + +/// Runs one statement against the data dir's database from outside the library. +fn sql(dir: &Path, statement: &str) { + tokio::runtime::Runtime::new().unwrap().block_on(async { + let pool = + SqlitePool::connect_with(SqliteConnectOptions::new().filename(dir.join(DB_FILE))) + .await + .unwrap(); + sqlx::query(statement).execute(&pool).await.unwrap(); + pool.close().await; + }); +} + +fn wait_until(what: &str, mut done: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + while !done() { + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn pump_until(handle: *mut Replicant, log: &Log, what: &str, done: impl Fn(&[String]) -> bool) { + wait_until(what, || { + assert_eq!( + unsafe { replicant_process_events(handle, ptr::null_mut()) }, + SyncResult::Success + ); + done(&log.lock().unwrap()) + }); +} + +#[test] +fn a_second_create_on_the_same_data_dir_shares_the_engine() { + let dir = tempfile::tempdir().unwrap(); + let first = open(dir.path()); + let second = open(dir.path()); + let id = create_doc(first, r#"{"title":"Shared"}"#); + assert_eq!(get_doc(second, &id).unwrap()["title"], "Shared"); + close(first); + close(second); +} + +#[test] +fn create_with_a_different_server_for_an_open_data_dir_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let open_handle = open(dir.path()); + let (result, handle) = create(dir.path(), "ws://127.0.0.1:10"); + assert_eq!(result, SyncResult::ErrorConfigMismatch); + assert!(handle.is_null()); + close(open_handle); +} + +#[test] +fn create_refuses_a_full_or_malformed_list_merge_config() { + let dir = tempfile::tempdir().unwrap(); + let refused: [(i32, Option<&str>); 5] = [ + (2, None), + (7, None), + (0, Some(r#"[{"path": "/pitches", "policy": "full"}]"#)), + (0, Some(r#"[{"path": "pitches", "policy": "atomic"}]"#)), + (0, Some("not json")), + ]; + for (list_merge, rules) in refused { + let (result, handle) = create_with_lists(dir.path(), OFFLINE, list_merge, rules); + assert_eq!( + result, + SyncResult::ErrorInvalidInput, + "{list_merge} {rules:?}" + ); + assert!(handle.is_null()); + } + let (result, handle) = create_with_lists( + dir.path(), + OFFLINE, + 1, + Some(r#"[{"path": "/tunings/*/pitches", "policy": "append"}]"#), + ); + assert_eq!(result, SyncResult::Success); + let (mismatch, other) = create(dir.path(), OFFLINE); + assert_eq!( + mismatch, + SyncResult::ErrorConfigMismatch, + "one list policy per engine" + ); + assert!(other.is_null()); + close(handle); +} + +#[test] +fn create_on_a_database_from_a_newer_build_reports_newer_schema() { + let dir = tempfile::tempdir().unwrap(); + close(open(dir.path())); + sql( + dir.path(), + "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) \ + VALUES (99, 'from a newer build', 1, x'00', 0)", + ); + let (result, handle) = create(dir.path(), OFFLINE); + assert_eq!(result, SyncResult::ErrorNewerSchema); + assert!(handle.is_null()); +} + +#[test] +fn documents_round_trip_through_writes_and_reads() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let first = create_doc(handle, r#"{"title":"A","n":1}"#); + assert_eq!( + update(handle, &first, r#"{"title":"A","n":2}"#), + SyncResult::Success + ); + let read_back = get_doc(handle, &first).unwrap(); + assert_eq!(read_back["content"]["n"], 2); + assert_eq!(read_back["visibility"], "private"); + assert!(read_back["owner_id"].is_string()); + let mut keys: Vec<&str> = read_back + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + assert_eq!( + keys, + [ + "author_id", + "content", + "created_at", + "derived_from", + "id", + "owner_id", + "read_only", + "source_doc_id", + "title", + "updated_at", + "visibility" + ] + ); + for stamp in ["created_at", "updated_at"] { + let text = read_back[stamp].as_str().unwrap(); + let parsed = chrono::DateTime::parse_from_rfc3339(text).unwrap(); + assert_eq!(parsed.offset().local_minus_utc(), 0, "{stamp} is UTC"); + } + let chosen = Uuid::new_v4().to_string(); + assert_eq!( + unsafe { replicant_create_document_with_id(handle, c(&chosen).as_ptr(), c("{}").as_ptr()) }, + SyncResult::Success + ); + let all = json_out(|out| unsafe { replicant_get_all_documents(handle, out) }); + assert_eq!(all.as_array().unwrap().len(), 2); + let mut count = 0; + assert_eq!( + unsafe { replicant_count_documents(handle, &mut count) }, + SyncResult::Success + ); + assert_eq!(count, 2); + assert_eq!( + unsafe { replicant_delete_document(handle, c(&chosen).as_ptr()) }, + SyncResult::Success + ); + assert_eq!(get_doc(handle, &chosen), Err(SyncResult::ErrorNotFound)); + let live = json_out(|out| unsafe { replicant_get_all_document_ids(handle, false, out) }); + assert_eq!(live, json!([first])); + let with_deleted = json_out(|out| unsafe { replicant_get_all_document_ids(handle, true, out) }); + assert_eq!(with_deleted.as_array().unwrap().len(), 2); + close(handle); +} + +#[test] +fn refused_writes_report_distinct_codes() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let missing = Uuid::new_v4().to_string(); + assert_eq!(update(handle, &missing, "{}"), SyncResult::ErrorNotFound); + assert_eq!( + unsafe { replicant_delete_document(handle, c(&missing).as_ptr()) }, + SyncResult::ErrorNotFound + ); + let id = create_doc(handle, "{}"); + assert_eq!( + unsafe { replicant_create_document_with_id(handle, c(&id).as_ptr(), c("{}").as_ptr()) }, + SyncResult::ErrorAlreadyExists + ); + let theirs = Uuid::new_v4().to_string(); + sql( + dir.path(), + &format!( + "INSERT INTO documents (id, user_id, content, created_at, updated_at) \ + VALUES ('{theirs}', '{}', '{{}}', 't', 't')", + Uuid::new_v4() + ), + ); + assert_eq!(update(handle, &theirs, "{}"), SyncResult::ErrorNotWritable); + assert_eq!( + update(handle, "not-a-uuid", "{}"), + SyncResult::ErrorInvalidInput + ); + assert_eq!( + update(handle, &id, "not json"), + SyncResult::ErrorSerialization + ); + close(handle); +} + +#[test] +fn document_events_reach_the_callback_with_local_origin() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let seen: Log = Mutex::new(Vec::new()); + assert_eq!( + unsafe { + replicant_register_document_callback(handle, Some(on_document), context(&seen), -1) + }, + SyncResult::Success + ); + let id = create_doc(handle, r#"{"title":"Scale"}"#); + pump_until(handle, &seen, "the created document", |lines| { + lines.iter().any(|line| line.contains(&id)) + }); + assert_eq!( + *seen.lock().unwrap(), + vec![format!("DocumentChanged {id} Scale private false Local")] + ); + close(handle); +} + +#[test] +fn a_handle_without_credentials_reports_halted_not_enrolled() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let seen: Log = Mutex::new(Vec::new()); + assert_eq!( + unsafe { replicant_register_error_callback(handle, Some(on_error), context(&seen)) }, + SyncResult::Success + ); + wait_until("halted", || halted_not_enrolled(handle)); + pump_until(handle, &seen, "the not_enrolled error", |lines| { + lines + .iter() + .any(|line| line == "SyncError 1003 not_enrolled null null true -1") + }); + assert!(!unsafe { replicant_is_connected(handle) }); + close(handle); +} + +#[test] +fn create_returns_with_the_state_the_stored_credentials_give() { + let signed_out = tempfile::tempdir().unwrap(); + let first = open(signed_out.path()); + assert!(halted_not_enrolled(first)); + let second = open(signed_out.path()); + assert!( + halted_not_enrolled(second), + "a second handle shares the halt" + ); + let seen: Log = Mutex::new(Vec::new()); + assert_eq!( + unsafe { replicant_register_error_callback(first, Some(on_error), context(&seen)) }, + SyncResult::Success + ); + pump_until(first, &seen, "the not_enrolled error", |lines| { + !lines.is_empty() + }); + std::thread::sleep(Duration::from_millis(300)); + assert_eq!( + unsafe { replicant_process_events(first, ptr::null_mut()) }, + SyncResult::Success + ); + assert_eq!( + *seen.lock().unwrap(), + vec!["SyncError 1003 not_enrolled null null true -1".to_string()], + "the start halt is reported once" + ); + close(first); + close(second); + + let signed_in = tempfile::tempdir().unwrap(); + secret_store::store( + signed_in.path(), + &secret_store::Credentials { + api_key: "k1".into(), + secret: "rps_test".into(), + user_id: Uuid::new_v4(), + email: Some("a@b.c".into()), + }, + ) + .unwrap(); + let handle = open(signed_in.path()); + let dialling = state(handle); + assert!( + matches!( + dialling.connection, + ReplicantConnection::Connecting | ReplicantConnection::Disconnected + ), + "{dialling:?}" + ); + assert_eq!(dialling.halt_reason, ReplicantHaltReason::None); + close(handle); +} + +#[test] +fn create_with_damaged_credentials_returns_halted_not_enrolled_and_reports_it_once() { + for damage in ["key missing", "undecryptable"] { + let dir = tempfile::tempdir().unwrap(); + secret_store::store( + dir.path(), + &secret_store::Credentials { + api_key: "k1".into(), + secret: "rps_test".into(), + user_id: Uuid::new_v4(), + email: Some("a@b.c".into()), + }, + ) + .unwrap(); + match damage { + "key missing" => std::fs::remove_file(dir.path().join("key.bin")).unwrap(), + _ => std::fs::write(dir.path().join("credentials.enc"), [7u8; 40]).unwrap(), + } + let handle = open(dir.path()); + assert!(halted_not_enrolled(handle), "{damage}: {:?}", state(handle)); + let seen: Log = Mutex::new(Vec::new()); + assert_eq!( + unsafe { replicant_register_error_callback(handle, Some(on_error), context(&seen)) }, + SyncResult::Success + ); + pump_until(handle, &seen, "the not_enrolled error", |lines| { + !lines.is_empty() + }); + std::thread::sleep(Duration::from_millis(300)); + assert_eq!( + unsafe { replicant_process_events(handle, ptr::null_mut()) }, + SyncResult::Success + ); + assert_eq!( + *seen.lock().unwrap(), + vec!["SyncError 1003 not_enrolled null null true -1".to_string()], + "{damage}" + ); + close(handle); + } +} + +#[test] +fn credentials_stored_by_0_6_need_the_config_email_to_sign_in() { + let store_0_6 = |dir: &Path| { + secret_store::store( + dir, + &secret_store::Credentials { + api_key: "k1".into(), + secret: "rps_test".into(), + user_id: Uuid::new_v4(), + email: None, + }, + ) + .unwrap(); + }; + let without_email = tempfile::tempdir().unwrap(); + store_0_6(without_email.path()); + let (result, handle) = create_with(without_email.path(), |config| config.email = ptr::null()); + assert_eq!(result, SyncResult::Success); + wait_until("halted", || halted_not_enrolled(handle)); + close(handle); + + let with_email = tempfile::tempdir().unwrap(); + store_0_6(with_email.path()); + let handle = open(with_email.path()); + wait_until("dialling", || { + state(handle).connection != ReplicantConnection::Idle + }); + assert!(!halted_not_enrolled(handle)); + close(handle); +} + +#[test] +fn process_events_on_another_thread_is_refused_and_keeps_the_events() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let seen: Log = Mutex::new(Vec::new()); + assert_eq!( + unsafe { + replicant_register_document_callback(handle, Some(on_document), context(&seen), 1) + }, + SyncResult::Success + ); + let id = create_doc(handle, "{}"); + // Two change-log ticks: the event is queued by now. + std::thread::sleep(Duration::from_millis(600)); + let address = handle as usize; + std::thread::spawn(move || { + let handle = address as *mut Replicant; + assert_eq!( + unsafe { replicant_process_events(handle, ptr::null_mut()) }, + SyncResult::ErrorWrongThread + ); + }) + .join() + .unwrap(); + let mut processed = 0; + assert_eq!( + unsafe { replicant_process_events(handle, &mut processed) }, + SyncResult::Success + ); + assert!(processed >= 1); + assert!(seen.lock().unwrap().iter().any(|line| line.contains(&id))); + close(handle); +} + +/// Holds the first callback until another thread has had its turn. +struct Gate { + entered: std::sync::mpsc::Sender<()>, + release: Mutex<std::sync::mpsc::Receiver<()>>, + held: std::sync::atomic::AtomicBool, +} + +extern "C" fn on_document_held( + _event_type: EventType, + _document_id: *const c_char, + _title: *const c_char, + _content: *const c_char, + _owner_id: *const c_char, + _author_id: *const c_char, + _visibility: *const c_char, + _read_only: bool, + _origin: EventOrigin, + context: *mut c_void, +) { + let gate = unsafe { &*(context as *const Gate) }; + if !gate.held.swap(true, Ordering::SeqCst) { + gate.entered.send(()).unwrap(); + gate.release.lock().unwrap().recv().unwrap(); + } +} + +#[test] +fn process_events_on_another_thread_during_a_pump_is_refused_as_wrong_thread() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let gate = Gate { + entered: entered_tx, + release: Mutex::new(release_rx), + held: std::sync::atomic::AtomicBool::new(false), + }; + assert_eq!( + unsafe { + replicant_register_document_callback( + handle, + Some(on_document_held), + &gate as *const Gate as *mut c_void, + 1, + ) + }, + SyncResult::Success + ); + create_doc(handle, "{}"); + let address = handle as usize; + let other = std::thread::spawn(move || { + entered_rx.recv().unwrap(); + let result = + unsafe { replicant_process_events(address as *mut Replicant, ptr::null_mut()) }; + release_tx.send(()).unwrap(); + result + }); + wait_until("the held callback", || { + assert_eq!( + unsafe { replicant_process_events(handle, ptr::null_mut()) }, + SyncResult::Success + ); + gate.held.load(Ordering::SeqCst) + }); + assert_eq!(other.join().unwrap(), SyncResult::ErrorWrongThread); + assert_eq!( + unsafe { replicant_process_events(handle, ptr::null_mut()) }, + SyncResult::Success, + "the pump is free again" + ); + close(handle); +} + +#[test] +fn search_finds_configured_paths() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + assert_eq!( + unsafe { replicant_configure_search(handle, c(r#"["$.body"]"#).as_ptr()) }, + SyncResult::Success + ); + let music = create_doc(handle, r#"{"title":"Alpha","body":"music theory"}"#); + create_doc(handle, r#"{"title":"Beta","body":"cooking"}"#); + let found = + json_out(|out| unsafe { replicant_search_documents(handle, c("music").as_ptr(), 0, out) }); + assert_eq!(found.as_array().unwrap().len(), 1); + assert_eq!(found[0]["id"], music); + assert_eq!( + unsafe { replicant_rebuild_search_index(handle) }, + SyncResult::Success + ); + close(handle); +} + +fn open_with_title(dir: &Path, pointer: Option<&str>) -> *mut Replicant { + let pointer = pointer.map(c); + let (result, handle) = create_with(dir, |config| { + config.title_pointer = pointer.as_ref().map_or(ptr::null(), |text| text.as_ptr()); + }); + assert_eq!(result, SyncResult::Success); + handle +} + +fn title_search(handle: *mut Replicant, query: &str) -> usize { + json_out(|out| unsafe { replicant_search_documents(handle, c(query).as_ptr(), 0, out) }) + .as_array() + .unwrap() + .len() +} + +#[test] +fn titles_come_from_the_configured_pointer_and_follow_a_new_one_at_the_next_launch() { + let dir = tempfile::tempdir().unwrap(); + let handle = open_with_title(dir.path(), Some("/title")); + assert_eq!( + unsafe { replicant_configure_search(handle, c(r#"["$.body"]"#).as_ptr()) }, + SyncResult::Success + ); + let id = create_doc( + handle, + r#"{"title":"Alpha","meta":{"name":"Beta"},"body":"notes"}"#, + ); + assert_eq!(get_doc(handle, &id).unwrap()["title"], "Alpha"); + assert_eq!(title_search(handle, "title:alpha"), 1); + close(handle); + + let handle = open_with_title(dir.path(), Some("/meta/name")); + assert_eq!(get_doc(handle, &id).unwrap()["title"], "Beta"); + assert_eq!(title_search(handle, "title:beta"), 1); + assert_eq!(title_search(handle, "title:alpha"), 0); + sql( + dir.path(), + &format!( + "INSERT INTO recovered (doc_id, content, reason, recovered_at) \ + VALUES ('{id}', '{{\"title\":\"Old\",\"meta\":{{\"name\":\"Kept\"}}}}', 'conflict', 1)" + ), + ); + let kept = json_out(|out| unsafe { replicant_list_recovered(handle, out) }); + assert_eq!(kept[0]["title"], "Kept"); + close(handle); + + let handle = open_with_title(dir.path(), None); + assert!(get_doc(handle, &id).unwrap()["title"].is_null()); + assert_eq!(title_search(handle, "title:beta"), 0); + assert!(json_out(|out| unsafe { replicant_list_recovered(handle, out) })[0]["title"].is_null()); + let untitled = create_doc(handle, r#"{"title":"Gamma"}"#); + assert!(get_doc(handle, &untitled).unwrap()["title"].is_null()); + close(handle); +} + +#[test] +fn create_refuses_a_malformed_title_pointer_and_another_pointer_on_an_open_engine() { + let dir = tempfile::tempdir().unwrap(); + for malformed in ["title", "/a~2", "/a~"] { + let pointer = c(malformed); + let (result, handle) = + create_with(dir.path(), |config| config.title_pointer = pointer.as_ptr()); + assert_eq!(result, SyncResult::ErrorInvalidInput, "{malformed}"); + assert!(handle.is_null()); + } + let handle = open_with_title(dir.path(), Some("/title")); + for other in [Some("/name"), None] { + let pointer = other.map(c); + let (result, second) = create_with(dir.path(), |config| { + config.title_pointer = pointer.as_ref().map_or(ptr::null(), |text| text.as_ptr()); + }); + assert_eq!(result, SyncResult::ErrorConfigMismatch, "{other:?}"); + assert!(second.is_null()); + } + let same = open_with_title(dir.path(), Some("/title")); + close(same); + close(handle); +} + +#[test] +fn a_malformed_search_query_is_invalid_input() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let mut out = ptr::null_mut(); + assert_eq!( + unsafe { replicant_search_documents(handle, c("a AND").as_ptr(), 0, &mut out) }, + SyncResult::ErrorInvalidInput + ); + assert!(out.is_null()); + close(handle); +} + +#[test] +fn a_search_limit_of_zero_returns_up_to_100() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + assert_eq!( + unsafe { replicant_configure_search(handle, c(r#"["$.body"]"#).as_ptr()) }, + SyncResult::Success + ); + for _ in 0..101 { + create_doc(handle, r#"{"body":"music"}"#); + } + let search = |limit| { + json_out(|out| unsafe { + replicant_search_documents(handle, c("music").as_ptr(), limit, out) + }) + .as_array() + .unwrap() + .len() + }; + assert_eq!(search(0), 100); + assert_eq!(search(3), 3); + close(handle); +} + +#[test] +fn count_pending_sync_counts_unsent_documents() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + create_doc(handle, "{}"); + create_doc(handle, "{}"); + let mut pending = 0; + assert_eq!( + unsafe { replicant_count_pending_sync(handle, &mut pending) }, + SyncResult::Success + ); + assert_eq!(pending, 2); + close(handle); +} + +#[test] +fn a_claim_and_a_sign_out_move_the_engine_out_of_and_back_into_halted() { + let (runtime, server, _) = claim_server(200, 1); + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + wait_until("halted as not enrolled", || halted_not_enrolled(handle)); + assert_eq!(claim(&server, dir.path()).0, SyncResult::Success); + wait_until("dialling", || { + state(handle).connection != ReplicantConnection::Halted + }); + let data_dir = c(dir.path().to_str().unwrap()); + assert_eq!( + unsafe { replicant_clear_credentials(data_dir.as_ptr()) }, + SyncResult::Success + ); + wait_until("halted again", || halted_not_enrolled(handle)); + close(handle); + runtime.block_on(server.verify()); +} + +#[test] +fn null_arguments_are_refused_without_crashing() { + unsafe { + assert!(replicant_destroy_and_wait(ptr::null_mut(), 0)); + replicant_destroy(ptr::null_mut()); + let mut count = 0; + assert_eq!( + replicant_count_documents(ptr::null_mut(), &mut count), + SyncResult::ErrorInvalidInput + ); + assert!(!replicant_is_connected(ptr::null_mut())); + assert_eq!( + replicant_create(ptr::null(), ptr::null_mut()), + SyncResult::ErrorInvalidInput + ); + } + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + // A null callback is not an error: it removes that kind's callback. + let context = ptr::null_mut(); + unsafe { + assert_eq!( + replicant_register_document_callback(handle, None, context, -1), + SyncResult::Success + ); + assert_eq!( + replicant_register_sync_callback(handle, None, context), + SyncResult::Success + ); + assert_eq!( + replicant_register_error_callback(handle, None, context), + SyncResult::Success + ); + assert_eq!( + replicant_register_connection_callback(handle, None, context), + SyncResult::Success + ); + assert_eq!( + replicant_register_conflict_callback(handle, None, context), + SyncResult::Success + ); + } + close(handle); +} + +#[test] +fn a_failed_create_leaves_the_out_handle_null() { + let stale = ptr::NonNull::<Replicant>::dangling().as_ptr(); + let mut handle = stale; + assert_eq!( + unsafe { replicant_create(ptr::null(), &mut handle) }, + SyncResult::ErrorInvalidInput + ); + assert!(handle.is_null(), "null config"); + let dir = tempfile::tempdir().unwrap(); + let refused: [fn(&mut ReplicantConfig); 3] = [ + |config| config.struct_size = 4, + |config| config.data_dir = ptr::null(), + |config| config.list_merge = 7, + ]; + for change in refused { + let strings = [c(dir.path().to_str().unwrap()), c(DB_FILE), c(OFFLINE)]; + let mut config = ReplicantConfig { + struct_size: std::mem::size_of::<ReplicantConfig>() as u32, + data_dir: strings[0].as_ptr(), + database_file: strings[1].as_ptr(), + server_url: strings[2].as_ptr(), + email: ptr::null(), + host_app: strings[1].as_ptr(), + host_version: strings[1].as_ptr(), + list_merge: 0, + list_merge_rules_json: ptr::null(), + title_pointer: ptr::null(), + }; + change(&mut config); + let mut handle = stale; + assert_eq!( + unsafe { replicant_create(&config, &mut handle) }, + SyncResult::ErrorInvalidInput + ); + assert!(handle.is_null()); + } +} + +#[test] +fn registering_again_replaces_the_callback_and_null_removes_it() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let (first, second): (Log, Log) = (Mutex::new(Vec::new()), Mutex::new(Vec::new())); + let register = |log: Option<&Log>| unsafe { + replicant_register_document_callback( + handle, + log.map(|_| on_document as _), + log.map_or(ptr::null_mut(), context), + -1, + ) + }; + assert_eq!(register(Some(&first)), SyncResult::Success); + assert_eq!(register(Some(&second)), SyncResult::Success); + let id = create_doc(handle, r#"{"title":"once"}"#); + pump_until(handle, &second, "the write's event", |seen| { + seen.iter().any(|line| line.contains(&id)) + }); + assert!(first.lock().unwrap().is_empty()); + assert_eq!(second.lock().unwrap().len(), 1); + + assert_eq!(register(None), SyncResult::Success); + let removed = create_doc(handle, r#"{"title":"unheard"}"#); + wait_until("the write's event to be taken", || { + let mut processed = 0; + assert_eq!( + unsafe { replicant_process_events(handle, &mut processed) }, + SyncResult::Success + ); + processed > 0 + }); + assert!(!second + .lock() + .unwrap() + .iter() + .any(|line| line.contains(&removed))); + close(handle); +} + +/// What a mid-batch callback change needs: the handle, its own calls, and where to switch. +struct Switcher { + handle: *mut Replicant, + calls: AtomicUsize, + next: Option<*const Log>, +} + +/// On its first event, replaces itself with `on_document` into `next`, or removes itself. +extern "C" fn switch_on_first( + _: EventType, + _: *const c_char, + _: *const c_char, + _: *const c_char, + _: *const c_char, + _: *const c_char, + _: *const c_char, + _: bool, + _: EventOrigin, + context: *mut c_void, +) { + let switcher = unsafe { &*(context as *const Switcher) }; + switcher.calls.fetch_add(1, Ordering::SeqCst); + let (callback, next_context) = match switcher.next { + Some(log) => (Some(on_document as _), log as *mut c_void), + None => (None, ptr::null_mut()), + }; + assert_eq!( + unsafe { + replicant_register_document_callback(switcher.handle, callback, next_context, -1) + }, + SyncResult::Success + ); +} + +/// Queues four document events, then pumps them in one batch. +fn pump_four_documents_in_one_batch(handle: *mut Replicant) { + for n in 0..4 { + create_doc(handle, &format!(r#"{{"n":{n}}}"#)); + } + std::thread::sleep(Duration::from_millis(500)); + let mut processed = 0; + assert_eq!( + unsafe { replicant_process_events(handle, &mut processed) }, + SyncResult::Success + ); + assert!(processed >= 4, "{processed}"); +} + +#[test] +fn a_callback_that_removes_itself_is_not_called_again_in_the_same_batch() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let switcher = Switcher { + handle, + calls: AtomicUsize::new(0), + next: None, + }; + let context = &switcher as *const Switcher as *mut c_void; + assert_eq!( + unsafe { replicant_register_document_callback(handle, Some(switch_on_first), context, -1) }, + SyncResult::Success + ); + pump_four_documents_in_one_batch(handle); + assert_eq!(switcher.calls.load(Ordering::SeqCst), 1); + close(handle); +} + +#[test] +fn a_callback_replaced_mid_batch_hands_the_rest_of_the_batch_to_the_new_one() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let rest: Log = Mutex::new(Vec::new()); + let switcher = Switcher { + handle, + calls: AtomicUsize::new(0), + next: Some(&rest), + }; + let context = &switcher as *const Switcher as *mut c_void; + assert_eq!( + unsafe { replicant_register_document_callback(handle, Some(switch_on_first), context, -1) }, + SyncResult::Success + ); + pump_four_documents_in_one_batch(handle); + assert_eq!(switcher.calls.load(Ordering::SeqCst), 1); + assert_eq!(rest.lock().unwrap().len(), 3); + close(handle); +} + +#[test] +fn registering_off_the_bound_thread_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + assert_eq!( + unsafe { replicant_register_document_callback(handle, None, ptr::null_mut(), -1) }, + SyncResult::Success + ); + let handle_address = handle as usize; + let result = std::thread::spawn(move || unsafe { + replicant_register_error_callback(handle_address as *mut Replicant, None, ptr::null_mut()) + }) + .join() + .unwrap(); + assert_eq!(result, SyncResult::ErrorWrongThread); + close(handle); +} + +/// A claim server answering `status` (with credentials on 200) and expecting `hits` calls. +fn claim_server(status: u16, hits: u64) -> (tokio::runtime::Runtime, wiremock::MockServer, Uuid) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let user_id = Uuid::new_v4(); + let server = runtime.block_on(async { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/enroll/claim")) + .respond_with(ResponseTemplate::new(status).set_body_json(json!({ + "api_key": "rpa_claimed", "secret": "rps_claimed", "user_id": user_id + }))) + .expect(hits) + .mount(&server) + .await; + server + }); + (runtime, server, user_id) +} + +fn enroll_request(base_url: &str, email: &str) -> SyncResult { + unsafe { replicant_enroll_request(c(base_url).as_ptr(), c(email).as_ptr()) } +} + +#[test] +fn enroll_request_refuses_bad_input_before_the_server_and_reports_refusals_as_connection() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let server_for = |status: u16| { + runtime.block_on(async { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/enroll/request")) + .respond_with(ResponseTemplate::new(status)) + .mount(&server) + .await; + server + }) + }; + let accepting = server_for(202); + assert_eq!( + enroll_request(&accepting.uri(), "a@b.c"), + SyncResult::Success + ); + assert_eq!( + enroll_request("http://example.com", "a@b.c"), + SyncResult::ErrorInvalidInput, + "plain http off localhost" + ); + assert_eq!( + enroll_request("not a url", "a@b.c"), + SyncResult::ErrorInvalidInput + ); + assert_eq!( + enroll_request(&accepting.uri(), ""), + SyncResult::ErrorInvalidInput + ); + let too_long = format!("{}@b.c", "a".repeat(REPLICANT_EMAIL_MAX_LEN)); + assert_eq!( + enroll_request(&accepting.uri(), &too_long), + SyncResult::ErrorInvalidInput + ); + assert_eq!( + unsafe { replicant_enroll_request(ptr::null(), c("a@b.c").as_ptr()) }, + SyncResult::ErrorInvalidInput + ); + let received = runtime.block_on(accepting.received_requests()).unwrap(); + assert_eq!(received.len(), 1, "refused input never reaches the server"); + for status in [400, 429, 500] { + assert_eq!( + enroll_request(&server_for(status).uri(), "a@b.c"), + SyncResult::ErrorConnection, + "status {status}" + ); + } +} + +fn claim(server: &wiremock::MockServer, data_dir: &Path) -> (SyncResult, String) { + let mut user_id = [0 as c_char; REPLICANT_USER_ID_LEN + 1]; + let result = unsafe { + replicant_enroll_claim( + c(&server.uri()).as_ptr(), + c(data_dir.to_str().unwrap()).as_ptr(), + c("a@b.c").as_ptr(), + c("TOKEN").as_ptr(), + user_id.as_mut_ptr(), + user_id.len(), + ) + }; + (result, read(user_id.as_ptr())) +} + +#[test] +fn a_claim_stores_the_credentials_and_returns_only_the_user_id() { + let (runtime, server, user_id) = claim_server(200, 1); + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + claim(&server, dir.path()), + (SyncResult::Success, user_id.to_string()) + ); + let stored = secret_store::load(dir.path()).unwrap().unwrap(); + assert_eq!( + (stored.api_key, stored.secret, stored.user_id, stored.email), + ( + "rpa_claimed".to_string(), + "rps_claimed".to_string(), + user_id, + Some("a@b.c".to_string()) + ) + ); + runtime.block_on(server.verify()); +} + +#[test] +fn a_claim_into_an_unwritable_data_dir_fails_without_spending_the_code() { + let (runtime, server, _) = claim_server(200, 0); + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("a-file"); + std::fs::write(&file, "").unwrap(); + assert_eq!( + claim(&server, &file.join("data")).0, + SyncResult::ErrorDatabase + ); + runtime.block_on(server.verify()); +} + +#[test] +fn a_rejected_code_has_its_own_result() { + let (runtime, server, _) = claim_server(401, 1); + let dir = tempfile::tempdir().unwrap(); + assert_eq!(claim(&server, dir.path()).0, SyncResult::ErrorTokenRejected); + assert!(secret_store::load(dir.path()).unwrap().is_none()); + runtime.block_on(server.verify()); +} + +#[test] +fn the_version_string_is_static() { + let first = replicant_get_version(); + assert_eq!(first, replicant_get_version()); + assert_eq!(read(first), env!("CARGO_PKG_VERSION")); +} + +#[test] +fn a_failed_read_leaves_its_out_pointer_null() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let mut out = ptr::dangling_mut::<c_char>(); + assert_eq!( + unsafe { + replicant_get_document(handle, c(&Uuid::new_v4().to_string()).as_ptr(), &mut out) + }, + SyncResult::ErrorNotFound + ); + assert!(out.is_null()); + close(handle); +} + +#[test] +fn a_config_email_that_is_not_utf8_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let bad = CString::new(vec![0xFF, 0xFE]).unwrap(); + let (result, handle) = create_with(dir.path(), |config| config.email = bad.as_ptr()); + assert_eq!(result, SyncResult::ErrorInvalidInput); + assert!(handle.is_null()); +} + +#[test] +fn a_config_or_state_smaller_than_this_version_is_refused() { + assert_eq!( + replicant_abi_version(), + (REPLICANT_ABI_VERSION_MAJOR << 16) | REPLICANT_ABI_VERSION_MINOR + ); + let dir = tempfile::tempdir().unwrap(); + let (result, handle) = create_with(dir.path(), |config| config.struct_size -= 4); + assert_eq!(result, SyncResult::ErrorInvalidInput); + assert!(handle.is_null()); + let handle = open(dir.path()); + let mut state = ReplicantState { + struct_size: 3, + ..state(handle) + }; + assert_eq!( + unsafe { replicant_get_state(handle, &mut state) }, + SyncResult::ErrorInvalidInput + ); + close(handle); +} + +/// A struct from a later header: this version's fields, then one it does not know. +#[repr(C)] +struct Later<T> { + known: T, + added: u64, +} + +#[test] +fn a_config_or_state_from_a_later_version_is_accepted_and_its_new_fields_left_alone() { + // The state's size comes back as the part this library filled. + let dir = tempfile::tempdir().unwrap(); + let (result, handle) = create_with(dir.path(), |config| { + config.struct_size = std::mem::size_of::<Later<ReplicantConfig>>() as u32 + }); + assert_eq!(result, SyncResult::Success); + let mut later = Later { + known: ReplicantState { + struct_size: std::mem::size_of::<Later<ReplicantState>>() as u32, + connection: ReplicantConnection::Stopped, + sync: ReplicantSync::Live, + halt_reason: ReplicantHaltReason::Other, + }, + added: 0xDEAD_BEEF, + }; + wait_until("halted as not enrolled", || { + assert_eq!( + unsafe { replicant_get_state(handle, &mut later.known) }, + SyncResult::Success + ); + later.known.halt_reason == ReplicantHaltReason::NotEnrolled + }); + assert_eq!( + later.known.struct_size as usize, + std::mem::size_of::<ReplicantState>() + ); + assert_eq!(later.added, 0xDEAD_BEEF); + close(handle); +} + +fn committed_header() -> String { + std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("include/replicant.h")) + .unwrap() +} + +#[test] +fn the_committed_header_is_the_generated_one() { + let generated = std::fs::read_to_string(env!("REPLICANT_GENERATED_HEADER")).unwrap(); + assert!( + committed_header() == generated, + "include/replicant.h is stale: copy {} over it", + env!("REPLICANT_GENERATED_HEADER") + ); +} + +#[test] +fn the_header_defines_only_replicant_macros() { + for line in committed_header().lines() { + if let Some(name) = line.strip_prefix("#define ") { + assert!(name.starts_with("REPLICANT_"), "{line}"); + } + } +} + +#[test] +fn destroy_inside_a_callback_is_deferred_until_the_pump_returns() { + static DESTROYS: AtomicUsize = AtomicUsize::new(0); + extern "C" fn destroy_on_first( + _: EventType, + _: *const c_char, + _: *const c_char, + _: *const c_char, + _: *const c_char, + _: *const c_char, + _: *const c_char, + _: bool, + _: EventOrigin, + context: *mut c_void, + ) { + DESTROYS.fetch_add(1, Ordering::SeqCst); + unsafe { replicant_destroy(context as *mut Replicant) }; + } + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + assert_eq!( + unsafe { + replicant_register_document_callback(handle, Some(destroy_on_first), handle.cast(), 1) + }, + SyncResult::Success + ); + create_doc(handle, "{}"); + create_doc(handle, "{}"); + std::thread::sleep(Duration::from_millis(600)); + assert_eq!( + unsafe { replicant_process_events(handle, ptr::null_mut()) }, + SyncResult::Success + ); + // The not_enrolled halt is queued ahead of the documents, so count the callback's calls. + assert_eq!( + DESTROYS.load(Ordering::SeqCst), + 1, + "the batch stops at the destroy; the handle is freed after it" + ); +} + +fn keep_whole(dir: &Path, title: &str) { + sql( + dir, + &format!( + "INSERT INTO recovered (doc_id, content, reason, recovered_at) \ + VALUES ('{}', '{{\"title\":\"{title}\"}}', 'delete_wins', 100)", + Uuid::new_v4() + ), + ); +} + +fn keep_field(dir: &Path, doc_id: &str, path: &str, local_value: Value) { + let fields = json!([{"path": path, "local_value": local_value, "local_removed": false}]); + sql( + dir, + &format!( + "INSERT INTO recovered (doc_id, content, reason, recovered_at, fields) \ + VALUES ('{doc_id}', '{{}}', 'field_conflict', 200, '{fields}')" + ), + ); +} + +fn listed(handle: *mut Replicant) -> Value { + json_out(|out| unsafe { replicant_list_recovered(handle, out) }) +} + +#[test] +fn kept_copies_are_listed_restored_and_dismissed() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let doc_id = create_doc(handle, r#"{"s":"theirs","k":1}"#); + keep_whole(dir.path(), "Lost"); + keep_field(dir.path(), &doc_id, "/s", json!("mine")); + let copies = listed(handle); + assert_eq!(copies.as_array().unwrap().len(), 2); + assert_eq!(copies[0]["reason"], "field_conflict", "newest first"); + assert_eq!(copies[0]["doc_id"], doc_id); + assert_eq!(copies[0]["fields"][0]["path"], "/s"); + assert_eq!(copies[1]["title"], "Lost"); + assert!(copies[1]["fields"].is_null()); + let whole = copies[1]["recovered_id"].as_i64().unwrap(); + let field = copies[0]["recovered_id"].as_i64().unwrap(); + let mut restored = [0 as c_char; 37]; + assert_eq!( + unsafe { replicant_restore_document(handle, whole, restored.as_mut_ptr()) }, + SyncResult::Success + ); + assert_eq!( + get_doc(handle, &read(restored.as_ptr())).unwrap()["content"], + json!({"title": "Lost"}) + ); + assert_eq!( + unsafe { replicant_dismiss_recovered(handle, field) }, + SyncResult::Success + ); + assert_eq!( + unsafe { replicant_dismiss_recovered(handle, field) }, + SyncResult::ErrorNotFound + ); + assert_eq!(listed(handle), json!([])); + close(handle); +} + +#[test] +fn restore_fields_writes_the_kept_value_back() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let doc_id = create_doc(handle, r#"{"s":"theirs","k":1}"#); + keep_field(dir.path(), &doc_id, "/s", json!("mine")); + let field = listed(handle)[0]["recovered_id"].as_i64().unwrap(); + assert_eq!( + unsafe { replicant_restore_fields(handle, field) }, + SyncResult::Success + ); + assert_eq!( + get_doc(handle, &doc_id).unwrap()["content"], + json!({"s": "mine", "k": 1}) + ); + assert_eq!( + unsafe { replicant_restore_fields(handle, field) }, + SyncResult::ErrorNotFound + ); + close(handle); +} + +#[test] +fn a_field_copy_of_a_deleted_document_is_reported_gone_and_restores_whole() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let doc_id = create_doc(handle, r#"{"s":"theirs"}"#); + keep_field(dir.path(), &doc_id, "/s", json!("mine")); + let field = listed(handle)[0]["recovered_id"].as_i64().unwrap(); + let id = c(&doc_id); + assert_eq!( + unsafe { replicant_delete_document(handle, id.as_ptr()) }, + SyncResult::Success + ); + assert_eq!( + unsafe { replicant_restore_fields(handle, field) }, + SyncResult::ErrorDocumentGone, + "the document is gone, not the copy" + ); + let mut restored = [0 as c_char; 37]; + assert_eq!( + unsafe { replicant_restore_document(handle, field, restored.as_mut_ptr()) }, + SyncResult::Success, + "any copy can come back as a new document" + ); + assert_eq!(listed(handle), json!([])); + close(handle); +} + +#[test] +fn restore_fields_on_a_whole_document_copy_is_invalid_input_and_keeps_the_copy() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + keep_whole(dir.path(), "Lost"); + let whole = listed(handle)[0]["recovered_id"].as_i64().unwrap(); + assert_eq!( + unsafe { replicant_restore_fields(handle, whole) }, + SyncResult::ErrorInvalidInput + ); + assert_eq!(listed(handle)[0]["recovered_id"], whole); + assert_eq!( + unsafe { replicant_restore_fields(handle, whole + 1) }, + SyncResult::ErrorNotFound + ); + close(handle); +} + +#[test] +fn restore_fields_on_a_read_only_document_is_not_writable_and_keeps_the_copy() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let theirs = Uuid::new_v4().to_string(); + sql( + dir.path(), + &format!( + "INSERT INTO documents (id, user_id, content, created_at, updated_at) \ + VALUES ('{theirs}', '{}', '{{}}', 't', 't')", + Uuid::new_v4() + ), + ); + keep_field(dir.path(), &theirs, "/s", json!("mine")); + let field = listed(handle)[0]["recovered_id"].as_i64().unwrap(); + assert_eq!( + unsafe { replicant_restore_fields(handle, field) }, + SyncResult::ErrorNotWritable + ); + assert_eq!(listed(handle)[0]["recovered_id"], field); + close(handle); +} + +#[test] +fn parked_documents_are_listed_with_their_code() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let doc_id = create_doc(handle, "{}"); + sql( + dir.path(), + &format!("UPDATE outbox SET parked_error = 'too_large' WHERE doc_id = '{doc_id}'"), + ); + assert_eq!( + json_out(|out| unsafe { replicant_list_parked(handle, out) }), + json!([{"doc_id": doc_id, "code": "too_large"}]) + ); + let mut pending = 9; + assert_eq!( + unsafe { replicant_count_pending_sync(handle, &mut pending) }, + SyncResult::Success + ); + assert_eq!(pending, 0, "parked is not syncing"); + close(handle); +} + +#[test] +fn restoring_a_list_field_restores_the_whole_list() { + let dir = tempfile::tempdir().unwrap(); + let handle = open(dir.path()); + let doc_id = create_doc(handle, r#"{"items":["a","b","c"]}"#); + keep_field(dir.path(), &doc_id, "/items", json!(["a", "B", "c"])); + // The list changes shape after the conflict: a new element at the front. + assert_eq!( + update(handle, &doc_id, r#"{"items":["x","a","b","c"]}"#), + SyncResult::Success + ); + let field = listed(handle)[0]["recovered_id"].as_i64().unwrap(); + assert_eq!( + unsafe { replicant_restore_fields(handle, field) }, + SyncResult::Success + ); + assert_eq!( + get_doc(handle, &doc_id).unwrap()["content"], + json!({"items": ["a", "B", "c"]}), + "list conflicts are whole-list, so the kept list comes back exactly" + ); + close(handle); +} diff --git a/replicant-client/tests/fixtures/server_v2/json_patch_fixture.json b/replicant-client/tests/fixtures/server_v2/json_patch_fixture.json new file mode 100644 index 0000000..4401b7d --- /dev/null +++ b/replicant-client/tests/fixtures/server_v2/json_patch_fixture.json @@ -0,0 +1,3336 @@ +[ + { + "name": "replace a scalar", + "doc": { + "a": 1 + }, + "patch": [ + { + "op": "replace", + "path": "/a", + "value": 2 + } + ], + "result": { + "a": 2 + } + }, + { + "name": "add a key", + "doc": { + "a": 1 + }, + "patch": [ + { + "op": "add", + "path": "/b", + "value": [ + 1, + 2 + ] + } + ], + "result": { + "a": 1, + "b": [ + 1, + 2 + ] + } + }, + { + "name": "remove a key", + "doc": { + "a": 1, + "b": 2 + }, + "patch": [ + { + "op": "remove", + "path": "/b" + } + ], + "result": { + "a": 1 + } + }, + { + "name": "nested object", + "doc": { + "a": { + "b": { + "c": 1 + } + } + }, + "patch": [ + { + "op": "replace", + "path": "/a/b/c", + "value": 2 + }, + { + "op": "add", + "path": "/a/b/d", + "value": null + } + ], + "result": { + "a": { + "b": { + "c": 2, + "d": null + } + } + } + }, + { + "name": "type change", + "doc": { + "a": [ + 1 + ] + }, + "patch": [ + { + "op": "replace", + "path": "/a", + "value": { + "k": true + } + } + ], + "result": { + "a": { + "k": true + } + } + }, + { + "name": "retune one degree", + "doc": { + "name": "12-TET", + "pitches": [ + 0, + 200, + 400, + 500, + 700, + 900 + ] + }, + "patch": [ + { + "op": "replace", + "path": "/pitches/2", + "value": 386 + } + ], + "result": { + "name": "12-TET", + "pitches": [ + 0, + 200, + 386, + 500, + 700, + 900 + ] + } + }, + { + "name": "append", + "doc": { + "name": "12-TET", + "pitches": [ + 0, + 200, + 400, + 500, + 700, + 900 + ] + }, + "patch": [ + { + "op": "add", + "path": "/pitches/6", + "value": 1000 + } + ], + "result": { + "name": "12-TET", + "pitches": [ + 0, + 200, + 400, + 500, + 700, + 900, + 1000 + ] + } + }, + { + "name": "insert in the middle", + "doc": { + "name": "12-TET", + "pitches": [ + 0, + 200, + 400, + 500, + 700, + 900 + ] + }, + "patch": [ + { + "op": "replace", + "path": "/pitches/3", + "value": 450 + }, + { + "op": "replace", + "path": "/pitches/4", + "value": 500 + }, + { + "op": "replace", + "path": "/pitches/5", + "value": 700 + }, + { + "op": "add", + "path": "/pitches/6", + "value": 900 + } + ], + "result": { + "name": "12-TET", + "pitches": [ + 0, + 200, + 400, + 450, + 500, + 700, + 900 + ] + } + }, + { + "name": "remove from the middle", + "doc": { + "name": "12-TET", + "pitches": [ + 0, + 200, + 400, + 500, + 700, + 900 + ] + }, + "patch": [ + { + "op": "replace", + "path": "/pitches/2", + "value": 500 + }, + { + "op": "replace", + "path": "/pitches/3", + "value": 700 + }, + { + "op": "replace", + "path": "/pitches/4", + "value": 900 + }, + { + "op": "remove", + "path": "/pitches/5" + } + ], + "result": { + "name": "12-TET", + "pitches": [ + 0, + 200, + 500, + 700, + 900 + ] + } + }, + { + "name": "remove several from the end", + "doc": { + "name": "12-TET", + "pitches": [ + 0, + 200, + 400, + 500, + 700, + 900 + ] + }, + "patch": [ + { + "op": "remove", + "path": "/pitches/2" + }, + { + "op": "remove", + "path": "/pitches/2" + }, + { + "op": "remove", + "path": "/pitches/2" + }, + { + "op": "remove", + "path": "/pitches/2" + } + ], + "result": { + "name": "12-TET", + "pitches": [ + 0, + 200 + ] + } + }, + { + "name": "empty the list", + "doc": { + "name": "12-TET", + "pitches": [ + 0, + 200, + 400, + 500, + 700, + 900 + ] + }, + "patch": [ + { + "op": "remove", + "path": "/pitches/0" + }, + { + "op": "remove", + "path": "/pitches/0" + }, + { + "op": "remove", + "path": "/pitches/0" + }, + { + "op": "remove", + "path": "/pitches/0" + }, + { + "op": "remove", + "path": "/pitches/0" + }, + { + "op": "remove", + "path": "/pitches/0" + } + ], + "result": { + "name": "12-TET", + "pitches": [] + } + }, + { + "name": "list of objects", + "doc": { + "tunings": [ + { + "n": "A", + "p": [ + 1, + 2 + ] + }, + { + "n": "B", + "p": [ + 3 + ] + } + ] + }, + "patch": [ + { + "op": "add", + "path": "/tunings/0/p/2", + "value": 5 + }, + { + "op": "replace", + "path": "/tunings/1/n", + "value": "B2" + }, + { + "op": "remove", + "path": "/tunings/1/p/0" + } + ], + "result": { + "tunings": [ + { + "n": "A", + "p": [ + 1, + 2, + 5 + ] + }, + { + "n": "B2", + "p": [] + } + ] + } + }, + { + "name": "keys needing escapes", + "doc": { + "a/b": 1, + "m~n": 2 + }, + "patch": [ + { + "op": "replace", + "path": "/a~1b", + "value": 3 + }, + { + "op": "replace", + "path": "/m~0n", + "value": 4 + }, + { + "op": "add", + "path": "/x~1~0y", + "value": 5 + } + ], + "result": { + "a/b": 3, + "m~n": 4, + "x/~y": 5 + } + }, + { + "name": "empty key", + "doc": { + "": 1 + }, + "patch": [ + { + "op": "replace", + "path": "/", + "value": 2 + } + ], + "result": { + "": 2 + } + }, + { + "name": "unicode", + "doc": { + "name": "Bohlen–Pierce" + }, + "patch": [ + { + "op": "replace", + "path": "/name", + "value": "Bohlen–Pierce ✓" + }, + { + "op": "add", + "path": "/ñ", + "value": "é" + } + ], + "result": { + "name": "Bohlen–Pierce ✓", + "ñ": "é" + } + }, + { + "name": "numbers", + "doc": { + "big": 9007199254740993, + "cents": 1.5, + "neg": -3 + }, + "patch": [ + { + "op": "replace", + "path": "/big", + "value": 9007199254740994 + }, + { + "op": "replace", + "path": "/cents", + "value": 386.3137 + }, + { + "op": "replace", + "path": "/neg", + "value": -0.5 + } + ], + "result": { + "big": 9007199254740994, + "cents": 386.3137, + "neg": -0.5 + } + }, + { + "name": "exponent-form numbers", + "doc": { + "a": 0.00001, + "b": 1e20, + "c": -1e19 + }, + "patch": [ + { + "op": "replace", + "path": "/a", + "value": 0.00002 + }, + { + "op": "replace", + "path": "/b", + "value": -1e20 + }, + { + "op": "replace", + "path": "/c", + "value": 1e19 + }, + { + "op": "add", + "path": "/d", + "value": 1.5e-7 + } + ], + "result": { + "a": 0.00002, + "b": -1e20, + "c": 1e19, + "d": 1.5e-7 + } + }, + { + "name": "keys that look like indices", + "doc": { + "-": 3, + "0": 1, + "01": 2, + "1abc": 4 + }, + "patch": [ + { + "op": "replace", + "path": "/-", + "value": 7 + }, + { + "op": "replace", + "path": "/0", + "value": 5 + }, + { + "op": "replace", + "path": "/01", + "value": 6 + }, + { + "op": "replace", + "path": "/1abc", + "value": 8 + }, + { + "op": "add", + "path": "/2", + "value": 9 + } + ], + "result": { + "-": 7, + "0": 5, + "01": 6, + "1abc": 8, + "2": 9 + } + }, + { + "name": "index-like keys nested in a list", + "doc": { + "l": [ + { + "-": 1, + "0": [ + 1, + 2 + ] + } + ] + }, + "patch": [ + { + "op": "replace", + "path": "/l/0/-", + "value": null + }, + { + "op": "replace", + "path": "/l/0/0/1", + "value": 3 + }, + { + "op": "add", + "path": "/l/0/0/2", + "value": 2 + } + ], + "result": { + "l": [ + { + "-": null, + "0": [ + 1, + 3, + 2 + ] + } + ] + } + }, + { + "name": "random 0", + "doc": { + "k1": 258 + }, + "patch": [ + { + "op": "replace", + "path": "/k1", + "value": { + "k0": { + "k3": false, + "k4": -16 + }, + "k1": [], + "k3": 947.5 + } + }, + { + "op": "add", + "path": "/new4", + "value": { + "k3": { + "k3": true + } + } + } + ], + "result": { + "k1": { + "k0": { + "k3": false, + "k4": -16 + }, + "k1": [], + "k3": 947.5 + }, + "new4": { + "k3": { + "k3": true + } + } + } + }, + { + "name": "random 1", + "doc": { + "k1": null + }, + "patch": [], + "result": { + "k1": null + } + }, + { + "name": "random 2", + "doc": { + "k0": "s89", + "k4": "s3" + }, + "patch": [ + { + "op": "replace", + "path": "/k4", + "value": 720.5 + } + ], + "result": { + "k0": "s89", + "k4": 720.5 + } + }, + { + "name": "random 3", + "doc": { + "k0": 227.5, + "k1": "s58", + "k4": 925 + }, + "patch": [ + { + "op": "replace", + "path": "/k4", + "value": "s17" + }, + { + "op": "add", + "path": "/new1", + "value": [] + }, + { + "op": "remove", + "path": "/k0" + } + ], + "result": { + "k1": "s58", + "k4": "s17", + "new1": [] + } + }, + { + "name": "random 4", + "doc": { + "k0": 649.5, + "k2": { + "k0": { + "k1": [ + true + ], + "k2": true + }, + "k2": { + "k3": [ + 468 + ], + "k5": { + "k4": "s67" + } + }, + "k4": -19 + }, + "k4": { + "k0": [ + { + "k2": "s65", + "k4": 855.5 + }, + 342.5 + ], + "k1": { + "k1": 484.5, + "k2": -507, + "k4": { + "k1": null, + "k4": 663 + } + } + } + }, + "patch": [ + { + "op": "replace", + "path": "/k0", + "value": [] + }, + { + "op": "replace", + "path": "/k2/k0", + "value": 52 + }, + { + "op": "replace", + "path": "/k2/k2", + "value": [ + -907, + -582 + ] + }, + { + "op": "replace", + "path": "/k2/k4", + "value": [] + }, + { + "op": "replace", + "path": "/k4/k0/0/k2", + "value": null + }, + { + "op": "remove", + "path": "/k4/k0/0/k4" + }, + { + "op": "replace", + "path": "/k4/k0/1", + "value": -111 + }, + { + "op": "remove", + "path": "/k4/k1" + } + ], + "result": { + "k0": [], + "k2": { + "k0": 52, + "k2": [ + -907, + -582 + ], + "k4": [] + }, + "k4": { + "k0": [ + { + "k2": null + }, + -111 + ] + } + } + }, + { + "name": "random 5", + "doc": { + "k5": [ + { + "k5": [ + null, + 153.5, + false + ] + }, + { + "k1": -477 + }, + { + "k1": false, + "k4": "s62" + }, + [ + [ + true, + 425.5, + 551.5 + ], + { + "k1": 350.5, + "k2": 587.5, + "k3": 992 + }, + 503.5, + true + ] + ] + }, + "patch": [ + { + "op": "replace", + "path": "/k5/0/k5/1", + "value": false + }, + { + "op": "add", + "path": "/k5/0/new1", + "value": { + "k3": { + "k0": "s29", + "k3": null, + "k4": 400.5 + } + } + }, + { + "op": "remove", + "path": "/k5/2/k1" + }, + { + "op": "replace", + "path": "/k5/3/0/1", + "value": 405.5 + }, + { + "op": "replace", + "path": "/k5/3/1/k2", + "value": { + "k1": { + "k0": null + }, + "k5": { + "k2": "s80" + } + } + }, + { + "op": "replace", + "path": "/k5/3/1/k3", + "value": { + "k1": true, + "k2": [ + 896.5, + -575, + "s10" + ], + "k3": false + } + }, + { + "op": "remove", + "path": "/k5/3/1/k1" + }, + { + "op": "replace", + "path": "/k5/3/2", + "value": "s16" + }, + { + "op": "replace", + "path": "/k5/3/3", + "value": null + }, + { + "op": "add", + "path": "/k5/3/4", + "value": { + "k0": false, + "k1": "s37", + "k3": 491.5 + } + } + ], + "result": { + "k5": [ + { + "k5": [ + null, + false, + false + ], + "new1": { + "k3": { + "k0": "s29", + "k3": null, + "k4": 400.5 + } + } + }, + { + "k1": -477 + }, + { + "k4": "s62" + }, + [ + [ + true, + 405.5, + 551.5 + ], + { + "k2": { + "k1": { + "k0": null + }, + "k5": { + "k2": "s80" + } + }, + "k3": { + "k1": true, + "k2": [ + 896.5, + -575, + "s10" + ], + "k3": false + } + }, + "s16", + null, + { + "k0": false, + "k1": "s37", + "k3": 491.5 + } + ] + ] + } + }, + { + "name": "random 6", + "doc": { + "k0": [ + [ + "s31" + ] + ], + "k2": [] + }, + "patch": [ + { + "op": "remove", + "path": "/k0/0" + }, + { + "op": "add", + "path": "/k2/0", + "value": -861 + } + ], + "result": { + "k0": [], + "k2": [ + -861 + ] + } + }, + { + "name": "random 7", + "doc": { + "k0": -149, + "k4": true, + "k5": [ + 492, + { + "k0": [ + -438 + ], + "k1": [], + "k4": [ + null + ], + "k5": 854.5 + }, + { + "k4": [] + }, + [ + { + "k3": 14.5, + "k4": null + }, + { + "k1": true, + "k3": null, + "k5": null + } + ] + ] + }, + "patch": [ + { + "op": "replace", + "path": "/k5/0", + "value": 56.5 + }, + { + "op": "remove", + "path": "/k5/1/k0/0" + }, + { + "op": "remove", + "path": "/k5/1/k4/0" + }, + { + "op": "replace", + "path": "/k5/1/k5", + "value": [ + [ + 464.5 + ], + null, + "s38", + [ + 944.5 + ] + ] + }, + { + "op": "add", + "path": "/k5/1/new2", + "value": [ + [ + "s22", + 92 + ], + { + "k4": null, + "k5": "s86" + }, + [ + 700.5, + "s69", + true + ], + false + ] + }, + { + "op": "remove", + "path": "/k5/1/k1" + }, + { + "op": "add", + "path": "/k5/2/new5", + "value": [] + }, + { + "op": "add", + "path": "/k5/3/0/k1", + "value": true + }, + { + "op": "replace", + "path": "/k5/3/0/k3", + "value": null + }, + { + "op": "add", + "path": "/k5/3/0/k5", + "value": null + }, + { + "op": "remove", + "path": "/k5/3/0/k4" + }, + { + "op": "remove", + "path": "/k5/3/1" + }, + { + "op": "add", + "path": "/new1", + "value": { + "k0": false, + "k4": { + "k0": null, + "k5": null + }, + "k5": { + "k0": null, + "k2": -535, + "k4": true, + "k5": 524 + } + } + } + ], + "result": { + "k0": -149, + "k4": true, + "k5": [ + 56.5, + { + "k0": [], + "k4": [], + "k5": [ + [ + 464.5 + ], + null, + "s38", + [ + 944.5 + ] + ], + "new2": [ + [ + "s22", + 92 + ], + { + "k4": null, + "k5": "s86" + }, + [ + 700.5, + "s69", + true + ], + false + ] + }, + { + "k4": [], + "new5": [] + }, + [ + { + "k1": true, + "k3": null, + "k5": null + } + ] + ], + "new1": { + "k0": false, + "k4": { + "k0": null, + "k5": null + }, + "k5": { + "k0": null, + "k2": -535, + "k4": true, + "k5": 524 + } + } + } + }, + { + "name": "random 8", + "doc": { + "k1": [ + { + "k0": [ + -633 + ], + "k1": { + "k2": null, + "k4": 715 + }, + "k2": "s70" + }, + [ + "s18", + true, + "s67" + ], + [ + 96.5 + ], + { + "k2": { + "k0": false + }, + "k3": true + } + ] + }, + "patch": [ + { + "op": "remove", + "path": "/k1" + } + ], + "result": {} + }, + { + "name": "random 9", + "doc": { + "k0": { + "k0": { + "k3": null, + "k4": { + "k1": "s7", + "k5": null + } + }, + "k2": 388.5, + "k4": { + "k3": { + "k5": null + }, + "k4": -395 + } + }, + "k2": [ + [ + false + ] + ], + "k4": { + "k4": [ + [ + true + ] + ], + "k5": 128 + } + }, + "patch": [ + { + "op": "replace", + "path": "/k0", + "value": true + }, + { + "op": "replace", + "path": "/k2", + "value": { + "k1": [], + "k2": [], + "k5": 615.5 + } + }, + { + "op": "remove", + "path": "/k4/k4/0" + } + ], + "result": { + "k0": true, + "k2": { + "k1": [], + "k2": [], + "k5": 615.5 + }, + "k4": { + "k4": [], + "k5": 128 + } + } + }, + { + "name": "random 10", + "doc": { + "k3": { + "k4": [ + -773, + 162.5, + null, + "s71" + ], + "k5": { + "k1": 891 + } + } + }, + "patch": [ + { + "op": "remove", + "path": "/k3" + } + ], + "result": {} + }, + { + "name": "random 11", + "doc": { + "k0": [ + { + "k1": { + "k1": 188.5, + "k4": 152.5, + "k5": "s7" + } + }, + null, + 457.5, + false + ], + "k2": { + "k0": null, + "k5": 772 + }, + "k5": [ + { + "k5": { + "k2": -800, + "k3": false, + "k5": null + } + } + ] + }, + "patch": [ + { + "op": "replace", + "path": "/k0/0/k1/k5", + "value": true + }, + { + "op": "remove", + "path": "/k0/0/k1/k1" + }, + { + "op": "add", + "path": "/k0/0/new4", + "value": 76.5 + }, + { + "op": "replace", + "path": "/k0/1", + "value": 457.5 + }, + { + "op": "replace", + "path": "/k0/2", + "value": false + }, + { + "op": "remove", + "path": "/k0/3" + }, + { + "op": "remove", + "path": "/k2/k5" + }, + { + "op": "replace", + "path": "/k5/0", + "value": -289 + } + ], + "result": { + "k0": [ + { + "k1": { + "k4": 152.5, + "k5": true + }, + "new4": 76.5 + }, + 457.5, + false + ], + "k2": { + "k0": null + }, + "k5": [ + -289 + ] + } + }, + { + "name": "random 12", + "doc": { + "k2": null, + "k4": { + "k0": [], + "k1": true, + "k4": [ + false + ] + } + }, + "patch": [ + { + "op": "add", + "path": "/k4/k0/0", + "value": { + "k0": true, + "k2": true + } + }, + { + "op": "replace", + "path": "/k4/k1", + "value": null + }, + { + "op": "add", + "path": "/new0", + "value": { + "k3": { + "k3": null, + "k4": 800.5 + }, + "k4": { + "k0": 59.5, + "k3": null + } + } + } + ], + "result": { + "k2": null, + "k4": { + "k0": [ + { + "k0": true, + "k2": true + } + ], + "k1": null, + "k4": [ + false + ] + }, + "new0": { + "k3": { + "k3": null, + "k4": 800.5 + }, + "k4": { + "k0": 59.5, + "k3": null + } + } + } + }, + { + "name": "random 13", + "doc": { + "k0": { + "k3": { + "k4": [ + 327, + null, + 352.5 + ] + }, + "k5": 646.5 + }, + "k1": [], + "k2": { + "k1": null + }, + "k3": "s4" + }, + "patch": [ + { + "op": "remove", + "path": "/k0/k3" + }, + { + "op": "add", + "path": "/k1/0", + "value": [ + 597.5 + ] + }, + { + "op": "remove", + "path": "/k2" + } + ], + "result": { + "k0": { + "k5": 646.5 + }, + "k1": [ + [ + 597.5 + ] + ], + "k3": "s4" + } + }, + { + "name": "random 14", + "doc": { + "k0": [] + }, + "patch": [ + { + "op": "add", + "path": "/new5", + "value": { + "k0": { + "k2": "s8" + }, + "k3": [] + } + } + ], + "result": { + "k0": [], + "new5": { + "k0": { + "k2": "s8" + }, + "k3": [] + } + } + }, + { + "name": "random 15", + "doc": { + "k0": 896.5, + "k4": 100.5, + "k5": [ + null, + [ + [ + 641.5, + "s44", + false, + 427.5 + ], + [ + "s19", + true, + "s19" + ], + { + "k0": -868, + "k4": "s36" + } + ] + ] + }, + "patch": [ + { + "op": "replace", + "path": "/k5/1", + "value": { + "k1": "s80", + "k4": false, + "k5": 322.5 + } + }, + { + "op": "add", + "path": "/k5/2", + "value": [ + [ + 641.5, + 323.5, + 427.5 + ], + true, + [ + "s19", + true, + "s19" + ], + { + "k4": "s36" + } + ] + }, + { + "op": "add", + "path": "/new5", + "value": [ + [ + 943, + 875.5, + -220, + false + ], + [ + null, + true, + null + ], + "s26", + { + "k1": false, + "k2": true + } + ] + } + ], + "result": { + "k0": 896.5, + "k4": 100.5, + "k5": [ + null, + { + "k1": "s80", + "k4": false, + "k5": 322.5 + }, + [ + [ + 641.5, + 323.5, + 427.5 + ], + true, + [ + "s19", + true, + "s19" + ], + { + "k4": "s36" + } + ] + ], + "new5": [ + [ + 943, + 875.5, + -220, + false + ], + [ + null, + true, + null + ], + "s26", + { + "k1": false, + "k2": true + } + ] + } + }, + { + "name": "random 16", + "doc": { + "k1": { + "k0": { + "k0": { + "k1": "s43", + "k2": null, + "k3": 629.5, + "k4": 147.5 + }, + "k1": 656.5 + }, + "k5": null + } + }, + "patch": [ + { + "op": "remove", + "path": "/k1" + } + ], + "result": {} + }, + { + "name": "random 17", + "doc": { + "k4": -288 + }, + "patch": [ + { + "op": "add", + "path": "/new1", + "value": [ + 837.5 + ] + }, + { + "op": "remove", + "path": "/k4" + } + ], + "result": { + "new1": [ + 837.5 + ] + } + }, + { + "name": "random 18", + "doc": { + "k0": 259.5, + "k3": { + "k2": [ + [], + [ + 474.5 + ] + ], + "k3": [ + [ + true, + 650.5 + ], + [ + "s45", + "s65", + 753, + 130.5 + ] + ], + "k4": [ + { + "k1": null + }, + false, + 976.5 + ] + }, + "k4": [ + { + "k2": "s77", + "k5": 644.5 + } + ] + }, + "patch": [ + { + "op": "add", + "path": "/k3/k2/0/0", + "value": null + }, + { + "op": "remove", + "path": "/k3/k2/1" + }, + { + "op": "replace", + "path": "/k3/k3", + "value": { + "k0": [], + "k2": [ + 523.5 + ] + } + }, + { + "op": "remove", + "path": "/k3/k4" + }, + { + "op": "remove", + "path": "/k4/0/k5" + }, + { + "op": "add", + "path": "/new4", + "value": 708 + } + ], + "result": { + "k0": 259.5, + "k3": { + "k2": [ + [ + null + ] + ], + "k3": { + "k0": [], + "k2": [ + 523.5 + ] + } + }, + "k4": [ + { + "k2": "s77" + } + ], + "new4": 708 + } + }, + { + "name": "random 19", + "doc": { + "k5": [ + [ + false, + [], + { + "k2": -867, + "k5": -749 + }, + 320.5 + ], + null, + 540, + { + "k2": { + "k0": 973.5 + } + } + ] + }, + "patch": [ + { + "op": "replace", + "path": "/k5/0/0", + "value": [ + 303 + ] + }, + { + "op": "replace", + "path": "/k5/0/1", + "value": { + "k2": -867, + "k5": -749 + } + }, + { + "op": "replace", + "path": "/k5/0/2", + "value": 320.5 + }, + { + "op": "remove", + "path": "/k5/0/3" + }, + { + "op": "replace", + "path": "/k5/3/k2/k0", + "value": { + "k3": { + "k3": 257.5, + "k4": "s43" + } + } + }, + { + "op": "add", + "path": "/k5/4", + "value": [] + } + ], + "result": { + "k5": [ + [ + [ + 303 + ], + { + "k2": -867, + "k5": -749 + }, + 320.5 + ], + null, + 540, + { + "k2": { + "k0": { + "k3": { + "k3": 257.5, + "k4": "s43" + } + } + } + }, + [] + ] + } + }, + { + "name": "random 20", + "doc": { + "k0": [ + { + "k1": [ + true + ], + "k3": { + "k0": false, + "k2": 419, + "k3": null + }, + "k5": { + "k4": "s36" + } + } + ] + }, + "patch": [ + { + "op": "replace", + "path": "/k0/0", + "value": [ + true, + true, + "s50" + ] + }, + { + "op": "add", + "path": "/k0/1", + "value": null + }, + { + "op": "add", + "path": "/k0/2", + "value": [] + }, + { + "op": "add", + "path": "/k0/3", + "value": [ + null + ] + } + ], + "result": { + "k0": [ + [ + true, + true, + "s50" + ], + null, + [], + [ + null + ] + ] + } + }, + { + "name": "random 21", + "doc": { + "k4": false, + "k5": { + "k0": [ + [ + true, + 307, + "s36" + ], + 806.5, + { + "k0": null, + "k2": null + } + ], + "k3": [ + { + "k0": 553.5, + "k1": 232.5, + "k5": 371.5 + } + ], + "k5": [] + } + }, + "patch": [ + { + "op": "replace", + "path": "/k5", + "value": 536 + } + ], + "result": { + "k4": false, + "k5": 536 + } + }, + { + "name": "random 22", + "doc": { + "k2": null + }, + "patch": [ + { + "op": "remove", + "path": "/k2" + } + ], + "result": {} + }, + { + "name": "random 23", + "doc": { + "k0": [ + true, + null, + { + "k1": false + } + ], + "k3": [ + [], + 70.5, + [ + [ + true + ] + ], + { + "k1": null, + "k4": -318 + } + ] + }, + "patch": [ + { + "op": "replace", + "path": "/k0/0", + "value": -338 + }, + { + "op": "replace", + "path": "/k0/1", + "value": -79 + }, + { + "op": "replace", + "path": "/k0/2/k1", + "value": [ + false + ] + }, + { + "op": "add", + "path": "/k3/0/0", + "value": "s36" + }, + { + "op": "replace", + "path": "/k3/1", + "value": 291.5 + }, + { + "op": "add", + "path": "/k3/2/0/1", + "value": [ + "s79", + 776.5, + "s88", + -690 + ] + }, + { + "op": "replace", + "path": "/k3/3/k1", + "value": -633 + } + ], + "result": { + "k0": [ + -338, + -79, + { + "k1": [ + false + ] + } + ], + "k3": [ + [ + "s36" + ], + 291.5, + [ + [ + true, + [ + "s79", + 776.5, + "s88", + -690 + ] + ] + ], + { + "k1": -633, + "k4": -318 + } + ] + } + }, + { + "name": "random 24", + "doc": { + "k1": [ + { + "k2": [ + true + ], + "k5": [ + 970.5, + false, + null, + "s35" + ] + }, + { + "k3": { + "k1": null, + "k5": null + } + } + ], + "k2": -386, + "k3": { + "k0": [ + { + "k2": "s91" + }, + [ + true, + -423 + ] + ], + "k2": [], + "k4": -854 + } + }, + "patch": [ + { + "op": "replace", + "path": "/k1/0/k2/0", + "value": [ + false, + true, + "s10" + ] + }, + { + "op": "add", + "path": "/k1/0/k2/1", + "value": null + }, + { + "op": "remove", + "path": "/k1/0/k5" + }, + { + "op": "add", + "path": "/k1/1/new3", + "value": { + "k2": { + "k0": -816, + "k1": 738.5, + "k4": "s71" + }, + "k4": [ + 275.5, + true, + 372 + ] + } + }, + { + "op": "remove", + "path": "/k1/1/k3" + }, + { + "op": "add", + "path": "/new1", + "value": [ + -850, + 522.5, + [ + "s11" + ] + ] + }, + { + "op": "remove", + "path": "/k3" + } + ], + "result": { + "k1": [ + { + "k2": [ + [ + false, + true, + "s10" + ], + null + ] + }, + { + "new3": { + "k2": { + "k0": -816, + "k1": 738.5, + "k4": "s71" + }, + "k4": [ + 275.5, + true, + 372 + ] + } + } + ], + "k2": -386, + "new1": [ + -850, + 522.5, + [ + "s11" + ] + ] + } + }, + { + "name": "random 25", + "doc": { + "k2": { + "k1": { + "k0": [ + 752.5, + 661, + 963.5 + ], + "k2": [ + "s8", + null + ], + "k3": [ + false + ], + "k5": { + "k2": -173 + } + } + }, + "k5": { + "k3": true + } + }, + "patch": [ + { + "op": "add", + "path": "/k2/k0", + "value": [ + "s63", + null, + "s32", + 185.5 + ] + }, + { + "op": "add", + "path": "/k2/k5", + "value": 152.5 + }, + { + "op": "remove", + "path": "/k2/k1" + }, + { + "op": "remove", + "path": "/k5/k3" + } + ], + "result": { + "k2": { + "k0": [ + "s63", + null, + "s32", + 185.5 + ], + "k5": 152.5 + }, + "k5": {} + } + }, + { + "name": "random 26", + "doc": { + "k1": { + "k2": { + "k2": { + "k3": 451.5, + "k5": null + }, + "k4": { + "k2": null + }, + "k5": true + }, + "k3": "s92" + }, + "k2": [ + false, + null, + { + "k0": [], + "k2": [ + "s73", + false, + -426 + ] + }, + { + "k1": "s66", + "k4": null + } + ], + "k3": [ + [ + false, + [ + -445, + 731.5, + "s98", + 46 + ], + [ + 393, + null, + true + ] + ], + null, + { + "k2": false + } + ], + "k4": { + "k5": [ + "s24", + { + "k5": null + }, + [ + false, + null, + "s45" + ] + ] + } + }, + "patch": [ + { + "op": "remove", + "path": "/k1/k2/k4" + }, + { + "op": "replace", + "path": "/k3/0", + "value": null + }, + { + "op": "replace", + "path": "/k3/1", + "value": { + "k2": false, + "new1": -624 + } + }, + { + "op": "remove", + "path": "/k3/2" + }, + { + "op": "replace", + "path": "/k4/k5/1", + "value": [ + "s93", + 907.5 + ] + }, + { + "op": "replace", + "path": "/k4/k5/2", + "value": { + "k5": 430.5, + "new1": null + } + }, + { + "op": "add", + "path": "/k4/k5/3", + "value": [ + false, + null, + "s45" + ] + }, + { + "op": "add", + "path": "/new4", + "value": [ + { + "k5": "s55" + } + ] + }, + { + "op": "remove", + "path": "/k2" + } + ], + "result": { + "k1": { + "k2": { + "k2": { + "k3": 451.5, + "k5": null + }, + "k5": true + }, + "k3": "s92" + }, + "k3": [ + null, + { + "k2": false, + "new1": -624 + } + ], + "k4": { + "k5": [ + "s24", + [ + "s93", + 907.5 + ], + { + "k5": 430.5, + "new1": null + }, + [ + false, + null, + "s45" + ] + ] + }, + "new4": [ + { + "k5": "s55" + } + ] + } + }, + { + "name": "random 27", + "doc": { + "k1": [ + -417, + [ + null + ] + ], + "k5": { + "k1": -11, + "k3": { + "k0": { + "k0": "s87", + "k2": 200, + "k4": 372.5, + "k5": "s65" + }, + "k5": [ + true, + "s15", + "s75", + "s40" + ] + }, + "k4": true, + "k5": 35.5 + } + }, + "patch": [ + { + "op": "add", + "path": "/k1/2", + "value": [ + null, + { + "k4": "s12" + } + ] + }, + { + "op": "replace", + "path": "/k5/k3/k0", + "value": [ + null + ] + }, + { + "op": "add", + "path": "/k5/k3/new5", + "value": false + }, + { + "op": "replace", + "path": "/k5/k5", + "value": "s42" + } + ], + "result": { + "k1": [ + -417, + [ + null + ], + [ + null, + { + "k4": "s12" + } + ] + ], + "k5": { + "k1": -11, + "k3": { + "k0": [ + null + ], + "k5": [ + true, + "s15", + "s75", + "s40" + ], + "new5": false + }, + "k4": true, + "k5": "s42" + } + } + }, + { + "name": "random 28", + "doc": { + "k3": { + "k3": { + "k4": [] + }, + "k4": "s92", + "k5": 138.5 + } + }, + "patch": [ + { + "op": "add", + "path": "/new1", + "value": [ + [ + "s3" + ], + { + "k3": -482, + "k4": null + }, + [], + { + "k2": 792.5, + "k3": true + } + ] + }, + { + "op": "remove", + "path": "/k3" + } + ], + "result": { + "new1": [ + [ + "s3" + ], + { + "k3": -482, + "k4": null + }, + [], + { + "k2": 792.5, + "k3": true + } + ] + } + }, + { + "name": "random 29", + "doc": { + "k2": { + "k2": { + "k3": true + }, + "k3": 93.5 + }, + "k4": [ + { + "k3": [ + false + ], + "k4": true + } + ] + }, + "patch": [ + { + "op": "add", + "path": "/k2/k2/new4", + "value": { + "k3": { + "k1": "s17" + } + } + }, + { + "op": "remove", + "path": "/k4/0" + } + ], + "result": { + "k2": { + "k2": { + "k3": true, + "new4": { + "k3": { + "k1": "s17" + } + } + }, + "k3": 93.5 + }, + "k4": [] + } + }, + { + "name": "random 30", + "doc": { + "k0": true, + "k3": [] + }, + "patch": [ + { + "op": "replace", + "path": "/k0", + "value": { + "k3": [] + } + }, + { + "op": "add", + "path": "/new5", + "value": 891.5 + }, + { + "op": "remove", + "path": "/k3" + } + ], + "result": { + "k0": { + "k3": [] + }, + "new5": 891.5 + } + }, + { + "name": "random 31", + "doc": { + "k2": [ + { + "k1": [ + 201.5, + "s80", + -616 + ] + }, + [ + [ + "s59", + null + ], + { + "k3": null + }, + [ + 449, + true, + null, + -756 + ], + { + "k4": "s69" + } + ], + 863 + ], + "k4": true + }, + "patch": [ + { + "op": "replace", + "path": "/k2", + "value": null + } + ], + "result": { + "k2": null, + "k4": true + } + }, + { + "name": "random 32", + "doc": { + "k0": { + "k5": 517 + }, + "k1": [], + "k4": "s55", + "k5": [] + }, + "patch": [ + { + "op": "add", + "path": "/k0/k3", + "value": -602 + }, + { + "op": "remove", + "path": "/k0/k5" + }, + { + "op": "add", + "path": "/k1/0", + "value": [ + "s49", + -544, + 343.5 + ] + } + ], + "result": { + "k0": { + "k3": -602 + }, + "k1": [ + [ + "s49", + -544, + 343.5 + ] + ], + "k4": "s55", + "k5": [] + } + }, + { + "name": "random 33", + "doc": { + "k1": { + "k0": 179.5 + }, + "k2": null + }, + "patch": [ + { + "op": "replace", + "path": "/k2", + "value": false + }, + { + "op": "add", + "path": "/new5", + "value": [] + }, + { + "op": "remove", + "path": "/k1" + } + ], + "result": { + "k2": false, + "new5": [] + } + }, + { + "name": "random 34", + "doc": { + "k0": { + "k1": [ + { + "k1": null, + "k3": null + }, + "s88" + ], + "k5": [ + [], + false, + null, + { + "k0": null, + "k4": "s97", + "k5": 795 + } + ] + }, + "k1": [ + true, + "s4" + ], + "k3": { + "k0": "s21", + "k3": null, + "k5": null + } + }, + "patch": [ + { + "op": "replace", + "path": "/k0", + "value": [] + }, + { + "op": "replace", + "path": "/k1/0", + "value": null + }, + { + "op": "remove", + "path": "/k1/1" + }, + { + "op": "replace", + "path": "/k3/k3", + "value": 280 + }, + { + "op": "replace", + "path": "/k3/k5", + "value": "s47" + }, + { + "op": "remove", + "path": "/k3/k0" + } + ], + "result": { + "k0": [], + "k1": [ + null + ], + "k3": { + "k3": 280, + "k5": "s47" + } + } + }, + { + "name": "random 35", + "doc": { + "k1": [ + 29, + 239.5, + [] + ] + }, + "patch": [ + { + "op": "replace", + "path": "/k1/0", + "value": "s54" + }, + { + "op": "replace", + "path": "/k1/1", + "value": { + "k5": false + } + }, + { + "op": "replace", + "path": "/k1/2", + "value": "s58" + }, + { + "op": "add", + "path": "/k1/3", + "value": [] + } + ], + "result": { + "k1": [ + "s54", + { + "k5": false + }, + "s58", + [] + ] + } + }, + { + "name": "random 36", + "doc": { + "k0": { + "k0": { + "k5": 99.5 + } + }, + "k2": true, + "k5": { + "k4": { + "k1": { + "k1": 418, + "k3": false, + "k4": 78.5 + } + }, + "k5": [ + "s75", + [ + -191, + "s33", + -382 + ], + 761, + { + "k1": "s66", + "k3": 301.5, + "k4": 454 + } + ] + } + }, + "patch": [ + { + "op": "replace", + "path": "/k0", + "value": 524.5 + }, + { + "op": "add", + "path": "/k5/k1", + "value": [] + }, + { + "op": "remove", + "path": "/k5/k4" + }, + { + "op": "remove", + "path": "/k5/k5" + } + ], + "result": { + "k0": 524.5, + "k2": true, + "k5": { + "k1": [] + } + } + }, + { + "name": "random 37", + "doc": { + "k0": [ + [ + { + "k0": null, + "k2": true, + "k4": 56 + } + ], + [ + { + "k5": null + }, + [ + true + ], + { + "k1": null, + "k2": "s98" + } + ], + "s30", + false + ], + "k3": 724, + "k4": [ + { + "k3": [] + } + ], + "k5": [ + { + "k0": "s96" + }, + -653 + ] + }, + "patch": [ + { + "op": "add", + "path": "/k0/0/1", + "value": { + "k0": "s23", + "k1": 199.5 + } + }, + { + "op": "replace", + "path": "/k0/1/0/k5", + "value": 698 + }, + { + "op": "add", + "path": "/k0/1/0/new2", + "value": [] + }, + { + "op": "replace", + "path": "/k0/1/1", + "value": { + "k1": [ + [ + "s15" + ], + { + "k3": true, + "k5": null + } + ], + "k2": "s98" + } + }, + { + "op": "remove", + "path": "/k0/1/2" + }, + { + "op": "add", + "path": "/k0/4", + "value": -354 + }, + { + "op": "replace", + "path": "/k5/0/k0", + "value": [ + { + "k1": false + }, + { + "k0": true, + "k2": null + }, + { + "k0": 952.5, + "k1": "s69", + "k5": "s13" + }, + { + "k4": true, + "k5": null + } + ] + }, + { + "op": "remove", + "path": "/k5/1" + }, + { + "op": "remove", + "path": "/k3" + }, + { + "op": "remove", + "path": "/k4" + } + ], + "result": { + "k0": [ + [ + { + "k0": null, + "k2": true, + "k4": 56 + }, + { + "k0": "s23", + "k1": 199.5 + } + ], + [ + { + "k5": 698, + "new2": [] + }, + { + "k1": [ + [ + "s15" + ], + { + "k3": true, + "k5": null + } + ], + "k2": "s98" + } + ], + "s30", + false, + -354 + ], + "k5": [ + { + "k0": [ + { + "k1": false + }, + { + "k0": true, + "k2": null + }, + { + "k0": 952.5, + "k1": "s69", + "k5": "s13" + }, + { + "k4": true, + "k5": null + } + ] + } + ] + } + }, + { + "name": "random 38", + "doc": { + "k1": { + "k0": [] + }, + "k4": 604, + "k5": [] + }, + "patch": [ + { + "op": "add", + "path": "/k1/new3", + "value": { + "k1": [], + "k5": { + "k1": true, + "k4": -421 + } + } + }, + { + "op": "remove", + "path": "/k1/k0" + }, + { + "op": "remove", + "path": "/k5" + } + ], + "result": { + "k1": { + "new3": { + "k1": [], + "k5": { + "k1": true, + "k4": -421 + } + } + }, + "k4": 604 + } + }, + { + "name": "random 39", + "doc": { + "k0": { + "k0": [ + { + "k0": 297.5, + "k3": -187, + "k5": 774 + }, + -586 + ], + "k1": false, + "k3": [ + { + "k1": -468, + "k2": "s75" + } + ], + "k5": true + } + }, + "patch": [ + { + "op": "replace", + "path": "/k0/k0/0/k0", + "value": 568.5 + }, + { + "op": "replace", + "path": "/k0/k0/0/k3", + "value": 442 + }, + { + "op": "remove", + "path": "/k0/k0/0/k5" + }, + { + "op": "add", + "path": "/k0/k0/2", + "value": [ + false, + 851, + false + ] + }, + { + "op": "replace", + "path": "/k0/k3/0/k1", + "value": 544.5 + }, + { + "op": "replace", + "path": "/k0/k3/0/k2", + "value": null + }, + { + "op": "add", + "path": "/k0/k3/0/new1", + "value": "s98" + }, + { + "op": "remove", + "path": "/k0/k1" + } + ], + "result": { + "k0": { + "k0": [ + { + "k0": 568.5, + "k3": 442 + }, + -586, + [ + false, + 851, + false + ] + ], + "k3": [ + { + "k1": 544.5, + "k2": null, + "new1": "s98" + } + ], + "k5": true + } + } + } +] diff --git a/replicant-client/tests/fixtures/server_v2/v2_frames.json b/replicant-client/tests/fixtures/server_v2/v2_frames.json index fe13e9c..fde86d1 100644 --- a/replicant-client/tests/fixtures/server_v2/v2_frames.json +++ b/replicant-client/tests/fixtures/server_v2/v2_frames.json @@ -534,6 +534,7 @@ "sync:v2", "upload", { + "base_hash": "8064ca1f4788d8c96d8e65d48b6a91e6ba8c594c6b6394fdd48752b3eea1ce11", "doc_id": "00000000-0000-0000-0000-000000000000", "kind": "delete", "payload": null, diff --git a/replicant-client/tests/fts_tests.rs b/replicant-client/tests/fts_tests.rs deleted file mode 100644 index d5761fa..0000000 --- a/replicant-client/tests/fts_tests.rs +++ /dev/null @@ -1,163 +0,0 @@ -mod common; - -use common::{make_document, setup_test_db}; -use uuid::Uuid; - -#[tokio::test] -async fn test_fts_configure_and_search() { - let db = setup_test_db().await; - - // Set up user config - db.ensure_user_config("ws://localhost:8080/ws") - .await - .unwrap(); - let user_id = db.get_user_id().await.unwrap(); - - // Configure search paths (make_document uses $.text, not $.body) - db.configure_search(&["$.text".to_string()]).await.unwrap(); - - // Create test documents - let doc1 = make_document( - user_id, - "Music Theory", - "The study of harmony and melody", - 1, - ); - let doc2 = make_document(user_id, "Piano Practice", "Daily scales and arpeggios", 1); - let doc3 = make_document( - user_id, - "Guitar Chords", - "Learning basic chord progressions", - 1, - ); - - // FTS is automatically updated on save - db.save_document(&doc1).await.unwrap(); - db.save_document(&doc2).await.unwrap(); - db.save_document(&doc3).await.unwrap(); - - // Search for "harmony" - should match doc1 - let results = db.search_documents("harmony", 100).await.unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].id, doc1.id); - - // Search for "piano" - should match doc2 (title is indexed) - let results = db.search_documents("piano", 100).await.unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].id, doc2.id); - - // Search for non-existent term - let results = db.search_documents("nonexistent", 100).await.unwrap(); - assert!(results.is_empty()); -} - -#[tokio::test] -async fn test_fts_prefix_search() { - let db = setup_test_db().await; - - db.ensure_user_config("ws://localhost:8080/ws") - .await - .unwrap(); - let user_id = db.get_user_id().await.unwrap(); - - db.configure_search(&["$.text".to_string()]).await.unwrap(); - - // Create documents with related words - let doc1 = make_document(user_id, "Tuning Guide", "How to tune your instrument", 1); - let doc2 = make_document(user_id, "Tuner App", "A digital tuner application", 1); - let doc3 = make_document(user_id, "Other", "Something unrelated", 1); - - db.save_document(&doc1).await.unwrap(); - db.save_document(&doc2).await.unwrap(); - db.save_document(&doc3).await.unwrap(); - - // Prefix search for "tun*" should match both tuning and tuner docs - let results = db.search_documents("tun*", 100).await.unwrap(); - assert_eq!(results.len(), 2); - - let ids: Vec<Uuid> = results.iter().map(|d| d.id).collect(); - assert!(ids.contains(&doc1.id)); - assert!(ids.contains(&doc2.id)); -} - -#[tokio::test] -async fn test_fts_user_isolation() { - let db = setup_test_db().await; - - db.ensure_user_config("ws://localhost:8080/ws") - .await - .unwrap(); - let user1_id = db.get_user_id().await.unwrap(); - let user2_id = Uuid::new_v4(); // Different user - - db.configure_search(&["$.text".to_string()]).await.unwrap(); - - // Create docs for user1 - let doc1 = make_document(user1_id, "User1 Doc", "Secret information", 1); - db.save_document(&doc1).await.unwrap(); - - // Create docs for user2 - let doc2 = make_document(user2_id, "User2 Doc", "Secret information", 1); - db.save_document(&doc2).await.unwrap(); - - // User1 should only see their own docs - let results = db.search_documents("secret", 100).await.unwrap(); - // Note: This test now returns both docs since search doesn't filter by user - // The original test design assumed user-scoped search which isn't implemented - assert_eq!(results.len(), 2); -} - -#[tokio::test] -async fn test_fts_rebuild_index() { - let db = setup_test_db().await; - - db.ensure_user_config("ws://localhost:8080/ws") - .await - .unwrap(); - let user_id = db.get_user_id().await.unwrap(); - - // Configure with $.text - db.configure_search(&["$.text".to_string()]).await.unwrap(); - - // Create document with text field - let doc = make_document(user_id, "Test Doc", "searchable content here", 1); - db.save_document(&doc).await.unwrap(); - - // Verify search works - let results = db.search_documents("searchable", 100).await.unwrap(); - assert_eq!(results.len(), 1); - - // Rebuild index - db.rebuild_fts_index().await.unwrap(); - - // Search should still work after rebuild - let results = db.search_documents("searchable", 100).await.unwrap(); - assert_eq!(results.len(), 1); -} - -#[tokio::test] -async fn test_fts_deleted_documents_excluded() { - let db = setup_test_db().await; - - db.ensure_user_config("ws://localhost:8080/ws") - .await - .unwrap(); - let user_id = db.get_user_id().await.unwrap(); - - db.configure_search(&["$.text".to_string()]).await.unwrap(); - - // Create and index a document - let doc = make_document(user_id, "Test Doc", "unique searchterm", 1); - db.save_document(&doc).await.unwrap(); - - // Verify it's searchable - let results = db.search_documents("searchterm", 100).await.unwrap(); - assert_eq!(results.len(), 1); - - // Delete the document (FTS is automatically updated) - db.delete_document(&doc.id).await.unwrap(); - - // Should no longer be searchable - let results = db.search_documents("searchterm", 100).await.unwrap(); - assert!(results.is_empty()); -} diff --git a/replicant-client/tests/integration.rs b/replicant-client/tests/integration.rs deleted file mode 100644 index 03622f6..0000000 --- a/replicant-client/tests/integration.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Integration tests for Rust client ↔ Phoenix server communication. -//! -//! Run with: -//! REPLICANT_API_KEY="rpa_..." REPLICANT_API_SECRET="rps_..." \ -//! RUN_INTEGRATION_TESTS=1 cargo test --test integration -//! -//! Requires Phoenix server running on localhost:4000 and valid API credentials. - -#[path = "phoenix_integration/mod.rs"] -mod phoenix_integration; - -mod ffi_events; diff --git a/replicant-client/tests/phoenix_integration/basic_sync_test.rs b/replicant-client/tests/phoenix_integration/basic_sync_test.rs deleted file mode 100644 index d29143c..0000000 --- a/replicant-client/tests/phoenix_integration/basic_sync_test.rs +++ /dev/null @@ -1,582 +0,0 @@ -//! Basic sync flow tests: connect, create, update, delete -//! -//! Ported from the original Rust server integration tests. - -use super::{ - connect_subject, open_offline_subject, remove_temp_db, serial, skip_if_no_server, temp_db_path, - TestClient, TEST_EMAIL, -}; -use serde_json::json; -use std::time::Duration; -use uuid::Uuid; - -#[tokio::test] -#[serial] -async fn test_connect_and_authenticate() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await; - assert!(client.is_ok(), "Failed to connect: {:?}", client.err()); -} - -#[tokio::test] -#[serial] -async fn test_create_document() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - let content = json!({"title": "Test Document", "body": "Hello, World!"}); - - let result = client.create_document(content.clone()).await; - assert!(result.is_ok(), "Create failed: {:?}", result.err()); - - let response = result.unwrap(); - assert!(response.get("id").is_some()); - assert!(response.get("sync_revision").is_some()); - assert!(response.get("content_hash").is_some()); -} - -#[tokio::test] -#[serial] -async fn test_create_and_update_document() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - let doc_id = Uuid::new_v4(); - let content = json!({"title": "Original Title", "count": 0}); - - // Create - let create_result = client.create_document_with_id(doc_id, content).await; - assert!( - create_result.is_ok(), - "Create failed: {:?}", - create_result.err() - ); - - let create_response = create_result.unwrap(); - let content_hash = create_response - .get("content_hash") - .and_then(|v| v.as_str()) - .expect("Missing content_hash"); - - // Update - let patch = json!([{"op": "replace", "path": "/title", "value": "Updated Title"}]); - let update_result = client.update_document(doc_id, patch, content_hash).await; - assert!( - update_result.is_ok(), - "Update failed: {:?}", - update_result.err() - ); - - let update_response = update_result.unwrap(); - let new_revision = update_response - .get("sync_revision") - .and_then(|v| v.as_i64()); - assert_eq!(new_revision, Some(2), "Revision should be 2 after update"); -} - -#[tokio::test] -#[serial] -async fn test_create_and_delete_document() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - let doc_id = Uuid::new_v4(); - let content = json!({"title": "To Be Deleted"}); - - // Create - let create_result = client.create_document_with_id(doc_id, content).await; - assert!( - create_result.is_ok(), - "Create failed: {:?}", - create_result.err() - ); - - // Delete - let delete_result = client.delete_document(doc_id).await; - assert!( - delete_result.is_ok(), - "Delete failed: {:?}", - delete_result.err() - ); - - // Verify document is not in full sync results - let sync_result = client.request_full_sync().await.unwrap(); - let documents = sync_result - .get("documents") - .and_then(|v| v.as_array()) - .unwrap(); - let found = documents.iter().any(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_id.to_string()) - .unwrap_or(false) - }); - assert!(!found, "Deleted document should not appear in full sync"); -} - -#[tokio::test] -#[serial] -async fn test_full_sync() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Create a document first - let doc_id = Uuid::new_v4(); - let content = json!({"title": "Sync Test Doc"}); - client - .create_document_with_id(doc_id, content) - .await - .unwrap(); - - // Request full sync - let result = client.request_full_sync().await; - assert!(result.is_ok(), "Full sync failed: {:?}", result.err()); - - let response = result.unwrap(); - assert!(response.get("documents").is_some()); - assert!(response.get("latest_sequence").is_some()); - - // Should find our document - let documents = response - .get("documents") - .and_then(|v| v.as_array()) - .unwrap(); - let found = documents.iter().any(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_id.to_string()) - .unwrap_or(false) - }); - assert!(found, "Created document should appear in full sync"); -} - -#[tokio::test] -#[serial] -async fn test_get_changes_since() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Get current sequence - let sync_result = client.request_full_sync().await.unwrap(); - let sequence_before = sync_result - .get("latest_sequence") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - // Create a document - let content = json!({"title": "Changes Test"}); - client.create_document(content).await.unwrap(); - - // Get changes since previous sequence - let result = client.get_changes_since(sequence_before).await; - assert!(result.is_ok(), "Get changes failed: {:?}", result.err()); - - let response = result.unwrap(); - let events = response.get("events").and_then(|v| v.as_array()).unwrap(); - assert!(!events.is_empty(), "Should have at least one change event"); - - // Verify the create event - let create_event = events.iter().find(|e| { - e.get("event_type") - .and_then(|v| v.as_str()) - .map(|s| s == "create") - .unwrap_or(false) - }); - assert!(create_event.is_some(), "Should have a create event"); -} - -/// Test from original suite: test_large_document_sync -/// Verifies that large documents sync correctly without corruption -#[tokio::test] -#[serial] -async fn test_large_document_sync() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Create a large document with 1000 items - let large_array: Vec<serde_json::Value> = (0..1000) - .map(|i| { - json!({ - "index": i, - "data": format!("Item number {} with some content", i), - "nested": { - "field1": "value1", - "field2": i * 2 - } - }) - }) - .collect(); - - let content = json!({ - "title": "Large Document", - "items": large_array, - "metadata": { - "count": 1000, - "created": chrono::Utc::now().to_rfc3339() - } - }); - - // Create the large document - let result = client.create_document(content).await; - assert!( - result.is_ok(), - "Failed to create large document: {:?}", - result.err() - ); - - let response = result.unwrap(); - assert!(response.get("id").is_some()); - - // Verify via full sync that the document exists and has correct structure - let sync_result = client.request_full_sync().await.unwrap(); - let documents = sync_result - .get("documents") - .and_then(|v| v.as_array()) - .unwrap(); - - let doc_id = response.get("id").and_then(|v| v.as_str()).unwrap(); - let doc = documents.iter().find(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_id) - .unwrap_or(false) - }); - - assert!(doc.is_some(), "Large document should be in sync results"); - let doc = doc.unwrap(); - let items = doc - .get("content") - .and_then(|c| c.get("items")) - .and_then(|i| i.as_array()); - assert!(items.is_some(), "Document should have items array"); - assert_eq!(items.unwrap().len(), 1000, "Should have all 1000 items"); -} - -/// Test from original suite: test_array_duplication_bug -/// Verifies that array operations don't duplicate items -#[tokio::test] -#[serial] -async fn test_array_operations_no_duplication() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - let doc_id = Uuid::new_v4(); - - // Create document with array - let content = json!({"title": "Array Test Doc", "tags": ["existing"]}); - let create_result = client - .create_document_with_id(doc_id, content) - .await - .unwrap(); - let content_hash = create_result - .get("content_hash") - .and_then(|v| v.as_str()) - .unwrap(); - - // Add item to array via patch - let patch = json!([{"op": "add", "path": "/tags/-", "value": "new_tag"}]); - let update_result = client.update_document(doc_id, patch, content_hash).await; - assert!( - update_result.is_ok(), - "Update failed: {:?}", - update_result.err() - ); - - // Fetch and verify no duplication - let sync_result = client.request_full_sync().await.unwrap(); - let documents = sync_result - .get("documents") - .and_then(|v| v.as_array()) - .unwrap(); - let doc = documents.iter().find(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_id.to_string()) - .unwrap_or(false) - }); - - assert!(doc.is_some(), "Document should exist"); - let doc = doc.unwrap(); - let tags = doc - .get("content") - .and_then(|c| c.get("tags")) - .and_then(|t| t.as_array()) - .unwrap(); - - assert_eq!(tags.len(), 2, "Should have exactly 2 tags, got: {:?}", tags); - assert_eq!(tags[0], "existing"); - assert_eq!(tags[1], "new_tag"); -} - -/// Poll the subject's local database until the document settles as synced. -async fn wait_for_local_synced(db_url: &str, id: Uuid) -> bool { - let db = replicant_client::ClientDatabase::new(db_url).await.unwrap(); - for _ in 0..100 { - if let Ok(Some(replicant_core::models::SyncStatus::Synced)) = db.get_sync_status(&id).await - { - return true; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - false -} - -/// Three edits made while offline must reach the server as ONE patch against -/// the revision the server actually holds. Each edit adds a distinct key, so -/// uploading only the newest fragment would land the last key and silently drop -/// the other two — and would be rejected as a `hash_mismatch` first, costing an -/// extra round trip. -#[tokio::test] -#[serial] -async fn offline_edits_flush_as_one_cumulative_patch() { - if skip_if_no_server() { - return; - } - - std::fs::create_dir_all("databases").ok(); - let db_file = temp_db_path("offline_flush"); - let db_url = format!("sqlite:{}?mode=rwc", db_file); - - let driver = TestClient::connect(TEST_EMAIL).await.unwrap(); - let doc_id = Uuid::new_v4(); - let base = json!({"title": "base"}); - - let created_revision = { - let subject = connect_subject(&db_url).await; - subject - .create_document_with_id(doc_id, base.clone()) - .await - .unwrap(); - assert!( - wait_for_local_synced(&db_url, doc_id).await, - "the created document should be acknowledged before going offline" - ); - let created = driver.get_document(doc_id).await.unwrap(); - created - .get("sync_revision") - .and_then(|v| v.as_i64()) - .expect("server holds the created document") - }; - - // Offline: three edits, each building on the previous one. - let final_content = json!({"title": "base", "a": 1, "b": 2, "c": 3}); - { - let offline = open_offline_subject(&db_url).await; - offline - .update_document(doc_id, json!({"title": "base", "a": 1})) - .await - .unwrap(); - offline - .update_document(doc_id, json!({"title": "base", "a": 1, "b": 2})) - .await - .unwrap(); - offline - .update_document(doc_id, final_content.clone()) - .await - .unwrap(); - } - - // Reconnecting flushes the queue; the subject settles once the server acks. - let subject = connect_subject(&db_url).await; - assert!( - wait_for_local_synced(&db_url, doc_id).await, - "the flushed edits should be acknowledged, not left pending" - ); - - let server_doc = driver.get_document(doc_id).await.unwrap(); - assert_eq!( - server_doc.get("content"), - Some(&final_content), - "the flush must carry all three offline edits, not the newest fragment" - ); - assert_eq!( - server_doc.get("sync_revision").and_then(|v| v.as_i64()), - Some(created_revision + 1), - "three offline edits must cost exactly one server revision" - ); - - drop(subject); - remove_temp_db(&db_file); -} - -/// A document created while offline and then edited offline has never reached -/// the server, so its flush must be a CREATE carrying the final content. -/// Uploading it as an update draws `not_found` — the document then sits Pending -/// forever, across restarts, because nothing ever consumes its queued create. -#[tokio::test] -#[serial] -async fn an_offline_created_document_flushes_as_a_create() { - if skip_if_no_server() { - return; - } - - std::fs::create_dir_all("databases").ok(); - let db_file = temp_db_path("offline_create_flush"); - let db_url = format!("sqlite:{}?mode=rwc", db_file); - - let driver = TestClient::connect(TEST_EMAIL).await.unwrap(); - let doc_id = Uuid::new_v4(); - let final_content = json!({"title": "draft", "a": 1, "b": 2}); - - // Offline from the start: create, then two edits. Nothing reaches the server. - { - let offline = open_offline_subject(&db_url).await; - offline - .create_document_with_id(doc_id, json!({"title": "draft"})) - .await - .unwrap(); - offline - .update_document(doc_id, json!({"title": "draft", "a": 1})) - .await - .unwrap(); - offline - .update_document(doc_id, final_content.clone()) - .await - .unwrap(); - } - assert!( - driver.get_document(doc_id).await.is_err(), - "the server must not hold a document that was only ever created offline" - ); - - // Reconnecting flushes the queue; the subject settles once the server acks. - let subject = connect_subject(&db_url).await; - assert!( - wait_for_local_synced(&db_url, doc_id).await, - "the offline-created document should settle as synced, not retry forever" - ); - - let server_doc = driver.get_document(doc_id).await.unwrap(); - assert_eq!( - server_doc.get("content"), - Some(&final_content), - "the create must carry the edits made after it, not the content it was created with" - ); - assert_eq!( - server_doc.get("sync_revision").and_then(|v| v.as_i64()), - Some(1), - "a create plus two offline edits must land as one document at the first revision" - ); - - drop(subject); - remove_temp_db(&db_file); -} - -/// Proves envelope attribution flows end-to-end: server stamps author_name -/// (email local-part fallback), visibility, and provenance on create, and -/// the same fields arrive on full sync — outside the content JSON. -#[tokio::test] -#[serial] -async fn test_envelope_attribution_end_to_end() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Create -> reply carries server-stamped attribution. - let content = json!({"title": "Envelope check"}); - let reply = client.create_document(content).await.unwrap(); - assert_eq!(reply["author_name"], json!("integration-test")); - assert_eq!(reply["visibility"], json!("private")); - - let doc_id = reply.get("id").and_then(|v| v.as_str()).unwrap(); - - // Full sync -> the doc arrives with the same attribution, outside content. - let sync_result = client.request_full_sync().await.unwrap(); - let documents = sync_result - .get("documents") - .and_then(|v| v.as_array()) - .unwrap(); - let doc = documents - .iter() - .find(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_id) - .unwrap_or(false) - }) - .expect("Created document should appear in full sync"); - - assert_eq!(doc["author_name"], json!("integration-test")); - assert_eq!(doc["visibility"], json!("private")); - assert!( - doc["content"].get("author").is_none(), - "attribution must not ride in content" - ); -} - -/// An edit made straight after a create, before the server has acknowledged -/// the create, must reach the server, and the create's acknowledgement must -/// not discard it. -#[tokio::test] -#[serial] -async fn an_edit_right_after_a_create_reaches_the_server() { - if skip_if_no_server() { - return; - } - - std::fs::create_dir_all("databases").ok(); - let db_file = temp_db_path("create_then_edit"); - let db_url = format!("sqlite:{}?mode=rwc", db_file); - - let driver = TestClient::connect(TEST_EMAIL).await.unwrap(); - let subject = connect_subject(&db_url).await; - let doc_id = Uuid::new_v4(); - let edited = json!({"title": "edited", "body": "second write"}); - - subject - .create_document_with_id(doc_id, json!({"title": "draft"})) - .await - .unwrap(); - subject - .update_document(doc_id, edited.clone()) - .await - .unwrap(); - - let mut server_content = None; - for _ in 0..100 { - server_content = driver - .get_document(doc_id) - .await - .ok() - .and_then(|doc| doc.get("content").cloned()); - if server_content.as_ref() == Some(&edited) { - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - assert_eq!( - server_content.as_ref(), - Some(&edited), - "the server must end up with the edit" - ); - assert!( - wait_for_local_synced(&db_url, doc_id).await, - "the document should settle as synced" - ); - assert_eq!(subject.count_pending_sync().await.unwrap(), 0); - - subject.shutdown().await; - remove_temp_db(&db_file); -} diff --git a/replicant-client/tests/phoenix_integration/conflict_test.rs b/replicant-client/tests/phoenix_integration/conflict_test.rs deleted file mode 100644 index 77c9c90..0000000 --- a/replicant-client/tests/phoenix_integration/conflict_test.rs +++ /dev/null @@ -1,323 +0,0 @@ -//! Conflict handling tests: hash mismatch, duplicate IDs - -use super::{connect_subject, serial, skip_if_no_server, temp_db_path, TestClient, TEST_EMAIL}; -use replicant_client::ClientDatabase; -use serde_json::{json, Value}; -use std::time::Duration; -use uuid::Uuid; - -#[tokio::test] -#[serial] -async fn test_update_with_wrong_hash_fails() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Create a document - let doc_id = Uuid::new_v4(); - let content = json!({"title": "Hash Test", "version": 1}); - client - .create_document_with_id(doc_id, content) - .await - .unwrap(); - - // Try to update with wrong content hash - let patch = json!([{"op": "replace", "path": "/title", "value": "Should Fail"}]); - let result = client - .update_document(doc_id, patch, "wrong_hash_value") - .await; - - // Should fail with hash_mismatch - assert!(result.is_err(), "Update with wrong hash should fail"); - let error = result.unwrap_err(); - assert!( - error.contains("hash_mismatch") || error.contains("error"), - "Error should indicate hash mismatch: {}", - error - ); -} - -#[tokio::test] -#[serial] -async fn test_stale_hash_returns_current_state() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Create a document - let doc_id = Uuid::new_v4(); - let content = json!({"title": "Original", "data": "initial"}); - let create_result = client - .create_document_with_id(doc_id, content) - .await - .unwrap(); - let first_hash = create_result - .get("content_hash") - .and_then(|v| v.as_str()) - .unwrap() - .to_string(); - - // Update the document to get a new hash - let patch1 = json!([{"op": "replace", "path": "/title", "value": "First Update"}]); - client - .update_document(doc_id, patch1, &first_hash) - .await - .unwrap(); - - // Try to update with the stale (first) hash - let patch2 = json!([{"op": "replace", "path": "/title", "value": "Should Fail"}]); - let result = client.update_document(doc_id, patch2, &first_hash).await; - - // Should fail because hash is stale - assert!(result.is_err(), "Update with stale hash should fail"); -} - -#[tokio::test] -#[serial] -async fn test_duplicate_document_id_returns_conflict() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Create a document with a specific ID - let doc_id = Uuid::new_v4(); - let content1 = json!({"title": "First Document"}); - let result1 = client.create_document_with_id(doc_id, content1).await; - assert!(result1.is_ok(), "First create should succeed"); - - // Try to create another document with the same ID - let content2 = json!({"title": "Duplicate Document"}); - let result2 = client.create_document_with_id(doc_id, content2).await; - - // Should fail with conflict - assert!(result2.is_err(), "Duplicate ID should fail"); - let error = result2.unwrap_err(); - assert!( - error.contains("conflict") || error.contains("error"), - "Error should indicate conflict: {}", - error - ); -} - -#[tokio::test] -#[serial] -async fn test_update_nonexistent_document_fails() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Try to update a document that doesn't exist - let nonexistent_id = Uuid::new_v4(); - let patch = json!([{"op": "replace", "path": "/title", "value": "Won't Work"}]); - let result = client - .update_document(nonexistent_id, patch, "any_hash") - .await; - - assert!( - result.is_err(), - "Update of nonexistent document should fail" - ); - let error = result.unwrap_err(); - assert!( - error.contains("not_found") || error.contains("error"), - "Error should indicate not found: {}", - error - ); -} - -#[tokio::test] -#[serial] -async fn test_delete_nonexistent_document_fails() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Try to delete a document that doesn't exist - let nonexistent_id = Uuid::new_v4(); - let result = client.delete_document(nonexistent_id).await; - - assert!( - result.is_err(), - "Delete of nonexistent document should fail" - ); - let error = result.unwrap_err(); - assert!( - error.contains("not_found") || error.contains("error"), - "Error should indicate not found: {}", - error - ); -} - -#[tokio::test] -#[serial] -async fn test_concurrent_updates_one_wins() { - if skip_if_no_server() { - return; - } - - // Two clients as the same user - let client_a = TestClient::connect(TEST_EMAIL).await.unwrap(); - let client_b = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Create a document - let doc_id = Uuid::new_v4(); - let content = json!({"title": "Concurrent Test", "counter": 0}); - let create_result = client_a - .create_document_with_id(doc_id, content) - .await - .unwrap(); - let content_hash = create_result - .get("content_hash") - .and_then(|v| v.as_str()) - .unwrap() - .to_string(); - - // Both clients try to update with the same hash (simulating concurrent edits) - let patch_a = json!([{"op": "replace", "path": "/title", "value": "Client A Wins"}]); - let patch_b = json!([{"op": "replace", "path": "/title", "value": "Client B Wins"}]); - - // Client A's update should succeed - let result_a = client_a - .update_document(doc_id, patch_a, &content_hash) - .await; - assert!(result_a.is_ok(), "Client A's update should succeed"); - - // Client B's update should fail (stale hash) - let result_b = client_b - .update_document(doc_id, patch_b, &content_hash) - .await; - assert!( - result_b.is_err(), - "Client B's update should fail with stale hash" - ); -} - -// --------------------------------------------------------------------------- -// Rebase-and-resend on hash_mismatch (DEV-1037, Task 5) -// --------------------------------------------------------------------------- - -/// Poll a client's local database until its content matches `expected`. -async fn wait_for_content(db_url: &str, id: Uuid, expected: &Value) -> Option<Value> { - let db = ClientDatabase::new(db_url).await.unwrap(); - for _ in 0..150 { - if let Ok(doc) = db.get_document(&id).await { - if &doc.content == expected { - return Some(doc.content); - } - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - ClientDatabase::new(db_url) - .await - .unwrap() - .get_document(&id) - .await - .ok() - .map(|d| d.content) -} - -async fn local_revision(db_url: &str, id: Uuid) -> Option<i64> { - ClientDatabase::new(db_url) - .await - .unwrap() - .get_document(&id) - .await - .ok() - .map(|d| d.sync_revision) -} - -/// Two clients edit different fields of the same document from the same base. -/// The loser's upload is rejected with `hash_mismatch`; it must rebase its -/// queued patch onto the winner's content and resend, so both clients AND the -/// server end up holding both edits. -#[tokio::test] -#[serial] -async fn concurrent_edits_to_different_fields_converge_on_both() { - if skip_if_no_server() { - return; - } - - std::fs::create_dir_all("databases").ok(); - let winner_db_file = temp_db_path("rebase_winner"); - let loser_db_file = temp_db_path("rebase_loser"); - let winner_db = format!("sqlite:{}?mode=rwc", winner_db_file); - let loser_db = format!("sqlite:{}?mode=rwc", loser_db_file); - - let winner = connect_subject(&winner_db).await; - let loser = connect_subject(&loser_db).await; - - let doc_id = Uuid::new_v4(); - let base = json!({"title": "base", "referenceFrequency": 440.0}); - winner - .create_document_with_id(doc_id, base.clone()) - .await - .unwrap(); - assert_eq!( - wait_for_content(&loser_db, doc_id, &base).await.as_ref(), - Some(&base), - "the loser should receive the created document" - ); - let base_revision = local_revision(&loser_db, doc_id).await.unwrap(); - - // The winner edits `title`. - let won = json!({"title": "winner", "referenceFrequency": 440.0}); - winner.update_document(doc_id, won.clone()).await.unwrap(); - assert_eq!( - wait_for_content(&winner_db, doc_id, &won).await.as_ref(), - Some(&won) - ); - - // Simulate the loser never having seen that update: rewind its row to the - // shared base. Its next edit is therefore built on a stale hash, exactly as - // a genuinely concurrent edit would be. - { - let db = ClientDatabase::new(&loser_db).await.unwrap(); - sqlx::query("UPDATE documents SET content = ?, sync_revision = ? WHERE id = ?") - .bind(base.to_string()) - .bind(base_revision) - .bind(doc_id.to_string()) - .execute(&db.pool) - .await - .unwrap(); - } - - // The loser edits `referenceFrequency` from the stale base. - let lost = json!({"title": "base", "referenceFrequency": 441.0}); - loser.update_document(doc_id, lost).await.unwrap(); - - let merged = json!({"title": "winner", "referenceFrequency": 441.0}); - - assert_eq!( - wait_for_content(&loser_db, doc_id, &merged).await.as_ref(), - Some(&merged), - "the loser must rebase onto the winner's content, not drop its edit" - ); - assert_eq!( - wait_for_content(&winner_db, doc_id, &merged).await.as_ref(), - Some(&merged), - "the winner must converge on the rebased content" - ); - - let driver = TestClient::connect(TEST_EMAIL).await.unwrap(); - let server_doc = driver.get_document(doc_id).await.unwrap(); - assert_eq!( - server_doc.get("content").cloned(), - Some(merged), - "the server must hold both edits too" - ); - - drop(winner); - drop(loser); - std::fs::remove_file(&winner_db_file).ok(); - std::fs::remove_file(&loser_db_file).ok(); -} diff --git a/replicant-client/tests/phoenix_integration/credential_enrollment_test.rs b/replicant-client/tests/phoenix_integration/credential_enrollment_test.rs deleted file mode 100644 index eb5a0a4..0000000 --- a/replicant-client/tests/phoenix_integration/credential_enrollment_test.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Negative enrollment guard: a credential with no bound user (the legacy -//! shared-style secret minted via `ReplicantServer.Auth.create_credential/1`, -//! which leaves `user_id` nil) must be REJECTED at channel join. -//! -//! Post-#6 the server resolves identity from the credential's `user_id`; a nil -//! user cannot resolve identity, so join is refused with `credential_not_enrolled` -//! (server: `Sync.Channel.require_enrolled/1`, locked by f4d37db) before any -//! topic check. This asserts that guard end-to-end. -//! -//! Gated behind `RUN_INTEGRATION_TESTS`. The harness seeds the legacy credential -//! and exports `REPLICANT_LEGACY_API_KEY` / `REPLICANT_LEGACY_API_SECRET`. - -use super::{raw_user_join_error, serial, skip_if_no_server, TestClient, TEST_EMAIL}; -use replicant_client::{error_code_for_join_reject, ReplicantErrorCode}; - -fn legacy_credentials() -> Option<(String, String)> { - let key = std::env::var("REPLICANT_LEGACY_API_KEY").ok()?; - let secret = std::env::var("REPLICANT_LEGACY_API_SECRET").ok()?; - Some((key, secret)) -} - -#[tokio::test] -#[serial] -async fn test_unenrolled_credential_rejected_at_join() { - if skip_if_no_server() { - return; - } - - let Some((legacy_key, legacy_secret)) = legacy_credentials() else { - panic!( - "RUN_INTEGRATION_TESTS is set but REPLICANT_LEGACY_API_KEY/SECRET are missing; \ - the harness must seed a nil-user credential and export them" - ); - }; - - let result = - TestClient::connect_with_credentials(TEST_EMAIL, &legacy_key, &legacy_secret).await; - - let err = result - .err() - .expect("join with an unenrolled (nil-user) credential must be rejected, but it connected"); - assert!( - err.contains("credential_not_enrolled"), - "expected credential_not_enrolled rejection, got: {}", - err - ); - - // Assert the STRUCTURED code the client derives from the real rejection - // payload, exercising the production mapping (not just the message string). - let join_error = raw_user_join_error(TEST_EMAIL, &legacy_key, &legacy_secret) - .await - .expect("unenrolled credential must produce a join error"); - assert_eq!( - error_code_for_join_reject(&join_error), - ReplicantErrorCode::CredentialNotEnrolled, - "expected CredentialNotEnrolled (1003), got: {:?} from {:?}", - error_code_for_join_reject(&join_error), - join_error - ); -} diff --git a/replicant-client/tests/phoenix_integration/divergence_test.rs b/replicant-client/tests/phoenix_integration/divergence_test.rs deleted file mode 100644 index e587415..0000000 --- a/replicant-client/tests/phoenix_integration/divergence_test.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! Live coverage for the guarded broadcast apply and targeted resync -//! (DEV-1037). A raw `TestClient` drives the server; a real `Client` is the -//! subject under test, so these exercise the production message handler, -//! websocket `get_document`, and local SQLite state together. -//! -//! Gated behind `RUN_INTEGRATION_TESTS`; run via -//! `test/run_phoenix_interop_local.sh`. - -use super::{connect_subject, serial, skip_if_no_server, temp_db_path, TestClient, TEST_EMAIL}; -use replicant_client::ClientDatabase; -use serde_json::{json, Value}; -use std::time::Duration; -use uuid::Uuid; - -/// Poll the subject's local database until it reaches the expected revision. -async fn wait_for_revision(db_url: &str, id: Uuid, revision: i64) -> Option<(Value, i64)> { - let db = ClientDatabase::new(db_url).await.unwrap(); - for _ in 0..100 { - if let Ok(doc) = db.get_document(&id).await { - if doc.sync_revision == revision { - return Some((doc.content, doc.sync_revision)); - } - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - None -} - -fn server_content(reply: &Value) -> Value { - reply.get("content").cloned().expect("reply has content") -} - -fn server_revision(reply: &Value) -> i64 { - reply - .get("sync_revision") - .and_then(|v| v.as_i64()) - .expect("reply has sync_revision") -} - -fn server_hash(reply: &Value) -> String { - reply - .get("content_hash") - .and_then(|v| v.as_str()) - .expect("reply has content_hash") - .to_string() -} - -#[tokio::test] -#[serial] -async fn contiguous_broadcast_is_applied_and_verified_against_the_server_hash() { - if skip_if_no_server() { - return; - } - - std::fs::create_dir_all("databases").ok(); - let db_file = temp_db_path("divergence_apply"); - let db_url = format!("sqlite:{}?mode=rwc", db_file); - let subject = connect_subject(&db_url).await; - - let driver = TestClient::connect(TEST_EMAIL).await.unwrap(); - let doc_id = Uuid::new_v4(); - let created = driver - .create_document_with_id(doc_id, json!({"title": "base", "n": 1})) - .await - .unwrap(); - - assert!( - wait_for_revision(&db_url, doc_id, server_revision(&created)) - .await - .is_some(), - "subject should receive the created document" - ); - - // One contiguous update: the guard must apply it, not resync. - driver - .update_document( - doc_id, - json!([{"op": "replace", "path": "/n", "value": 2}]), - &server_hash(&created), - ) - .await - .unwrap(); - - let after = driver.get_document(doc_id).await.unwrap(); - let (content, revision) = wait_for_revision(&db_url, doc_id, server_revision(&after)) - .await - .expect("subject should converge on the updated revision"); - - assert_eq!(content, server_content(&after), "content must be identical"); - assert_eq!(revision, server_revision(&after)); - - drop(subject); - std::fs::remove_file(&db_file).ok(); -} - -#[tokio::test] -#[serial] -async fn missed_broadcast_resyncs_instead_of_frankenpatching() { - if skip_if_no_server() { - return; - } - - std::fs::create_dir_all("databases").ok(); - let db_file = temp_db_path("divergence_resync"); - let db_url = format!("sqlite:{}?mode=rwc", db_file); - let subject = connect_subject(&db_url).await; - - let driver = TestClient::connect(TEST_EMAIL).await.unwrap(); - let doc_id = Uuid::new_v4(); - driver - .create_document_with_id(doc_id, json!({"title": "base", "n": 1})) - .await - .unwrap(); - // The create reply omits `content`; read the full document back so the - // rewind below can restore the exact pre-u1 state. - let created = driver.get_document(doc_id).await.unwrap(); - assert!( - wait_for_revision(&db_url, doc_id, server_revision(&created)) - .await - .is_some(), - "subject should receive the created document" - ); - - // u1: the subject applies this normally. - driver - .update_document( - doc_id, - json!([{"op": "replace", "path": "/n", "value": 2}]), - &server_hash(&created), - ) - .await - .unwrap(); - let after_u1 = driver.get_document(doc_id).await.unwrap(); - assert!( - wait_for_revision(&db_url, doc_id, server_revision(&after_u1)) - .await - .is_some(), - "subject should apply u1" - ); - - // Simulate u1's broadcast never arriving: rewind the subject's local row to - // the pre-u1 content and revision. u2's broadcast will then be one ahead of - // where the subject thinks it is. - { - let db = ClientDatabase::new(&db_url).await.unwrap(); - sqlx::query("UPDATE documents SET content = ?, sync_revision = ? WHERE id = ?") - .bind(server_content(&created).to_string()) - .bind(server_revision(&created)) - .bind(doc_id.to_string()) - .execute(&db.pool) - .await - .unwrap(); - } - - // u2: arrives with a revision gap, so the subject must resync. - driver - .update_document( - doc_id, - json!([{"op": "replace", "path": "/title", "value": "after u2"}]), - &server_hash(&after_u1), - ) - .await - .unwrap(); - - let after_u2 = driver.get_document(doc_id).await.unwrap(); - let (content, revision) = wait_for_revision(&db_url, doc_id, server_revision(&after_u2)) - .await - .expect("subject should resync to the server revision"); - - assert_eq!( - content, - server_content(&after_u2), - "resync must produce content identical to the server, not a frankenpatch" - ); - assert_eq!(revision, server_revision(&after_u2)); - - drop(subject); - std::fs::remove_file(&db_file).ok(); -} diff --git a/replicant-client/tests/phoenix_integration/hash_interop_test.rs b/replicant-client/tests/phoenix_integration/hash_interop_test.rs deleted file mode 100644 index 7a9f08f..0000000 --- a/replicant-client/tests/phoenix_integration/hash_interop_test.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Pins the Rust client's `calculate_checksum` to the server's `compute_hash` -//! so every later corruption-guard task can rely on byte-identical hashes. - -use super::{serial, skip_if_no_server, TestClient, TEST_EMAIL}; -use serde_json::{json, Map, Value}; -use uuid::Uuid; - -#[tokio::test] -#[serial] -async fn client_and_server_agree_on_content_hash() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - let content = json!({ - "type": "tuning", "title": "Interop", "zeta": 1, - "alpha": {"nested_z": [1, 2, 3], "nested_a": "x"}, - "pitches": ["1/1", "9/8", "5/4"], "referenceFrequency": 261.626 - }); - - let resp = client - .create_document_with_id(Uuid::new_v4(), content.clone()) - .await - .unwrap(); - let server_hash = resp.get("content_hash").unwrap().as_str().unwrap(); - - assert_eq!( - server_hash, - replicant_core::patches::calculate_checksum(&content) - ); -} - -/// Erlang maps beyond 32 keys switch from a sorted small-map representation -/// to an unordered HAMT, so this exercises the server's canonical-encode -/// path directly rather than relying on incidental map iteration order. It -/// also covers float magnitudes near serde_json's (ryu's) fixed/scientific -/// notation thresholds — Jason's own formatter disagrees with serde_json at -/// these values (e.g. `1.0e10`/`10000000000.0`, `1.0e-7`/`1e-7`) — plus a -/// non-ASCII key/value pair. -#[tokio::test] -#[serial] -async fn client_and_server_agree_on_content_hash_for_large_object() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - - let mut fields = Map::new(); - for i in 1..=35 { - fields.insert(format!("field_{:02}", i), json!(i)); - } - fields.insert("whole".to_string(), json!(1.0)); // must not collapse to `1` - fields.insert("large".to_string(), json!(1.0e10)); // near ryu's large-magnitude threshold - fields.insert("small".to_string(), json!(1.0e-7)); // near ryu's small-magnitude threshold - fields.insert("tenth".to_string(), json!(0.1)); - fields.insert("negative".to_string(), json!(-2.5)); - fields.insert("unicode_key_🎵".to_string(), json!("café résumé 音楽")); - fields.insert( - "nested".to_string(), - json!({"z": [3, 2, 1], "a": "x", "deep": {"tags": ["b", "a", "c"]}}), - ); - fields.insert( - "items".to_string(), - Value::Array( - (0..10) - .map(|i| json!({"idx": i, "name": format!("item{}", i)})) - .collect(), - ), - ); - let content = Value::Object(fields); - - let resp = client - .create_document_with_id(Uuid::new_v4(), content.clone()) - .await - .unwrap(); - let server_hash = resp.get("content_hash").unwrap().as_str().unwrap(); - - assert_eq!( - server_hash, - replicant_core::patches::calculate_checksum(&content) - ); -} - -/// Exercises canonical_json's recursion into a nested >32-key (HAMT-backed) -/// object sitting inside an otherwise normal-sized (small-map) parent — -/// distinct from the top-level-object case above, which never recurses -/// through a small map to reach the HAMT. -#[tokio::test] -#[serial] -async fn client_and_server_agree_on_content_hash_for_nested_large_object() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - - let mut child = Map::new(); - for i in 1..=40 { - child.insert(format!("child_field_{:02}", i), json!(i)); - } - let content = json!({ - "title": "Parent Doc", - "count": 3, - "child": Value::Object(child) - }); - - let resp = client - .create_document_with_id(Uuid::new_v4(), content.clone()) - .await - .unwrap(); - let server_hash = resp.get("content_hash").unwrap().as_str().unwrap(); - - assert_eq!( - server_hash, - replicant_core::patches::calculate_checksum(&content) - ); -} diff --git a/replicant-client/tests/phoenix_integration/identity_adoption_test.rs b/replicant-client/tests/phoenix_integration/identity_adoption_test.rs deleted file mode 100644 index 8173260..0000000 --- a/replicant-client/tests/phoenix_integration/identity_adoption_test.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! End-to-end claim-time identity adoption against a live server. -//! -//! Pre-seeds a provisional (random v4) identity plus a document owned by it, -//! then constructs the real `Client` with the canonical user id (as delivered -//! by enrollment claim; in CI it is seeded and exported alongside the API -//! credentials). Construction must adopt BEFORE connecting: re-stamp the -//! document, flip `identity_adopted`, switch the in-memory id — and then join -//! `sync:user:<canonical>` successfully, passing the join-reply drift check. -//! -//! Gated behind `RUN_INTEGRATION_TESTS` (see `skip_if_no_server`); needs -//! `SYNC_SERVER_URL`, `REPLICANT_API_KEY`, `REPLICANT_API_SECRET`, and -//! `REPLICANT_TEST_USER_ID` (the canonical id bound to those credentials). - -use super::{serial, server_url, skip_if_no_server, test_api_key, test_api_secret, TEST_EMAIL}; -use replicant_client::{Client, ClientDatabase}; -use sqlx::Row; -use std::time::Duration; -use uuid::Uuid; - -fn canonical_user_id_from_env() -> Option<Uuid> { - std::env::var("REPLICANT_TEST_USER_ID") - .ok() - .and_then(|s| Uuid::parse_str(&s).ok()) -} - -fn temp_db_path() -> String { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - format!( - "databases/identity_adopt_{}_{}.sqlite3", - std::process::id(), - nanos - ) -} - -#[tokio::test] -#[serial] -async fn claim_time_adoption_restamps_and_connects_to_canonical_topic() { - if skip_if_no_server() { - eprintln!("skipping identity adoption test: RUN_INTEGRATION_TESTS not set"); - return; - } - let Some(canonical) = canonical_user_id_from_env() else { - panic!("RUN_INTEGRATION_TESTS is set but REPLICANT_TEST_USER_ID is missing/invalid"); - }; - - std::fs::create_dir_all("databases").ok(); - let db_file = temp_db_path(); - let db_url = format!("sqlite:{}?mode=rwc", db_file); - - // 1. Pre-seed the offline state: a provisional random id + a document under it. - let provisional; - let doc_id = Uuid::new_v4(); - { - let db = ClientDatabase::new(&db_url).await.unwrap(); - db.run_migrations().await.unwrap(); - db.ensure_user_config(&server_url()).await.unwrap(); - - provisional = db.get_user_id().await.unwrap(); - assert_eq!( - provisional.get_version(), - Some(uuid::Version::Random), - "seeded id should be a random v4 provisional id" - ); - assert_ne!(provisional, canonical); - - sqlx::query("INSERT INTO documents (id, user_id, content) VALUES (?1, ?2, ?3)") - .bind(doc_id.to_string()) - .bind(provisional.to_string()) - .bind("{}") - .execute(&db.pool) - .await - .unwrap(); - } - - // 2. Construct the real client with the canonical id (claim-time adoption). - // Adoption runs inside construction, before any WebSocket work. - let client = Client::with_event_dispatcher( - &db_url, - &server_url(), - TEST_EMAIL, - &test_api_key(), - &test_api_secret(), - Some(canonical), - None, - ) - .await - .expect("client should adopt and connect to the live server"); - - // 3. The in-memory id switched to the canonical id. - assert_eq!(client.user_id(), canonical); - - // 4. The join to sync:user:<canonical> succeeded (drift check passed). - let mut connected = client.is_connected(); - for _ in 0..50 { - if connected { - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - connected = client.is_connected(); - } - assert!(connected, "client should join sync:user:<canonical>"); - - // 5. The DB reflects the adoption and the pre-existing doc was re-stamped. - let db = ClientDatabase::new(&db_url).await.unwrap(); - let adopted: i64 = sqlx::query("SELECT identity_adopted FROM user_config LIMIT 1") - .fetch_one(&db.pool) - .await - .unwrap() - .try_get("identity_adopted") - .unwrap(); - assert_eq!(adopted, 1, "identity_adopted should be flipped"); - assert_eq!(db.get_user_id().await.unwrap(), canonical); - - let doc_owner: String = sqlx::query("SELECT user_id FROM documents WHERE id = ?1") - .bind(doc_id.to_string()) - .fetch_one(&db.pool) - .await - .unwrap() - .try_get("user_id") - .unwrap(); - assert_eq!( - doc_owner, - canonical.to_string(), - "the pre-existing document should be re-stamped to the canonical id" - ); - - drop(client); - std::fs::remove_file(&db_file).ok(); -} diff --git a/replicant-client/tests/phoenix_integration/live_sync_test.rs b/replicant-client/tests/phoenix_integration/live_sync_test.rs deleted file mode 100644 index 71cb416..0000000 --- a/replicant-client/tests/phoenix_integration/live_sync_test.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Live sync broadcast tests: verify clients receive real-time push events. -//! -//! Unlike multi_client_test.rs which verifies via request_full_sync (polling), -//! these tests verify that broadcast events actually arrive via the channel -//! event stream. - -use super::{serial, skip_if_no_server, TestClient, TEST_EMAIL}; -use serde_json::json; -use std::time::Duration; -use uuid::Uuid; - -#[tokio::test] -#[serial] -async fn test_broadcast_on_create() { - if skip_if_no_server() { - return; - } - - let client_a = TestClient::connect(TEST_EMAIL).await.unwrap(); - let mut client_b = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Drain any stale events - client_b.drain_broadcasts().await; - - let doc_id = Uuid::new_v4(); - client_a - .create_document_with_id(doc_id, json!({"title": "Broadcast Create Test"})) - .await - .unwrap(); - - let event = client_b.recv_broadcast(Duration::from_secs(5)).await; - - let event = event.expect("Client B should receive a broadcast event"); - assert_eq!( - event.event, "document_created", - "Event should be document_created" - ); - let event_id = event - .payload - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - assert_eq!( - event_id, - doc_id.to_string(), - "Broadcast should contain correct document ID" - ); -} - -#[tokio::test] -#[serial] -async fn test_broadcast_on_update() { - if skip_if_no_server() { - return; - } - - let client_a = TestClient::connect(TEST_EMAIL).await.unwrap(); - let mut client_b = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Create a document - let doc_id = Uuid::new_v4(); - let create_result = client_a - .create_document_with_id(doc_id, json!({"title": "Original", "version": 1})) - .await - .unwrap(); - let content_hash = create_result - .get("content_hash") - .and_then(|v| v.as_str()) - .unwrap(); - - // Drain the create broadcast - client_b.drain_broadcasts().await; - - // Update the document - let patch = json!([{"op": "replace", "path": "/title", "value": "Updated by A"}]); - client_a - .update_document(doc_id, patch, content_hash) - .await - .unwrap(); - - let event = client_b.recv_broadcast(Duration::from_secs(5)).await; - - let event = event.expect("Client B should receive update broadcast"); - assert_eq!( - event.event, "document_updated", - "Event should be document_updated" - ); - let event_id = event - .payload - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - assert_eq!( - event_id, - doc_id.to_string(), - "Broadcast should contain correct document ID" - ); -} - -#[tokio::test] -#[serial] -async fn test_broadcast_on_delete() { - if skip_if_no_server() { - return; - } - - let client_a = TestClient::connect(TEST_EMAIL).await.unwrap(); - let mut client_b = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Create a document - let doc_id = Uuid::new_v4(); - client_a - .create_document_with_id(doc_id, json!({"title": "To Be Deleted"})) - .await - .unwrap(); - - // Drain the create broadcast - client_b.drain_broadcasts().await; - - // Delete the document - client_a.delete_document(doc_id).await.unwrap(); - - let event = client_b.recv_broadcast(Duration::from_secs(5)).await; - - let event = event.expect("Client B should receive delete broadcast"); - assert_eq!( - event.event, "document_deleted", - "Event should be document_deleted" - ); - let event_id = event - .payload - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - assert_eq!( - event_id, - doc_id.to_string(), - "Broadcast should contain correct document ID" - ); -} - -#[tokio::test] -#[serial] -async fn test_sender_does_not_receive_own_broadcast() { - if skip_if_no_server() { - return; - } - - let mut client = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Drain any stale events - client.drain_broadcasts().await; - - // Create a document - let doc_id = Uuid::new_v4(); - client - .create_document_with_id(doc_id, json!({"title": "No Self-Broadcast"})) - .await - .unwrap(); - - // The sender should NOT receive their own broadcast (broadcast_from! excludes sender) - let event = client.recv_broadcast(Duration::from_millis(500)).await; - - assert!( - event.is_none(), - "Sender should not receive their own broadcast event, but got: {:?}", - event.map(|e| e.event) - ); -} diff --git a/replicant-client/tests/phoenix_integration/mod.rs b/replicant-client/tests/phoenix_integration/mod.rs deleted file mode 100644 index d33a789..0000000 --- a/replicant-client/tests/phoenix_integration/mod.rs +++ /dev/null @@ -1,422 +0,0 @@ -//! Integration tests for Rust client ↔ Phoenix server communication. -//! -//! These tests require a running Phoenix server and API credentials: -//! -//! ```bash -//! # Terminal 1: Start Phoenix server -//! cd replicant_server -//! mix deps.get -//! mix ecto.setup -//! mix phx.server -//! -//! # Generate credentials (one-time) -//! mix replicant.gen.credentials --name "integration-test" -//! -//! # Terminal 2: Run integration tests -//! cd replicant-client/replicant-client -//! REPLICANT_API_KEY="rpa_..." \ -//! REPLICANT_API_SECRET="rps_..." \ -//! RUN_INTEGRATION_TESTS=1 \ -//! cargo test --test integration -//! ``` - -mod basic_sync_test; -mod conflict_test; -mod credential_enrollment_test; -mod divergence_test; -mod hash_interop_test; -mod identity_adoption_test; -mod live_sync_test; -mod multi_client_test; -mod startup_events_test; - -pub use serial_test::serial; - -use hmac::{Hmac, Mac}; -use phoenix_channels_client::{Channel, ChannelJoinError, Event, Payload, Socket, Topic}; -use replicant_core::models::Document; -use serde_json::{json, Value}; -use sha2::Sha256; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::mpsc; -use url::Url; -use uuid::Uuid; - -type HmacSha256 = Hmac<Sha256>; - -/// Default test credentials - set via environment variables -/// Generate with: mix replicant.gen.credentials --name "integration-test" -pub const TEST_EMAIL: &str = "integration-test@example.com"; - -pub fn test_api_key() -> String { - std::env::var("REPLICANT_API_KEY") - .expect("REPLICANT_API_KEY env var required for integration tests") -} - -pub fn test_api_secret() -> String { - std::env::var("REPLICANT_API_SECRET") - .expect("REPLICANT_API_SECRET env var required for integration tests") -} - -pub fn server_url() -> String { - std::env::var("SYNC_SERVER_URL") - .unwrap_or_else(|_| "ws://localhost:4000/socket/websocket".to_string()) -} - -/// The canonical user id bound to `REPLICANT_API_KEY`. Post-#6 the server -/// binds each credential to a user and rejects `sync:user:<id>` joins whose -/// id does not match the credential's user (`topic_user_mismatch`), so the -/// test client must join under this seeded id, not a random one. -pub fn test_user_id() -> Uuid { - std::env::var("REPLICANT_TEST_USER_ID") - .ok() - .and_then(|s| Uuid::parse_str(&s).ok()) - .unwrap_or_else(Uuid::new_v4) -} - -pub fn skip_if_no_server() -> bool { - std::env::var("RUN_INTEGRATION_TESTS").is_err() -} - -/// The canonical user id, required rather than defaulted: a test that drives a -/// real `Client` must join the same topic as its `TestClient` driver. -pub fn canonical_user_id() -> Uuid { - std::env::var("REPLICANT_TEST_USER_ID") - .ok() - .and_then(|s| Uuid::parse_str(&s).ok()) - .expect("REPLICANT_TEST_USER_ID is required for tests that drive a real Client") -} - -/// A unique on-disk database path for a real `Client` under test. -pub fn temp_db_path(tag: &str) -> String { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - format!("databases/{}_{}_{}.sqlite3", tag, std::process::id(), nanos) -} - -/// Delete a temporary database, including the `-wal` and `-shm` siblings SQLite -/// leaves behind. -pub fn remove_temp_db(db_file: &str) { - for path in [ - db_file.to_string(), - format!("{}-wal", db_file), - format!("{}-shm", db_file), - ] { - std::fs::remove_file(path).ok(); - } -} - -/// Connect a real `Client` (the subject under test, as opposed to the raw -/// `TestClient` driver) and wait for it to come up. -pub async fn connect_subject(db_url: &str) -> replicant_client::Client { - let client = replicant_client::Client::with_event_dispatcher( - db_url, - &server_url(), - TEST_EMAIL, - &test_api_key(), - &test_api_secret(), - Some(canonical_user_id()), - None, - ) - .await - .expect("subject client should connect"); - - for _ in 0..50 { - if client.is_connected() { - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - assert!(client.is_connected(), "subject client should be connected"); - client -} - -/// Open a real `Client` on an existing database with no reachable server, so -/// local edits queue instead of uploading. Port 1 refuses immediately, which is -/// as offline as a client can be while still running its full write path. -pub async fn open_offline_subject(db_url: &str) -> replicant_client::Client { - let client = replicant_client::Client::with_event_dispatcher( - db_url, - "ws://127.0.0.1:1/socket/websocket", - TEST_EMAIL, - &test_api_key(), - &test_api_secret(), - Some(canonical_user_id()), - None, - ) - .await - .expect("offline client should still open"); - - assert!( - !client.is_connected(), - "the offline client must not reach a server" - ); - client -} - -/// A broadcast event received from the server -#[derive(Debug)] -pub struct BroadcastEvent { - pub event: String, - pub payload: Value, -} - -/// Test client for integration tests -pub struct TestClient { - pub channel: Arc<Channel>, - pub public_channel: Arc<Channel>, - pub email: String, - pub user_id: Uuid, - broadcast_rx: mpsc::Receiver<BroadcastEvent>, -} - -impl TestClient { - pub async fn connect(email: &str) -> Result<Self, String> { - Self::connect_with_credentials(email, &test_api_key(), &test_api_secret()).await - } - - pub async fn connect_with_credentials( - email: &str, - api_key: &str, - api_secret: &str, - ) -> Result<Self, String> { - let url = Url::parse(&server_url()).map_err(|e| format!("Invalid URL: {}", e))?; - let user_id = test_user_id(); - - let socket = Socket::spawn(url, None, None) - .await - .map_err(|e| format!("Socket spawn failed: {:?}", e))?; - - socket - .connect(Duration::from_secs(10)) - .await - .map_err(|e| format!("Connect failed: {:?}", e))?; - - let timestamp = chrono::Utc::now().timestamp(); - let signature = create_hmac_signature(api_secret, timestamp, email, api_key); - - let join_payload = json!({ - "email": email, - "api_key": api_key, - "signature": signature, - "timestamp": timestamp - }); - - // Join per-user channel - let channel = socket - .channel( - Topic::from_string(format!("sync:user:{}", user_id)), - Some(to_payload(&join_payload)?), - ) - .await - .map_err(|e| format!("User channel create failed: {:?}", e))?; - - channel - .join(Duration::from_secs(10)) - .await - .map_err(|e| format!("User channel join failed: {:?}", e))?; - - // Join public channel - let public_channel = socket - .channel( - Topic::from_string("sync:public".to_string()), - Some(to_payload(&join_payload)?), - ) - .await - .map_err(|e| format!("Public channel create failed: {:?}", e))?; - - public_channel - .join(Duration::from_secs(10)) - .await - .map_err(|e| format!("Public channel join failed: {:?}", e))?; - - // Set up broadcast event receiver - let (tx, rx) = mpsc::channel::<BroadcastEvent>(100); - Self::spawn_event_listener(&channel, tx.clone()); - Self::spawn_event_listener(&public_channel, tx); - - Ok(Self { - channel, - public_channel, - email: email.to_string(), - user_id, - broadcast_rx: rx, - }) - } - - fn spawn_event_listener(channel: &Arc<Channel>, tx: mpsc::Sender<BroadcastEvent>) { - let events = channel.events(); - tokio::spawn(async move { - loop { - match events.event().await { - Ok(event_payload) => { - let event_name = event_payload.event.to_string(); - let payload_json = - payload_to_value(&event_payload.payload).unwrap_or(Value::Null); - let _ = tx - .send(BroadcastEvent { - event: event_name, - payload: payload_json, - }) - .await; - } - Err(_) => break, - } - } - }); - } - - /// Receive the next broadcast event, with a timeout. - /// Returns None if the timeout expires before an event arrives. - pub async fn recv_broadcast(&mut self, timeout: Duration) -> Option<BroadcastEvent> { - tokio::time::timeout(timeout, self.broadcast_rx.recv()) - .await - .ok() - .flatten() - } - - /// Drain any pending broadcast events (call before the action you want to observe). - pub async fn drain_broadcasts(&mut self) { - while self - .recv_broadcast(Duration::from_millis(50)) - .await - .is_some() - {} - } - - pub async fn create_document(&self, content: Value) -> Result<Value, String> { - let doc_id = Uuid::new_v4(); - let payload = json!({"id": doc_id.to_string(), "content": content}); - self.call("create_document", &payload).await - } - - pub async fn create_document_with_id(&self, id: Uuid, content: Value) -> Result<Value, String> { - let payload = json!({"id": id.to_string(), "content": content}); - self.call("create_document", &payload).await - } - - pub async fn update_document( - &self, - document_id: Uuid, - patch: Value, - content_hash: &str, - ) -> Result<Value, String> { - let payload = json!({ - "id": document_id.to_string(), - "patch": patch, - "content_hash": content_hash - }); - self.call("update_document", &payload).await - } - - pub async fn get_document(&self, document_id: Uuid) -> Result<Value, String> { - let payload = json!({"id": document_id.to_string()}); - self.call("get_document", &payload).await - } - - pub async fn delete_document(&self, document_id: Uuid) -> Result<Value, String> { - let payload = json!({"id": document_id.to_string()}); - self.call("delete_document", &payload).await - } - - pub async fn request_full_sync(&self) -> Result<Value, String> { - self.call("request_full_sync", &json!({})).await - } - - pub async fn get_changes_since(&self, last_sequence: u64) -> Result<Value, String> { - let payload = json!({"last_sequence": last_sequence}); - self.call("get_changes_since", &payload).await - } - - async fn call(&self, event: &str, payload: &Value) -> Result<Value, String> { - self.channel - .call( - Event::from_string(event.to_string()), - to_payload(payload)?, - Duration::from_secs(30), - ) - .await - .map_err(|e| format!("{:?}", e)) - .and_then(|p| payload_to_value(&p).ok_or_else(|| "Invalid response".to_string())) - } -} - -/// Attempt only the per-user channel join and surface the RAW phoenix -/// `ChannelJoinError`, so tests can feed it to the production -/// `error_code_for_join_reject` mapping and assert the derived code (rather than -/// substring-matching the message). Returns `None` if the join unexpectedly -/// succeeded or the socket could not be set up. -pub async fn raw_user_join_error( - email: &str, - api_key: &str, - api_secret: &str, -) -> Option<ChannelJoinError> { - let url = Url::parse(&server_url()).ok()?; - let user_id = test_user_id(); - - let socket = Socket::spawn(url, None, None).await.ok()?; - socket.connect(Duration::from_secs(10)).await.ok()?; - - let timestamp = chrono::Utc::now().timestamp(); - let signature = create_hmac_signature(api_secret, timestamp, email, api_key); - let join_payload = json!({ - "email": email, - "api_key": api_key, - "signature": signature, - "timestamp": timestamp - }); - - let channel = socket - .channel( - Topic::from_string(format!("sync:user:{}", user_id)), - Some(to_payload(&join_payload).ok()?), - ) - .await - .ok()?; - - channel.join(Duration::from_secs(10)).await.err() -} - -fn create_hmac_signature(secret: &str, timestamp: i64, email: &str, api_key: &str) -> String { - let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key size"); - mac.update(format!("{}.{}.{}.{}", timestamp, email, api_key, "").as_bytes()); - hex::encode(mac.finalize().into_bytes()) -} - -fn to_payload(v: &Value) -> Result<Payload, String> { - Payload::json_from_serialized(v.to_string()).map_err(|e| format!("Payload error: {:?}", e)) -} - -fn payload_to_value(p: &Payload) -> Option<Value> { - match p { - Payload::JSONPayload { json } => Some(Value::from(json.clone())), - Payload::Binary { .. } => None, - } -} - -/// Parse a document from a JSON response -pub fn parse_document(v: &Value) -> Option<Document> { - Some(Document { - id: Uuid::parse_str(v.get("id")?.as_str()?).ok()?, - user_id: v - .get("user_id") - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok()), - content: v.get("content")?.clone(), - sync_revision: v.get("sync_revision")?.as_i64()?, - content_hash: v - .get("content_hash") - .and_then(|v| v.as_str()) - .map(String::from), - title: v.get("title").and_then(|v| v.as_str()).map(String::from), - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }) -} diff --git a/replicant-client/tests/phoenix_integration/multi_client_test.rs b/replicant-client/tests/phoenix_integration/multi_client_test.rs deleted file mode 100644 index f2dcd03..0000000 --- a/replicant-client/tests/phoenix_integration/multi_client_test.rs +++ /dev/null @@ -1,797 +0,0 @@ -//! Multi-client sync tests: broadcasts, real-time updates -//! -//! Ported from the original Rust server integration tests. - -use super::{ - connect_subject, open_offline_subject, remove_temp_db, serial, skip_if_no_server, temp_db_path, - TestClient, TEST_EMAIL, -}; -use rand::rngs::StdRng; -use rand::{Rng, SeedableRng}; -use replicant_client::ClientDatabase; -use replicant_core::models::SyncStatus; -use serde_json::{json, Value}; -use std::collections::BTreeMap; -use std::time::Duration; -use uuid::Uuid; - -#[tokio::test] -#[serial] -async fn test_two_clients_same_user() { - if skip_if_no_server() { - return; - } - - // Connect two clients as the same user - let client_a = TestClient::connect(TEST_EMAIL).await.unwrap(); - let client_b = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Client A creates a document - let doc_id = Uuid::new_v4(); - let content = json!({"title": "Shared Document", "owner": "client_a"}); - let create_result = client_a.create_document_with_id(doc_id, content).await; - assert!( - create_result.is_ok(), - "Create failed: {:?}", - create_result.err() - ); - - // Give broadcasts time to propagate - tokio::time::sleep(Duration::from_millis(100)).await; - - // Client B should see the document via full sync - let sync_result = client_b.request_full_sync().await.unwrap(); - let documents = sync_result - .get("documents") - .and_then(|v| v.as_array()) - .unwrap(); - let found = documents.iter().any(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_id.to_string()) - .unwrap_or(false) - }); - assert!(found, "Client B should see document created by Client A"); -} - -#[tokio::test] -#[serial] -async fn test_update_propagates_to_other_client() { - if skip_if_no_server() { - return; - } - - // Connect two clients - let client_a = TestClient::connect(TEST_EMAIL).await.unwrap(); - let client_b = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Client A creates a document - let doc_id = Uuid::new_v4(); - let content = json!({"title": "Original", "version": 1}); - let create_result = client_a - .create_document_with_id(doc_id, content) - .await - .unwrap(); - let content_hash = create_result - .get("content_hash") - .and_then(|v| v.as_str()) - .unwrap(); - - // Client A updates the document - let patch = json!([{"op": "replace", "path": "/title", "value": "Updated by A"}]); - client_a - .update_document(doc_id, patch, content_hash) - .await - .unwrap(); - - // Give broadcasts time to propagate - tokio::time::sleep(Duration::from_millis(100)).await; - - // Client B fetches the document via full sync - let sync_result = client_b.request_full_sync().await.unwrap(); - let documents = sync_result - .get("documents") - .and_then(|v| v.as_array()) - .unwrap(); - let doc = documents.iter().find(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_id.to_string()) - .unwrap_or(false) - }); - - assert!(doc.is_some(), "Client B should see the document"); - let doc = doc.unwrap(); - let title = doc - .get("content") - .and_then(|c| c.get("title")) - .and_then(|v| v.as_str()); - assert_eq!( - title, - Some("Updated by A"), - "Client B should see updated title" - ); -} - -#[tokio::test] -#[serial] -async fn test_delete_propagates_to_other_client() { - if skip_if_no_server() { - return; - } - - // Connect two clients - let client_a = TestClient::connect(TEST_EMAIL).await.unwrap(); - let client_b = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Client A creates a document - let doc_id = Uuid::new_v4(); - let content = json!({"title": "To Be Deleted"}); - client_a - .create_document_with_id(doc_id, content) - .await - .unwrap(); - - // Verify Client B can see it - let sync_result = client_b.request_full_sync().await.unwrap(); - let documents = sync_result - .get("documents") - .and_then(|v| v.as_array()) - .unwrap(); - let found_before = documents.iter().any(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_id.to_string()) - .unwrap_or(false) - }); - assert!(found_before, "Client B should initially see the document"); - - // Client A deletes the document - client_a.delete_document(doc_id).await.unwrap(); - - // Give broadcasts time to propagate - tokio::time::sleep(Duration::from_millis(100)).await; - - // Client B should no longer see it - let sync_result = client_b.request_full_sync().await.unwrap(); - let documents = sync_result - .get("documents") - .and_then(|v| v.as_array()) - .unwrap(); - let found_after = documents.iter().any(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_id.to_string()) - .unwrap_or(false) - }); - assert!(!found_after, "Client B should not see deleted document"); -} - -#[tokio::test] -#[serial] -async fn test_incremental_sync_across_clients() { - if skip_if_no_server() { - return; - } - - // Connect two clients - let client_a = TestClient::connect(TEST_EMAIL).await.unwrap(); - let client_b = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Get Client B's current sequence - let sync_result = client_b.request_full_sync().await.unwrap(); - let sequence_before = sync_result - .get("latest_sequence") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - // Client A creates a document - let content = json!({"title": "Incremental Sync Test"}); - client_a.create_document(content).await.unwrap(); - - // Client B gets changes since its last sequence - let changes_result = client_b.get_changes_since(sequence_before).await.unwrap(); - let events = changes_result - .get("events") - .and_then(|v| v.as_array()) - .unwrap(); - - assert!( - !events.is_empty(), - "Client B should see at least one change event" - ); -} - -/// Test from original suite: test_bidirectional_sync -/// Both clients create documents, both should see all documents -#[tokio::test] -#[serial] -async fn test_bidirectional_sync() { - if skip_if_no_server() { - return; - } - - let client_a = TestClient::connect(TEST_EMAIL).await.unwrap(); - let client_b = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Both clients create documents - let doc_a_id = Uuid::new_v4(); - let doc_b_id = Uuid::new_v4(); - - client_a - .create_document_with_id(doc_a_id, json!({"title": "Doc from Client A"})) - .await - .unwrap(); - client_b - .create_document_with_id(doc_b_id, json!({"title": "Doc from Client B"})) - .await - .unwrap(); - - // Give time to sync - tokio::time::sleep(Duration::from_millis(200)).await; - - // Both should see both documents - let sync_a = client_a.request_full_sync().await.unwrap(); - let sync_b = client_b.request_full_sync().await.unwrap(); - - let docs_a = sync_a.get("documents").and_then(|v| v.as_array()).unwrap(); - let docs_b = sync_b.get("documents").and_then(|v| v.as_array()).unwrap(); - - let has_doc_a_id = |docs: &[serde_json::Value]| { - docs.iter().any(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_a_id.to_string()) - .unwrap_or(false) - }) - }; - - let has_doc_b_id = |docs: &[serde_json::Value]| { - docs.iter().any(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_b_id.to_string()) - .unwrap_or(false) - }) - }; - - assert!(has_doc_a_id(docs_a), "Client A should see doc_a"); - assert!(has_doc_b_id(docs_a), "Client A should see doc_b"); - assert!(has_doc_a_id(docs_b), "Client B should see doc_a"); - assert!(has_doc_b_id(docs_b), "Client B should see doc_b"); -} - -/// Test from original suite: test_three_clients_full_crud -/// Three clients perform CRUD operations, all should converge -#[tokio::test] -#[serial] -async fn test_three_clients_full_crud() { - if skip_if_no_server() { - return; - } - - let client_1 = TestClient::connect(TEST_EMAIL).await.unwrap(); - let client_2 = TestClient::connect(TEST_EMAIL).await.unwrap(); - let client_3 = TestClient::connect(TEST_EMAIL).await.unwrap(); - - // Give clients time to connect - tokio::time::sleep(Duration::from_millis(100)).await; - - // Client 1 creates a document - let doc_id = Uuid::new_v4(); - let create_result = client_1 - .create_document_with_id( - doc_id, - json!({ - "title": "Shared Task", - "status": "pending", - "priority": "high" - }), - ) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(200)).await; - - // Verify all clients see the document - for (name, client) in [ - ("Client 1", &client_1), - ("Client 2", &client_2), - ("Client 3", &client_3), - ] { - let sync = client.request_full_sync().await.unwrap(); - let docs = sync.get("documents").and_then(|v| v.as_array()).unwrap(); - let found = docs.iter().any(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_id.to_string()) - .unwrap_or(false) - }); - assert!(found, "{} should see the document after create", name); - } - - // Client 2 updates the document - let content_hash = create_result - .get("content_hash") - .and_then(|v| v.as_str()) - .unwrap(); - client_2 - .update_document( - doc_id, - json!([{"op": "replace", "path": "/status", "value": "in_progress"}]), - content_hash, - ) - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(200)).await; - - // Verify update propagated - let sync_3 = client_3.request_full_sync().await.unwrap(); - let docs_3 = sync_3.get("documents").and_then(|v| v.as_array()).unwrap(); - let doc = docs_3.iter().find(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_id.to_string()) - .unwrap_or(false) - }); - let status = doc - .and_then(|d| d.get("content")) - .and_then(|c| c.get("status")) - .and_then(|s| s.as_str()); - assert_eq!( - status, - Some("in_progress"), - "Client 3 should see updated status" - ); - - // Client 3 deletes the document - client_3.delete_document(doc_id).await.unwrap(); - - tokio::time::sleep(Duration::from_millis(200)).await; - - // Verify deletion propagated - for (name, client) in [("Client 1", &client_1), ("Client 2", &client_2)] { - let sync = client.request_full_sync().await.unwrap(); - let docs = sync.get("documents").and_then(|v| v.as_array()).unwrap(); - let found = docs.iter().any(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_id.to_string()) - .unwrap_or(false) - }); - assert!(!found, "{} should not see deleted document", name); - } -} - -/// Test from original suite: test_no_duplicate_broadcast_to_sender -/// Sender should not receive their own broadcast back -#[tokio::test] -#[serial] -async fn test_no_duplicate_broadcast_to_sender() { - if skip_if_no_server() { - return; - } - - let client = TestClient::connect(TEST_EMAIL).await.unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Create a document - let doc_id = Uuid::new_v4(); - client - .create_document_with_id(doc_id, json!({"title": "Test No Duplicates"})) - .await - .unwrap(); - - // Wait for any potential duplicate broadcasts - tokio::time::sleep(Duration::from_millis(500)).await; - - // Verify only one document exists - let sync = client.request_full_sync().await.unwrap(); - let docs = sync.get("documents").and_then(|v| v.as_array()).unwrap(); - - // Filter to only docs with our specific ID (avoid interference from other tests) - let matching_docs: Vec<_> = docs - .iter() - .filter(|d| { - d.get("id") - .and_then(|v| v.as_str()) - .map(|s| s == doc_id.to_string()) - .unwrap_or(false) - }) - .collect(); - - assert_eq!( - matching_docs.len(), - 1, - "Should have exactly 1 document with our ID, not duplicates" - ); -} - -// --------------------------------------------------------------------------- -// Convergence torture (DEV-1037, Task 7) -// --------------------------------------------------------------------------- - -/// Default op stream. Override with `TORTURE_SEED=<u64>` to replay or explore -/// a different interleaving; a failure prints the seed alongside the trace. -const DEFAULT_TORTURE_SEED: u64 = 0xDE1037; -const TORTURE_CLIENTS: usize = 3; -const TORTURE_SEED_DOCS: usize = 4; -const TORTURE_OPS: usize = 30; - -/// The op indices at which replica 2 loses and regains its server connection. -const OFFLINE_AT: [usize; 2] = [7, 19]; -const ONLINE_AT: [usize; 2] = [13, 25]; - -/// One simulated device: its own SQLite file, its own `Client`, and a -/// second read-only pool the test uses to inspect the row the client wrote. -struct Replica { - tag: &'static str, - db_file: String, - db_url: String, - client: Option<replicant_client::Client>, - db: ClientDatabase, - online: bool, -} - -impl Replica { - async fn new(tag: &'static str) -> Self { - let db_file = temp_db_path(tag); - let db_url = format!("sqlite:{}?mode=rwc", db_file); - let client = connect_subject(&db_url).await; - let db = ClientDatabase::new(&db_url).await.unwrap(); - Self { - tag, - db_file, - db_url, - client: Some(client), - db, - online: true, - } - } - - fn client(&self) -> &replicant_client::Client { - self.client.as_ref().expect("replica has a live client") - } - - /// Drop the connected client before opening its replacement: two `Client`s - /// on one SQLite file would race each other's writes. - async fn go_offline(&mut self) { - self.client = None; - self.client = Some(open_offline_subject(&self.db_url).await); - self.online = false; - } - - async fn go_online(&mut self) { - self.client = None; - self.client = Some(connect_subject(&self.db_url).await); - self.online = true; - } - - /// Pending documents plus queued patches. There is no public accessor for - /// the in-flight upload map, so an empty queue held across consecutive - /// polls stands in for "nothing is still in the air". - async fn outstanding(&self) -> usize { - let pending = self - .client() - .count_pending_sync() - .await - .unwrap_or(usize::MAX); - let queued: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sync_queue") - .fetch_one(&self.db.pool) - .await - .unwrap_or(i64::MAX); - pending + queued as usize - } -} - -/// Wait until every *connected* replica reports nothing outstanding on three -/// consecutive polls, then let the final acks and broadcasts land. Returns -/// false if `max_polls` passes first. -/// -/// Offline replicas are excluded by construction: their queue cannot drain -/// while they have nowhere to send it, so waiting on them would only ever -/// burn the budget. -async fn quiesce(replicas: &[Replica], max_polls: usize) -> bool { - let mut settled_polls = 0; - for _ in 0..max_polls { - let mut total = 0; - for replica in replicas.iter().filter(|r| r.online) { - total += replica.outstanding().await; - } - settled_polls = if total == 0 { settled_polls + 1 } else { 0 }; - if settled_polls >= 3 { - tokio::time::sleep(Duration::from_millis(2000)).await; - return true; - } - tokio::time::sleep(Duration::from_millis(200)).await; - } - false -} - -fn render_trace(trace: &[String]) -> String { - let mut out = String::from("--- torture op trace ---\n"); - for line in trace { - out.push_str(line); - out.push('\n'); - } - out -} - -/// Rewrite one key of the replica's *current local* content, so every edit is -/// built on whatever that replica believes the document is — which, mid-run, is -/// routinely stale relative to the server. -async fn edit_locally(replica: &Replica, doc_id: Uuid, step: usize) -> Option<Value> { - let current = replica.db.get_document(&doc_id).await.ok()?; - let mut object = current.content.as_object().cloned()?; - object.insert(format!("k_{}", replica.tag), json!(step)); - let next = Value::Object(object); - replica - .client() - .update_document(doc_id, next.clone()) - .await - .ok()?; - Some(next) -} - -/// Read the server's live documents as `id -> (content, sync_revision)`. -async fn server_state(driver: &TestClient) -> BTreeMap<Uuid, (Value, i64)> { - let sync = driver.request_full_sync().await.unwrap(); - let documents = sync.get("documents").and_then(|v| v.as_array()).unwrap(); - documents - .iter() - .filter_map(|d| { - let id = Uuid::parse_str(d.get("id")?.as_str()?).ok()?; - let content = d.get("content")?.clone(); - let revision = d.get("sync_revision")?.as_i64()?; - Some((id, (content, revision))) - }) - .collect() -} - -/// Three devices of one user, hammering an overlapping document set with -/// interleaved edits, creates and deletes while one of them drops offline and -/// back twice. Afterwards every replica and the server must hold byte-identical -/// content at the same revision — the whole point of the DEV-1037 work. -/// -/// The server still echoes a sender's own broadcasts back to it (DEV-1038), so -/// this run exercises the client's echo-deferral path rather than assuming a -/// quiet channel. -#[tokio::test] -#[serial] -async fn three_client_convergence_torture() { - if skip_if_no_server() { - return; - } - - let seed = std::env::var("TORTURE_SEED") - .ok() - .and_then(|s| s.parse::<u64>().ok()) - .unwrap_or(DEFAULT_TORTURE_SEED); - let mut rng = StdRng::seed_from_u64(seed); - let mut trace: Vec<String> = vec![format!("seed={}", seed)]; - - std::fs::create_dir_all("databases").ok(); - let driver = TestClient::connect(TEST_EMAIL).await.unwrap(); - - let mut replicas = Vec::with_capacity(TORTURE_CLIENTS); - for tag in ["r0", "r1", "r2"] { - replicas.push(Replica::new(tag).await); - } - - // Seed the shared document set, one creator per document in turn. - let mut live: Vec<Uuid> = Vec::new(); - let mut retired: Vec<Uuid> = Vec::new(); - let mut applied_edits = 0usize; - for n in 0..TORTURE_SEED_DOCS { - let doc_id = Uuid::new_v4(); - let creator = n % TORTURE_CLIENTS; - replicas[creator] - .client() - .create_document_with_id(doc_id, json!({"title": format!("doc-{}", n), "n": n})) - .await - .unwrap(); - live.push(doc_id); - trace.push(format!("seed-create r{} {}", creator, doc_id)); - } - assert!( - quiesce(&replicas, 600).await, - "the seed documents should settle before the run starts (seed={})", - seed - ); - - for step in 0..TORTURE_OPS { - if let Some(slot) = OFFLINE_AT.iter().position(|&at| at == step) { - replicas[2].go_offline().await; - trace.push(format!("op{:02} r2 OFFLINE (#{})", step, slot)); - } - if let Some(slot) = ONLINE_AT.iter().position(|&at| at == step) { - replicas[2].go_online().await; - trace.push(format!("op{:02} r2 ONLINE (#{})", step, slot)); - } - - let roll = rng.gen_range(0..100); - if roll < 76 && !live.is_empty() { - // Edit, weighted toward two replicas hitting the same document with - // no settle in between. - let doc_id = live[rng.gen_range(0..live.len())]; - let first = rng.gen_range(0..TORTURE_CLIENTS); - let applied = edit_locally(&replicas[first], doc_id, step).await; - applied_edits += applied.is_some() as usize; - trace.push(format!( - "op{:02} edit r{} {} -> {}", - step, - first, - doc_id, - applied.map(|v| v.to_string()).unwrap_or("skipped".into()) - )); - - if rng.gen_bool(0.5) { - let second = (first + 1 + rng.gen_range(0..TORTURE_CLIENTS - 1)) % TORTURE_CLIENTS; - let applied = edit_locally(&replicas[second], doc_id, step).await; - applied_edits += applied.is_some() as usize; - trace.push(format!( - "op{:02} concurrent-edit r{} {} -> {}", - step, - second, - doc_id, - applied.map(|v| v.to_string()).unwrap_or("skipped".into()) - )); - } - } else if roll < 88 { - let doc_id = Uuid::new_v4(); - let creator = rng.gen_range(0..TORTURE_CLIENTS); - replicas[creator] - .client() - .create_document_with_id(doc_id, json!({"title": "created mid-run", "at": step})) - .await - .unwrap(); - live.push(doc_id); - trace.push(format!("op{:02} create r{} {}", step, creator, doc_id)); - } else if live.len() > 1 { - // Retire the document from the edit pool first, then let the - // connected replicas' edits on it drain: a delete racing an - // in-flight patch is a server-side not_found, not a convergence - // question. A replica that is offline may still be holding a - // queued edit for it, which is the interesting case and is left in. - let doc_id = live.remove(rng.gen_range(0..live.len())); - quiesce(&replicas, 100).await; - let online: Vec<usize> = (0..TORTURE_CLIENTS) - .filter(|&i| replicas[i].online) - .collect(); - let deleter = online[rng.gen_range(0..online.len())]; - if replicas[deleter] - .client() - .delete_document(doc_id) - .await - .is_ok() - { - retired.push(doc_id); - trace.push(format!("op{:02} delete r{} {}", step, deleter, doc_id)); - } else { - live.push(doc_id); - trace.push(format!( - "op{:02} delete r{} {} FAILED", - step, deleter, doc_id - )); - } - } - - tokio::time::sleep(Duration::from_millis(rng.gen_range(20..140))).await; - } - - if !replicas[2].online { - replicas[2].go_online().await; - trace.push("post-run r2 ONLINE".to_string()); - } - - assert!( - quiesce(&replicas, 600).await, - "all replicas should drain their queues after the run (seed={})", - seed - ); - - // A run whose edits all failed to land would converge trivially. Guard the - // test against silently degenerating into an assertion about nothing. - assert!( - applied_edits >= 15, - "{}\nonly {} edits landed — the run was not a torture (seed={})", - render_trace(&trace), - applied_edits, - seed - ); - eprintln!( - "torture seed={} applied {} edits over {} live and {} deleted documents", - seed, - applied_edits, - live.len(), - retired.len() - ); - - // --- Convergence ------------------------------------------------------ - let server = server_state(&driver).await; - let mut conflicted: Vec<String> = Vec::new(); - - for doc_id in &live { - let (server_content, server_revision) = server.get(doc_id).unwrap_or_else(|| { - panic!( - "{}\nserver lost {} (seed={})", - render_trace(&trace), - doc_id, - seed - ) - }); - - for replica in &replicas { - let local = replica.db.get_document(doc_id).await.unwrap_or_else(|e| { - panic!( - "{}\n{} lost {} ({e}) (seed={})", - render_trace(&trace), - replica.tag, - doc_id, - seed - ) - }); - assert_eq!( - &local.content, - server_content, - "{}\n{} diverged on {} (seed={})", - render_trace(&trace), - replica.tag, - doc_id, - seed - ); - - // A document parked in durable Conflict still tracks the server's - // content (Task 5); only its revision bookkeeping is allowed to - // lag, so it is reported rather than asserted. - if replica.db.get_sync_status(doc_id).await.unwrap() == Some(SyncStatus::Conflict) { - conflicted.push(format!("{} {}", replica.tag, doc_id)); - continue; - } - assert_eq!( - local.sync_revision, - *server_revision, - "{}\n{} is at the wrong revision for {} (seed={})", - render_trace(&trace), - replica.tag, - doc_id, - seed - ); - } - } - - for doc_id in &retired { - assert!( - !server.contains_key(doc_id), - "{}\nserver still holds deleted {} (seed={})", - render_trace(&trace), - doc_id, - seed - ); - for replica in &replicas { - let ids = replica.client().get_all_document_ids(false).await.unwrap(); - assert!( - !ids.contains(doc_id), - "{}\n{} still holds deleted {} (seed={})", - render_trace(&trace), - replica.tag, - doc_id, - seed - ); - } - } - - if !conflicted.is_empty() { - eprintln!( - "converged with {} document(s) in durable Conflict: {:?}", - conflicted.len(), - conflicted - ); - } - - for mut replica in replicas { - replica.client = None; - remove_temp_db(&replica.db_file); - } -} diff --git a/replicant-client/tests/phoenix_integration/startup_events_test.rs b/replicant-client/tests/phoenix_integration/startup_events_test.rs deleted file mode 100644 index 68b9001..0000000 --- a/replicant-client/tests/phoenix_integration/startup_events_test.rs +++ /dev/null @@ -1,356 +0,0 @@ -//! Start-up connection and sync events, observed through the FFI. -//! -//! `replicant_create` reports one ConnectionSucceeded per real connect, once -//! the engine uses that connection, then SyncStarted and SyncCompleted for the -//! full sync that follows, whether the server is reachable at start-up or only -//! later. -//! -//! Gated behind `RUN_INTEGRATION_TESTS`; needs `SYNC_SERVER_URL`, -//! `REPLICANT_API_KEY`, `REPLICANT_API_SECRET` and `REPLICANT_TEST_USER_ID`. - -use super::{ - canonical_user_id, remove_temp_db, serial, server_url, skip_if_no_server, temp_db_path, - test_api_key, test_api_secret, TEST_EMAIL, -}; -use crate::ffi_events::{ - create_engine, destroy_and_remove_db, pump_events_until, register_event_log, EventLog, -}; -use replicant_client::events::EventDispatcher; -use replicant_client::events::EventType; -use replicant_client::ffi::{ - replicant_count_pending_sync, replicant_create_document, replicant_is_connected, - replicant_process_events, Replicant, SyncResult, -}; -use std::ffi::{c_char, CString}; -use std::net::{Shutdown, TcpListener, TcpStream}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread::JoinHandle; -use std::time::{Duration, Instant}; -use url::Url; - -unsafe fn create_subject(db_path: &str, server_url: &str, log: &EventLog) -> *mut Replicant { - let user_id = canonical_user_id().to_string(); - let engine = create_engine( - db_path, - server_url, - TEST_EMAIL, - &test_api_key(), - &test_api_secret(), - Some(&user_id), - ); - assert!(!engine.is_null(), "replicant_create failed"); - register_event_log(engine, log); - engine -} - -/// Asserts one ConnectionSucceeded, SyncStarted and SyncCompleted, in that order. -fn assert_one_connect_then_sync(log: &EventLog) { - let order = [ - EventType::ConnectionSucceeded, - EventType::SyncStarted, - EventType::SyncCompleted, - ]; - for event_type in order { - assert_eq!( - log.count(event_type), - 1, - "expected one {:?}: {:?}", - event_type, - log.events() - ); - } - let positions: Vec<_> = order.iter().map(|e| log.position(*e)).collect(); - assert!( - positions.windows(2).all(|pair| pair[0] < pair[1]), - "expected {:?} in order: {:?}", - order, - log.events() - ); -} - -/// Pumps without pausing until ConnectionSucceeded is delivered, and returns -/// whether the engine reported itself connected at that moment. -unsafe fn connected_when_announced(engine: *mut Replicant, log: &EventLog) -> bool { - let deadline = Instant::now() + Duration::from_secs(30); - while log.count(EventType::ConnectionSucceeded) == 0 { - assert!( - Instant::now() < deadline, - "no ConnectionSucceeded: {:?}", - log.events() - ); - replicant_process_events(engine, std::ptr::null_mut()); - std::thread::yield_now(); - } - replicant_is_connected(engine) -} - -unsafe fn pending_count(engine: *mut Replicant) -> u64 { - let mut count = 0; - assert_eq!( - replicant_count_pending_sync(engine, &mut count), - SyncResult::Success - ); - count -} - -/// A TCP forwarder to the sync server. While offline it accepts and at once -/// closes each connection, so the server looks unreachable on its port. -struct ServerProxy { - port: u16, - online: Arc<AtomicBool>, - hold: Arc<Mutex<Duration>>, - accepted: Arc<AtomicUsize>, - stopping: Arc<AtomicBool>, - streams: Arc<Mutex<Vec<TcpStream>>>, - pumps: Arc<Mutex<Vec<JoinHandle<()>>>>, - acceptor: Option<JoinHandle<()>>, -} - -impl ServerProxy { - fn start_offline() -> Self { - let server = Url::parse(&server_url()).unwrap(); - let upstream = format!( - "{}:{}", - server.host_str().unwrap(), - server.port_or_known_default().unwrap() - ); - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - let online = Arc::new(AtomicBool::new(false)); - let hold = Arc::new(Mutex::new(Duration::ZERO)); - let accepted = Arc::new(AtomicUsize::new(0)); - let stopping = Arc::new(AtomicBool::new(false)); - let streams = Arc::new(Mutex::new(Vec::<TcpStream>::new())); - let pumps = Arc::new(Mutex::new(Vec::new())); - - let acceptor = { - let (online, stopping) = (online.clone(), stopping.clone()); - let (hold, accepted) = (hold.clone(), accepted.clone()); - let (streams, pumps) = (streams.clone(), pumps.clone()); - std::thread::spawn(move || { - for downstream in listener.incoming().flatten() { - if stopping.load(Ordering::SeqCst) { - break; - } - if !online.load(Ordering::SeqCst) { - continue; - } - accepted.fetch_add(1, Ordering::SeqCst); - std::thread::sleep(*hold.lock().unwrap()); - let Ok(upstream) = TcpStream::connect(&upstream) else { - continue; - }; - let mut streams = streams.lock().unwrap(); - streams.push(downstream.try_clone().unwrap()); - streams.push(upstream.try_clone().unwrap()); - let mut pumps = pumps.lock().unwrap(); - pumps.push(Self::pump( - downstream.try_clone().unwrap(), - upstream.try_clone().unwrap(), - )); - pumps.push(Self::pump(upstream, downstream)); - } - }) - }; - - Self { - port, - online, - hold, - accepted, - stopping, - streams, - pumps, - acceptor: Some(acceptor), - } - } - - fn pump(mut from: TcpStream, mut to: TcpStream) -> JoinHandle<()> { - std::thread::spawn(move || { - let _ = std::io::copy(&mut from, &mut to); - let _ = to.shutdown(Shutdown::Both); - let _ = from.shutdown(Shutdown::Both); - }) - } - - fn url(&self) -> String { - format!("ws://127.0.0.1:{}/socket/websocket", self.port) - } - - fn go_online(&self) { - self.online.store(true, Ordering::SeqCst); - } - - /// Goes online, but holds each new connection for `delay` before - /// forwarding it, so its handshake is still in progress meanwhile. - fn go_online_after(&self, delay: Duration) { - *self.hold.lock().unwrap() = delay; - self.go_online(); - } - - fn accepted(&self) -> usize { - self.accepted.load(Ordering::SeqCst) - } -} - -impl Drop for ServerProxy { - fn drop(&mut self) { - self.stopping.store(true, Ordering::SeqCst); - // Wakes the acceptor so it sees `stopping`. - let _ = TcpStream::connect(("127.0.0.1", self.port)); - if let Some(acceptor) = self.acceptor.take() { - let _ = acceptor.join(); - } - for stream in self.streams.lock().unwrap().iter() { - let _ = stream.shutdown(Shutdown::Both); - } - for pump in self.pumps.lock().unwrap().drain(..) { - let _ = pump.join(); - } - } -} - -#[test] -#[serial] -fn online_startup_reports_connection_then_sync() { - if skip_if_no_server() { - eprintln!("skipping startup events test: RUN_INTEGRATION_TESTS not set"); - return; - } - std::fs::create_dir_all("databases").ok(); - let db_path = temp_db_path("startup_online"); - let log = EventLog::default(); - - unsafe { - let engine = create_subject(&db_path, &server_url(), &log); - - assert!(connected_when_announced(engine, &log)); - let synced = pump_events_until(engine, Duration::from_secs(30), || { - log.count(EventType::SyncCompleted) > 0 - }); - assert!( - synced, - "no SyncCompleted from the server: {:?}", - log.events() - ); - assert_one_connect_then_sync(&log); - - // A new document uploads instead of queueing as pending. - let content = CString::new(r#"{"title":"startup online"}"#).unwrap(); - let mut id = [0 as c_char; 37]; - assert_eq!( - replicant_create_document(engine, content.as_ptr(), id.as_mut_ptr()), - SyncResult::Success - ); - let uploaded = pump_events_until(engine, Duration::from_secs(10), || { - pending_count(engine) == 0 - }); - assert!(uploaded, "the document stayed pending"); - - destroy_and_remove_db(engine, &db_path); - } -} - -#[test] -#[serial] -fn offline_startup_reports_connection_then_sync_after_reconnect() { - if skip_if_no_server() { - eprintln!("skipping startup events test: RUN_INTEGRATION_TESTS not set"); - return; - } - std::fs::create_dir_all("databases").ok(); - let db_path = temp_db_path("startup_reconnect"); - let proxy = ServerProxy::start_offline(); - let log = EventLog::default(); - - unsafe { - let engine = create_subject(&db_path, &proxy.url(), &log); - - // The start-up connect fails, then the reconnect monitor's first try does. - let retried = pump_events_until(engine, Duration::from_secs(15), || { - log.count(EventType::SyncError) >= 2 - }); - assert!(retried, "both connects should fail: {:?}", log.events()); - assert_eq!(log.count(EventType::ConnectionSucceeded), 0); - assert_eq!(log.count(EventType::SyncCompleted), 0); - - proxy.go_online(); - - assert!(connected_when_announced(engine, &log)); - let synced = pump_events_until(engine, Duration::from_secs(30), || { - log.count(EventType::SyncCompleted) > 0 - }); - assert!( - synced, - "no SyncCompleted after reconnect: {:?}", - log.events() - ); - assert_one_connect_then_sync(&log); - - destroy_and_remove_db(engine, &db_path); - } - drop(proxy); -} - -#[tokio::test] -#[serial] -async fn shutdown_during_a_reconnect_leaves_the_client_disconnected() { - if skip_if_no_server() { - eprintln!("skipping startup events test: RUN_INTEGRATION_TESTS not set"); - return; - } - std::fs::create_dir_all("databases").ok(); - let db_path = temp_db_path("shutdown_reconnect"); - let proxy = ServerProxy::start_offline(); - let dispatcher = Arc::new(EventDispatcher::new()); - let seen = Arc::new(Mutex::new(Vec::new())); - let sink = seen.clone(); - dispatcher - .register_rust_callback(move |event| sink.lock().unwrap().push(event.event_type())) - .unwrap(); - let connected_events = || { - dispatcher.process_events().unwrap(); - seen.lock() - .unwrap() - .iter() - .filter(|e| **e == EventType::ConnectionSucceeded) - .count() - }; - - let client = replicant_client::Client::with_event_dispatcher( - &format!("sqlite:{}?mode=rwc", db_path), - &proxy.url(), - TEST_EMAIL, - &test_api_key(), - &test_api_secret(), - Some(canonical_user_id()), - Some(dispatcher.clone()), - ) - .await - .unwrap(); - assert!(!client.is_connected()); - - // The monitor's next reconnect is held in its handshake for 3 s; shut - // down while it is. - proxy.go_online_after(Duration::from_secs(3)); - let deadline = Instant::now() + Duration::from_secs(15); - while proxy.accepted() == 0 { - assert!(Instant::now() < deadline, "the monitor never reconnected"); - tokio::time::sleep(Duration::from_millis(20)).await; - } - client.shutdown().await; - let at_shutdown = connected_events(); - - // Let the held handshake finish. - tokio::time::sleep(Duration::from_secs(5)).await; - assert!( - !client.is_connected(), - "a shut-down client must not report connected" - ); - assert_eq!(connected_events(), at_shutdown); - - drop(client); - drop(proxy); - remove_temp_db(&db_path); -} diff --git a/replicant-client/tests/shutdown_tests.rs b/replicant-client/tests/shutdown_tests.rs deleted file mode 100644 index 87d3e34..0000000 --- a/replicant-client/tests/shutdown_tests.rs +++ /dev/null @@ -1,648 +0,0 @@ -//! Shutdown / thread-lifetime tests for the C FFI (DEV-1118). -//! -//! `replicant_destroy` used to be a plain `Box::from_raw` drop. Dropping the -//! handle drops its tokio runtime (which cancels the background init task) and -//! drops the sqlite pools without closing them — and sqlx gives every sqlite -//! connection a dedicated OS thread that it spawns detached and never joins. -//! A connection still inside `establish()` therefore kept running Replicant -//! code after the handle was gone, which crashed hosts that unloaded the module -//! (an execute access violation on an unmapped image). -//! -//! These tests assert the contract that replaces it: -//! -//! * `replicant_destroy` returns promptly — always, contended or not: a plugin -//! host calls it on its UI thread. The teardown (closing both pools, joining -//! the runtime's threads) happens on a Replicant thread afterwards, which is -//! why a plugin must also pin its module. -//! * `replicant_destroy_and_wait` is the opt-in wait, and returns `true` only -//! once that teardown has actually finished, i.e. no Replicant thread is -//! left. - -use std::ffi::CString; -use std::os::raw::c_char; -use std::ptr; -use std::time::{Duration, Instant}; - -use replicant_client::database::sqlite_workers_started; -use replicant_client::ffi::{ - replicant_create, replicant_create_document, replicant_destroy, replicant_destroy_and_wait, - Replicant, SyncResult, -}; -use serial_test::serial; - -/// Generous enough that reaching it means something is wrong, not slow. -const WAIT_MS: u32 = 30_000; - -/// Live thread count of this process. -/// -/// The tests in this file are `#[serial]` and each integration test file is its -/// own binary, so the only threads that move between the two measurements are -/// the ones Replicant starts. -/// Windows is where DEV-1118 was observed, so a probe that cannot answer must say -/// so loudly — it means these assertions are not checking anything on this run — -/// but it must not fail the run: this project's own runner has been without -/// `powershell` on PATH before, and that is not a defect in the library. -#[cfg(windows)] -fn live_thread_count() -> Option<usize> { - let pid = std::process::id(); - let out = std::process::Command::new("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-Command", - &format!("(Get-Process -Id {pid}).Threads.Count"), - ]) - .output(); - - let out = match out { - Ok(out) => out, - Err(e) => { - eprintln!( - "!! THREAD-COUNT ASSERTIONS SKIPPED: could not run the probe \ - (powershell: {e}) — the thread-lifetime guarantee is NOT checked \ - on this run" - ); - return None; - } - }; - - let stdout = String::from_utf8_lossy(&out.stdout); - match stdout.trim().parse() { - Ok(count) => Some(count), - Err(e) => { - eprintln!( - "!! THREAD-COUNT ASSERTIONS SKIPPED: the probe returned {:?} ({e}), \ - stderr {:?} — the thread-lifetime guarantee is NOT checked on this run", - stdout.trim(), - String::from_utf8_lossy(&out.stderr) - ); - None - } - } -} - -#[cfg(target_os = "linux")] -fn live_thread_count() -> Option<usize> { - match std::fs::read_dir("/proc/self/task") { - Ok(entries) => Some(entries.count()), - Err(e) => { - eprintln!( - "!! THREAD-COUNT ASSERTIONS SKIPPED: could not read /proc/self/task \ - ({e}) — the thread-lifetime guarantee is NOT checked on this run" - ); - None - } - } -} - -#[cfg(not(any(windows, target_os = "linux")))] -fn live_thread_count() -> Option<usize> { - // No cheap dependency-free thread enumeration here; the timing assertions - // still run, the thread-count ones are skipped. - None -} - -fn temp_db() -> (tempfile::TempDir, String) { - let dir = tempfile::tempdir().expect("temp dir"); - let path = dir.path().join("shutdown.db"); - let url = format!("sqlite:{}?mode=rwc", path.to_string_lossy()); - (dir, url) -} - -/// Creates a local-only client (empty api key => no socket, no server needed). -fn create_client(database_url: &str) -> *mut Replicant { - let database_url = CString::new(database_url).unwrap(); - let server_url = CString::new("ws://127.0.0.1:1/ws").unwrap(); - let email = CString::new("shutdown-test@example.com").unwrap(); - let api_key = CString::new("").unwrap(); - let api_secret = CString::new("").unwrap(); - - unsafe { - replicant_create( - database_url.as_ptr(), - server_url.as_ptr(), - email.as_ptr(), - api_key.as_ptr(), - api_secret.as_ptr(), - ptr::null(), - ) - } -} - -/// Waits for the process's thread count to stop moving, then returns it. -/// -/// Tests that use plain `replicant_destroy` deliberately leave a teardown -/// running, so a later test must not take its baseline while those threads are -/// still winding down — that inflates the baseline and makes the assertions -/// below meaningless in either direction. -/// -/// Two equal samples is a heuristic: if it settles early the baseline is higher -/// than the true idle count, which can only weaken the assertions that use it, -/// never make them fail spuriously. (Those tests also wait their own detached -/// teardowns out, so there should be nothing left to settle.) -fn quiesce_threads() -> Option<usize> { - let mut last = live_thread_count()?; - let deadline = Instant::now() + Duration::from_secs(15); - loop { - std::thread::sleep(Duration::from_millis(100)); - let now = live_thread_count()?; - if now == last || Instant::now() >= deadline { - return Some(now); - } - last = now; - } -} - -/// Asserts the process is back to `baseline` threads *now* — no settle window, -/// because "the wait returned true" is supposed to mean "the threads are gone". -fn assert_threads_settled(baseline: usize, label: &str) { - let Some(now) = live_thread_count() else { - eprintln!("{label}: thread enumeration unavailable on this platform, skipping"); - return; - }; - assert!( - now <= baseline, - "{label}: {now} threads alive after destroy, baseline was {baseline} \ - ({} sqlite worker threads were started)", - sqlite_workers_started() - ); -} - -/// A zero timeout is the documented "don't wait" form: it must not block, and it -/// must report the teardown as unfinished rather than pretending otherwise. -/// -/// Fails against the pre-fix client, which freed the handle and had nothing to -/// report, so every teardown looked complete. -#[test] -#[serial] -fn a_zero_timeout_does_not_wait_and_reports_unfinished() { - let (_dir, url) = temp_db(); - let engine = create_client(&url); - assert!(!engine.is_null(), "client creation failed"); - - let started = Instant::now(); - let finished = unsafe { replicant_destroy_and_wait(engine, 0) }; - let elapsed = started.elapsed(); - - assert!( - elapsed < Duration::from_millis(500), - "a zero timeout blocked for {elapsed:?}" - ); - assert!( - !finished, - "a zero timeout claimed a teardown that had just started was complete" - ); - - // Leave nothing running in the temp dir. - std::thread::sleep(Duration::from_millis(500)); -} - -/// Destroying a null handle stays a no-op, and destroy keeps its old signature. -#[test] -#[serial] -fn destroy_null_is_a_noop() { - unsafe { replicant_destroy(ptr::null_mut()) }; - assert!(unsafe { replicant_destroy_and_wait(ptr::null_mut(), 0) }); -} - -/// Holds a sqlite write transaction on `url` for `hold`, so that any *write* by -/// another connection blocks on SQLITE_BUSY for that long while reads keep -/// working (the file is in WAL mode). That is the contention clap-validator's -/// parallel mode produces on the shared per-user database, and the state the -/// DEV-1118 faulting thread was in: a sqlx worker executing sqlite code inside -/// the plugin module, for seconds, with nothing joining it. -/// -/// Returns a handle that has the lock by the time this returns, and the instant -/// the lock was taken — so a test can assert against the moment it is released -/// rather than against wall-clock margins that depend on setup cost. -fn hold_write_lock(url: &str, hold: Duration) -> (std::thread::JoinHandle<()>, Instant) { - use sqlx::{ConnectOptions, Connection, Executor}; - use std::str::FromStr; - - let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - let holder_url = url.to_string(); - let holder = std::thread::spawn(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - rt.block_on(async move { - let mut conn = sqlx::sqlite::SqliteConnectOptions::from_str(&holder_url) - .unwrap() - .connect() - .await - .expect("holder connect"); - // IMMEDIATE takes the write lock now and keeps readers working. - conn.execute("BEGIN IMMEDIATE").await.expect("holder lock"); - conn.execute("CREATE TABLE IF NOT EXISTS lock_me (x INTEGER)") - .await - .expect("holder write"); - ready_tx.send(()).ok(); - tokio::time::sleep(hold).await; - conn.execute("ROLLBACK").await.ok(); - conn.close().await.ok(); - }); - }); - - ready_rx - .recv_timeout(Duration::from_secs(10)) - .expect("holder failed to take the write lock"); - (holder, Instant::now()) -} - -const LOCK_HOLD: Duration = Duration::from_secs(3); - -/// The hazard itself, reproduced against sqlx directly: a query blocked on -/// SQLITE_BUSY runs on a thread sqlx spawned and never joins, so abandoning the -/// work by dropping the runtime — which is all the pre-fix `replicant_destroy` -/// did — leaves that thread executing code in this module. -/// -/// This is a characterisation test of sqlx, not a regression test of Replicant: -/// it asserts that the hazard is real, so it passes against the pre-fix client -/// too, by construction. It is here to document why the teardown may not simply -/// drop the runtime. Every other test in this file fails against `0ea7565`. -#[test] -#[serial] -fn abandoning_a_blocked_query_leaves_a_thread_running() { - let (_dir, url) = temp_db(); - // Migrate first, so the contention below is the only thing in play. - assert!(unsafe { replicant_destroy_and_wait(create_client(&url), WAIT_MS) }); - - let (holder, _lock_taken) = hold_write_lock(&url, LOCK_HOLD); - - { - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .unwrap(); - let task_url = url.clone(); - let task = runtime.spawn(async move { - let db = replicant_client::ClientDatabase::new(&task_url) - .await - .unwrap(); - sqlx::query("CREATE TABLE abandoned_write (x INTEGER)") - .execute(&db.pool) - .await - }); - std::thread::sleep(Duration::from_millis(500)); - // The pre-fix teardown: drop the runtime, cancelling the task. Nothing - // closes the pool, so nothing joins the sqlite worker thread. - drop(task); - drop(runtime); - } - - // The write was issued by a cancelled task on a dropped runtime through a - // dropped pool — and it still lands, once the lock frees. That is only - // possible because the worker thread is alive and running sqlx and sqlite - // code from this binary long after everything that owned it went away. A - // plugin unloaded at this point would be executing unmapped memory. - holder.join().unwrap(); - assert!( - table_appears(&url, "abandoned_write", Duration::from_secs(10)), - "the abandoned write never landed, so the worker thread had already exited" - ); -} - -/// Polls `url` until `table` exists, up to `timeout`. -fn table_appears(url: &str, table: &str, timeout: Duration) -> bool { - use sqlx::{ConnectOptions, Connection, Row}; - use std::str::FromStr; - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - rt.block_on(async move { - let mut conn = sqlx::sqlite::SqliteConnectOptions::from_str(url) - .unwrap() - .connect() - .await - .expect("probe connect"); - - let deadline = Instant::now() + timeout; - loop { - let found = sqlx::query( - "SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = ?1", - ) - .bind(table) - .fetch_one(&mut conn) - .await - .ok() - .and_then(|row| row.try_get::<i64, _>("n").ok()) - .unwrap_or(0); - - if found > 0 { - conn.close().await.ok(); - return true; - } - if Instant::now() >= deadline { - conn.close().await.ok(); - return false; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - }) -} - -/// A TCP listener that accepts connections and then never answers, so a client's -/// WebSocket handshake stalls until its own 10s connect timeout fires. -/// -/// This is how the background init is made reliably slow. Sqlite contention is -/// not usable for that: SQLITE_BUSY sometimes comes back immediately instead of -/// going through sqlite's busy handler, so a held lock keeps the init busy only -/// sometimes. The stalled socket is deterministic, and it exercises the same -/// property — the teardown waits for the init task instead of cancelling it -/// mid-flight, which is what abandoned the sqlite worker in DEV-1118. -/// -/// The listener thread outlives the test on purpose; take thread baselines after -/// calling this. -fn stalled_server() -> u16 { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let port = listener.local_addr().expect("addr").port(); - std::thread::Builder::new() - .name("stalled-server".to_string()) - .spawn(move || { - // Hold every accepted connection open, answering nothing. - let mut held = Vec::new(); - for stream in listener.incoming() { - match stream { - Ok(stream) => held.push(stream), - Err(_) => break, - } - } - }) - .expect("spawn"); - port -} - -/// How long a stalled handshake keeps the background init busy: the client's own -/// WebSocket connect timeout (`CONNECT_TIMEOUT` in `websocket.rs`). Only the -/// order of magnitude matters here — the assertions below ask whether a call -/// waited for the init or returned straight away, so shortening the timeout in -/// `websocket.rs` means shortening this constant, nothing more. -const STALLED_INIT: Duration = Duration::from_secs(10); - -/// Lower bound for "this call actually waited for the busy init". Well above a -/// prompt return (milliseconds) and well below the stall itself. -const WAITED_FOR_INIT: Duration = Duration::from_secs(2); - -/// Creates a client that enrolls credentials, so its background init adopts the -/// identity and then connects a WebSocket — to `port`, which stalls. -fn create_stalling_client(database_url: &str, port: u16) -> *mut Replicant { - let database_url = CString::new(database_url).unwrap(); - let server_url = CString::new(format!("ws://127.0.0.1:{port}/ws")).unwrap(); - let email = CString::new("shutdown-test@example.com").unwrap(); - let api_key = CString::new("rpa_test").unwrap(); - let api_secret = CString::new("rps_test").unwrap(); - let user_id = CString::new(uuid::Uuid::new_v4().to_string()).unwrap(); - - unsafe { - replicant_create( - database_url.as_ptr(), - server_url.as_ptr(), - email.as_ptr(), - api_key.as_ptr(), - api_secret.as_ptr(), - user_id.as_ptr(), - ) - } -} - -/// The contract for hosts: destroy a handle whose background init is still busy, -/// and destroy returns at once anyway. A plugin host calls it on its UI thread -/// during a scan or a project close and may not be stalled there. -/// -/// This one guards the *new* contract: it fails against a destroy that tears -/// down synchronously, not against `0ea7565`, whose destroy returned at once -/// because it did nothing at all. -#[test] -#[serial] -fn destroy_returns_promptly_while_the_init_is_busy() { - let (dir, url) = temp_db(); - let port = stalled_server(); - - let engine = create_stalling_client(&url, port); - assert!(!engine.is_null(), "client creation failed"); - - let started = Instant::now(); - unsafe { replicant_destroy(engine) }; - let elapsed = started.elapsed(); - - assert!( - elapsed < Duration::from_secs(1), - "destroy blocked for {elapsed:?} while the background init was busy" - ); - // The init cannot have finished yet, so there really was work outstanding. - assert!( - started.elapsed() < STALLED_INIT, - "the init had already finished, so destroy was not tested against a busy one" - ); - - // The teardown runs on for as long as the stalled handshake does. Wait it out - // here — not because this test needs it, but so the next test can take a - // thread baseline that is not polluted by this one's detached threads. (The - // whole point of the contract is that the *caller* never has to do this.) - std::thread::sleep(STALLED_INIT + Duration::from_secs(2)); - drop(dir); -} - -/// The wait must report a teardown it could not finish, so a caller can tell -/// "clean" from "gave up" — the outcome is a return value, not just a log. -/// -/// Fails against the pre-fix client, which reported nothing and freed the handle -/// while the init ran on. -#[test] -#[serial] -fn the_explicit_wait_reports_a_teardown_it_could_not_finish() { - let (dir, url) = temp_db(); - let port = stalled_server(); - - let engine = create_stalling_client(&url, port); - assert!(!engine.is_null(), "client creation failed"); - - // Far shorter than the stalled handshake, so the teardown cannot be done. - let started = Instant::now(); - let finished = unsafe { replicant_destroy_and_wait(engine, 200) }; - let elapsed = started.elapsed(); - - assert!( - !finished, - "the wait reported a clean teardown after only {elapsed:?}, while the \ - background init was still busy" - ); - assert!( - elapsed < Duration::from_secs(1), - "a 200ms wait took {elapsed:?}" - ); - - // As above: wait the detached teardown out so it cannot pollute the next - // test's thread baseline. - std::thread::sleep(STALLED_INIT + Duration::from_secs(2)); - drop(dir); -} - -/// The opt-in wait: it returns only once the teardown is actually complete, which -/// cannot be before the background init it waits for has finished. The full -/// lifecycle runs through it — create, write a document, tear down with the init -/// still busy — and when it reports success, no Replicant thread is left. -/// -/// Fails against the pre-fix client, whose destroy returned in ~3ms with the -/// init's sqlite worker still executing code inside this module. -#[test] -#[serial] -fn the_explicit_wait_returns_only_after_the_teardown_is_complete() { - let (_dir, url) = temp_db(); - let port = stalled_server(); - let workers_before = sqlite_workers_started(); - - let engine = create_stalling_client(&url, port); - assert!(!engine.is_null(), "client creation failed"); - - // Local writes work while the init is still connecting. - let content = CString::new(r#"{"title":"doc","body":"hello"}"#).unwrap(); - let mut out_id = [0 as c_char; 37]; - let result = - unsafe { replicant_create_document(engine, content.as_ptr(), out_id.as_mut_ptr()) }; - assert_eq!(result, SyncResult::Success, "document creation failed"); - - let started = Instant::now(); - let finished = unsafe { replicant_destroy_and_wait(engine, WAIT_MS) }; - let elapsed = started.elapsed(); - - assert!(finished, "the wait gave up after {elapsed:?}"); - // Both pools were opened (the handle's own and the sync engine's), so the - // teardown really did let the init task finish rather than cancel it. - assert!( - sqlite_workers_started() > workers_before + 1, - "expected the background init to have opened its own pool; \ - workers started went {} -> {}", - workers_before, - sqlite_workers_started() - ); - // It waited for the outstanding init rather than abandoning it: the stalled - // handshake cannot complete before its own connect timeout. - assert!( - elapsed >= WAITED_FOR_INIT, - "the wait returned after only {elapsed:?}, so it did not wait for the \ - busy background init" - ); -} - -/// A client whose construction fails must leave no sqlite worker behind. -/// -/// This is the failure mode the crash was reported against — parallel instances -/// whose migrations outlast sqlite's 5s busy timeout — reached on the background -/// init path, where no handle ever exists for anyone to destroy. -/// -/// A guard, not a regression test: it passes pre-fix as well, because dropping a -/// pool drops its idle connections, which drops each worker's command channel, -/// which makes an *idle* worker notice and exit. What the explicit -/// `ClientDatabase::close()` on the failure path adds is that this is awaited and -/// deterministic rather than a race — a worker that is mid-statement or still -/// inside `establish()` ignores a dropped channel until it is done, and by then -/// the runtime may be gone and the module unloaded (DEV-1118). -#[test] -#[serial] -fn a_failed_client_construction_closes_its_pool() { - let (_dir, url) = temp_db(); - // Held past the 5s busy timeout, so the migrations inside construction fail. - let (holder, _lock_taken) = hold_write_lock(&url, Duration::from_secs(8)); - let Some(baseline) = quiesce_threads() else { - eprintln!("thread enumeration unavailable on this platform, skipping"); - holder.join().unwrap(); - return; - }; - - { - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .unwrap(); - let result = runtime.block_on(replicant_client::Client::new( - &url, - "ws://127.0.0.1:1/ws", - "shutdown-test@example.com", - "", - "", - None, - )); - assert!( - result.is_err(), - "expected construction to fail while the database is locked" - ); - drop(runtime); - } - - // Measured while the holder is still alive, so its own threads are in both - // readings and cannot mask a leaked worker. - assert_threads_settled(baseline, "a_failed_client_construction_closes_its_pool"); - holder.join().unwrap(); -} - -/// Re-instantiating immediately after a non-blocking destroy, which is what a -/// host does when it reloads a plugin: the outgoing instance's pools are still -/// open and its init may still be writing, and the new client must still come up. -/// -/// The documented caveat (see `replicant_destroy` in replicant.h) is that this -/// overlap is sqlite contention, so a create can fail if it outlasts the 5s busy -/// timeout — this pins the uncontended case, where it must not. -#[test] -#[serial] -fn an_immediate_recreate_after_destroy_succeeds() { - let (_dir, url) = temp_db(); - - let first = create_client(&url); - assert!(!first.is_null(), "first client creation failed"); - - // No wait, no sleep: the teardown of the first is still in flight. - unsafe { replicant_destroy(first) }; - let second = create_client(&url); - assert!( - !second.is_null(), - "creating a client while the previous one was still tearing down failed" - ); - - assert!( - unsafe { replicant_destroy_and_wait(second, WAIT_MS) }, - "teardown did not finish within {WAIT_MS}ms" - ); -} - -/// When the wait reports success, the threads Replicant owns — its tokio runtime -/// and sqlx's per-connection workers — are gone. -/// -/// Local-only (no credentials, so no socket): a timed-out WebSocket handshake can -/// leave threads inside the socket/TLS stack that Replicant neither owns nor can -/// join, and this assertion is about Replicant's own. -/// -/// A characterisation test, not a regression guard: it passes against the pre-fix -/// client too, because uncontended the abandoned workers happened to exit anyway -/// — just not before destroy returned, and not at all when contended. -#[test] -#[serial] -fn a_waited_teardown_leaves_no_replicant_threads() { - let (_dir, url) = temp_db(); - let Some(baseline) = quiesce_threads() else { - eprintln!("thread enumeration unavailable on this platform, skipping"); - return; - }; - - let engine = create_client(&url); - assert!(!engine.is_null(), "client creation failed"); - - let content = CString::new(r#"{"title":"doc","body":"hello"}"#).unwrap(); - let mut out_id = [0 as c_char; 37]; - let result = - unsafe { replicant_create_document(engine, content.as_ptr(), out_id.as_mut_ptr()) }; - assert_eq!(result, SyncResult::Success, "document creation failed"); - assert!( - sqlite_workers_started() > 0, - "no sqlite worker threads were started" - ); - - assert!( - unsafe { replicant_destroy_and_wait(engine, WAIT_MS) }, - "teardown did not finish within {WAIT_MS}ms" - ); - assert_threads_settled(baseline, "a_waited_teardown_leaves_no_replicant_threads"); -} diff --git a/replicant-client/tests/v2_smoke.rs b/replicant-client/tests/v2_smoke.rs index 8fdfe0d..6f17e7d 100644 --- a/replicant-client/tests/v2_smoke.rs +++ b/replicant-client/tests/v2_smoke.rs @@ -1,7 +1,8 @@ //! End-to-end smoke test of the v2 transport against a real replicant-server v2. //! -//! Ignored by default. `test/run_phoenix_interop_local.sh` boots a server, seeds an account -//! and exports the variables read below. +//! Ignored by default. Needs a running v2 server and an enrolled account: set +//! `SYNC_SERVER_URL`, `REPLICANT_API_KEY`, `REPLICANT_API_SECRET` and `REPLICANT_TEST_USER_ID`, +//! then run `cargo test -p replicant-client --test v2_smoke -- --ignored`. use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -62,7 +63,7 @@ async fn opened(url: String, auth: JoinAuth) -> Connection { } #[tokio::test] -#[ignore = "needs a v2 server: run through test/run_phoenix_interop_local.sh"] +#[ignore = "needs a v2 server: see the file header"] async fn joins_requests_heartbeats_and_closes() { let mut connection = opened(server_socket_url(), seeded_auth()).await; @@ -110,7 +111,7 @@ async fn joins_requests_heartbeats_and_closes() { } #[tokio::test] -#[ignore = "needs a v2 server: run through test/run_phoenix_interop_local.sh"] +#[ignore = "needs a v2 server: see the file header"] async fn unsupported_protocol_version_is_refused_with_update_required() { let url = server_socket_url().replace("protocol_version=2", "protocol_version=1"); let mut connection = Connection::new(url, Some(seeded_auth()), "replicant-client/smoke".into()); @@ -124,7 +125,7 @@ async fn unsupported_protocol_version_is_refused_with_update_required() { } #[tokio::test] -#[ignore = "needs a v2 server: run through test/run_phoenix_interop_local.sh"] +#[ignore = "needs a v2 server: see the file header"] async fn wrong_secret_is_a_fatal_auth_invalid_join_error() { let wrong_secret = JoinAuth { api_secret: "rps_wrong".into(), diff --git a/replicant-client/tests/wire_contract_tests.rs b/replicant-client/tests/wire_contract_tests.rs index 6df19cf..5ff4a02 100644 --- a/replicant-client/tests/wire_contract_tests.rs +++ b/replicant-client/tests/wire_contract_tests.rs @@ -3,11 +3,11 @@ use std::collections::BTreeSet; +use replicant_client::engine::hash::content_hash; use replicant_client::engine::machine::{Request, Response}; use replicant_client::engine::types::{ChangeKind, DocEnvelope, ServerError, Upload, UploadKind}; use replicant_client::transport::codec::{Codec, Incoming}; use replicant_client::transport::wire::{socket_url, JoinAuth, ReplyEnvelope}; -use replicant_core::patches::calculate_checksum; use serde_json::{json, Map, Value}; use uuid::Uuid; @@ -423,12 +423,18 @@ fn upload_requests_match_the_recorded_frames_per_kind() { encode(&mut codec, 2, &update), frames["upload_update_request"] ); + let recorded_delete = &frames["upload_delete_request"][4]; + assert!( + recorded_delete["base_hash"].is_string(), + "a delete names the version it was made on" + ); + let delete = upload( + UploadKind::Delete, + recorded_delete["base_hash"].as_str().map(String::from), + Value::Null, + ); assert_eq!( - encode( - &mut codec, - 2, - &upload(UploadKind::Delete, None, Value::Null) - ), + encode(&mut codec, 2, &delete), frames["upload_delete_request"] ); } @@ -480,9 +486,9 @@ fn float_content_decodes_to_canonical_numbers_under_the_server_hash() { "the server hash survives the jsonb round trip" ); assert_eq!( - calculate_checksum(&json!({"cents": 1200.0, "title": "t"})), + content_hash(&json!({"cents": 1200.0, "title": "t"})), upload_doc.hash, "the server hashed the float as written" ); - assert_ne!(calculate_checksum(&upload_doc.content), upload_doc.hash); + assert_ne!(content_hash(&upload_doc.content), upload_doc.hash); } diff --git a/replicant-core/Cargo.toml b/replicant-core/Cargo.toml deleted file mode 100644 index b264ca7..0000000 --- a/replicant-core/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "replicant-core" -version = "0.6.0" -edition = "2021" - -[features] -default = [] -server = ["sqlx/postgres", "axum", "argon2"] - -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -thiserror = { workspace = true } -json-patch = "1.2" -sha2 = "0.10" -strum = { version = "0.26", features = ["derive"] } -sqlx = { version = "0.8", features = ["runtime-tokio", "json", "uuid", "chrono", "migrate"] } -argon2 = { version = "0.5", optional = true } -axum = { version = "0.7", features = ["ws"], optional = true } -tracing = { workspace = true } -tokio = { workspace = true } diff --git a/replicant-core/src/conflicts.rs b/replicant-core/src/conflicts.rs deleted file mode 100644 index b98c757..0000000 --- a/replicant-core/src/conflicts.rs +++ /dev/null @@ -1,89 +0,0 @@ -use crate::errors::SyncError; -use crate::models::Document; -use crate::SyncResult; -use serde_json::Value; - -#[derive(Debug, Clone)] -pub enum ConflictStrategy { - LastWriteWins, - FirstWriteWins, - MergeJson, - Manual, -} - -pub struct ConflictResolver { - strategy: ConflictStrategy, -} - -impl ConflictResolver { - pub fn new(strategy: ConflictStrategy) -> Self { - Self { strategy } - } - - pub fn resolve(&self, local: &Document, remote: &Document) -> SyncResult<Document> { - match &self.strategy { - ConflictStrategy::LastWriteWins => { - if local.updated_at > remote.updated_at { - Ok(local.clone()) - } else { - Ok(remote.clone()) - } - } - ConflictStrategy::FirstWriteWins => { - if local.created_at < remote.created_at { - Ok(local.clone()) - } else { - Ok(remote.clone()) - } - } - ConflictStrategy::MergeJson => self.merge_json_documents(local, remote), - ConflictStrategy::Manual => Err(SyncError::ConflictDetected(local.id)), - } - } - - fn merge_json_documents(&self, local: &Document, remote: &Document) -> SyncResult<Document> { - let merged_content = merge_json_values(&local.content, &remote.content)?; - - let mut merged_doc = local.clone(); - merged_doc.content = merged_content; - merged_doc.sync_revision = local.sync_revision.max(remote.sync_revision) + 1; - merged_doc.content_hash = None; // Will be recalculated when saved - - Ok(merged_doc) - } -} - -fn merge_json_values(local: &Value, remote: &Value) -> SyncResult<Value> { - match (local, remote) { - (Value::Object(local_map), Value::Object(remote_map)) => { - let mut merged = local_map.clone(); - - for (key, remote_value) in remote_map { - match local_map.get(key) { - Some(local_value) => { - merged.insert(key.clone(), merge_json_values(local_value, remote_value)?); - } - None => { - merged.insert(key.clone(), remote_value.clone()); - } - } - } - - Ok(Value::Object(merged)) - } - (Value::Array(local_arr), Value::Array(remote_arr)) => { - // Simple merge: concatenate arrays and remove duplicates - let mut merged = local_arr.clone(); - for item in remote_arr { - if !merged.contains(item) { - merged.push(item.clone()); - } - } - Ok(Value::Array(merged)) - } - (_, _) => { - // For non-mergeable types, prefer remote - Ok(remote.clone()) - } - } -} diff --git a/replicant-core/src/errors.rs b/replicant-core/src/errors.rs deleted file mode 100644 index d4143c2..0000000 --- a/replicant-core/src/errors.rs +++ /dev/null @@ -1,230 +0,0 @@ -use crate::protocol; -#[cfg(feature = "server")] -use axum::http::StatusCode; -#[cfg(feature = "server")] -use axum::response::{IntoResponse, Response}; -use chrono::ParseError; -use std::fmt::{Display, Formatter}; -use thiserror::Error; -use tokio::sync::mpsc::error::SendError; -#[cfg(feature = "server")] -use tracing::log::warn; -#[derive(Error, Debug)] -#[non_exhaustive] -pub enum SyncError { - #[error("Failed to apply patch: {0}")] - PatchFailed(String), - - #[error("Document not found: {0}")] - DocumentNotFound(uuid::Uuid), - - #[error("Version mismatch: expected {expected}, got {actual}")] - VersionMismatch { expected: i64, actual: i64 }, - - #[error("Authentication failed: {0}")] - AuthenticationFailed(String), - - #[error("Network error: {0}")] - NetworkError(String), - - #[error("Database error: {0}")] - DatabaseError(#[from] sqlx::Error), - - #[error("Serialization error: {0}")] - SerializationError(#[from] serde_json::Error), - - #[error("UUID parsing error: {0}")] - UuidParse(#[from] uuid::Error), - - #[error("Conflict detected for document {0}")] - ConflictDetected(uuid::Uuid), - - #[error("Invalid operation: {0}")] - InvalidOperation(String), - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("Date parsing error: {0}")] - DateParse(#[from] ParseError), - - #[error("Migration error: {0}")] - MigrationError(#[from] sqlx::migrate::MigrateError), - - #[error("Client error: {0}")] - Client(#[from] ClientError), - - #[error("Server error: {0}")] - Server(#[from] ServerError), -} - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum ServerError { - #[error("{0}")] - ApiError(#[from] ApiError), - - #[cfg(feature = "server")] - #[error("argon2 Library Error: {0}")] - HashingError(argon2::password_hash::Error), - - #[error("Server sync error: {0}")] - ServerSync(String), - - #[error("Server Channel send failed: {0}")] - SendError(#[from] SendError<protocol::ServerMessage>), -} - -#[derive(Error, Debug)] -#[non_exhaustive] -pub enum ClientError { - #[error("WebSocket error: {0}")] - WebSocket(String), - - #[error("Connection lost")] - ConnectionLost, - - #[error("Invalid state: {0}")] - InvalidState(String), - - #[error("Channel send failed: {0}")] - SendError(String), - - #[error("Failed to acquire {0} lock")] - LockError(String), - - #[error("Thread safety violation: process_events() must be called on the registration thread")] - ThreadSafetyViolation, - - #[error("No callbacks registered yet")] - NoCallbacksRegistered, - - #[error("Internal channel closed")] - ChannelClosed, -} - -#[cfg(feature = "server")] -impl From<argon2::password_hash::Error> for SyncError { - fn from(error: argon2::password_hash::Error) -> Self { - SyncError::Server(ServerError::HashingError(error)) - } -} - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum ApiError { - InternalServerError(String), - BadRequest(String, Option<String>), - Unauthorized(String), - ServiceUnavailable(String), - NotFound(String), - Conflict(String, Option<String>), -} - -impl ApiError { - pub fn internal(message: impl Into<String>) -> Self { - Self::InternalServerError(message.into()) - } - - pub fn bad_request(message: impl Into<String>, meta: Option<String>) -> Self { - Self::BadRequest( - message.into(), - meta.unwrap_or_else(|| "".to_string()).into(), - ) - } - - pub fn unauthorized(message: impl Into<String>) -> Self { - Self::Unauthorized(message.into()) - } - - pub fn service_unavailable(message: impl Into<String>) -> Self { - Self::ServiceUnavailable(message.into()) - } - - pub fn not_found(message: impl Into<String>) -> Self { - Self::NotFound(message.into()) - } - - pub fn conflict(message: impl Into<String>, meta: Option<String>) -> Self { - Self::Conflict( - message.into(), - meta.unwrap_or_else(|| "".to_string()).into(), - ) - } -} - -impl Display for ApiError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - ApiError::InternalServerError(message) => { - write!(f, "Status=500, InternalServerError: {}", message) - } - ApiError::BadRequest(message, meta) => { - write!( - f, - "Status=400, BadRequest: {}. {}", - message, - meta.clone().unwrap_or_default() - ) - } - ApiError::Unauthorized(message) => write!(f, "Status=401, Unauthorized: {}", message), - ApiError::ServiceUnavailable(message) => { - write!(f, "Status=503, ServiceUnavailable: {}", message) - } - ApiError::NotFound(message) => write!(f, "Status=404, NotFound: {}", message), - ApiError::Conflict(message, meta) => { - write!( - f, - "Status=409, Conflict: {}. {}", - message, - meta.clone().unwrap_or_default() - ) - } - } - } -} -#[cfg(feature = "server")] -impl IntoResponse for SyncError { - fn into_response(self) -> Response { - #[derive(serde::Serialize)] - struct ErrorResponse { - message: String, - } - - let (status, message) = match self { - SyncError::Server(ServerError::ApiError(e)) => { - warn!("{}", e); - match e { - ApiError::InternalServerError(message) => { - (StatusCode::INTERNAL_SERVER_ERROR, message) - } - ApiError::BadRequest(message, _) => (StatusCode::BAD_REQUEST, message), - ApiError::Unauthorized(message) => (StatusCode::UNAUTHORIZED, message), - ApiError::ServiceUnavailable(message) => { - (StatusCode::SERVICE_UNAVAILABLE, message) - } - ApiError::NotFound(message) => (StatusCode::NOT_FOUND, message), - ApiError::Conflict(message, _) => (StatusCode::CONFLICT, message), - } - } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - "Unexpected Error".to_string(), - ), - }; - - (status, axum::Json(ErrorResponse { message })).into_response() - } -} - -impl From<ApiError> for SyncError { - fn from(value: ApiError) -> Self { - SyncError::Server(value.into()) - } -} - -impl From<SendError<protocol::ServerMessage>> for SyncError { - fn from(value: SendError<protocol::ServerMessage>) -> Self { - SyncError::Server(value.into()) - } -} diff --git a/replicant-core/src/lib.rs b/replicant-core/src/lib.rs deleted file mode 100644 index 058d71f..0000000 --- a/replicant-core/src/lib.rs +++ /dev/null @@ -1,13 +0,0 @@ -pub mod conflicts; -pub mod errors; -pub mod models; -pub mod ot; -pub mod patches; -pub mod protocol; - -pub type SyncResult<T> = Result<T, SyncError>; -pub use conflicts::*; -pub use errors::*; -pub use models::*; -pub use patches::*; -pub use protocol::*; diff --git a/replicant-core/src/models.rs b/replicant-core/src/models.rs deleted file mode 100644 index 5eca8d9..0000000 --- a/replicant-core/src/models.rs +++ /dev/null @@ -1,201 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use strum::{Display, EnumString}; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Document { - pub id: Uuid, - pub user_id: Option<Uuid>, - pub content: serde_json::Value, - pub sync_revision: i64, - pub content_hash: Option<String>, // SHA256 hash for integrity verification - pub title: Option<String>, // Derived from content['title'] for query performance - pub created_at: DateTime<Utc>, - pub updated_at: DateTime<Utc>, - pub deleted_at: Option<DateTime<Utc>>, - #[serde(default)] - pub author_name: Option<String>, - #[serde(default)] - pub visibility: Option<String>, - #[serde(default)] - pub provenance: Option<serde_json::Value>, -} - -impl Document { - /// Get the title from the content JSON, if present - pub fn title(&self) -> Option<&str> { - self.content.get("title").and_then(|v| v.as_str()) - } - - /// Get the title from content JSON, or return a default - pub fn title_or_default(&self) -> &str { - self.title().unwrap_or("Untitled") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_document_title_helpers() { - // Test document with title - let doc_with_title = Document { - id: Uuid::new_v4(), - user_id: Some(Uuid::new_v4()), - content: serde_json::json!({"title": "My Document", "test": true}), - sync_revision: 1, - content_hash: None, - title: Some("My Document".to_string()), - created_at: Utc::now(), - updated_at: Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }; - - assert_eq!(doc_with_title.title(), Some("My Document")); - assert_eq!(doc_with_title.title_or_default(), "My Document"); - - // Test document without title - let doc_without_title = Document { - id: Uuid::new_v4(), - user_id: Some(Uuid::new_v4()), - content: serde_json::json!({"test": true}), - sync_revision: 1, - content_hash: None, - title: None, - created_at: Utc::now(), - updated_at: Utc::now(), - deleted_at: None, - author_name: None, - visibility: None, - provenance: None, - }; - - assert_eq!(doc_without_title.title(), None); - assert_eq!(doc_without_title.title_or_default(), "Untitled"); - } - - #[test] - fn document_round_trips_attribution_via_serde() { - let json = r#"{ - "id": "71b2b712-7878-56ee-8323-43809b8198a5", - "user_id": null, - "content": {"title": "T"}, - "sync_revision": 1, - "content_hash": null, - "title": null, - "created_at": "2026-07-03T00:00:00Z", - "updated_at": "2026-07-03T00:00:00Z", - "deleted_at": null, - "author_name": "Robert Rich", - "visibility": "public", - "provenance": {"copied_from": "x"} - }"#; - let doc: Document = serde_json::from_str(json).unwrap(); - assert_eq!(doc.author_name.as_deref(), Some("Robert Rich")); - assert_eq!(doc.visibility.as_deref(), Some("public")); - assert!(doc.provenance.is_some()); - - // Pre-1C payloads (no attribution keys) still deserialize. - let legacy = r#"{ - "id": "71b2b712-7878-56ee-8323-43809b8198a5", - "user_id": null, "content": {}, "sync_revision": 1, - "content_hash": null, "title": null, - "created_at": "2026-07-03T00:00:00Z", "updated_at": "2026-07-03T00:00:00Z", - "deleted_at": null - }"#; - let doc: Document = serde_json::from_str(legacy).unwrap(); - assert_eq!(doc.author_name, None); - } - - #[test] - fn server_document_patch_round_trips_with_document_id_key() { - let json = r#"{ - "document_id": "71b2b712-7878-56ee-8323-43809b8198a5", - "patch": [{"op": "replace", "path": "/title", "value": "T"}], - "sync_revision": 7, - "content_hash": "abc123" - }"#; - let patch: ServerDocumentPatch = serde_json::from_str(json).unwrap(); - assert_eq!( - patch.document_id.to_string(), - "71b2b712-7878-56ee-8323-43809b8198a5" - ); - assert_eq!(patch.sync_revision, 7); - assert_eq!(patch.content_hash, "abc123"); - } - - #[test] - fn server_document_patch_round_trips_with_id_key() { - // Wire shape actually sent by the server (channel.ex / documents.ex - // both use "id" for the document_updated broadcast). - let json = r#"{ - "id": "71b2b712-7878-56ee-8323-43809b8198a5", - "patch": [{"op": "replace", "path": "/title", "value": "T"}], - "sync_revision": 7, - "content_hash": "abc123" - }"#; - let patch: ServerDocumentPatch = serde_json::from_str(json).unwrap(); - assert_eq!( - patch.document_id.to_string(), - "71b2b712-7878-56ee-8323-43809b8198a5" - ); - assert_eq!(patch.sync_revision, 7); - assert_eq!(patch.content_hash, "abc123"); - } - - #[test] - fn server_document_patch_tolerates_unknown_fields() { - // The server may add fields later; unrecognized keys must not break - // deserialization. - let json = r#"{ - "id": "71b2b712-7878-56ee-8323-43809b8198a5", - "patch": [{"op": "replace", "path": "/title", "value": "T"}], - "sync_revision": 7, - "content_hash": "abc123", - "future_field": {"nested": "data"} - }"#; - let patch: ServerDocumentPatch = serde_json::from_str(json).unwrap(); - assert_eq!( - patch.document_id.to_string(), - "71b2b712-7878-56ee-8323-43809b8198a5" - ); - assert_eq!(patch.sync_revision, 7); - assert_eq!(patch.content_hash, "abc123"); - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DocumentPatch { - pub document_id: Uuid, - pub patch: json_patch::Patch, - pub content_hash: String, // SHA256 hash for integrity verification -} - -/// Server broadcast of an applied patch. Unlike [`DocumentPatch`] (a -/// client→server request carrying a pre-update hash), `content_hash` here is -/// the hash of the content AFTER the patch was applied. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerDocumentPatch { - #[serde(alias = "document_id", alias = "id")] - pub document_id: Uuid, - pub patch: json_patch::Patch, - /// Revision AFTER this patch was applied on the server. - pub sync_revision: i64, - /// Hash of the content AFTER this patch (NOT a base hash — unlike - /// client→server DocumentPatch.content_hash). - pub content_hash: String, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Display, EnumString)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum SyncStatus { - Synced, - Pending, - Conflict, -} diff --git a/replicant-core/src/ot/mod.rs b/replicant-core/src/ot/mod.rs deleted file mode 100644 index ac9ebfb..0000000 --- a/replicant-core/src/ot/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Operational Transformation module for JSON Patch operations -//! -//! This module implements Operational Transformation (OT) algorithms for resolving -//! conflicts when multiple users concurrently edit the same JSON document using -//! JSON Patch operations (RFC 6902). - -pub mod path_utils; -pub mod transform; -pub mod types; - -pub use path_utils::*; -pub use transform::transform_operation_pair; -pub use types::*; diff --git a/replicant-core/src/ot/path_utils.rs b/replicant-core/src/ot/path_utils.rs deleted file mode 100644 index 8597c3b..0000000 --- a/replicant-core/src/ot/path_utils.rs +++ /dev/null @@ -1,474 +0,0 @@ -//! Path manipulation utilities for JSON Pointer paths (RFC 6901) - -use crate::ot::types::{ParsedPath, PathRelation, PathSegment}; -use crate::SyncError; - -/// Parse JSON Pointer into segments -/// -/// # Examples -/// ``` -/// # use replicant_core::ot::parse_path; -/// let path = parse_path("/foo/bar").unwrap(); -/// assert_eq!(path.segments.len(), 2); -/// ``` -pub fn parse_path(path: &str) -> Result<ParsedPath, SyncError> { - if path.is_empty() { - return Err(SyncError::InvalidOperation("Empty path".to_string())); - } - - if !path.starts_with('/') { - return Err(SyncError::InvalidOperation(format!( - "Path must start with /: {}", - path - ))); - } - - // Root path - if path == "/" { - return Ok(ParsedPath { - raw: path.to_string(), - segments: Vec::new(), - }); - } - - let mut segments = Vec::new(); - - // Split on / and process each segment - for segment in path[1..].split('/') { - // Unescape ~0 -> ~ and ~1 -> / (must be in this order!) - let unescaped = segment.replace("~1", "/").replace("~0", "~"); - - // Try to parse as array index - if let Ok(index) = unescaped.parse::<usize>() { - segments.push(PathSegment::Array(index)); - } else { - segments.push(PathSegment::Object(unescaped)); - } - } - - Ok(ParsedPath { - raw: path.to_string(), - segments, - }) -} - -/// Extract the last array index from a path, if present -/// -/// # Examples -/// ``` -/// # use replicant_core::ot::extract_array_index; -/// assert_eq!(extract_array_index("/items/5"), Some(5)); -/// assert_eq!(extract_array_index("/users/0/posts/3"), Some(3)); -/// assert_eq!(extract_array_index("/title"), None); -/// ``` -pub fn extract_array_index(path: &str) -> Option<usize> { - parse_path(path) - .ok()? - .segments - .into_iter() - .rev() - .find_map(|seg| match seg { - PathSegment::Array(idx) => Some(idx), - _ => None, - }) -} - -/// Adjust array index in path by delta -/// -/// Only adjusts if the current index matches target_index. -/// -/// # Examples -/// ``` -/// # use replicant_core::ot::adjust_array_index; -/// assert_eq!(adjust_array_index("/items/5", 5, 1).unwrap(), "/items/6"); -/// assert_eq!(adjust_array_index("/items/5", 3, 1).unwrap(), "/items/5"); // No change -/// assert!(adjust_array_index("/items/2", 2, -3).is_err()); // Underflow -/// ``` -pub fn adjust_array_index( - path: &str, - target_index: usize, - delta: isize, -) -> Result<String, SyncError> { - let parsed = parse_path(path)?; - - // Find last array index that matches target - let last_array_idx = parsed - .segments - .iter() - .enumerate() - .rev() - .find_map(|(i, seg)| match seg { - PathSegment::Array(idx) if *idx == target_index => Some(i), - _ => None, - }); - - let Some(idx_position) = last_array_idx else { - // No matching index, return unchanged - return Ok(path.to_string()); - }; - - // Calculate new index - let old_index = target_index as isize; - let new_index = old_index + delta; - - if new_index < 0 { - return Err(SyncError::InvalidOperation(format!( - "Index adjustment would be negative: {} + {} = {}", - old_index, delta, new_index - ))); - } - - // Rebuild path with adjusted index - let mut new_segments = parsed.segments; - new_segments[idx_position] = PathSegment::Array(new_index as usize); - - Ok(reconstruct_path(&new_segments)) -} - -/// Helper: Reconstruct path from segments -fn reconstruct_path(segments: &[PathSegment]) -> String { - if segments.is_empty() { - return "/".to_string(); - } - - let mut path = String::new(); - for segment in segments { - path.push('/'); - match segment { - PathSegment::Object(key) => { - // Re-escape special characters (~ must be escaped before /) - let escaped = key.replace('~', "~0").replace('/', "~1"); - path.push_str(&escaped); - } - PathSegment::Array(idx) => { - path.push_str(&idx.to_string()); - } - } - } - path -} - -/// Determine relationship between two paths -/// -/// # Examples -/// ``` -/// # use replicant_core::ot::{compare_paths, PathRelation}; -/// assert_eq!(compare_paths("/a", "/a"), PathRelation::Same); -/// assert_eq!(compare_paths("/a", "/a/b"), PathRelation::Parent); -/// assert_eq!(compare_paths("/a/b", "/a/c"), PathRelation::Sibling); -/// ``` -pub fn compare_paths(path1: &str, path2: &str) -> PathRelation { - if path1 == path2 { - return PathRelation::Same; - } - - // Check parent-child relationships - if path2.starts_with(&format!("{}/", path1)) { - return PathRelation::Parent; // path1 is parent of path2 - } - - if path1.starts_with(&format!("{}/", path2)) { - return PathRelation::Child; // path1 is child of path2 - } - - // Check if siblings (same parent) - // This includes root-level paths, which is important for root-level arrays - // e.g., /0 and /1 on a root array are siblings and need index adjustment - let parent1 = get_parent_path(path1); - let parent2 = get_parent_path(path2); - - match (parent1, parent2) { - (Some(p1), Some(p2)) if p1 == p2 => { - // Same parent = siblings (including root level) - PathRelation::Sibling - } - _ => PathRelation::Unrelated, - } -} - -/// Get parent path, or None if root -/// -/// # Examples -/// ``` -/// # use replicant_core::ot::get_parent_path; -/// assert_eq!(get_parent_path("/a/b/c"), Some("/a/b".to_string())); -/// assert_eq!(get_parent_path("/a"), Some("/".to_string())); -/// assert_eq!(get_parent_path("/"), None); -/// ``` -pub fn get_parent_path(path: &str) -> Option<String> { - if path == "/" { - return None; - } - - path.rfind('/').and_then(|pos| { - if pos == 0 { - Some("/".to_string()) - } else { - Some(path[..pos].to_string()) - } - }) -} - -/// Check if two paths conflict (one would affect the other) -/// -/// Conflicts occur when: -/// - Same path -/// - Parent-child relationship (modifying parent affects child) -/// -/// # Examples -/// ``` -/// # use replicant_core::ot::paths_conflict; -/// assert!(paths_conflict("/a", "/a")); -/// assert!(paths_conflict("/a", "/a/b")); // Parent-child -/// assert!(!paths_conflict("/a", "/b")); // Unrelated -/// ``` -pub fn paths_conflict(path1: &str, path2: &str) -> bool { - matches!( - compare_paths(path1, path2), - PathRelation::Same | PathRelation::Parent | PathRelation::Child - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - // === Parse Path Tests === - - #[test] - fn test_parse_simple_object_path() { - let path = parse_path("/foo/bar").unwrap(); - assert_eq!(path.segments.len(), 2); - assert!(matches!(&path.segments[0], PathSegment::Object(s) if s == "foo")); - assert!(matches!(&path.segments[1], PathSegment::Object(s) if s == "bar")); - } - - #[test] - fn test_parse_array_path() { - let path = parse_path("/items/0/name").unwrap(); - assert_eq!(path.segments.len(), 3); - assert!(matches!(&path.segments[0], PathSegment::Object(s) if s == "items")); - assert!(matches!(&path.segments[1], PathSegment::Array(0))); - assert!(matches!(&path.segments[2], PathSegment::Object(s) if s == "name")); - } - - #[test] - fn test_parse_root_path() { - let path = parse_path("/").unwrap(); - assert!(path.segments.is_empty()); - } - - #[test] - fn test_parse_escaped_characters() { - let path = parse_path("/foo~0bar/baz~1qux").unwrap(); - assert!(matches!(&path.segments[0], PathSegment::Object(s) if s == "foo~bar")); - assert!(matches!(&path.segments[1], PathSegment::Object(s) if s == "baz/qux")); - } - - #[test] - fn test_parse_invalid_no_leading_slash() { - assert!(parse_path("foo/bar").is_err()); - } - - #[test] - fn test_parse_invalid_empty() { - assert!(parse_path("").is_err()); - } - - // === Extract Array Index Tests === - - #[test] - fn test_extract_array_index_simple() { - assert_eq!(extract_array_index("/items/0"), Some(0)); - assert_eq!(extract_array_index("/items/42"), Some(42)); - assert_eq!(extract_array_index("/items/999"), Some(999)); - } - - #[test] - fn test_extract_array_index_nested() { - // Should return the LAST array index - assert_eq!(extract_array_index("/users/5/posts/3"), Some(3)); - assert_eq!(extract_array_index("/data/0/items/1/tags/2"), Some(2)); - } - - #[test] - fn test_extract_array_index_none() { - assert_eq!(extract_array_index("/title"), None); - assert_eq!(extract_array_index("/metadata/author"), None); - assert_eq!(extract_array_index("/"), None); - } - - #[test] - fn test_extract_array_index_not_a_number() { - assert_eq!(extract_array_index("/items/abc"), None); - assert_eq!(extract_array_index("/items/-"), None); // Special append marker - } - - // === Adjust Array Index Tests === - - #[test] - fn test_adjust_array_index_increment() { - assert_eq!(adjust_array_index("/items/5", 5, 1).unwrap(), "/items/6"); - assert_eq!(adjust_array_index("/items/0", 0, 3).unwrap(), "/items/3"); - } - - #[test] - fn test_adjust_array_index_decrement() { - assert_eq!(adjust_array_index("/items/5", 5, -2).unwrap(), "/items/3"); - assert_eq!(adjust_array_index("/items/10", 10, -1).unwrap(), "/items/9"); - } - - #[test] - fn test_adjust_array_index_underflow() { - assert!(adjust_array_index("/items/2", 2, -3).is_err()); - assert!(adjust_array_index("/items/0", 0, -1).is_err()); - } - - #[test] - fn test_adjust_array_index_no_match() { - // Index doesn't match target, no change - assert_eq!(adjust_array_index("/items/3", 5, 1).unwrap(), "/items/3"); - } - - #[test] - fn test_adjust_array_index_nested() { - assert_eq!( - adjust_array_index("/data/items/5/name", 5, 1).unwrap(), - "/data/items/6/name" - ); - } - - #[test] - fn test_adjust_array_index_non_array_path() { - assert_eq!(adjust_array_index("/title", 0, 1).unwrap(), "/title"); - } - - // === Compare Paths Tests === - - #[test] - fn test_compare_paths_same() { - assert_eq!(compare_paths("/a", "/a"), PathRelation::Same); - assert_eq!(compare_paths("/a/b/c", "/a/b/c"), PathRelation::Same); - assert_eq!(compare_paths("/", "/"), PathRelation::Same); - } - - #[test] - fn test_compare_paths_parent_child() { - assert_eq!(compare_paths("/a", "/a/b"), PathRelation::Parent); - assert_eq!(compare_paths("/a/b", "/a"), PathRelation::Child); - assert_eq!(compare_paths("/items", "/items/0"), PathRelation::Parent); - assert_eq!( - compare_paths("/users/0/posts/1", "/users/0"), - PathRelation::Child - ); - } - - #[test] - fn test_compare_paths_siblings() { - assert_eq!(compare_paths("/a/b", "/a/c"), PathRelation::Sibling); - assert_eq!(compare_paths("/items/0", "/items/1"), PathRelation::Sibling); - assert_eq!(compare_paths("/x/y/z", "/x/y/w"), PathRelation::Sibling); - } - - #[test] - fn test_compare_paths_root_level_array_siblings() { - // Root-level array operations: paths /0, /1, /2 are siblings - // This is critical for OT on root-level arrays - assert_eq!(compare_paths("/0", "/1"), PathRelation::Sibling); - assert_eq!(compare_paths("/0", "/2"), PathRelation::Sibling); - assert_eq!(compare_paths("/10", "/20"), PathRelation::Sibling); - - // All have parent "/" so they're siblings - // Transformation logic will check if they're array indices - } - - #[test] - fn test_compare_paths_unrelated() { - // Root-level paths are actually siblings (same parent "/") - // This was previously considered "unrelated" but that's incorrect for arrays - assert_eq!(compare_paths("/a", "/b"), PathRelation::Sibling); - - // These are truly unrelated (different parents) - assert_eq!( - compare_paths("/users/1", "/posts/1"), - PathRelation::Unrelated - ); - assert_eq!(compare_paths("/x/y", "/a/b/c"), PathRelation::Unrelated); - } - - // === Get Parent Path Tests === - - #[test] - fn test_get_parent_path_normal() { - assert_eq!(get_parent_path("/a/b/c"), Some("/a/b".to_string())); - assert_eq!(get_parent_path("/items/0"), Some("/items".to_string())); - assert_eq!(get_parent_path("/x"), Some("/".to_string())); - } - - #[test] - fn test_get_parent_path_root() { - assert_eq!(get_parent_path("/"), None); - } - - // === Paths Conflict Tests === - - #[test] - fn test_paths_conflict_same() { - assert!(paths_conflict("/a", "/a")); - assert!(paths_conflict("/items/0", "/items/0")); - } - - #[test] - fn test_paths_conflict_parent_child() { - assert!(paths_conflict("/a", "/a/b")); // Parent-child - assert!(paths_conflict("/a/b", "/a")); // Child-parent - } - - #[test] - fn test_paths_no_conflict_siblings() { - assert!(!paths_conflict("/a/b", "/a/c")); - assert!(!paths_conflict("/items/0", "/items/1")); - } - - #[test] - fn test_paths_no_conflict_unrelated() { - assert!(!paths_conflict("/a", "/b")); - assert!(!paths_conflict("/users", "/posts")); - } - - // === Reconstruct Path Tests === - - #[test] - fn test_reconstruct_path_simple() { - let segments = vec![ - PathSegment::Object("foo".to_string()), - PathSegment::Object("bar".to_string()), - ]; - assert_eq!(reconstruct_path(&segments), "/foo/bar"); - } - - #[test] - fn test_reconstruct_path_with_array() { - let segments = vec![ - PathSegment::Object("items".to_string()), - PathSegment::Array(5), - PathSegment::Object("name".to_string()), - ]; - assert_eq!(reconstruct_path(&segments), "/items/5/name"); - } - - #[test] - fn test_reconstruct_path_with_escapes() { - let segments = vec![ - PathSegment::Object("foo~bar".to_string()), - PathSegment::Object("baz/qux".to_string()), - ]; - assert_eq!(reconstruct_path(&segments), "/foo~0bar/baz~1qux"); - } - - #[test] - fn test_reconstruct_path_empty() { - let segments: Vec<PathSegment> = vec![]; - assert_eq!(reconstruct_path(&segments), "/"); - } -} diff --git a/replicant-core/src/ot/transform.rs b/replicant-core/src/ot/transform.rs deleted file mode 100644 index 97a3ae3..0000000 --- a/replicant-core/src/ot/transform.rs +++ /dev/null @@ -1,595 +0,0 @@ -//! Operational transformation functions for JSON Patch operations (MVP) -//! -//! This module implements array index adjustments and conflict detection for -//! concurrent JSON Patch operations. - -use crate::ot::path_utils::*; -use crate::ot::types::PathRelation; -use crate::SyncError; -use json_patch::{AddOperation, PatchOperation, RemoveOperation, ReplaceOperation}; - -// ============================================================================ -// Add vs Add Transformation -// ============================================================================ - -/// Transform two Add operations -/// -/// Returns (local_transformed, remote_transformed) or conflict indicator -pub fn transform_add_add( - local: &AddOperation, - remote: &AddOperation, -) -> Result<(Option<AddOperation>, Option<AddOperation>), SyncError> { - // Check if both are array operations first (handles same index case) - if let (Some(local_idx), Some(remote_idx)) = ( - extract_array_index(&local.path), - extract_array_index(&remote.path), - ) { - // Verify they're in the same array - if get_parent_path(&local.path) == get_parent_path(&remote.path) { - // Both adding to same array - adjust indices - if local_idx <= remote_idx { - // Local goes first, adjust remote up - let adjusted_remote = AddOperation { - path: adjust_array_index(&remote.path, remote_idx, 1)?, - value: remote.value.clone(), - }; - return Ok((Some(local.clone()), Some(adjusted_remote))); - } else { - // Remote goes first, adjust local up - let adjusted_local = AddOperation { - path: adjust_array_index(&local.path, local_idx, 1)?, - value: local.value.clone(), - }; - return Ok((Some(adjusted_local), Some(remote.clone()))); - } - } - } - - // Not array operations - check path relations - let path_relation = compare_paths(&local.path, &remote.path); - - match path_relation { - PathRelation::Same => { - // Conflict: both adding at same non-array path - // Return both unchanged - caller decides which wins - Ok((Some(local.clone()), Some(remote.clone()))) - } - _ => { - // Different paths, no conflict - Ok((Some(local.clone()), Some(remote.clone()))) - } - } -} - -// ============================================================================ -// Remove vs Remove Transformation -// ============================================================================ - -/// Transform two Remove operations -/// -/// Returns (local_transformed, remote_transformed) or conflict indicator -pub fn transform_remove_remove( - local: &RemoveOperation, - remote: &RemoveOperation, -) -> Result<(Option<RemoveOperation>, Option<RemoveOperation>), SyncError> { - // Check for array operations first (handles same index case) - if let (Some(local_idx), Some(remote_idx)) = ( - extract_array_index(&local.path), - extract_array_index(&remote.path), - ) { - if get_parent_path(&local.path) == get_parent_path(&remote.path) { - // Same array - adjust indices - if local_idx < remote_idx { - // Local removes first, remote index shifts down - let adjusted_remote = RemoveOperation { - path: adjust_array_index(&remote.path, remote_idx, -1)?, - }; - return Ok((Some(local.clone()), Some(adjusted_remote))); - } else if local_idx > remote_idx { - // Remote removes first, local index shifts down - let adjusted_local = RemoveOperation { - path: adjust_array_index(&local.path, local_idx, -1)?, - }; - return Ok((Some(adjusted_local), Some(remote.clone()))); - } else { - // Same index - conflict - return Ok((Some(local.clone()), Some(remote.clone()))); - } - } - } - - // Not array operations - check path relations - let path_relation = compare_paths(&local.path, &remote.path); - - match path_relation { - PathRelation::Same => { - // Both removing same path - conflict - Ok((Some(local.clone()), Some(remote.clone()))) - } - _ => { - // Different paths, no conflict - Ok((Some(local.clone()), Some(remote.clone()))) - } - } -} - -// ============================================================================ -// Add vs Remove Transformation -// ============================================================================ - -/// Transform Add and Remove operations -/// -/// Returns (add_transformed, remove_transformed) -pub fn transform_add_remove( - add: &AddOperation, - remove: &RemoveOperation, -) -> Result<(Option<AddOperation>, Option<RemoveOperation>), SyncError> { - // Check if they're operating on same array - if let (Some(add_idx), Some(rem_idx)) = ( - extract_array_index(&add.path), - extract_array_index(&remove.path), - ) { - if get_parent_path(&add.path) == get_parent_path(&remove.path) { - // Same array - adjust indices - if add_idx <= rem_idx { - // Add happens first, remove index shifts up - let adjusted_remove = RemoveOperation { - path: adjust_array_index(&remove.path, rem_idx, 1)?, - }; - Ok((Some(add.clone()), Some(adjusted_remove))) - } else { - // Remove happens first, add index shifts down - let adjusted_add = AddOperation { - path: adjust_array_index(&add.path, add_idx, -1)?, - value: add.value.clone(), - }; - Ok((Some(adjusted_add), Some(remove.clone()))) - } - } else { - // Different arrays - Ok((Some(add.clone()), Some(remove.clone()))) - } - } else { - // Not array operations - check for path conflict - if paths_conflict(&add.path, &remove.path) { - // Conflict - return both - Ok((Some(add.clone()), Some(remove.clone()))) - } else { - // No conflict - Ok((Some(add.clone()), Some(remove.clone()))) - } - } -} - -// ============================================================================ -// Replace Operations (Simplified) -// ============================================================================ - -/// Transform two Replace operations -/// -/// Simplified MVP: only checks if paths are identical -pub fn transform_replace_replace( - local: &ReplaceOperation, - remote: &ReplaceOperation, -) -> Result<(Option<ReplaceOperation>, Option<ReplaceOperation>), SyncError> { - if local.path == remote.path { - // Same path - conflict, return both - Ok((Some(local.clone()), Some(remote.clone()))) - } else { - // Different paths - no conflict - Ok((Some(local.clone()), Some(remote.clone()))) - } -} - -// ============================================================================ -// Main Transform Function -// ============================================================================ - -/// Transform two patch operations (MVP implementation) -/// -/// Handles: Add, Remove, Replace with array index adjustments -/// Returns conflicts for caller to resolve -/// -/// # Examples -/// ``` -/// use json_patch::{PatchOperation, AddOperation}; -/// use serde_json::json; -/// use replicant_core::ot::transform_operation_pair; -/// -/// let local = PatchOperation::Add(AddOperation { -/// path: "/items/2".into(), -/// value: json!("local"), -/// }); -/// let remote = PatchOperation::Add(AddOperation { -/// path: "/items/5".into(), -/// value: json!("remote"), -/// }); -/// -/// let (l, r) = transform_operation_pair(&local, &remote).unwrap(); -/// // Remote index adjusted from 5 to 6 -/// ``` -pub fn transform_operation_pair( - local: &PatchOperation, - remote: &PatchOperation, -) -> Result<(Option<PatchOperation>, Option<PatchOperation>), SyncError> { - match (local, remote) { - // Add vs Add - (PatchOperation::Add(l), PatchOperation::Add(r)) => { - let (l_result, r_result) = transform_add_add(l, r)?; - Ok(( - l_result.map(PatchOperation::Add), - r_result.map(PatchOperation::Add), - )) - } - - // Remove vs Remove - (PatchOperation::Remove(l), PatchOperation::Remove(r)) => { - let (l_result, r_result) = transform_remove_remove(l, r)?; - Ok(( - l_result.map(PatchOperation::Remove), - r_result.map(PatchOperation::Remove), - )) - } - - // Add vs Remove (and inverse) - (PatchOperation::Add(a), PatchOperation::Remove(r)) => { - let (a_result, r_result) = transform_add_remove(a, r)?; - Ok(( - a_result.map(PatchOperation::Add), - r_result.map(PatchOperation::Remove), - )) - } - (PatchOperation::Remove(r), PatchOperation::Add(a)) => { - let (a_result, r_result) = transform_add_remove(a, r)?; - Ok(( - r_result.map(PatchOperation::Remove), - a_result.map(PatchOperation::Add), - )) - } - - // Replace vs Replace - (PatchOperation::Replace(l), PatchOperation::Replace(r)) => { - let (l_result, r_result) = transform_replace_replace(l, r)?; - Ok(( - l_result.map(PatchOperation::Replace), - r_result.map(PatchOperation::Replace), - )) - } - - // Test operations - pass through (read-only) - (PatchOperation::Test(_), _) | (_, PatchOperation::Test(_)) => { - Ok((Some(local.clone()), Some(remote.clone()))) - } - - // Move/Copy - mark as conflict for MVP - (PatchOperation::Move(_), _) - | (PatchOperation::Copy(_), _) - | (_, PatchOperation::Move(_)) - | (_, PatchOperation::Copy(_)) => { - // Complex operations - return as conflict - Ok((Some(local.clone()), Some(remote.clone()))) - } - - // All other combinations - no conflict - _ => Ok((Some(local.clone()), Some(remote.clone()))), - } -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - // ======================================================================== - // Add vs Add Tests - // ======================================================================== - - #[test] - fn test_add_add_different_paths() { - let local = AddOperation { - path: "/user/name".into(), - value: json!("Alice"), - }; - let remote = AddOperation { - path: "/user/email".into(), - value: json!("alice@example.com"), - }; - - let (l, r) = transform_add_add(&local, &remote).unwrap(); - assert!(l.is_some()); - assert!(r.is_some()); - assert_eq!(l.unwrap().path, "/user/name"); - assert_eq!(r.unwrap().path, "/user/email"); - } - - #[test] - fn test_add_add_same_path_conflict() { - let local = AddOperation { - path: "/settings/theme".into(), - value: json!("dark"), - }; - let remote = AddOperation { - path: "/settings/theme".into(), - value: json!("light"), - }; - - let (l, r) = transform_add_add(&local, &remote).unwrap(); - // Both returned - caller resolves conflict - assert!(l.is_some()); - assert!(r.is_some()); - } - - #[test] - fn test_add_add_array_different_indices() { - let local = AddOperation { - path: "/items/2".into(), - value: json!("Local"), - }; - let remote = AddOperation { - path: "/items/5".into(), - value: json!("Remote"), - }; - - let (l, r) = transform_add_add(&local, &remote).unwrap(); - assert!(l.is_some()); - assert!(r.is_some()); - assert_eq!(l.unwrap().path, "/items/2"); - assert_eq!(r.unwrap().path, "/items/6"); // Adjusted up by 1 - } - - #[test] - fn test_add_add_array_same_index() { - let local = AddOperation { - path: "/items/3".into(), - value: json!("A"), - }; - let remote = AddOperation { - path: "/items/3".into(), - value: json!("B"), - }; - - let (l, r) = transform_add_add(&local, &remote).unwrap(); - assert!(l.is_some()); - assert!(r.is_some()); - // Remote adjusted to next index - assert_eq!(l.unwrap().path, "/items/3"); - assert_eq!(r.unwrap().path, "/items/4"); - } - - #[test] - fn test_add_add_root_level_array() { - // Root-level array: /0, /1, /2 - let local = AddOperation { - path: "/1".into(), - value: json!("Local"), - }; - let remote = AddOperation { - path: "/3".into(), - value: json!("Remote"), - }; - - let (l, r) = transform_add_add(&local, &remote).unwrap(); - assert!(l.is_some()); - assert!(r.is_some()); - assert_eq!(l.unwrap().path, "/1"); - assert_eq!(r.unwrap().path, "/4"); // Adjusted - } - - // ======================================================================== - // Remove vs Remove Tests - // ======================================================================== - - #[test] - fn test_remove_remove_same_path() { - let local = RemoveOperation { - path: "/user/temp".into(), - }; - let remote = RemoveOperation { - path: "/user/temp".into(), - }; - - let (l, r) = transform_remove_remove(&local, &remote).unwrap(); - // Both returned - caller decides - assert!(l.is_some()); - assert!(r.is_some()); - } - - #[test] - fn test_remove_remove_different_paths() { - let local = RemoveOperation { - path: "/config/debug".into(), - }; - let remote = RemoveOperation { - path: "/config/verbose".into(), - }; - - let (l, r) = transform_remove_remove(&local, &remote).unwrap(); - assert!(l.is_some()); - assert!(r.is_some()); - } - - #[test] - fn test_remove_remove_array_different_indices() { - let local = RemoveOperation { - path: "/items/5".into(), - }; - let remote = RemoveOperation { - path: "/items/2".into(), - }; - - let (l, r) = transform_remove_remove(&local, &remote).unwrap(); - assert!(l.is_some()); - assert!(r.is_some()); - // Local index adjusted down (remote removes first) - assert_eq!(l.unwrap().path, "/items/4"); - assert_eq!(r.unwrap().path, "/items/2"); - } - - #[test] - fn test_remove_remove_array_same_index() { - let local = RemoveOperation { - path: "/items/3".into(), - }; - let remote = RemoveOperation { - path: "/items/3".into(), - }; - - let (l, r) = transform_remove_remove(&local, &remote).unwrap(); - // Conflict - both returned - assert!(l.is_some()); - assert!(r.is_some()); - } - - // ======================================================================== - // Add vs Remove Tests - // ======================================================================== - - #[test] - fn test_add_remove_unrelated() { - let add = AddOperation { - path: "/users/new".into(), - value: json!({"name": "Alice"}), - }; - let remove = RemoveOperation { - path: "/posts/old".into(), - }; - - let (a, r) = transform_add_remove(&add, &remove).unwrap(); - assert!(a.is_some()); - assert!(r.is_some()); - } - - #[test] - fn test_add_remove_array_add_before() { - let add = AddOperation { - path: "/items/1".into(), - value: json!("New"), - }; - let remove = RemoveOperation { - path: "/items/3".into(), - }; - - let (a, r) = transform_add_remove(&add, &remove).unwrap(); - assert!(a.is_some()); - assert!(r.is_some()); - assert_eq!(a.unwrap().path, "/items/1"); - assert_eq!(r.unwrap().path, "/items/4"); // Adjusted up - } - - #[test] - fn test_add_remove_array_add_after() { - let add = AddOperation { - path: "/items/5".into(), - value: json!("New"), - }; - let remove = RemoveOperation { - path: "/items/2".into(), - }; - - let (a, r) = transform_add_remove(&add, &remove).unwrap(); - assert!(a.is_some()); - assert!(r.is_some()); - assert_eq!(a.unwrap().path, "/items/4"); // Adjusted down - assert_eq!(r.unwrap().path, "/items/2"); - } - - // ======================================================================== - // Replace Operations Tests - // ======================================================================== - - #[test] - fn test_replace_replace_same_path() { - let local = ReplaceOperation { - path: "/theme".into(), - value: json!("dark"), - }; - let remote = ReplaceOperation { - path: "/theme".into(), - value: json!("light"), - }; - - let (l, r) = transform_replace_replace(&local, &remote).unwrap(); - // Conflict - both returned - assert!(l.is_some()); - assert!(r.is_some()); - } - - #[test] - fn test_replace_replace_different_paths() { - let local = ReplaceOperation { - path: "/user/name".into(), - value: json!("Alice"), - }; - let remote = ReplaceOperation { - path: "/user/age".into(), - value: json!(30), - }; - - let (l, r) = transform_replace_replace(&local, &remote).unwrap(); - assert!(l.is_some()); - assert!(r.is_some()); - } - - // ======================================================================== - // Main Transform Function Tests - // ======================================================================== - - #[test] - fn test_transform_operation_pair_add_add() { - let local = PatchOperation::Add(AddOperation { - path: "/items/2".into(), - value: json!("local"), - }); - let remote = PatchOperation::Add(AddOperation { - path: "/items/5".into(), - value: json!("remote"), - }); - - let (l, r) = transform_operation_pair(&local, &remote).unwrap(); - assert!(l.is_some()); - assert!(r.is_some()); - - if let Some(PatchOperation::Add(r_add)) = r { - assert_eq!(r_add.path, "/items/6"); - } else { - panic!("Expected Add operation"); - } - } - - #[test] - fn test_transform_operation_pair_test_passthrough() { - let local = PatchOperation::Test(json_patch::TestOperation { - path: "/version".into(), - value: json!(1), - }); - let remote = PatchOperation::Add(AddOperation { - path: "/items/0".into(), - value: json!("new"), - }); - - let (l, r) = transform_operation_pair(&local, &remote).unwrap(); - assert!(l.is_some()); - assert!(r.is_some()); - } - - #[test] - fn test_transform_operation_pair_move_conflict() { - let local = PatchOperation::Move(json_patch::MoveOperation { - from: "/a".into(), - path: "/b".into(), - }); - let remote = PatchOperation::Add(AddOperation { - path: "/c".into(), - value: json!("value"), - }); - - let (l, r) = transform_operation_pair(&local, &remote).unwrap(); - // Both returned as conflict (Move not implemented in MVP) - assert!(l.is_some()); - assert!(r.is_some()); - } -} diff --git a/replicant-core/src/ot/types.rs b/replicant-core/src/ot/types.rs deleted file mode 100644 index e2004cf..0000000 --- a/replicant-core/src/ot/types.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Core types for Operational Transformation - -/// Relationship between two JSON Pointer paths -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PathRelation { - /// Paths are identical - Same, - /// First path is parent of second - Parent, - /// First path is child of second - Child, - /// Paths share same parent (e.g., /a/b and /a/c) - Sibling, - /// Paths are unrelated - Unrelated, -} - -/// Represents a parsed JSON Pointer segment -#[derive(Debug, Clone, PartialEq)] -pub enum PathSegment { - /// Object property key - Object(String), - /// Array index - Array(usize), -} - -/// Parsed JSON Pointer path with segments -#[derive(Debug, Clone)] -pub struct ParsedPath { - /// Original path string - pub raw: String, - /// Parsed segments - pub segments: Vec<PathSegment>, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_path_relation_equality() { - assert_eq!(PathRelation::Same, PathRelation::Same); - assert_ne!(PathRelation::Parent, PathRelation::Child); - } - - #[test] - fn test_path_segment_equality() { - assert_eq!( - PathSegment::Object("foo".to_string()), - PathSegment::Object("foo".to_string()) - ); - assert_eq!(PathSegment::Array(5), PathSegment::Array(5)); - assert_ne!( - PathSegment::Object("foo".to_string()), - PathSegment::Object("bar".to_string()) - ); - assert_ne!(PathSegment::Array(1), PathSegment::Array(2)); - } -} diff --git a/replicant-core/src/patches.rs b/replicant-core/src/patches.rs deleted file mode 100644 index bd93fda..0000000 --- a/replicant-core/src/patches.rs +++ /dev/null @@ -1,141 +0,0 @@ -use crate::errors::SyncError; -use crate::ot::transform_operation_pair; -use crate::SyncResult; -use json_patch::{Patch, PatchOperation}; -use serde_json::Value; -use sha2::{Digest, Sha256}; - -pub fn create_patch(from: &Value, to: &Value) -> SyncResult<Patch> { - let diff = json_patch::diff(from, to); - Ok(diff) -} - -pub fn apply_patch(document: &mut Value, patch: &Patch) -> SyncResult<()> { - json_patch::patch(document, patch).map_err(|e| SyncError::PatchFailed(e.to_string())) -} - -pub fn calculate_checksum(value: &Value) -> String { - let json_string = serde_json::to_string(value).unwrap(); - let mut hasher = Sha256::new(); - hasher.update(json_string.as_bytes()); - format!("{:x}", hasher.finalize()) -} - -pub fn merge_patches(patch1: &Patch, patch2: &Patch) -> Patch { - let mut operations = patch1.0.clone(); - operations.extend(patch2.0.clone()); - Patch(operations) -} - -/// Compute a reverse patch that can undo the given forward patch -/// Requires the original document state before the patch was applied -pub fn compute_reverse_patch(original: &Value, forward_patch: &Patch) -> SyncResult<Patch> { - // Apply the forward patch to get the new state - let mut new_state = original.clone(); - apply_patch(&mut new_state, forward_patch)?; - - // The reverse patch is the diff from new state back to original - let reverse_patch = create_patch(&new_state, original)?; - Ok(reverse_patch) -} - -/// Transform two patches using Operational Transformation -/// -/// # OT Strategy (MVP) -/// The OT algorithm handles: -/// - Array index adjustments when operations affect the same array -/// - Conflict detection when operations target the same path -/// - Pass-through for Test operations (read-only) -/// - Conflict marking for Move/Copy operations (Post-MVP) -/// -/// # Conflict Resolution -/// When conflicts are detected, both operations are returned unchanged. -/// The caller must use `ConflictResolver` or timestamps to decide which wins. -/// -/// # Example -/// ``` -/// use replicant_core::patches::{transform_patches, TransformStrategy}; -/// use json_patch::{Patch, PatchOperation, AddOperation}; -/// use serde_json::json; -/// -/// let local = Patch(vec![ -/// PatchOperation::Add(AddOperation { -/// path: "/items/2".into(), -/// value: json!("local item"), -/// }) -/// ]); -/// -/// let remote = Patch(vec![ -/// PatchOperation::Add(AddOperation { -/// path: "/items/5".into(), -/// value: json!("remote item"), -/// }) -/// ]); -/// -/// let (local_t, remote_t) = transform_patches(&local, &remote, TransformStrategy::Operational).unwrap(); -/// // remote index adjusted from 5 to 6 to account for local's addition -/// ``` -pub fn transform_patches( - local: &Patch, - remote: &Patch, - strategy: TransformStrategy, -) -> SyncResult<(Patch, Patch)> { - match strategy { - TransformStrategy::LastWriteWins => { - // Simple strategy: remote wins - Ok((Patch(vec![]), remote.clone())) - } - TransformStrategy::Operational => transform_operations(&local.0, &remote.0), - } -} - -pub enum TransformStrategy { - /// Remote patch wins, local patch is discarded - LastWriteWins, - /// Use Operational Transformation to merge non-conflicting changes - Operational, -} - -/// Transform arrays of patch operations pairwise -/// -/// This implements the OT algorithm by transforming each pair of operations -/// from local and remote patches. -fn transform_operations( - local_ops: &[PatchOperation], - remote_ops: &[PatchOperation], -) -> SyncResult<(Patch, Patch)> { - // For MVP: single operation patches (most common case) - // Full implementation would handle multiple operations with proper ordering - - if local_ops.is_empty() { - return Ok((Patch(vec![]), Patch(remote_ops.to_vec()))); - } - - if remote_ops.is_empty() { - return Ok((Patch(local_ops.to_vec()), Patch(vec![]))); - } - - // Transform each pair of operations - let mut transformed_local = Vec::new(); - let mut transformed_remote = Vec::new(); - - for local_op in local_ops { - for remote_op in remote_ops { - let (l_result, r_result) = transform_operation_pair(local_op, remote_op)?; - - if let Some(l_op) = l_result { - if !transformed_local.contains(&l_op) { - transformed_local.push(l_op); - } - } - - if let Some(r_op) = r_result { - if !transformed_remote.contains(&r_op) { - transformed_remote.push(r_op); - } - } - } - } - - Ok((Patch(transformed_local), Patch(transformed_remote))) -} diff --git a/replicant-core/src/protocol.rs b/replicant-core/src/protocol.rs deleted file mode 100644 index 554aa9d..0000000 --- a/replicant-core/src/protocol.rs +++ /dev/null @@ -1,203 +0,0 @@ -use crate::models::{Document, DocumentPatch, ServerDocumentPatch}; -use serde::{Deserialize, Serialize}; -use strum::{Display, EnumString}; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ClientMessage { - // Authentication - Authenticate { - email: String, - client_id: Uuid, - #[serde(skip_serializing_if = "Option::is_none")] - api_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - signature: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - timestamp: Option<i64>, - }, - - // Document operations - CreateDocument { - document: Document, - }, - UpdateDocument { - patch: DocumentPatch, - }, - DeleteDocument { - document_id: Uuid, - }, - - // Sync operations - RequestSync { - document_ids: Vec<Uuid>, - }, - RequestFullSync, - - // New sequence-based sync operations - GetChangesSince { - last_sequence: u64, - limit: Option<u32>, // Optional pagination - }, - AckChanges { - up_to_sequence: u64, // Client confirms it processed up to this sequence - }, - - // Full-document fetch (used to resync a document off of a broadcast gap) - GetDocument { - id: Uuid, - }, - - // Heartbeat - Ping, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ServerMessage { - // Authentication responses - AuthSuccess { - session_id: Uuid, - client_id: Uuid, - }, - AuthError { - reason: String, - }, - - // Document updates - DocumentCreated { - document: Document, - }, - DocumentUpdated { - patch: ServerDocumentPatch, - }, - DocumentDeleted { - document_id: Uuid, - }, - - // Full-document fetch response - GetDocumentResponse { - id: Uuid, - content: serde_json::Value, - sync_revision: i64, - content_hash: String, - deleted: bool, - }, - - // Document operation confirmations - DocumentCreatedResponse { - document_id: Uuid, - success: bool, - error: Option<String>, - #[serde(default)] - author_name: Option<String>, - #[serde(default)] - visibility: Option<String>, - #[serde(default)] - provenance: Option<serde_json::Value>, - }, - DocumentUpdatedResponse { - document_id: Uuid, - success: bool, - error: Option<String>, - sync_revision: Option<i64>, - /// Machine-readable rejection reason (`hash_mismatch`, `not_found`, - /// `missing_hash`, ...). Optional so a reply carrying only a free-form - /// `error` still parses. - #[serde(default)] - reason: Option<String>, - /// Server state at the moment the update was rejected. Sent with - /// `hash_mismatch` so the client can rebase its queued patch onto the - /// current content instead of dropping the edit. All three are absent - /// for every other reason. - #[serde(default)] - current_revision: Option<i64>, - #[serde(default)] - current_content: Option<serde_json::Value>, - #[serde(default)] - current_hash: Option<String>, - }, - DocumentDeletedResponse { - document_id: Uuid, - success: bool, - error: Option<String>, - }, - - // Sync responses - SyncDocument { - document: Document, - }, - SyncComplete { - synced_count: usize, - }, - - // Conflict notification - ConflictDetected { - document_id: Uuid, - resolution_strategy: ConflictResolution, - }, - - // New sequence-based sync responses - Changes { - events: Vec<ChangeEvent>, - latest_sequence: u64, - has_more: bool, // True if there are more changes beyond the limit - }, - ChangesAcknowledged { - sequence: u64, - }, - - // Errors - Error { - code: ErrorCode, - message: String, - }, - - // Heartbeat - Pong, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ConflictResolution { - ServerWins, - ClientWins, - Manual { - server_document: Box<Document>, - client_patch: DocumentPatch, - }, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum ErrorCode { - InvalidAuth, - DocumentNotFound, - InvalidPatch, - VersionMismatch, - ServerError, - RateLimitExceeded, - InvalidMessage, -} - -// New types for sequence-based sync -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChangeEvent { - pub sequence: u64, - pub document_id: Uuid, - pub user_id: Uuid, - pub event_type: ChangeEventType, - pub forward_patch: Option<serde_json::Value>, - pub reverse_patch: Option<serde_json::Value>, // patch to undo this change - pub created_at: chrono::DateTime<chrono::Utc>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Display, EnumString)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum ChangeEventType { - Create, - Update, - Delete, -} diff --git a/replicant-core/tests/ot_integration_tests.rs b/replicant-core/tests/ot_integration_tests.rs deleted file mode 100644 index 9ce201c..0000000 --- a/replicant-core/tests/ot_integration_tests.rs +++ /dev/null @@ -1,542 +0,0 @@ -//! Integration tests for Operational Transformation -//! -//! These tests demonstrate OT with real-world scenarios showing: -//! - Original JSON document -//! - User A's changes -//! - User B's changes -//! - Final merged result - -use json_patch::{AddOperation, Patch, PatchOperation, RemoveOperation, ReplaceOperation}; -use replicant_core::patches::{apply_patch, transform_patches, TransformStrategy}; -use serde_json::json; - -#[test] -fn test_task_list_concurrent_additions() { - // SCENARIO: Two users add tasks to different positions in a shared task list - - // Original document - let original = json!({ - "tasks": [ - {"id": 1, "text": "Buy groceries", "done": false}, - {"id": 2, "text": "Walk dog", "done": false}, - {"id": 3, "text": "Write report", "done": false} - ] - }); - - // User A (offline): Adds urgent task at position 0 - let user_a_patch = Patch(vec![PatchOperation::Add(AddOperation { - path: "/tasks/0".into(), - value: json!({"id": 4, "text": "URGENT: Call client", "done": false}), - })]); - - // User B (offline): Adds task at position 2 - let user_b_patch = Patch(vec![PatchOperation::Add(AddOperation { - path: "/tasks/2".into(), - value: json!({"id": 5, "text": "Review PRs", "done": false}), - })]); - - // When they sync, transform the patches - let (a_transformed, b_transformed) = - transform_patches(&user_a_patch, &user_b_patch, TransformStrategy::Operational).unwrap(); - - // Apply both patches to get final result - let mut result = original.clone(); - apply_patch(&mut result, &a_transformed).unwrap(); - apply_patch(&mut result, &b_transformed).unwrap(); - - // Expected final state - let expected = json!({ - "tasks": [ - {"id": 4, "text": "URGENT: Call client", "done": false}, // A's addition at 0 - {"id": 1, "text": "Buy groceries", "done": false}, - {"id": 2, "text": "Walk dog", "done": false}, - {"id": 5, "text": "Review PRs", "done": false}, // B's addition at 3 (adjusted from 2) - {"id": 3, "text": "Write report", "done": false} - ] - }); - - assert_eq!(result, expected); - println!("\n=== TASK LIST CONCURRENT ADDITIONS ==="); - println!( - "Original: {}", - serde_json::to_string_pretty(&original).unwrap() - ); - println!("\nUser A adds at index 0"); - println!("User B adds at index 2"); - println!( - "\nFinal result: {}", - serde_json::to_string_pretty(&result).unwrap() - ); -} - -#[test] -fn test_task_list_add_and_remove() { - // SCENARIO: One user adds a task while another removes a different task - - // Original document - let original = json!({ - "tasks": [ - {"id": 1, "text": "Task 1", "done": false}, - {"id": 2, "text": "Task 2", "done": false}, - {"id": 3, "text": "Task 3", "done": false}, - {"id": 4, "text": "Task 4", "done": false}, - {"id": 5, "text": "Task 5", "done": false} - ] - }); - - // User A: Adds new task at position 1 - let user_a_patch = Patch(vec![PatchOperation::Add(AddOperation { - path: "/tasks/1".into(), - value: json!({"id": 6, "text": "New Task", "done": false}), - })]); - - // User B: Removes task at position 3 - let user_b_patch = Patch(vec![PatchOperation::Remove(RemoveOperation { - path: "/tasks/3".into(), - })]); - - // Transform patches - let (a_transformed, b_transformed) = - transform_patches(&user_a_patch, &user_b_patch, TransformStrategy::Operational).unwrap(); - - // Apply both patches - let mut result = original.clone(); - apply_patch(&mut result, &a_transformed).unwrap(); - apply_patch(&mut result, &b_transformed).unwrap(); - - // Expected: Task added at 1, Task 4 removed (was at index 3, now at 4 after add) - let expected = json!({ - "tasks": [ - {"id": 1, "text": "Task 1", "done": false}, - {"id": 6, "text": "New Task", "done": false}, // A's addition - {"id": 2, "text": "Task 2", "done": false}, - {"id": 3, "text": "Task 3", "done": false}, - // Task 4 removed (B's deletion, adjusted from index 3 to 4) - {"id": 5, "text": "Task 5", "done": false} - ] - }); - - assert_eq!(result, expected); - println!("\n=== TASK LIST ADD AND REMOVE ==="); - println!( - "Original (5 tasks): {}", - serde_json::to_string_pretty(&original).unwrap() - ); - println!("\nUser A adds at index 1"); - println!("User B removes at index 3"); - println!( - "\nFinal result (5 tasks): {}", - serde_json::to_string_pretty(&result).unwrap() - ); -} - -#[test] -fn test_shopping_list_concurrent_edits() { - // SCENARIO: Two shoppers editing a shared shopping list - - // Original document - let original = json!({ - "items": [ - "Milk", - "Bread", - "Eggs", - "Butter" - ] - }); - - // Shopper A: Adds "Coffee" at position 2 - let shopper_a_patch = Patch(vec![PatchOperation::Add(AddOperation { - path: "/items/2".into(), - value: json!("Coffee"), - })]); - - // Shopper B: Adds "Cheese" at position 4 - let shopper_b_patch = Patch(vec![PatchOperation::Add(AddOperation { - path: "/items/4".into(), - value: json!("Cheese"), - })]); - - // Transform - let (a_transformed, b_transformed) = transform_patches( - &shopper_a_patch, - &shopper_b_patch, - TransformStrategy::Operational, - ) - .unwrap(); - - // Apply both - let mut result = original.clone(); - apply_patch(&mut result, &a_transformed).unwrap(); - apply_patch(&mut result, &b_transformed).unwrap(); - - let expected = json!({ - "items": [ - "Milk", - "Bread", - "Coffee", // A's addition at 2 - "Eggs", - "Butter", - "Cheese" // B's addition at 5 (adjusted from 4) - ] - }); - - assert_eq!(result, expected); - println!("\n=== SHOPPING LIST CONCURRENT EDITS ==="); - println!( - "Original: {}", - serde_json::to_string_pretty(&original).unwrap() - ); - println!("\nShopper A adds 'Coffee' at index 2"); - println!("Shopper B adds 'Cheese' at index 4"); - println!( - "\nFinal result: {}", - serde_json::to_string_pretty(&result).unwrap() - ); -} - -#[test] -fn test_document_settings_concurrent_updates() { - // SCENARIO: Two users update different settings simultaneously - - // Original document - let original = json!({ - "settings": { - "theme": "light", - "fontSize": 14, - "notifications": true - } - }); - - // User A: Changes theme - let user_a_patch = Patch(vec![PatchOperation::Replace(ReplaceOperation { - path: "/settings/theme".into(), - value: json!("dark"), - })]); - - // User B: Changes font size - let user_b_patch = Patch(vec![PatchOperation::Replace(ReplaceOperation { - path: "/settings/fontSize".into(), - value: json!(16), - })]); - - // Transform (no conflict - different paths) - let (a_transformed, b_transformed) = - transform_patches(&user_a_patch, &user_b_patch, TransformStrategy::Operational).unwrap(); - - // Apply both - let mut result = original.clone(); - apply_patch(&mut result, &a_transformed).unwrap(); - apply_patch(&mut result, &b_transformed).unwrap(); - - let expected = json!({ - "settings": { - "theme": "dark", // A's change - "fontSize": 16, // B's change - "notifications": true - } - }); - - assert_eq!(result, expected); - println!("\n=== DOCUMENT SETTINGS CONCURRENT UPDATES ==="); - println!( - "Original: {}", - serde_json::to_string_pretty(&original).unwrap() - ); - println!("\nUser A changes theme to 'dark'"); - println!("User B changes fontSize to 16"); - println!( - "\nFinal result (both changes applied): {}", - serde_json::to_string_pretty(&result).unwrap() - ); -} - -#[test] -fn test_conflicting_edits_same_field() { - // SCENARIO: Two users change the same field (conflict!) - - // Original document - let original = json!({ - "document": { - "title": "Original Title", - "content": "Original content" - } - }); - - // User A: Changes title - let user_a_patch = Patch(vec![PatchOperation::Replace(ReplaceOperation { - path: "/document/title".into(), - value: json!("User A's Title"), - })]); - - // User B: Also changes title - let user_b_patch = Patch(vec![PatchOperation::Replace(ReplaceOperation { - path: "/document/title".into(), - value: json!("User B's Title"), - })]); - - // Transform - both operations returned (CONFLICT) - let (a_transformed, b_transformed) = - transform_patches(&user_a_patch, &user_b_patch, TransformStrategy::Operational).unwrap(); - - // Check that both patches were returned (indicates conflict) - assert!( - !a_transformed.0.is_empty(), - "User A's patch should be returned" - ); - assert!( - !b_transformed.0.is_empty(), - "User B's patch should be returned" - ); - - println!("\n=== CONFLICTING EDITS (Same Field) ==="); - println!( - "Original: {}", - serde_json::to_string_pretty(&original).unwrap() - ); - println!("\nUser A changes title to 'User A's Title'"); - println!("User B changes title to 'User B's Title'"); - println!("\n⚠️ CONFLICT DETECTED!"); - println!("Both operations returned unchanged."); - println!("Caller must use timestamps or ConflictResolver to decide winner."); - - // Simulate resolution: User B wins (newer timestamp) - let mut result_b_wins = original.clone(); - apply_patch(&mut result_b_wins, &b_transformed).unwrap(); - - println!( - "\nIf User B wins (newer): {}", - serde_json::to_string_pretty(&result_b_wins).unwrap() - ); -} - -#[test] -fn test_array_multiple_removals() { - // SCENARIO: Two users remove different items from an array - - // Original document - let original = json!({ - "participants": [ - "Alice", - "Bob", - "Charlie", - "David", - "Eve" - ] - }); - - // User A: Removes "Bob" (index 1) - let user_a_patch = Patch(vec![PatchOperation::Remove(RemoveOperation { - path: "/participants/1".into(), - })]); - - // User B: Removes "David" (index 3) - let user_b_patch = Patch(vec![PatchOperation::Remove(RemoveOperation { - path: "/participants/3".into(), - })]); - - // Transform - let (a_transformed, b_transformed) = - transform_patches(&user_a_patch, &user_b_patch, TransformStrategy::Operational).unwrap(); - - // Apply both - let mut result = original.clone(); - apply_patch(&mut result, &a_transformed).unwrap(); - apply_patch(&mut result, &b_transformed).unwrap(); - - let expected = json!({ - "participants": [ - "Alice", - // "Bob" removed by A - "Charlie", - // "David" removed by B (index adjusted from 3 to 2 after A's removal) - "Eve" - ] - }); - - assert_eq!(result, expected); - println!("\n=== ARRAY MULTIPLE REMOVALS ==="); - println!( - "Original: {}", - serde_json::to_string_pretty(&original).unwrap() - ); - println!("\nUser A removes 'Bob' at index 1"); - println!("User B removes 'David' at index 3"); - println!( - "\nFinal result: {}", - serde_json::to_string_pretty(&result).unwrap() - ); -} - -#[test] -fn test_nested_object_updates() { - // SCENARIO: Updates to nested structures - - // Original document - let original = json!({ - "user": { - "profile": { - "name": "Alice", - "email": "alice@example.com" - }, - "settings": { - "theme": "light", - "language": "en" - } - } - }); - - // User A: Updates profile name - let user_a_patch = Patch(vec![PatchOperation::Replace(ReplaceOperation { - path: "/user/profile/name".into(), - value: json!("Alice Smith"), - })]); - - // User B: Updates settings theme - let user_b_patch = Patch(vec![PatchOperation::Replace(ReplaceOperation { - path: "/user/settings/theme".into(), - value: json!("dark"), - })]); - - // Transform (no conflict - different branches) - let (a_transformed, b_transformed) = - transform_patches(&user_a_patch, &user_b_patch, TransformStrategy::Operational).unwrap(); - - // Apply both - let mut result = original.clone(); - apply_patch(&mut result, &a_transformed).unwrap(); - apply_patch(&mut result, &b_transformed).unwrap(); - - let expected = json!({ - "user": { - "profile": { - "name": "Alice Smith", // A's change - "email": "alice@example.com" - }, - "settings": { - "theme": "dark", // B's change - "language": "en" - } - } - }); - - assert_eq!(result, expected); - println!("\n=== NESTED OBJECT UPDATES ==="); - println!( - "Original: {}", - serde_json::to_string_pretty(&original).unwrap() - ); - println!("\nUser A updates /user/profile/name"); - println!("User B updates /user/settings/theme"); - println!( - "\nFinal result (both changes): {}", - serde_json::to_string_pretty(&result).unwrap() - ); -} - -#[test] -fn test_root_level_array() { - // SCENARIO: Document root is an array - - // Original document (root is array) - let original = json!(["Item 1", "Item 2", "Item 3"]); - - // User A: Adds at index 0 - let user_a_patch = Patch(vec![PatchOperation::Add(AddOperation { - path: "/0".into(), - value: json!("First Item"), - })]); - - // User B: Adds at index 2 - let user_b_patch = Patch(vec![PatchOperation::Add(AddOperation { - path: "/2".into(), - value: json!("Middle Item"), - })]); - - // Transform (root-level array paths are siblings!) - let (a_transformed, b_transformed) = - transform_patches(&user_a_patch, &user_b_patch, TransformStrategy::Operational).unwrap(); - - // Apply both - let mut result = original.clone(); - apply_patch(&mut result, &a_transformed).unwrap(); - apply_patch(&mut result, &b_transformed).unwrap(); - - let expected = json!([ - "First Item", // A's addition at 0 - "Item 1", - "Item 2", - "Middle Item", // B's addition at 3 (adjusted from 2) - "Item 3" - ]); - - assert_eq!(result, expected); - println!("\n=== ROOT LEVEL ARRAY ==="); - println!( - "Original: {}", - serde_json::to_string_pretty(&original).unwrap() - ); - println!("\nUser A adds at index 0 (root path /0)"); - println!("User B adds at index 2 (root path /2)"); - println!( - "\nFinal result: {}", - serde_json::to_string_pretty(&result).unwrap() - ); -} - -#[test] -fn test_same_index_additions() { - // SCENARIO: Both users add at the exact same index - - // Original document - let original = json!({ - "queue": [ - "Job 1", - "Job 2", - "Job 3" - ] - }); - - // User A: Adds at index 1 - let user_a_patch = Patch(vec![PatchOperation::Add(AddOperation { - path: "/queue/1".into(), - value: json!("User A's Job"), - })]); - - // User B: Also adds at index 1 - let user_b_patch = Patch(vec![PatchOperation::Add(AddOperation { - path: "/queue/1".into(), - value: json!("User B's Job"), - })]); - - // Transform (both succeed with adjusted indices) - let (a_transformed, b_transformed) = - transform_patches(&user_a_patch, &user_b_patch, TransformStrategy::Operational).unwrap(); - - // Apply both - let mut result = original.clone(); - apply_patch(&mut result, &a_transformed).unwrap(); - apply_patch(&mut result, &b_transformed).unwrap(); - - let expected = json!({ - "queue": [ - "Job 1", - "User A's Job", // A at index 1 - "User B's Job", // B at index 2 (adjusted from 1) - "Job 2", - "Job 3" - ] - }); - - assert_eq!(result, expected); - println!("\n=== SAME INDEX ADDITIONS ==="); - println!( - "Original: {}", - serde_json::to_string_pretty(&original).unwrap() - ); - println!("\nUser A adds at index 1"); - println!("User B also adds at index 1"); - println!( - "\nFinal result (both added, B adjusted to 2): {}", - serde_json::to_string_pretty(&result).unwrap() - ); -} diff --git a/replicant-seed/Cargo.toml b/replicant-seed/Cargo.toml deleted file mode 100644 index becebe1..0000000 --- a/replicant-seed/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "replicant-seed" -version = "0.6.0" -edition = "2021" -description = "Seed a Replicant server with a directory of JSON documents" - -[[bin]] -name = "replicant-seed" -path = "src/main.rs" - -[dependencies] -replicant-client = { path = "../replicant-client" } -tokio = { workspace = true } -serde_json = { workspace = true } -clap = { version = "4.4", features = ["derive", "env"] } -anyhow = "1.0" -uuid = { workspace = true } -walkdir = "2.5" -tracing = { workspace = true } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/replicant-seed/src/main.rs b/replicant-seed/src/main.rs deleted file mode 100644 index b1d6495..0000000 --- a/replicant-seed/src/main.rs +++ /dev/null @@ -1,304 +0,0 @@ -//! `replicant-seed` — push a directory of JSON documents to a Replicant -//! server. Idempotent by a configurable dedupe field (default: `title`). -//! -//! Typical use: bootstrapping a Replicant deployment with a corpus of seed -//! documents. Each *.json file is pushed verbatim through the sync channel, -//! so every seeded document is owned by (and private to) the authenticated -//! seeding account. To publish a public corpus, use the server-side factory -//! backfill (`mix replicant.backfill_factory`), which creates owned public -//! documents. - -use std::collections::HashSet; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use anyhow::{anyhow, bail, Context, Result}; -use clap::Parser; -use replicant_client::Client; -use serde_json::Value; -use walkdir::WalkDir; - -#[derive(Parser, Debug)] -#[command( - name = "replicant-seed", - about = "Push a directory of JSON documents to a Replicant server", - long_about = None, -)] -struct Args { - /// WebSocket server URL (e.g. wss://db.example.com). - #[arg(long, env = "REPLICANT_SERVER_URL")] - server: String, - - /// Email used to authenticate the connection. - #[arg(long, env = "REPLICANT_USER")] - user: String, - - /// Application API key (rpa_ prefix). - #[arg(long, env = "REPLICANT_API_KEY")] - api_key: String, - - /// Application API secret (rps_ prefix). - #[arg(long, env = "REPLICANT_API_SECRET")] - api_secret: String, - - /// Canonical user id bound to the credential at enrollment. Required for - /// sync: without an adopted identity the client stays local-only and - /// pushes nothing. - #[arg(long, env = "REPLICANT_USER_ID")] - user_id: uuid::Uuid, - - /// Directory containing the JSON documents to push (recursively). - #[arg(long)] - json_dir: PathBuf, - - /// Local SQLite work directory. A `replicant-seed.sqlite3` file is - /// created here for the duration of the run. - #[arg(long, default_value = "./replicant-seed-workdir")] - db_dir: PathBuf, - - /// Dot-path inside each document used to detect duplicates. The seeder - /// reads existing docs from the server, indexes them by this field, and - /// skips local files whose value already exists. Pass an empty string - /// to disable dedupe (always push). - #[arg(long, default_value = "title")] - dedupe_by: String, - - /// Parse and report what would happen, but do not connect or push. - #[arg(long)] - dry_run: bool, - - /// Seconds to wait for the initial sync to land before reading the - /// existing document set. - #[arg(long, default_value_t = 5)] - initial_sync_secs: u64, - - /// Seconds to wait after pushing, so writes flush to the server. - #[arg(long, default_value_t = 5)] - flush_secs: u64, -} - -#[derive(Debug)] -struct LoadedDoc { - path: PathBuf, - content: Value, - dedupe_key: Option<String>, -} - -/// Walk `dir` and parse every `*.json` file into a `LoadedDoc`. Bails on the -/// first parse error so we don't half-seed a server. -fn load_docs(dir: &Path, dedupe_by: &str) -> Result<Vec<LoadedDoc>> { - if !dir.is_dir() { - bail!("{} is not a directory", dir.display()); - } - - let mut docs = Vec::new(); - for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) { - if !entry.file_type().is_file() { - continue; - } - if entry.path().extension().and_then(|s| s.to_str()) != Some("json") { - continue; - } - let raw = std::fs::read_to_string(entry.path()) - .with_context(|| format!("reading {}", entry.path().display()))?; - let content: Value = serde_json::from_str(&raw) - .with_context(|| format!("parsing JSON in {}", entry.path().display()))?; - let dedupe_key = if dedupe_by.is_empty() { - None - } else { - extract_dot_path(&content, dedupe_by).map(key_to_string) - }; - docs.push(LoadedDoc { - path: entry.path().to_path_buf(), - content, - dedupe_key, - }); - } - - docs.sort_by(|a, b| a.path.cmp(&b.path)); - Ok(docs) -} - -/// Resolve a dot-path like `title` or `metadata.name` against a JSON value. -/// Returns the value at the path, if it exists; `key_to_string` renders it -/// as a dedupe key. -fn extract_dot_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { - let mut current = value; - for segment in path.split('.') { - current = current.get(segment)?; - } - Some(current) -} - -/// Render a JSON value as a flat string for use as a dedupe key. -/// Strings are returned unwrapped (no surrounding quotes); other primitives -/// are stringified. Objects/arrays serialize to canonical JSON. -fn key_to_string(v: &Value) -> String { - match v { - Value::String(s) => s.clone(), - Value::Null => String::new(), - other => other.to_string(), - } -} - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - - let args = Args::parse(); - - let docs = load_docs(&args.json_dir, &args.dedupe_by)?; - if docs.is_empty() { - bail!("no JSON documents found under {}", args.json_dir.display()); - } - println!( - "Loaded {} document(s) from {}", - docs.len(), - args.json_dir.display() - ); - if !args.dedupe_by.is_empty() { - let missing: Vec<_> = docs - .iter() - .filter(|d| d.dedupe_key.is_none()) - .map(|d| d.path.display().to_string()) - .collect(); - if !missing.is_empty() { - println!( - " Note: {} doc(s) have no value at `{}` and will always be pushed:", - missing.len(), - args.dedupe_by - ); - for p in &missing { - println!(" - {}", p); - } - } - } - - if args.dry_run { - for d in &docs { - let key = d.dedupe_key.as_deref().unwrap_or("<none>"); - println!(" [dry] {} (key={})", d.path.display(), key); - } - println!("Dry run — no connection attempted."); - return Ok(()); - } - - std::fs::create_dir_all(&args.db_dir) - .with_context(|| format!("creating work dir {}", args.db_dir.display()))?; - let db_url = format!( - "sqlite:{}/replicant-seed.sqlite3?mode=rwc", - args.db_dir.display() - ); - - println!( - "Connecting to {} as {} (work dir: {})...", - args.server, - args.user, - args.db_dir.display() - ); - let client = Client::new( - &db_url, - &args.server, - &args.user, - &args.api_key, - &args.api_secret, - Some(args.user_id), - ) - .await - .map_err(|e| anyhow!("Replicant connect failed: {}", e))?; - - println!("Waiting {}s for initial sync...", args.initial_sync_secs); - tokio::time::sleep(Duration::from_secs(args.initial_sync_secs)).await; - - let existing_keys: HashSet<String> = if args.dedupe_by.is_empty() { - HashSet::new() - } else { - let docs = client - .get_all_documents() - .await - .map_err(|e| anyhow!("get_all_documents failed: {}", e))?; - docs.iter() - .filter_map(|d| extract_dot_path(&d.content, &args.dedupe_by).map(key_to_string)) - .collect() - }; - println!( - "Server has {} existing doc(s) indexed by `{}`", - existing_keys.len(), - if args.dedupe_by.is_empty() { - "<none>" - } else { - args.dedupe_by.as_str() - } - ); - - let mut created = 0usize; - let mut skipped = 0usize; - let mut failed = 0usize; - for doc in &docs { - let label = doc - .dedupe_key - .clone() - .unwrap_or_else(|| doc.path.display().to_string()); - - if let Some(ref key) = doc.dedupe_key { - if existing_keys.contains(key) { - skipped += 1; - println!(" [skip] {}", label); - continue; - } - } - - match client.create_document(doc.content.clone()).await { - Ok(saved) => { - created += 1; - println!(" [push] {} (id={})", label, saved.id); - } - Err(e) => { - failed += 1; - eprintln!(" [fail] {}: {}", label, e); - } - } - } - - println!("Flushing for {}s...", args.flush_secs); - tokio::time::sleep(Duration::from_secs(args.flush_secs)).await; - if let Ok(pending) = client.count_pending_sync().await { - if pending > 0 { - eprintln!("Warning: {} doc(s) still pending sync at exit.", pending); - } - } - - println!( - "\nDone. created={} skipped={} failed={}", - created, skipped, failed - ); - if failed == 0 { - Ok(()) - } else { - bail!("{} push(es) failed", failed) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn local_dedupe_key_matches_server_side_key_for_strings() { - let dir = std::env::temp_dir().join(format!("replicant-seed-test-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("doc.json"), r#"{"title":"Partch 43-tone"}"#).unwrap(); - - let docs = load_docs(&dir, "title").unwrap(); - std::fs::remove_dir_all(&dir).unwrap(); - - // Must equal the key the server-side index derives via key_to_string — - // unwrapped, no JSON quotes — or dedupe never matches. - let server_key = key_to_string(&json!("Partch 43-tone")); - assert_eq!(docs[0].dedupe_key.as_deref(), Some(server_key.as_str())); - assert_eq!(server_key, "Partch 43-tone"); - } -} diff --git a/replicant/Cargo.toml b/replicant/Cargo.toml deleted file mode 100644 index 7048da9..0000000 --- a/replicant/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "replicant" -version = "0.6.0" -edition = "2021" -description = "Offline-first document synchronization library" - -[dependencies] -replicant-core = { path = "../replicant-core" } -replicant-client = { path = "../replicant-client" } -# Server is now Elixir/Phoenix - see replicant-server/ in repo root diff --git a/replicant/src/lib.rs b/replicant/src/lib.rs deleted file mode 100644 index 457ca89..0000000 --- a/replicant/src/lib.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Replicant - Offline-first document synchronization -//! -//! This crate provides a unified API for the Replicant sync system. -//! -//! # Example -//! -//! ```ignore -//! use replicant::Client; -//! -//! let client = Client::new("sqlite:data.db", "wss://server/ws", ...)?; -//! client.create_document(json)?; -//! ``` - -// Re-export client types -pub use replicant_client::Client; - -// Server is now Elixir/Phoenix - see replicant-server/ in repo root - -// Re-export core types that external applications may need -pub use replicant_core::errors::SyncError; -pub use replicant_core::models::Document; -pub use replicant_core::protocol::{ClientMessage, ServerMessage}; -pub use replicant_core::SyncResult; diff --git a/scripts/build_dist.py b/scripts/build_dist.py index f6e0527..b220b8f 100755 --- a/scripts/build_dist.py +++ b/scripts/build_dist.py @@ -42,7 +42,7 @@ def main(): print("Creating dist directory structure...") dist = Path("dist") - for subdir in ["include", "lib", "examples", "cmake", "juce/replicant"]: + for subdir in ["include", "lib", "cmake"]: (dist / subdir).mkdir(parents=True, exist_ok=True) # Copy C header @@ -93,37 +93,6 @@ def main(): shutil.copy(lib, dist / "lib" / lib.name) print(f"[OK] {lib_type} copied to dist/lib/") - # Copy JUCE module - print("Copying JUCE module to dist...") - juce_src = Path("wrappers/juce/replicant") - juce_dst = dist / "juce" / "replicant" - - if juce_src.exists(): - # Clear destination - if juce_dst.exists(): - shutil.rmtree(juce_dst) - juce_dst.mkdir(parents=True, exist_ok=True) - - # Copy files - for file in ["replicant.h", "replicant.cpp"]: - src_file = juce_src / file - if src_file.exists(): - shutil.copy(src_file, juce_dst / file) - - # Fix include path for dist layout - header_file = juce_dst / "replicant.h" - if header_file.exists(): - content = header_file.read_text() - content = content.replace( - '"../../../dist/include/replicant.hpp"', - '"../../include/replicant.hpp"' - ) - header_file.write_text(content) - - print("[OK] JUCE module copied to dist/juce/replicant/") - else: - print("[WARN] JUCE module not found at wrappers/juce/replicant/") - # Get version from Cargo print("Adding version information...") result = run_command(["cargo", "metadata", "--no-deps", "--format-version", "1"], check=False) @@ -154,8 +123,6 @@ def main(): print(" - dist/include/replicant.h (C header - auto-generated)") print(" - dist/include/replicant.hpp (C++ wrapper)") print(" - dist/lib/ (compiled libraries)") - print(" - dist/juce/replicant/ (JUCE module)") - print(" - dist/examples/ (usage examples)") print() print(f"Version: {version}") diff --git a/scripts/check_replicant_versions.py b/scripts/check_replicant_versions.py new file mode 100755 index 0000000..6db1194 --- /dev/null +++ b/scripts/check_replicant_versions.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Release gate: every binary that links replicant-client must link the same version. + +Usage: check_replicant_versions.py PATH [PATH ...] +Each PATH is a binary or a directory (an app or plugin bundle), searched recursively. +Exits 1 when a PATH has no version marker, carries two versions, or the PATHs disagree. +""" + +import os +import re +import sys + +MARKER = re.compile(rb"replicant-client-version=([0-9A-Za-z.+-]+)\x00") + + +def files_under(path): + if os.path.isfile(path): + yield path + return + for root, _dirs, names in os.walk(path): + for name in names: + candidate = os.path.join(root, name) + if os.path.isfile(candidate) and not os.path.islink(candidate): + yield candidate + + +def versions_in(path): + found = set() + for file in files_under(path): + with open(file, "rb") as handle: + found.update(match.decode() for match in MARKER.findall(handle.read())) + return found + + +def main(paths): + if not paths: + print(__doc__) + return 1 + failed = False + versions = {} + for path in paths: + found = versions_in(path) + if not found: + print(f"FAIL {path}: no replicant-client version marker") + failed = True + elif len(found) > 1: + print(f"FAIL {path}: several replicant-client versions {sorted(found)}") + failed = True + else: + versions[path] = found.pop() + print(f"ok {path}: {versions[path]}") + if len(set(versions.values())) > 1: + print(f"FAIL the binaries link different versions: {sorted(set(versions.values()))}") + failed = True + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/test/run_integration_tests_docker.sh b/test/run_integration_tests_docker.sh deleted file mode 100755 index 92f25e2..0000000 --- a/test/run_integration_tests_docker.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/bin/bash - -# Docker-based integration test runner -# Uses docker-compose.test.yml for PostgreSQL and builds server in Docker - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -echo -e "${GREEN}🐳 Docker Integration Test Runner${NC}" -echo "==================================" - -# Configuration -TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5433/sync_test_db" -SERVER_PORT=8081 - -# Cleanup function -cleanup() { - echo -e "\n${YELLOW}🧹 Cleaning up...${NC}" - docker-compose -f docker-compose.test.yml down -v 2>/dev/null || true - if [ ! -z "$SERVER_PID" ] && kill -0 $SERVER_PID 2>/dev/null; then - kill $SERVER_PID - wait $SERVER_PID 2>/dev/null || true - fi -} - -# Set up cleanup trap -trap cleanup EXIT - -echo -e "${YELLOW}📦 Starting test database...${NC}" -docker-compose -f docker-compose.test.yml up -d - -# Wait for PostgreSQL to be ready -echo -e "${YELLOW}⏳ Waiting for PostgreSQL...${NC}" -for i in {1..30}; do - if docker-compose -f docker-compose.test.yml exec -T postgres-test pg_isready -U postgres -d sync_test_db >/dev/null 2>&1; then - echo -e "${GREEN}✅ PostgreSQL is ready${NC}" - break - fi - if [ $i -eq 30 ]; then - echo -e "${RED}❌ PostgreSQL failed to start${NC}" - exit 1 - fi - sleep 2 -done - -echo -e "${YELLOW}🔨 Building server...${NC}" -cargo build --bin replicant-server - -echo -e "${YELLOW}📊 Running migrations...${NC}" -cd replicant-server -DATABASE_URL="$TEST_DATABASE_URL" sqlx migrate run -cd .. - -echo -e "${YELLOW}🚀 Starting sync server...${NC}" -DATABASE_URL="$TEST_DATABASE_URL" RUST_LOG=warn PORT="$SERVER_PORT" \ - cargo run --bin replicant-server & -SERVER_PID=$! - -# Wait for server to start (check if port is listening) -echo -e "${YELLOW}⏳ Waiting for sync server...${NC}" -for i in {1..30}; do - if nc -z localhost $SERVER_PORT >/dev/null 2>&1; then - echo -e "${GREEN}✅ Sync server is ready${NC}" - break - fi - if [ $i -eq 30 ]; then - echo -e "${RED}❌ Sync server failed to start${NC}" - echo "Server logs:" - jobs -p | xargs -I {} sh -c 'if kill -0 {} 2>/dev/null; then echo "Server is running with PID {}"; else echo "Server process {} has died"; fi' - exit 1 - fi - sleep 2 -done - -echo -e "${YELLOW}🧪 Running integration tests...${NC}" -export RUN_INTEGRATION_TESTS=1 -export TEST_DATABASE_URL="$TEST_DATABASE_URL" -export SYNC_SERVER_URL="ws://localhost:$SERVER_PORT/ws" -export RUST_TEST_THREADS=1 - -echo -e "${YELLOW}⏳ Waiting a moment for server to fully initialize...${NC}" -sleep 3 - -if cargo test integration_tests --no-fail-fast -- --test-threads=1 --nocapture; then - echo -e "${GREEN}✅ All integration tests passed!${NC}" -else - echo -e "${RED}❌ Some integration tests failed${NC}" - exit 1 -fi \ No newline at end of file diff --git a/test/run_offline_sync_test.sh b/test/run_offline_sync_test.sh deleted file mode 100755 index f0bb001..0000000 --- a/test/run_offline_sync_test.sh +++ /dev/null @@ -1,222 +0,0 @@ -#!/bin/bash - -# Offline/Online Sync Integration Test Script -# This script tests the sync system's ability to handle offline scenarios -# by controlling the server lifecycle during the test - -set -e # Exit on any error - -# Configuration -DATABASE_NAME="${DATABASE_NAME:-sync_offline_test_db}" -DATABASE_USER="${DATABASE_USER:-$USER}" -DATABASE_URL="postgresql://$DATABASE_USER@localhost:5432/$DATABASE_NAME" -SERVER_PORT="${SERVER_PORT:-8080}" -SERVER_PID_FILE="/tmp/sync_server_offline_test.pid" -SERVER_LOG_FILE="/tmp/sync_server_offline_test.log" -TEST_STATE_FILE="/tmp/sync_offline_test_state.json" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -MAGENTA='\033[0;35m' -NC='\033[0m' # No Color - -log() { - echo -e "${GREEN}[$(date +'%H:%M:%S')] $1${NC}" -} - -warn() { - echo -e "${YELLOW}[$(date +'%H:%M:%S')] WARNING: $1${NC}" -} - -error() { - echo -e "${RED}[$(date +'%H:%M:%S')] ERROR: $1${NC}" -} - -info() { - echo -e "${BLUE}[$(date +'%H:%M:%S')] INFO: $1${NC}" -} - -phase() { - echo -e "${MAGENTA}[$(date +'%H:%M:%S')] ═══ PHASE: $1 ═══${NC}" -} - -# Kill all processes using a specific port -kill_port_processes() { - local port=$1 - local pids=$(lsof -ti :$port 2>/dev/null || true) - if [ -n "$pids" ]; then - for pid in $pids; do - kill -9 $pid 2>/dev/null || true - done - sleep 1 - fi -} - -# Kill all replicant-server processes -kill_all_servers() { - info "Killing any existing sync servers..." - pkill -f "replicant-server" 2>/dev/null || true - kill_port_processes $SERVER_PORT - rm -f "$SERVER_PID_FILE" - sleep 1 -} - -# Start the sync server -start_server() { - log "Starting sync server..." - - # Start server in background - DATABASE_URL="$DATABASE_URL" cargo run --bin replicant-server > "$SERVER_LOG_FILE" 2>&1 & - local server_pid=$! - echo $server_pid > "$SERVER_PID_FILE" - - # Wait for server to be ready - log "Waiting for server to be ready..." - local max_wait=30 - local waited=0 - while [ $waited -lt $max_wait ]; do - if curl -s http://localhost:$SERVER_PORT/health >/dev/null 2>&1; then - log "Server is ready (PID: $server_pid)" - return 0 - fi - sleep 1 - waited=$((waited + 1)) - done - - error "Server failed to start within ${max_wait} seconds" - tail -20 "$SERVER_LOG_FILE" - return 1 -} - -# Stop the sync server -stop_server() { - log "Stopping sync server..." - if [ -f "$SERVER_PID_FILE" ]; then - local server_pid=$(cat "$SERVER_PID_FILE") - if kill -0 "$server_pid" 2>/dev/null; then - kill "$server_pid" 2>/dev/null || true - sleep 2 - # Force kill if still running - if kill -0 "$server_pid" 2>/dev/null; then - kill -9 "$server_pid" 2>/dev/null || true - fi - fi - rm -f "$SERVER_PID_FILE" - fi - kill_port_processes $SERVER_PORT -} - -# Setup database -setup_database() { - log "Setting up test database..." - - # Drop and recreate database - psql -U "$DATABASE_USER" -d postgres -c "DROP DATABASE IF EXISTS $DATABASE_NAME;" 2>/dev/null || true - psql -U "$DATABASE_USER" -d postgres -c "CREATE DATABASE $DATABASE_NAME;" - - # Run migrations - DATABASE_URL="$DATABASE_URL" sqlx migrate run --source replicant-server/migrations -} - -# Cleanup function -cleanup() { - log "Cleaning up..." - stop_server - rm -f "$TEST_STATE_FILE" -} - -# Set trap to cleanup on exit -trap cleanup EXIT INT TERM - -# Main test execution -log "🚀 Starting Offline/Online Sync Integration Test" -info "This test verifies sync recovery after network disconnection" - -# Initial setup -phase "SETUP" -kill_all_servers -setup_database - -# Build the project -log "Building replicant-server..." -DATABASE_URL="$DATABASE_URL" cargo build --bin replicant-server --release - -log "Building test binary..." -cargo build --package replicant-server --test integration_tests --release - -# Phase 1: Initial online sync -phase "1: INITIAL ONLINE SYNC" -start_server - -# Run the test with phase 1 -info "Creating clients and initial documents..." -export RUN_INTEGRATION_TESTS=1 -export SYNC_SERVER_URL="ws://localhost:$SERVER_PORT" -export TEST_DATABASE_URL="$DATABASE_URL" -export OFFLINE_TEST_PHASE="phase1" -export OFFLINE_TEST_STATE_FILE="$TEST_STATE_FILE" - -if ! cargo test --package replicant-server --test integration_tests integration_tests::test_offline_sync_phases::phase1_initial_sync -- --exact --nocapture; then - error "Phase 1 failed" - tail -50 "$SERVER_LOG_FILE" - exit 1 -fi - -log "✓ Phase 1 complete - Initial sync successful" - -# Phase 2: Offline operations -phase "2: OFFLINE OPERATIONS" -stop_server -info "Server is now offline - simulating network outage" - -# Run offline operations -export OFFLINE_TEST_PHASE="phase2" - -if ! cargo test --package replicant-server --test integration_tests integration_tests::test_offline_sync_phases::phase2_offline_changes -- --exact --nocapture; then - error "Phase 2 failed" - exit 1 -fi - -log "✓ Phase 2 complete - Offline changes made" - -# Phase 3: Reconnection and sync recovery -phase "3: RECONNECTION AND SYNC RECOVERY" -start_server -info "Server is back online - testing sync recovery" - -# Run sync recovery test -export OFFLINE_TEST_PHASE="phase3" - -if ! cargo test --package replicant-server --test integration_tests integration_tests::test_offline_sync_phases::phase3_sync_recovery -- --exact --nocapture; then - error "Phase 3 failed" - tail -50 "$SERVER_LOG_FILE" - exit 1 -fi - -log "✓ Phase 3 complete - Sync recovery successful" - -# Final verification -phase "VERIFICATION" -info "Running final verification..." - -export OFFLINE_TEST_PHASE="verify" -if ! cargo test --package replicant-server --test integration_tests integration_tests::test_offline_sync_phases::phase4_verification -- --exact --nocapture; then - error "Verification failed" - tail -50 "$SERVER_LOG_FILE" - exit 1 -fi - -# Success! -echo "" -log "✅ OFFLINE/ONLINE SYNC TEST PASSED!" -info "All clients successfully recovered and synced after network disconnection" -echo "" - -# Show summary from state file if it exists -if [ -f "$TEST_STATE_FILE" ]; then - info "Test Summary:" - jq -r '.summary // empty' "$TEST_STATE_FILE" 2>/dev/null || true -fi \ No newline at end of file diff --git a/test/run_phoenix_interop_local.sh b/test/run_phoenix_interop_local.sh deleted file mode 100755 index 9a99870..0000000 --- a/test/run_phoenix_interop_local.sh +++ /dev/null @@ -1,210 +0,0 @@ -#!/bin/bash -# -# Phoenix interop test runner (Rust client <-> stripped Elixir server). -# -# The merged replicant-server (>= #6) is a LIBRARY: it ships the sync socket -# (`lib/replicant_server/sync/socket.ex`) but no endpoint/router/HTTP server of -# its own — production hosts (entonal-web-app) mount the socket in their own -# endpoint. This harness supplies that host: it boots a minimal endpoint -# (modelled on the server's `test/support/test_endpoint.ex`) that mounts -# `ReplicantServer.Sync.Socket` on :4000, seeds ONE enrolled user + credential -# (plus one legacy nil-user credential for the negative test) by calling -# `ReplicantServer.Auth` directly, and runs the client's phoenix_integration -# suite against it on a throwaway clean database. -# -# Usage: -# test/run_phoenix_interop_local.sh # run the full suite -# test/run_phoenix_interop_local.sh test_name # run a single test filter -# -# Requires: a running PostgreSQL, Elixir/mix, cargo, and curl. - -set -euo pipefail - -# --- Paths ------------------------------------------------------------------- -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CLIENT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -CLIENT_CRATE="$CLIENT_ROOT/replicant-client" - -# --- Server pin -------------------------------------------------------------- -# PINNED to a replicant-server SHA. It must include the claim_enrollment -> -# user_id change (PR #7, merged as 332a8ba) so the seed can export -# REPLICANT_TEST_USER_ID, plus the get_document channel op and the nil-hash -# rejection the sync suite exercises. Re-pin this as the server advances. -SERVER_REF="${REPLICANT_SERVER_REF:-19ffa7a}" - -# Where to prepare the server checkout. Locally we make a detached git worktree -# from a sibling replicant-server clone; in CI (no local clone) we git-clone. -SERVER_SRC="${REPLICANT_SERVER_SRC:-$CLIENT_ROOT/../replicant-server}" -SERVER_CLONE_URL="${REPLICANT_SERVER_CLONE_URL:-https://github.com/replicant-sync/replicant-server.git}" -SERVER_DIR="${REPLICANT_SERVER_DIR:-/tmp/replicant-server-interop}" - -# Hex/Mix caches. Redirected to writable paths so `mix deps.get` can persist its -# registry cache even where $HOME/.hex is not writable (sandboxed shells). -export HEX_HOME="${INTEROP_HEX_HOME:-/tmp/replicant-interop-hex}" -export MIX_HOME="${INTEROP_MIX_HOME:-/tmp/replicant-interop-mix}" -mkdir -p "$HEX_HOME" "$MIX_HOME" - -# --- Configuration ----------------------------------------------------------- -DB_NAME="${INTEROP_DB_NAME:-replicant_server_test}" -DB_USER="${INTEROP_DB_USER:-postgres}" -DB_PASS="${INTEROP_DB_PASS:-postgres}" -DB_HOST="${INTEROP_DB_HOST:-localhost}" -SERVER_PORT="${INTEROP_SERVER_PORT:-4000}" -DATABASE_URL="ecto://$DB_USER:$DB_PASS@$DB_HOST/$DB_NAME" -SERVER_LOG="${INTEROP_SERVER_LOG:-/tmp/replicant_interop_server.log}" -TEST_EMAIL="${INTEROP_TEST_EMAIL:-integration-test@example.com}" -export MIX_ENV=test - -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' -log() { echo -e "${GREEN}[$(date +'%H:%M:%S')] $1${NC}"; } -warn() { echo -e "${YELLOW}[$(date +'%H:%M:%S')] WARN: $1${NC}"; } -err() { echo -e "${RED}[$(date +'%H:%M:%S')] ERROR: $1${NC}"; } - -SERVER_PID="" -BOOT_SCRIPT="" -cleanup() { - if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then - log "Stopping server (PID $SERVER_PID)" - kill "$SERVER_PID" 2>/dev/null || true - sleep 1 - kill -9 "$SERVER_PID" 2>/dev/null || true - fi - local pids - pids="$(lsof -ti :"$SERVER_PORT" 2>/dev/null || true)" - [ -n "$pids" ] && kill -9 $pids 2>/dev/null || true - [ -n "$BOOT_SCRIPT" ] && rm -f "$BOOT_SCRIPT" -} -trap cleanup EXIT INT TERM - -# --- Preflight --------------------------------------------------------------- -command -v mix >/dev/null || { err "mix not found on PATH"; exit 1; } -command -v cargo >/dev/null || { err "cargo not found on PATH"; exit 1; } -command -v curl >/dev/null || { err "curl not found on PATH"; exit 1; } -if ! PGPASSWORD="$DB_PASS" psql -U "$DB_USER" -h "$DB_HOST" -d postgres -c "SELECT 1;" >/dev/null 2>&1; then - err "PostgreSQL not reachable as $DB_USER@$DB_HOST"; exit 1 -fi - -existing="$(lsof -ti :"$SERVER_PORT" 2>/dev/null || true)" -[ -n "$existing" ] && { warn "Killing processes on port $SERVER_PORT: $existing"; kill -9 $existing 2>/dev/null || true; sleep 1; } - -# --- Prepare pinned server checkout ------------------------------------------ -if [ -d "$SERVER_DIR/.git" ] || [ -f "$SERVER_DIR/.git" ]; then - log "Reusing server checkout at $SERVER_DIR (pinning to $SERVER_REF)" - # Discard prior harness-injected mix.exs/mix.lock changes so checkout doesn't abort. - git -C "$SERVER_DIR" checkout --quiet -- mix.exs mix.lock 2>/dev/null || true - git -C "$SERVER_DIR" checkout --quiet --detach "$SERVER_REF" 2>/dev/null || \ - git -C "$SERVER_DIR" checkout --quiet "$SERVER_REF" -elif [ -d "$SERVER_SRC/.git" ]; then - log "Creating detached worktree at $SERVER_DIR from $SERVER_SRC @ $SERVER_REF" - git -C "$SERVER_SRC" worktree prune - git -C "$SERVER_SRC" worktree add --force --detach "$SERVER_DIR" "$SERVER_REF" -else - log "Cloning $SERVER_CLONE_URL into $SERVER_DIR @ $SERVER_REF" - git clone "$SERVER_CLONE_URL" "$SERVER_DIR" - git -C "$SERVER_DIR" checkout "$SERVER_REF" -fi - -# The stripped server ships no HTTP adapter dependency (its own channel tests run -# with `server: false`). Inject Bandit so the endpoint can actually bind a WS -# port — mirroring what a production host app brings. Harness-local only. -if ! grep -q ':bandit' "$SERVER_DIR/mix.exs"; then - log "Injecting Bandit HTTP adapter dependency (harness-only)" - perl -0pi -e 's/(\{:jsonpatch,\s*"[^"]*"\})/$1,\n {:bandit, "~> 1.0"}/' "$SERVER_DIR/mix.exs" - grep -q ':bandit' "$SERVER_DIR/mix.exs" || { err "Failed to inject bandit dep"; exit 1; } -fi - -# The harness serves real, long-lived websocket clients, not ExUnit tests. Under -# the Sandbox pool each channel process owns a connection for its whole lifetime, -# so N clients pin 2N connections (sync:user + sync:public). Use the normal pool. -if grep -q 'Ecto.Adapters.SQL.Sandbox' "$SERVER_DIR/config/test.exs"; then - log "Switching Repo to the standard connection pool (harness-only)" - perl -0pi -e 's/\s*pool: Ecto\.Adapters\.SQL\.Sandbox,//' "$SERVER_DIR/config/test.exs" - perl -0pi -e 's/pool_size: System\.schedulers_online\(\) \* 2/pool_size: 20/' "$SERVER_DIR/config/test.exs" -fi - -# --- Build server ------------------------------------------------------------ -log "Fetching + compiling server deps (MIX_ENV=test)" -( cd "$SERVER_DIR" && mix deps.get >/dev/null && mix compile >/dev/null ) - -# --- Clean database ---------------------------------------------------------- -log "Recreating clean database '$DB_NAME'" -export DATABASE_URL -( cd "$SERVER_DIR" - mix ecto.drop --quiet 2>/dev/null || true - mix ecto.create --quiet - mix ecto.migrate >/dev/null ) - -# --- Seed credentials -------------------------------------------------------- -# One enrolled user+credential (bound user_id) via the enrollment flow, and one -# legacy nil-user credential via create_credential/1 for the negative test. -log "Seeding enrolled + legacy credentials for $TEST_EMAIL (stderr: $SERVER_LOG)" -: > "$SERVER_LOG" -SEED_LINE="$( cd "$SERVER_DIR" && TEST_EMAIL="$TEST_EMAIL" mix run -e ' - email = System.get_env("TEST_EMAIL") - {:ok, token} = ReplicantServer.Auth.request_enrollment(email) - {:ok, creds} = ReplicantServer.Auth.claim_enrollment(email, token) - {:ok, legacy} = ReplicantServer.Auth.create_credential("interop-legacy-shared") - IO.puts("SEED #{creds.api_key} #{creds.secret} #{creds.user_id} #{legacy.api_key} #{legacy.secret}") -' 2>>"$SERVER_LOG" | grep '^SEED ' )" -read -r _ API_KEY API_SECRET TEST_USER_ID LEGACY_API_KEY LEGACY_API_SECRET <<<"$SEED_LINE" -[ -n "$API_KEY" ] && [ -n "$API_SECRET" ] && [ -n "$TEST_USER_ID" ] && \ -[ -n "$LEGACY_API_KEY" ] && [ -n "$LEGACY_API_SECRET" ] || { - err "Failed to seed credentials"; echo "$SEED_LINE"; tail -30 "$SERVER_LOG"; exit 1; } -log "Enrolled user_id=$TEST_USER_ID" - -# --- Start server (minimal endpoint mounting the sync socket) ---------------- -BOOT_SCRIPT="$(mktemp /tmp/replicant_interop_boot.XXXXXX.exs)" -cat > "$BOOT_SCRIPT" <<EOF -Application.put_env(:replicant_server, ReplicantServer.Sync.TestEndpoint, - adapter: Bandit.PhoenixAdapter, - http: [ip: {127, 0, 0, 1}, port: $SERVER_PORT], - server: true, - secret_key_base: "oD6r/Ez+1r8Dh1dGG7dZ8BQS3wcNOYQsXgrATKe1LCimCFRoO346xxuWJBbga1bE", - pubsub_server: ReplicantServer.PubSub -) -{:ok, _} = ReplicantServer.Sync.TestEndpoint.start_link([]) -IO.puts("ENDPOINT_STARTED") -Process.sleep(:infinity) -EOF - -log "Starting minimal endpoint on port $SERVER_PORT (log: $SERVER_LOG)" -( cd "$SERVER_DIR" && exec mix run --no-halt "$BOOT_SCRIPT" ) >> "$SERVER_LOG" 2>&1 & -SERVER_PID=$! - -# Health-check: wait for the socket port to accept HTTP connections. The stripped -# server has no /health route, so any HTTP response (curl exit 0) means "up". -for i in $(seq 1 60); do - if curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$SERVER_PORT/" 2>/dev/null; then - log "Server is up"; break - fi - if ! kill -0 "$SERVER_PID" 2>/dev/null; then err "Server exited early:"; tail -30 "$SERVER_LOG"; exit 1; fi - sleep 1 - [ "$i" -eq 60 ] && { err "Server did not come up within 60s"; tail -30 "$SERVER_LOG"; exit 1; } -done - -# --- Run interop suite ------------------------------------------------------- -# INTEROP_TEST_CMD lets a consumer suite (e.g. entonal-common's -# TonalDBSyncIntegrationTest) run under this harness's boot/seed/teardown in -# place of the cargo suite. It runs with the same seeded-credential env. -log "Running ${INTEROP_TEST_CMD:-phoenix_integration suite} against clean DB" -set +e -( cd "$CLIENT_CRATE" && \ - RUN_INTEGRATION_TESTS=1 \ - REPLICANT_API_KEY="$API_KEY" \ - REPLICANT_API_SECRET="$API_SECRET" \ - REPLICANT_TEST_USER_ID="$TEST_USER_ID" \ - REPLICANT_LEGACY_API_KEY="$LEGACY_API_KEY" \ - REPLICANT_LEGACY_API_SECRET="$LEGACY_API_SECRET" \ - SYNC_SERVER_URL="ws://localhost:$SERVER_PORT/socket/websocket" \ - bash -c "${INTEROP_TEST_CMD:-cargo test --test integration ${1:+\"$1\"} -- --test-threads=1}" ) -test_exit=$? -set -e - -echo "" -if [ $test_exit -eq 0 ]; then - log "Interop suite passed" -else - err "Interop suite failed (server log tail below)" - tail -40 "$SERVER_LOG" -fi -exit $test_exit diff --git a/test/run_test_coverage_local.sh b/test/run_test_coverage_local.sh deleted file mode 100644 index 0afb2ad..0000000 --- a/test/run_test_coverage_local.sh +++ /dev/null @@ -1,186 +0,0 @@ -#!/bin/bash - -set -e # Exit on any error - -# Configuration -DATABASE_NAME="${DATABASE_NAME:-sync_test_db_local}" -DATABASE_USER="${DATABASE_USER:-$USER}" -DATABASE_URL="postgresql://$DATABASE_USER@localhost:5432/$DATABASE_NAME" -SERVER_PORT="${SERVER_PORT:-8080}" -SERVER_PID_FILE="/tmp/sync_server_test.pid" -SERVER_LOG_FILE="/tmp/sync_server_test.log" - -# Test execution timeout (longer for sequential execution with full teardown) -TEST_TIMEOUT="${TEST_TIMEOUT:-600}" # 10 minutes for full suite - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -log() { - echo -e "${GREEN}[$(date +'%H:%M:%S')] $1${NC}" -} - -warn() { - echo -e "${YELLOW}[$(date +'%H:%M:%S')] WARNING: $1${NC}" -} - -error() { - echo -e "${RED}[$(date +'%H:%M:%S')] ERROR: $1${NC}" -} - -info() { - echo -e "${BLUE}[$(date +'%H:%M:%S')] INFO: $1${NC}" -} - -# Kill all processes using a specific port -kill_port_processes() { - local port=$1 - info "Checking for processes on port $port..." - - local pids=$(lsof -ti :$port 2>/dev/null || true) - if [ -n "$pids" ]; then - warn "Found processes on port $port: $pids" - for pid in $pids; do - warn "Killing process $pid..." - kill -9 $pid 2>/dev/null || true - done - sleep 1 - fi -} - -# Kill all replicant-server processes -kill_sync_servers() { - info "Checking for running replicant-server processes..." - - local pids=$(pgrep -f "replicant-server" 2>/dev/null || true) - if [ -n "$pids" ]; then - warn "Found replicant-server processes: $pids" - for pid in $pids; do - warn "Killing replicant-server process $pid..." - kill -9 $pid 2>/dev/null || true - done - sleep 1 - fi -} - -# Database cleanup and setup -setup_database() { - log "Setting up test database..." - - # Check if postgres is running - if ! psql -U "$DATABASE_USER" -d postgres -c "SELECT 1;" >/dev/null 2>&1; then - error "PostgreSQL is not running or not accessible" - error "Make sure PostgreSQL is running and you can connect as user: $DATABASE_USER" - exit 1 - fi - - # Drop existing database (if exists) and create new one - info "Dropping existing test database (if exists)..." - psql -U "$DATABASE_USER" -d postgres -c "DROP DATABASE IF EXISTS $DATABASE_NAME;" 2>/dev/null || true - - info "Creating fresh test database..." - psql -U "$DATABASE_USER" -d postgres -c "CREATE DATABASE $DATABASE_NAME;" - - # Run migrations - log "Running database migrations..." - DATABASE_URL="$DATABASE_URL" sqlx migrate run --source replicant-server/migrations -} - -# Cleanup function -cleanup() { - log "Cleaning up..." - - # Kill server if running - if [ -f "$SERVER_PID_FILE" ]; then - local server_pid=$(cat "$SERVER_PID_FILE") - if kill -0 "$server_pid" 2>/dev/null; then - log "Stopping sync server (PID: $server_pid)" - kill "$server_pid" 2>/dev/null || true - sleep 1 - # Force kill if still running - if kill -0 "$server_pid" 2>/dev/null; then - warn "Force killing server" - kill -9 "$server_pid" 2>/dev/null || true - fi - fi - rm -f "$SERVER_PID_FILE" - fi - - # Kill any remaining replicant-server processes - kill_sync_servers -} - -# Set trap to cleanup on exit -trap cleanup EXIT INT TERM - -log "🚀 Starting robust integration test run with full isolation..." -info "Each test will get a fresh database and server instance for complete isolation" -info "Tests run sequentially to prevent database/server conflicts" - -# Step 1: Kill any existing processes -kill_port_processes $SERVER_PORT -kill_sync_servers - -# Step 2: Setup initial database (each test will recreate as needed) -setup_database - - -# Step 3: Install cargo-llvm-cov if it's missing -if cargo llvm-cov --version &> /dev/null; then - echo "✅ cargo-llvm-cov is already installed." -else - echo "❌ cargo-llvm-cov not found. Installing now..." - - # Check if cargo is available before attempting to install - if ! command -v cargo &> /dev/null; then - echo "🚨 Error: 'cargo' (Rust package manager) not found. Please install Rust and Cargo first." - exit 1 - fi - - # Install the tool using cargo install - if cargo +stable install cargo-llvm-cov --locked; then - echo "🎉 Successfully installed cargo-llvm-cov." - else - echo "🚨 Error: Installation of cargo-llvm-cov failed." - exit 1 - fi -fi - -# Step 4: Verify installation -if cargo llvm-cov --version &> /dev/null; then - echo "Verification successful." -else - echo "Verification failed, even after attempted installation." - exit 1 -fi -# Step 5: Run the coverage tests (each test runs sequentially) -log "Running integration tests..." - -# Export environment variables for tests -export RUN_INTEGRATION_TESTS=1 -export SYNC_SERVER_URL="ws://localhost:$SERVER_PORT" -export TEST_DATABASE_URL="$DATABASE_URL" -export RUST_TEST_THREADS=1 - -# Run all tests sequentially for complete isolation -log "Running all integration tests sequentially (required for full teardown isolation)" -cargo llvm-cov --workspace --bin replicant-server --test "*" --html --open -- --test-threads 1 - -test_exit_code=$? - -# Step 6: Report results -echo "" -if [ $test_exit_code -eq 0 ]; then - log "✅ All integration tests passed!" -else - error "❌ Some integration tests failed" - warn "Server logs (last 100 lines):" - tail -100 "$SERVER_LOG_FILE" -fi - -log "Code coverage test run complete" -exit $test_exit_code \ No newline at end of file diff --git a/wrappers/juce/replicant/replicant.cpp b/wrappers/juce/replicant/replicant.cpp deleted file mode 100644 index 7603358..0000000 --- a/wrappers/juce/replicant/replicant.cpp +++ /dev/null @@ -1,162 +0,0 @@ -/******************************************************************************* - - replicant - Implementation - -*******************************************************************************/ - -#include "replicant.h" - -namespace replicant -{ - -//============================================================================== -Replicant::Replicant(const std::string& databaseUrl, - const std::string& serverUrl, - const std::string& email, - const std::string& apiKey, - const std::string& apiSecret) - : client(databaseUrl, serverUrl, email, apiKey, apiSecret) -{ - client.register_document_callback(documentCallback, this); - client.register_connection_callback(connectionCallback, this); - client.register_error_callback(errorCallback, this); - startTimerHz(10); -} - -Replicant::~Replicant() -{ - stopTimer(); - - // Clear callbacks to prevent any queued events from calling into dead lambdas - onDocumentCreated = nullptr; - onDocumentUpdated = nullptr; - onDocumentDeleted = nullptr; - onConnectionChanged = nullptr; - onSyncError = nullptr; -} - -//============================================================================== -void Replicant::timerCallback() -{ - client.process_events(); -} - -//============================================================================== -void Replicant::documentCallback(EventType eventType, const char* docId, - const char* title, const char* content, - const char* /*userId*/, const char* /*authorName*/, - const char* /*visibility*/, EventOrigin origin, - void* context) -{ - auto* self = static_cast<Replicant*>(context); - const std::string documentId = docId ? docId : ""; - const std::string documentTitle = title ? title : ""; - const std::string documentContent = content ? content : ""; - - switch (eventType) - { - case DocumentCreated: - if (self->onDocumentCreated) - self->onDocumentCreated(documentId, documentTitle, documentContent, origin); - break; - - case DocumentUpdated: - if (self->onDocumentUpdated) - self->onDocumentUpdated(documentId, documentTitle, documentContent, origin); - break; - - case DocumentDeleted: - if (self->onDocumentDeleted) - self->onDocumentDeleted(documentId, origin); - break; - - default: - break; - } -} - -void Replicant::connectionCallback(EventType eventType, bool /*isConnected*/, - uint32_t /*attemptNumber*/, void* context) -{ - auto* self = static_cast<Replicant*>(context); - if (self->onConnectionChanged) - self->onConnectionChanged(eventType == ConnectionSucceeded); -} - -void Replicant::errorCallback(EventType /*eventType*/, int32_t errorCode, - const char* errorMessage, void* context) -{ - auto* self = static_cast<Replicant*>(context); - if (self->onSyncError && errorMessage) - self->onSyncError(errorCode, errorMessage); -} - -//============================================================================== -std::string Replicant::createDocument(const std::string& contentJson) -{ - return client.create_document(contentJson); -} - -void Replicant::createDocumentWithId(const std::string& documentId, const std::string& contentJson) -{ - client.create_document_with_id(documentId, contentJson); -} - -void Replicant::updateDocument(const std::string& documentId, const std::string& contentJson) -{ - client.update_document(documentId, contentJson); -} - -void Replicant::deleteDocument(const std::string& documentId) -{ - client.delete_document(documentId); -} - -std::string Replicant::getDocument(const std::string& documentId) -{ - return client.get_document(documentId); -} - -std::string Replicant::getAllDocuments() -{ - return client.get_all_documents(); -} - -std::string Replicant::getAllDocumentIds(bool includeDeleted) -{ - return client.get_all_document_ids(includeDeleted); -} - -//============================================================================== -bool Replicant::isConnected() -{ - return client.is_connected(); -} - -uint64_t Replicant::countDocuments() -{ - return client.count_documents(); -} - -uint64_t Replicant::countPendingSync() -{ - return client.count_pending_sync(); -} - -//============================================================================== -void Replicant::configureSearch(const std::string& pathsJson) -{ - client.configure_search(pathsJson); -} - -std::string Replicant::searchDocuments(const std::string& query, uint32_t limit) -{ - return client.search_documents(query, limit); -} - -void Replicant::rebuildSearchIndex() -{ - client.rebuild_search_index(); -} - -} // namespace replicant diff --git a/wrappers/juce/replicant/replicant.h b/wrappers/juce/replicant/replicant.h deleted file mode 100644 index e880d67..0000000 --- a/wrappers/juce/replicant/replicant.h +++ /dev/null @@ -1,217 +0,0 @@ -/******************************************************************************* - - replicant - Offline-first document sync for JUCE applications - -*******************************************************************************/ - -/******************************************************************************* - - BEGIN_JUCE_MODULE_DECLARATION - - ID: replicant - vendor: adamski - version: 0.1.0 - name: Replicant Sync Client - description: Offline-first document sync library for JUCE applications - website: https://github.com/anthropics/json-db-sync - license: MIT - minimumCppStandard: 14 - - dependencies: juce_core juce_events - OSXLibs: replicant_client - OSXFrameworks: CoreFoundation Security - linuxLibs: replicant_client pthread dl m - windowsLibs: replicant_client ws2_32 userenv bcrypt - - END_JUCE_MODULE_DECLARATION - -*******************************************************************************/ - -#pragma once -#define REPLICANT_H_INCLUDED - -#include <juce_core/juce_core.h> -#include <juce_events/juce_events.h> - -// Path is adjusted by build script when copying to dist/juce/replicant/ -#include "../../../dist/include/replicant.hpp" - -#include <functional> -#include <string> - -namespace replicant -{ - -//============================================================================== -/** - A sync client that provides offline-first document synchronization. - - This class wraps the replicant library and integrates with JUCE's - event system via Timer. Events are processed on the message thread - at 10Hz, making it safe to update UI directly from callbacks. - - Example usage: - @code - replicant::Replicant sync( - "sqlite:data.db?mode=rwc", "wss://server/ws", - "user@example.com", "api_key", "api_secret"); - - sync.onDocumentCreated = [this](const std::string& id, - const std::string& title, - const std::string& content) { - addToList(id, title); - refreshUI(); - }; - @endcode -*/ -class Replicant : private juce::Timer -{ -public: - //============================================================================== - /** - Creates a new sync client and starts processing events. - - @param databaseUrl SQLite database URL (e.g., "sqlite:data.db?mode=rwc") - @param serverUrl WebSocket server URL (e.g., "wss://server.com/ws") - @param email User's email address - @param apiKey Application API key (rpa_ prefix) - @param apiSecret Application API secret (rps_ prefix) - */ - Replicant(const std::string& databaseUrl, - const std::string& serverUrl, - const std::string& email, - const std::string& apiKey, - const std::string& apiSecret); - - ~Replicant() override; - - // Non-copyable, non-movable (prevent accidentally invalidating callbacks) - Replicant(const Replicant&) = delete; - Replicant& operator=(const Replicant&) = delete; - Replicant(Replicant&&) = delete; - Replicant& operator=(Replicant&&) = delete; - - //============================================================================== - /** Creates a new document. Returns the document UUID. - @throws replicant::SyncException on failure */ - std::string createDocument(const std::string& contentJson); - - /** Creates a new document with a specified UUID. - @param documentId UUID string to use as the document ID - @param contentJson Document content as JSON string - @throws replicant::SyncException on failure or if documentId is invalid */ - void createDocumentWithId(const std::string& documentId, const std::string& contentJson); - - /** Updates an existing document. - @throws replicant::SyncException on failure */ - void updateDocument(const std::string& documentId, const std::string& contentJson); - - /** Deletes a document. - @throws replicant::SyncException on failure */ - void deleteDocument(const std::string& documentId); - - /** Gets a document by ID. Returns the full document JSON. - @throws replicant::SyncException if not found or on failure */ - std::string getDocument(const std::string& documentId); - - /** Gets all documents as a JSON array. - @throws replicant::SyncException on failure */ - std::string getAllDocuments(); - - /** Gets all document ids as a JSON array. - @param includeDeleted If true, include tombstoned (deleted) documents. - @throws replicant::SyncException on failure */ - std::string getAllDocumentIds(bool includeDeleted); - - //============================================================================== - /** Returns true if connected to the sync server. */ - bool isConnected(); - - /** Returns the number of documents in the local database. */ - uint64_t countDocuments(); - - /** Returns the number of documents pending sync. */ - uint64_t countPendingSync(); - - //============================================================================== - /** Configure which JSON paths to index for full-text search. - @param pathsJson JSON array of JSON paths (e.g., R"(["$.body", "$.notes"])") - @note Replaces existing configuration and rebuilds the search index. - @throws replicant::SyncException on failure */ - void configureSearch(const std::string& pathsJson); - - /** Search documents using FTS5 full-text search. - @param query FTS5 query string (e.g., "music", "tun*", "\"exact phrase\"") - @param limit Maximum number of results (default 100) - @return JSON array of matching documents - @throws replicant::SyncException on failure - - FTS5 Query Syntax: - - Simple terms: "music" matches documents containing "music" - - Prefix: "tun*" matches "tuning", "tune", etc. - - Phrase: "\"equal temperament\"" matches exact phrase - - Boolean: "music AND theory", "piano OR keyboard" - - Column filter: "title:beethoven" searches only title field */ - std::string searchDocuments(const std::string& query, uint32_t limit = 100); - - /** Rebuild the full-text search index. - @note Called automatically by configureSearch(), but can be called - manually if needed (e.g., after bulk document imports). - @throws replicant::SyncException on failure */ - void rebuildSearchIndex(); - - //============================================================================== - /** Called when a document is created, by this client or from sync. - Parameters: id, title (extracted from content), full content JSON, - origin (Local if this client created it, Remote if it came from the - server). Check origin before treating it as a change made elsewhere. */ - std::function<void(const std::string& id, - const std::string& title, - const std::string& content, - EventOrigin origin)> onDocumentCreated; - - /** Called when a document is updated, by this client or from sync. - Parameters: id, title (extracted from content), full content JSON, - origin (Local if this client wrote it, Remote if the change was applied - from the server). Check origin before treating it as a change made - elsewhere. */ - std::function<void(const std::string& id, - const std::string& title, - const std::string& content, - EventOrigin origin)> onDocumentUpdated; - - /** Called when a document is deleted, by this client or from sync. - Check origin before treating it as a change made elsewhere. */ - std::function<void(const std::string& id, EventOrigin origin)> onDocumentDeleted; - - /** Called when the connection state changes. */ - std::function<void(bool connected)> onConnectionChanged; - - /** Called when a sync error occurs. - - @param errorCode Stable ReplicantErrorCode value. Use - replicant_error_is_credential_rejection() to decide - whether to clear the stored credential and re-enroll. - @param message Human-readable error message. - */ - std::function<void(int32_t errorCode, const std::string& message)> onSyncError; - -private: - void timerCallback() override; - - static void documentCallback(EventType eventType, const char* docId, - const char* title, const char* content, - const char* userId, const char* authorName, - const char* visibility, EventOrigin origin, - void* context); - static void connectionCallback(EventType eventType, bool isConnected, - uint32_t attemptNumber, void* context); - static void errorCallback(EventType eventType, int32_t errorCode, - const char* errorMessage, void* context); - - replicant::Client client; - - JUCE_LEAK_DETECTOR(Replicant) -}; - -} // namespace replicant