diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4cd335f..b456522 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,8 +11,6 @@ permissions: jobs: build: runs-on: ubuntu-latest - env: - SQLX_OFFLINE: "true" steps: - name: Checkout uses: actions/checkout@v6 @@ -39,8 +37,6 @@ jobs: lint: runs-on: ubuntu-latest - env: - SQLX_OFFLINE: "true" steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 0703ce1..5bca3b4 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -11,8 +11,6 @@ permissions: jobs: check: runs-on: ubuntu-latest - env: - SQLX_OFFLINE: "true" steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.sqlx/query-390b0befdb942f71c0559d1f9d0f9fc863edb4506c048fc0c691d327d45e94d6.json b/.sqlx/query-390b0befdb942f71c0559d1f9d0f9fc863edb4506c048fc0c691d327d45e94d6.json deleted file mode 100644 index b9c3e07..0000000 --- a/.sqlx/query-390b0befdb942f71c0559d1f9d0f9fc863edb4506c048fc0c691d327d45e94d6.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT\n id as \"id!\",\n short_id,\n user_id as \"user_id!\",\n type as \"type!\",\n status as \"status!\",\n title,\n content,\n summary,\n is_flagged,\n project_id,\n metadata,\n source,\n created_at,\n updated_at,\n deleted_at\n FROM notes\n WHERE user_id = ?\n AND (deleted_at IS NOT NULL) = ?\n AND (? IS NULL OR type = ?)\n AND (? IS NULL OR project_id = ?)\n ORDER BY created_at DESC\n LIMIT ?\n ", - "describe": { - "columns": [ - { - "name": "id!", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "short_id", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "user_id!", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "type!", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "status!", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "title", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "content", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "summary", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "is_flagged", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "project_id", - "ordinal": 9, - "type_info": "Text" - }, - { - "name": "metadata", - "ordinal": 10, - "type_info": "Text" - }, - { - "name": "source", - "ordinal": 11, - "type_info": "Text" - }, - { - "name": "created_at", - "ordinal": 12, - "type_info": "Text" - }, - { - "name": "updated_at", - "ordinal": 13, - "type_info": "Text" - }, - { - "name": "deleted_at", - "ordinal": 14, - "type_info": "Text" - } - ], - "parameters": { - "Right": 7 - }, - "nullable": [ - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true - ] - }, - "hash": "390b0befdb942f71c0559d1f9d0f9fc863edb4506c048fc0c691d327d45e94d6" -} diff --git a/.sqlx/query-44ea3c047064b2ade99173cba8963293fe0445d243aacdc162c552ad7beddbcc.json b/.sqlx/query-44ea3c047064b2ade99173cba8963293fe0445d243aacdc162c552ad7beddbcc.json deleted file mode 100644 index 3d92c59..0000000 --- a/.sqlx/query-44ea3c047064b2ade99173cba8963293fe0445d243aacdc162c552ad7beddbcc.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT\n id as \"id!\",\n short_id,\n user_id as \"user_id!\",\n type as \"type!\",\n status as \"status!\",\n title,\n content,\n summary,\n is_flagged,\n project_id,\n metadata,\n source,\n created_at,\n updated_at,\n deleted_at\n FROM notes\n WHERE user_id = ?\n AND (deleted_at IS NOT NULL) = ?\n AND (? IS NULL OR type = ?)\n AND (? IS NULL OR project_id = ?)\n AND EXISTS (\n SELECT 1 FROM json_each(?) AS kw\n WHERE title LIKE '%' || kw.value || '%'\n OR content LIKE '%' || kw.value || '%'\n OR summary LIKE '%' || kw.value || '%'\n )\n ORDER BY updated_at DESC\n LIMIT ?\n ", - "describe": { - "columns": [ - { - "name": "id!", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "short_id", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "user_id!", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "type!", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "status!", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "title", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "content", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "summary", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "is_flagged", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "project_id", - "ordinal": 9, - "type_info": "Text" - }, - { - "name": "metadata", - "ordinal": 10, - "type_info": "Text" - }, - { - "name": "source", - "ordinal": 11, - "type_info": "Text" - }, - { - "name": "created_at", - "ordinal": 12, - "type_info": "Text" - }, - { - "name": "updated_at", - "ordinal": 13, - "type_info": "Text" - }, - { - "name": "deleted_at", - "ordinal": 14, - "type_info": "Text" - } - ], - "parameters": { - "Right": 8 - }, - "nullable": [ - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true - ] - }, - "hash": "44ea3c047064b2ade99173cba8963293fe0445d243aacdc162c552ad7beddbcc" -} diff --git a/.sqlx/query-4dbe422b3ddd4d38678dcf3e11866e9142edb76a8f61f866e36d8d18787f5115.json b/.sqlx/query-4dbe422b3ddd4d38678dcf3e11866e9142edb76a8f61f866e36d8d18787f5115.json deleted file mode 100644 index cf23926..0000000 --- a/.sqlx/query-4dbe422b3ddd4d38678dcf3e11866e9142edb76a8f61f866e36d8d18787f5115.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n UPDATE projects SET\n color = CASE WHEN ? THEN ? ELSE color END\n WHERE user_id = ? AND id = ?\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "4dbe422b3ddd4d38678dcf3e11866e9142edb76a8f61f866e36d8d18787f5115" -} diff --git a/.sqlx/query-7443153016b2e0567652fdb1cc537e681c1dbeed63c419a20aa7f83c4ac33137.json b/.sqlx/query-7443153016b2e0567652fdb1cc537e681c1dbeed63c419a20aa7f83c4ac33137.json deleted file mode 100644 index c9855a3..0000000 --- a/.sqlx/query-7443153016b2e0567652fdb1cc537e681c1dbeed63c419a20aa7f83c4ac33137.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT COUNT(*) as \"count!: i64\"\n FROM notes\n WHERE user_id = ?\n AND (deleted_at IS NOT NULL) = ?\n AND (? IS NULL OR type = ?)\n AND (? IS NULL OR project_id = ?)\n ", - "describe": { - "columns": [ - { - "name": "count!: i64", - "ordinal": 0, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 6 - }, - "nullable": [ - false - ] - }, - "hash": "7443153016b2e0567652fdb1cc537e681c1dbeed63c419a20aa7f83c4ac33137" -} diff --git a/AGENTS.md b/AGENTS.md index 05e7a41..d84140f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,22 +34,12 @@ cargo fmt --all --check # format check Or use the justfile: `just build`, `just test`, `just check`, `just install` -### SQLx metadata - -After changing any `sqlx::query!`, `query_as!`, or `query_scalar!` macro, run -`just sqlx-prepare` and commit the `.sqlx` changes. Do not hand-edit `.sqlx` -files. - -`just sqlx-prepare` validates SQLite macros against the local fixture schema. -Keep `scripts/sqlx-sqlite-schema.sql` in sync with SQLite macro-selected -columns. - ## Git Hooks (lefthook) This repo uses lefthook for git hooks. Install once with `lefthook install` (or `just setup`). - **pre-commit** runs `cargo fmt --all --check` — validates formatting (does NOT auto-fix). If it fails, run `cargo fmt --all` then re-commit. -- **pre-push** runs the workspace/all-target/all-feature SQLx offline check, clippy with warnings denied, and cargo deny. Requires `cargo install cargo-deny`. +- **pre-push** runs the workspace/all-target/all-feature check, clippy with warnings denied, and cargo deny. Requires `cargo install cargo-deny`. Manual usage: @@ -60,13 +50,12 @@ lefthook run pre-push # run pre-push hooks ## Key Dependencies -- **powersync** — local path dependency (SQLite sync engine) +- **powersync** — Guion fork of the SQLite sync engine - **rusqlite** — SQLite with bundled + load_extension - **clap** — CLI framework (derive macros) - **tokio** — async runtime - **reqwest** — HTTP client (auth + PostgREST backend) - **serde/serde_json** — serialization -- **sqlx** — typed local SQLite access pending migration to the shared PowerSync pool ## Project Conventions diff --git a/Cargo.lock b/Cargo.lock index 9f3bffe..acaf21f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -23,12 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -137,15 +119,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -197,12 +170,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bindgen" version = "0.72.1" @@ -228,9 +195,6 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -dependencies = [ - "serde_core", -] [[package]] name = "block-buffer" @@ -247,12 +211,6 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.1" @@ -397,12 +355,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const_format" version = "0.2.35" @@ -423,6 +375,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -448,30 +410,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -557,17 +495,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - [[package]] name = "deranged" version = "0.5.8" @@ -585,9 +512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid", "crypto-common", - "subtle", ] [[package]] @@ -622,12 +547,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - [[package]] name = "dunce" version = "1.0.5" @@ -645,9 +564,6 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -dependencies = [ - "serde", -] [[package]] name = "encoding_rs" @@ -697,17 +613,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "etcetera" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.48.0", -] - [[package]] name = "event-listener" version = "5.4.1" @@ -758,7 +663,7 @@ name = "flicknote-auth" version = "0.4.3" dependencies = [ "base64", - "rand 0.9.4", + "rand", "reqwest 0.13.2", "serde", "serde_json", @@ -786,12 +691,13 @@ dependencies = [ "libc", "log", "open", + "powersync", "reqwest 0.12.28", "rmcp", + "rusqlite", "schemars 1.2.1", "serde", "serde_json", - "sqlx", "tempfile", "tokio", "url", @@ -809,11 +715,11 @@ dependencies = [ "log", "powersync", "pulldown-cmark", + "rusqlite", "schemars 1.2.1", "serde", "serde_json", "sha2", - "sqlx", "tempfile", "thiserror 2.0.18", "tokio", @@ -842,17 +748,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - [[package]] name = "fnv" version = "1.0.7" @@ -865,6 +760,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -922,17 +823,6 @@ dependencies = [ "futures-util", ] -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - [[package]] name = "futures-io" version = "0.3.32" @@ -1082,24 +972,13 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] - [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1107,23 +986,17 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown 0.14.5", + "foldhash 0.2.0", ] [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -1138,33 +1011,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "http" version = "1.4.0" @@ -1267,9 +1113,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1577,15 +1425,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - [[package]] name = "leb128fmt" version = "0.1.0" @@ -1608,29 +1447,20 @@ dependencies = [ "windows-link", ] -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libredox" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags", "libc", - "plain", - "redox_syscall 0.7.5", ] [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -1655,15 +1485,6 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.29" @@ -1676,16 +1497,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - [[package]] name = "memchr" version = "2.8.0" @@ -1735,22 +1546,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", -] - [[package]] name = "num-conv" version = "0.2.0" @@ -1779,26 +1574,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1806,7 +1581,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -1850,29 +1624,6 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - [[package]] name = "pastey" version = "0.2.3" @@ -1885,15 +1636,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1912,39 +1654,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" 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 = "portable-atomic" version = "1.13.1" @@ -1977,8 +1692,8 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "powersync" -version = "0.0.5" -source = "git+https://github.com/GuionAI/powersync-native?tag=v0.0.5-guion.1#41dcdfb12f03ca0ce5caa61660f6b1d923568eaf" +version = "0.0.7" +source = "git+https://github.com/GuionAI/powersync-native?rev=133f7eabacfbd49b3240f8313807105eab0ef315#133f7eabacfbd49b3240f8313807105eab0ef315" dependencies = [ "async-channel", "async-lock", @@ -1989,8 +1704,10 @@ dependencies = [ "event-listener", "futures-lite", "log", + "num-traits", "pin-project-lite", "powersync_core", + "powersync_sqlite_nostd", "reqwest 0.13.2", "rusqlite", "scopeguard", @@ -2004,9 +1721,9 @@ dependencies = [ [[package]] name = "powersync_core" -version = "0.4.12" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77f9c7f0117bda7f68ca872528e0c2c78de94d34f81a6e167e2046e8578bd080" +checksum = "32ab437bc28b8007e789d4d4b39861a7dbd1eca102d4c453f3ba12d320b0de7a" dependencies = [ "bytes", "const_format", @@ -2024,9 +1741,9 @@ dependencies = [ [[package]] name = "powersync_sqlite_nostd" -version = "0.4.12" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a5a95198e2ab901965138fced0315621073ef557a04b5552a51774658abf13" +checksum = "f4e318c28daeba1a83f93eb42917972b243a5def587eb27a7c7e5da938df3b67" dependencies = [ "bindgen", "num-derive 0.4.2", @@ -2102,7 +1819,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.4", + "rand", "ring", "rustc-hash", "rustls", @@ -2149,35 +1866,14 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -2187,16 +1883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "rand_core", ] [[package]] @@ -2208,24 +1895,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_syscall" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" -dependencies = [ - "bitflags", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -2424,37 +2093,28 @@ dependencies = [ ] [[package]] -name = "rsa" -version = "0.9.10" +name = "rsqlite-vfs" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", + "hashbrown 0.16.1", + "thiserror 2.0.18", ] [[package]] name = "rusqlite" -version = "0.32.1" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ "bitflags", "fallible-iterator", "fallible-streaming-iterator", - "hashlink 0.9.1", + "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -2519,7 +2179,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -2633,7 +2293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2752,17 +2412,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sha2" version = "0.10.9" @@ -2790,16 +2439,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[package]] name = "slab" version = "0.4.12" @@ -2811,9 +2450,6 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] [[package]] name = "socket2" @@ -2826,218 +2462,15 @@ dependencies = [ ] [[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink 0.10.0", - "indexmap 2.13.0", - "log", - "memchr", - "once_cell", - "percent-encoding", - "serde", - "serde_json", - "sha2", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.117", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn 2.0.117", - "tokio", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "bytes", - "chrono", - "crc", - "digest", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "percent-encoding", - "rand 0.8.6", - "rsa", - "serde", - "sha1", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.18", - "tracing", - "uuid", - "whoami", -] - -[[package]] -name = "sqlx-postgres" -version = "0.8.6" +name = "sqlite-wasm-rs" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand 0.8.6", - "serde", - "serde_json", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.18", - "tracing", - "uuid", - "whoami", -] - -[[package]] -name = "sqlx-sqlite" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" -dependencies = [ - "atoi", - "chrono", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror 2.0.18", - "tracing", - "url", - "uuid", + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", ] [[package]] @@ -3046,17 +2479,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - [[package]] name = "strsim" version = "0.11.1" @@ -3111,6 +2533,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3257,17 +2700,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -3332,7 +2764,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3376,33 +2807,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-xid" version = "0.2.6" @@ -3511,12 +2921,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -3674,16 +3078,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] - [[package]] name = "winapi-util" version = "0.1.11" @@ -3734,6 +3128,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -3761,15 +3166,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3812,21 +3208,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -3866,12 +3247,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3890,12 +3265,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3914,12 +3283,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3950,12 +3313,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3974,12 +3331,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3998,12 +3349,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4022,12 +3367,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index eb9abd0..d18a1ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,9 +21,9 @@ pre-release-commit-message = "chore: release {{version}}" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt", "macros", "time"] } -powersync = { git = "https://github.com/GuionAI/powersync-native", tag = "v0.0.5-guion.1", features = ["tokio", "reqwest"] } +powersync = { git = "https://github.com/GuionAI/powersync-native", rev = "133f7eabacfbd49b3240f8313807105eab0ef315", features = ["tokio", "reqwest"] } base64 = "0.22" -rusqlite = { version = "0.32", features = ["load_extension", "bundled"] } +rusqlite = { version = "0.39", features = ["load_extension", "bundled"] } async-trait = "0.1" thiserror = "2" uuid = { version = "1", features = ["v4"] } diff --git a/README.md b/README.md index 77d94da..cfb2915 100644 --- a/README.md +++ b/README.md @@ -28,21 +28,10 @@ just test # Lint + format check just check -# Refresh sqlx offline metadata after SQL macro changes -just sqlx-prepare - # Install to ~/.cargo/bin just install ``` -CI sets `SQLX_OFFLINE=true`. After adding or changing `sqlx::query!`, -`query_as!`, or `query_scalar!` macros, run `just sqlx-prepare` and commit -the generated `.sqlx` metadata. The prepare script checks SQLite against a -local fixture database. - -Runtime-built `sqlx::query` calls are checked at build time for Rust types, but -sqlx does not emit offline metadata for them. - Or directly with cargo: ```bash diff --git a/flake.nix b/flake.nix index 04aa691..21aec4c 100644 --- a/flake.nix +++ b/flake.nix @@ -18,11 +18,12 @@ nativeBuildInputs = with pkgs; [ rust pkg-config - sqlx-cli + llvmPackages.libclang sqlite ]; buildInputs = with pkgs; [ + cacert openssl sqlite ] ++ lib.optionals stdenv.isDarwin [ @@ -38,15 +39,14 @@ cargoLock = { lockFile = ./Cargo.lock; outputHashes = { - "powersync-0.0.5" = "sha256-vtzXdD54gQUn4TpG/XSUoGVF3H7zSApl85tjXvBEBlk="; + "powersync-0.0.7" = "sha256-Ndeu/7054EsfgVHQwu0W7CjbjT1q/e15qX2V3A0XihA="; }; }; nativeBuildInputs = nativeBuildInputs; buildInputs = buildInputs; - - # sqlx offline mode — use checked-in query cache - SQLX_OFFLINE = "true"; + LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; meta = with pkgs.lib; { description = "Local-first note management CLI with cloud sync"; @@ -65,8 +65,8 @@ devShells.default = pkgs.mkShell { inherit nativeBuildInputs buildInputs; - - SQLX_OFFLINE = "true"; + LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; shellHook = '' echo "flicknote-cli dev shell (rust $(rustc --version | cut -d' ' -f2))" diff --git a/flicknote-cli/Cargo.toml b/flicknote-cli/Cargo.toml index b05b20c..d91e5fb 100644 --- a/flicknote-cli/Cargo.toml +++ b/flicknote-cli/Cargo.toml @@ -42,7 +42,8 @@ httpdate = "1.0.3" [dev-dependencies] async-trait = { workspace = true } -sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "sqlite"] } +powersync = { workspace = true } +rusqlite = { workspace = true } uuid = { workspace = true } [lints] diff --git a/flicknote-cli/src/main_tests/mcp.rs b/flicknote-cli/src/main_tests/mcp.rs index 89c5b09..bcaffa2 100644 --- a/flicknote-cli/src/main_tests/mcp.rs +++ b/flicknote-cli/src/main_tests/mcp.rs @@ -2,16 +2,18 @@ use std::collections::BTreeSet; use std::sync::Arc; use async_trait::async_trait; -use flicknote_core::backend::{InsertNoteReq, NoteDb, SqliteBackend}; +use flicknote_core::backend::{InsertNoteReq, LocalPowerSyncBackend, NoteDb}; use flicknote_core::config::{Config, ConfigPaths}; -use flicknote_core::db::Database; +use flicknote_core::schema::app_schema; use flicknote_core::services::error::ServiceError; use flicknote_core::services::ports::{ CreateNote, CreatedNote, NoteCreator, ShareGateway, ShareResource, }; use flicknote_sync::app::Application; use flicknote_sync::ipc::{ServerInfo, serve_app, socket_path}; +use powersync::{ConnectionPool, PowerSyncDatabase, env::PowerSyncEnvironment}; use rmcp::ServiceExt; +use rusqlite::params; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, ReadHalf, WriteHalf}; use crate::mcp; @@ -146,11 +148,32 @@ fn test_config(directory: &std::path::Path) -> Config { } } -async fn seeded_backend(config: &Config) -> (Arc, String, String) { - let backend = Arc::new(SqliteBackend { - db: Database::open_local(config).await.unwrap(), - user_id: "test-user".to_string(), - }); +fn test_database(config: &Config) -> PowerSyncDatabase { + struct NoHttp; + + #[async_trait] + impl powersync::http::HttpClient for NoHttp { + async fn send( + &self, + _request: powersync::http::Request, + ) -> Result { + panic!("MCP tests must not make PowerSync HTTP requests") + } + } + + PowerSyncEnvironment::powersync_auto_extension().unwrap(); + let pool = ConnectionPool::open(&config.paths.db_file).unwrap(); + let environment = + PowerSyncEnvironment::custom(NoHttp, pool, PowerSyncEnvironment::tokio_timer()); + PowerSyncDatabase::new(environment, app_schema()) +} + +async fn seeded_backend(config: &Config) -> (Arc, String, String) { + let db = test_database(config); + let backend = Arc::new(LocalPowerSyncBackend::new( + db.clone(), + "test-user".to_string(), + )); let project_id = backend.create_project("MCP Project").await.unwrap(); let note_uuid = uuid::Uuid::new_v4().to_string(); backend @@ -166,12 +189,14 @@ async fn seeded_backend(config: &Config) -> (Arc, String, String) }) .await .unwrap(); - sqlx::query("UPDATE notes SET short_id = 42, source = ? WHERE id = ?") - .bind(r#"{"link":{"content":"one\ntwo\nthree"}}"#) - .bind(¬e_uuid) - .execute(&backend.db.pool) - .await + let writer = db.writer().await.unwrap(); + writer + .execute( + "UPDATE notes SET short_id = 42, source = ? WHERE id = ?", + params![r#"{"link":{"content":"one\ntwo\nthree"}}"#, note_uuid], + ) .unwrap(); + drop(writer); let no_source_id = uuid::Uuid::new_v4().to_string(); backend .insert_note(&InsertNoteReq { @@ -186,11 +211,14 @@ async fn seeded_backend(config: &Config) -> (Arc, String, String) }) .await .unwrap(); - sqlx::query("UPDATE notes SET short_id = 43 WHERE id = ?") - .bind(&no_source_id) - .execute(&backend.db.pool) - .await + let writer = db.writer().await.unwrap(); + writer + .execute( + "UPDATE notes SET short_id = 43 WHERE id = ?", + params![no_source_id], + ) .unwrap(); + drop(writer); let alpha_id = flicknote_core::services::markdown::parse_markdown( "## Alpha\n\nOld text.\n\n## Beta\n\nKeep me.", ) diff --git a/flicknote-cli/tests/mcp_stdio.rs b/flicknote-cli/tests/mcp_stdio.rs index e127cfa..706190c 100644 --- a/flicknote-cli/tests/mcp_stdio.rs +++ b/flicknote-cli/tests/mcp_stdio.rs @@ -5,9 +5,9 @@ use std::thread; use std::time::{Duration, Instant}; use async_trait::async_trait; -use flicknote_core::backend::{InsertNoteReq, NoteDb, SqliteBackend}; +use flicknote_core::backend::{InsertNoteReq, LocalPowerSyncBackend, NoteDb}; use flicknote_core::config::{Config, ConfigPaths}; -use flicknote_core::db::Database; +use flicknote_core::schema::app_schema; use flicknote_core::services::error::ServiceError; use flicknote_core::services::ports::{ CreateNote, CreatedNote, NoteCreator, ShareGateway, ShareResource, @@ -17,6 +17,7 @@ use flicknote_sync::ipc::{ AppRequest, AppResponse, DaemonRequest, DaemonResponse, ServerInfo, read_request, serve_app, socket_path, write_response, }; +use powersync::{ConnectionPool, PowerSyncDatabase, env::PowerSyncEnvironment}; struct UnusedCreator; @@ -60,6 +61,26 @@ fn test_config(config_root: &std::path::Path, data_root: &std::path::Path) -> Co } } +fn test_database(config: &Config) -> PowerSyncDatabase { + struct NoHttp; + + #[async_trait] + impl powersync::http::HttpClient for NoHttp { + async fn send( + &self, + _request: powersync::http::Request, + ) -> Result { + panic!("CLI integration tests must not make PowerSync HTTP requests") + } + } + + PowerSyncEnvironment::powersync_auto_extension().unwrap(); + let pool = ConnectionPool::open(&config.paths.db_file).unwrap(); + let environment = + PowerSyncEnvironment::custom(NoHttp, pool, PowerSyncEnvironment::tokio_timer()); + PowerSyncDatabase::new(environment, app_schema()) +} + struct DaemonGuard { shutdown: Option>, thread: Option>, @@ -161,10 +182,10 @@ fn spawn_test_daemon(config_root: &std::path::Path, data_root: &std::path::Path) .build() .unwrap() .block_on(async move { - let backend = std::sync::Arc::new(SqliteBackend { - db: Database::open_local(&config).await.unwrap(), - user_id: "test-user".to_string(), - }); + let backend = std::sync::Arc::new(LocalPowerSyncBackend::new( + test_database(&config), + "test-user".to_string(), + )); let app = std::sync::Arc::new(Application::new( backend, std::sync::Arc::new(UnusedCreator), @@ -215,11 +236,7 @@ async fn seed_workspace( write_session(config_root); let config = test_config(config_root, data_root); std::fs::create_dir_all(&config.paths.data_dir).unwrap(); - let database = Database::open_local(&config).await.unwrap(); - let backend = SqliteBackend { - db: database, - user_id: "test-user".to_string(), - }; + let backend = LocalPowerSyncBackend::new(test_database(&config), "test-user".to_string()); let project_id = backend.create_project("Legacy project").await.unwrap(); let note_id = uuid::Uuid::new_v4().to_string(); backend diff --git a/flicknote-core/Cargo.toml b/flicknote-core/Cargo.toml index 498f4cc..e2d517a 100644 --- a/flicknote-core/Cargo.toml +++ b/flicknote-core/Cargo.toml @@ -12,13 +12,13 @@ serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } powersync = { workspace = true, optional = true } +rusqlite = { workspace = true } async-trait = { workspace = true } thiserror = { workspace = true } flicknote-auth = { path = "../flicknote-auth" } dirs = { workspace = true } chrono = "0.4" uuid = { workspace = true } -sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json", "macros"] } pulldown-cmark = { version = "0.13.1", default-features = false } sha2 = "0.10" yaml_serde = "0.10" diff --git a/flicknote-core/src/backend.rs b/flicknote-core/src/backend.rs index ce321c3..17b5d50 100644 --- a/flicknote-core/src/backend.rs +++ b/flicknote-core/src/backend.rs @@ -1,11 +1,5 @@ use async_trait::async_trait; -#[cfg(feature = "powersync")] -use sqlx::SqlitePool; -#[cfg(feature = "powersync")] -use crate::TOPIC_EXTRACTION_KEY; -#[cfg(feature = "powersync")] -use crate::db::Database; use crate::error::CliError; use crate::types::{Note, Project}; @@ -180,1645 +174,11 @@ pub trait NoteDb: Send + Sync { ) -> Result<(), CliError>; } -// ─── SqliteBackend ─────────────────────────────────────────────────────────── - -#[cfg(feature = "powersync")] -pub struct SqliteBackend { - pub db: Database, - pub user_id: String, -} - -// SQLite SQL constants — all scope by user_id. -// id column is TEXT in SQLite schema, so LIKE works directly. - -#[cfg(feature = "powersync")] -const SQ_RESOLVE_UUID: &str = - "SELECT id FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1"; -#[cfg(feature = "powersync")] -const SQ_RESOLVE_SHORT_ID: &str = - "SELECT id FROM notes WHERE user_id = ? AND short_id = ? AND deleted_at IS NULL LIMIT 1"; -#[cfg(feature = "powersync")] -const SQ_RESOLVE_ARCHIVED_UUID: &str = - "SELECT id FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NOT NULL LIMIT 1"; -#[cfg(feature = "powersync")] -const SQ_RESOLVE_ARCHIVED_SHORT_ID: &str = - "SELECT id FROM notes WHERE user_id = ? AND short_id = ? AND deleted_at IS NOT NULL LIMIT 1"; -#[cfg(feature = "powersync")] -const SQ_FIND: &str = "SELECT id, short_id, user_id, type, status, title, content, summary, is_flagged, \ - project_id, metadata, source, created_at, updated_at, deleted_at \ - FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1"; -#[cfg(feature = "powersync")] -const SQ_FIND_ARCHIVED: &str = "SELECT id, short_id, user_id, type, status, title, content, summary, is_flagged, \ - project_id, metadata, source, created_at, updated_at, deleted_at \ - FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NOT NULL LIMIT 1"; -#[cfg(feature = "powersync")] -const SQ_FIND_CONTENT: &str = - "SELECT content FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1"; -#[cfg(feature = "powersync")] -const SQ_INSERT: &str = "INSERT INTO notes \ - (id, user_id, type, status, title, content, metadata, project_id, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; -#[cfg(feature = "powersync")] -const SQ_UPDATE_CONTENT: &str = "UPDATE notes \ - SET content = ?, status = CASE WHEN ? THEN 'ai_queued' ELSE status END, updated_at = ? \ - WHERE user_id = ? AND id = ?"; -#[cfg(feature = "powersync")] -const SQ_SET_DELETED_AT: &str = - "UPDATE notes SET deleted_at = ?, updated_at = ? WHERE user_id = ? AND id = ?"; -#[cfg(feature = "powersync")] -const SQ_SET_DELETED_AT_NULL: &str = - "UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE user_id = ? AND id = ?"; -#[cfg(feature = "powersync")] -const SQ_UPDATE_PROJECT: &str = - "UPDATE notes SET project_id = ?, updated_at = ? WHERE user_id = ? AND id = ?"; - -#[cfg(feature = "powersync")] -const SQ_FIND_PROJECT: &str = "SELECT id FROM projects WHERE user_id = ? AND name = ? \ - AND (is_archived = 0 OR is_archived IS NULL) LIMIT 1"; -#[cfg(feature = "powersync")] -const SQ_FIND_PROJECT_NAME: &str = "SELECT name FROM projects WHERE user_id = ? AND id = ? LIMIT 1"; -#[cfg(feature = "powersync")] -const SQ_LIST_PROJECTS_ACTIVE: &str = "SELECT id, user_id, name, color, is_archived, created_at FROM projects \ - WHERE user_id = ? AND (is_archived = 0 OR is_archived IS NULL) ORDER BY name"; -#[cfg(feature = "powersync")] -const SQ_LIST_PROJECTS_ARCHIVED: &str = "SELECT id, user_id, name, color, is_archived, created_at FROM projects \ - WHERE user_id = ? AND is_archived = 1 ORDER BY name"; -#[cfg(feature = "powersync")] -const SQ_CREATE_PROJECT: &str = - "INSERT INTO projects (id, user_id, name, is_archived, created_at) VALUES (?, ?, ?, 0, ?)"; -#[cfg(feature = "powersync")] -const SQ_COUNT_PROJECT_NOTES: &str = - "SELECT COUNT(*) FROM notes WHERE user_id = ? AND project_id = ? AND deleted_at IS NULL"; -#[cfg(feature = "powersync")] -const SQ_DELETE_PROJECT: &str = "DELETE FROM projects WHERE user_id = ? AND id = ?"; - -#[cfg(feature = "powersync")] -const SQ_UNDO_DELETE: &str = "UPDATE notes SET deleted_at = NULL, updated_at = ? \ - WHERE id = (SELECT id FROM notes WHERE deleted_at IS NOT NULL AND user_id = ? \ - ORDER BY deleted_at DESC LIMIT 1)"; - -#[cfg(feature = "powersync")] -const SQ_UPDATE_TITLE: &str = - "UPDATE notes SET title = ?, updated_at = ? WHERE user_id = ? AND id = ?"; -#[cfg(feature = "powersync")] -const SQ_UPDATE_FLAGGED: &str = - "UPDATE notes SET is_flagged = ?, updated_at = ? WHERE user_id = ? AND id = ?"; -#[cfg(feature = "powersync")] -const SQ_LIST_EXTRACTIONS: &str = "SELECT note_id, key, value FROM note_extractions \ - WHERE user_id = ? AND key IN (SELECT value FROM json_each(?)) \ - AND note_id IN (SELECT value FROM json_each(?)) \ - ORDER BY key, value"; -#[cfg(feature = "powersync")] -const SQ_LIST_EXTRACTION_VALUES: &str = "SELECT DISTINCT e.value FROM note_extractions e \ - JOIN notes n ON n.id = e.note_id AND n.user_id = e.user_id \ - WHERE e.user_id = ? AND e.key IN (SELECT value FROM json_each(?)) \ - AND (n.deleted_at IS NOT NULL) = ? \ - ORDER BY e.value"; -#[cfg(feature = "powersync")] -const SQ_CLEAR_EXTRACTIONS: &str = "DELETE FROM note_extractions \ - WHERE user_id = ? AND note_id = ? AND key = ?"; -#[cfg(feature = "powersync")] -// PowerSync managed tables expose an implicit text `id` column for row identity. -// We write it so extraction rows sync, but reads/deletes use the domain key. -const SQ_INSERT_EXTRACTION: &str = - "INSERT INTO note_extractions (id, note_id, user_id, key, value) VALUES (?, ?, ?, ?, ?)"; - -#[cfg(feature = "powersync")] -const SQ_FIND_PROJECT_BY_ID: &str = "SELECT id, user_id, name, color, is_archived, created_at FROM projects WHERE user_id = ? AND id = ? LIMIT 1"; -#[cfg(feature = "powersync")] -const SQ_RESOLVE_PROJECT: &str = "SELECT id FROM projects WHERE user_id = ? AND id = ? LIMIT 1"; -#[cfg(feature = "powersync")] -const SQ_ARCHIVE_PROJECT: &str = "UPDATE projects SET is_archived = 1 WHERE user_id = ? AND id = ?"; -#[cfg(feature = "powersync")] -async fn resolve_sqlite_uuid_id( - pool: &SqlitePool, - sql: &str, - user_id: &str, - input: &str, - missing: impl FnOnce() -> CliError, -) -> Result { - if uuid::Uuid::parse_str(input).is_err() { - return Err(missing()); - } - let rows = sqlx::query_scalar::<_, String>(sql) - .bind(user_id) - .bind(input) - .fetch_all(pool) - .await?; - - match rows.as_slice() { - [id] => Ok(id.clone()), - [] => Err(missing()), - [_, _, ..] => unreachable!("exact UUID lookup returns at most one row"), - } -} - -#[cfg(feature = "powersync")] -async fn resolve_sqlite_note_id( - pool: &SqlitePool, - user_id: &str, - input: &str, - uuid_sql: &str, - short_id_sql: &str, -) -> Result { - match parse_note_lookup(input)? { - NoteLookup::ShortId(short_id) => { - if let Some(id) = sqlx::query_scalar::<_, String>(short_id_sql) - .bind(user_id) - .bind(short_id) - .fetch_optional(pool) - .await? - { - return Ok(id); - } - Err(CliError::NoteNotFound { - id: input.to_string(), - }) - } - NoteLookup::Uuid(uuid) => sqlx::query_scalar::<_, String>(uuid_sql) - .bind(user_id) - .bind(uuid) - .fetch_optional(pool) - .await? - .ok_or_else(|| CliError::NoteNotFound { - id: input.to_string(), - }), - } -} - #[cfg(feature = "powersync")] -async fn sqlite_exists( - pool: &SqlitePool, - sql: &str, - user_id: &str, - id: &str, -) -> Result { - let exists = sqlx::query_scalar::<_, i64>(sql) - .bind(user_id) - .bind(id) - .fetch_optional(pool) - .await?; - Ok(exists.is_some()) -} +mod local; #[cfg(feature = "powersync")] -#[async_trait] -impl NoteDb for SqliteBackend { - fn user_id(&self) -> &str { - &self.user_id - } - - async fn resolve_note_id(&self, prefix: &str) -> Result { - resolve_sqlite_note_id( - &self.db.pool, - &self.user_id, - prefix, - SQ_RESOLVE_UUID, - SQ_RESOLVE_SHORT_ID, - ) - .await - } - - async fn resolve_archived_note_id(&self, prefix: &str) -> Result { - resolve_sqlite_note_id( - &self.db.pool, - &self.user_id, - prefix, - SQ_RESOLVE_ARCHIVED_UUID, - SQ_RESOLVE_ARCHIVED_SHORT_ID, - ) - .await - } - - async fn find_note(&self, id: &str) -> Result { - sqlx::query_as::<_, Note>(SQ_FIND) - .bind(&self.user_id) - .bind(id) - .fetch_optional(&self.db.pool) - .await? - .ok_or_else(|| CliError::NoteNotFound { id: id.to_string() }) - } - - async fn find_archived_note(&self, id: &str) -> Result { - sqlx::query_as::<_, Note>(SQ_FIND_ARCHIVED) - .bind(&self.user_id) - .bind(id) - .fetch_optional(&self.db.pool) - .await? - .ok_or_else(|| CliError::NoteNotFound { id: id.to_string() }) - } - - async fn find_note_content(&self, id: &str) -> Result, CliError> { - sqlx::query_scalar::<_, Option>(SQ_FIND_CONTENT) - .bind(&self.user_id) - .bind(id) - .fetch_optional(&self.db.pool) - .await? - .ok_or_else(|| CliError::NoteNotFound { id: id.to_string() }) - } - - async fn list_notes(&self, filter: &NoteFilter<'_>) -> Result, CliError> { - let limit = i64::from(filter.limit); - Ok(sqlx::query_as!( - Note, - r#" - SELECT - id as "id!", - short_id, - user_id as "user_id!", - type as "type!", - status as "status!", - title, - content, - summary, - is_flagged, - project_id, - metadata, - source, - created_at, - updated_at, - deleted_at - FROM notes - WHERE user_id = ? - AND (deleted_at IS NOT NULL) = ? - AND (? IS NULL OR type = ?) - AND (? IS NULL OR project_id = ?) - ORDER BY created_at DESC - LIMIT ? - "#, - self.user_id, - filter.archived, - filter.note_type, - filter.note_type, - filter.project_id, - filter.project_id, - limit, - ) - .fetch_all(&self.db.pool) - .await?) - } - - async fn search_notes( - &self, - keywords: &[String], - filter: &NoteFilter<'_>, - ) -> Result, CliError> { - if keywords.is_empty() { - return Err(CliError::Other( - "search_notes requires at least one keyword".into(), - )); - } - let limit = i64::from(filter.limit); - let keywords_json = serde_json::to_string(keywords)?; - Ok(sqlx::query_as!( - Note, - r#" - SELECT - id as "id!", - short_id, - user_id as "user_id!", - type as "type!", - status as "status!", - title, - content, - summary, - is_flagged, - project_id, - metadata, - source, - created_at, - updated_at, - deleted_at - FROM notes - WHERE user_id = ? - AND (deleted_at IS NOT NULL) = ? - AND (? IS NULL OR type = ?) - AND (? IS NULL OR project_id = ?) - AND EXISTS ( - SELECT 1 FROM json_each(?) AS kw - WHERE title LIKE '%' || kw.value || '%' - OR content LIKE '%' || kw.value || '%' - OR summary LIKE '%' || kw.value || '%' - ) - ORDER BY updated_at DESC - LIMIT ? - "#, - self.user_id, - filter.archived, - filter.note_type, - filter.note_type, - filter.project_id, - filter.project_id, - keywords_json, - limit, - ) - .fetch_all(&self.db.pool) - .await?) - } - - async fn search_notes_structured( - &self, - search: &NoteSearch, - filter: &NoteFilter<'_>, - ) -> Result, CliError> { - if search.keywords.is_empty() && search.extractions.is_empty() { - return Err(CliError::Other( - "search_notes_structured requires at least one keyword or structured filter".into(), - )); - } - let limit = i64::from(filter.limit); - let keywords_json = serde_json::to_string(&search.keywords)?; - let extractions_json = serde_json::to_string( - &search - .extractions - .iter() - .map(|filter| { - serde_json::json!({ - "key": filter.key, - "value": filter.value, - }) - }) - .collect::>(), - )?; - Ok(sqlx::query_as::<_, Note>( - r#" - SELECT - id, - short_id, - user_id, - type, - status, - title, - content, - summary, - is_flagged, - project_id, - metadata, - source, - created_at, - updated_at, - deleted_at - FROM notes - WHERE user_id = ? - AND (deleted_at IS NOT NULL) = ? - AND (? IS NULL OR type = ?) - AND (? IS NULL OR project_id = ?) - AND ( - json_array_length(?) = 0 OR EXISTS ( - SELECT 1 FROM json_each(?) AS kw - WHERE title LIKE '%' || kw.value || '%' - OR content LIKE '%' || kw.value || '%' - OR summary LIKE '%' || kw.value || '%' - ) - ) - AND NOT EXISTS ( - SELECT 1 FROM json_each(?) AS filter - WHERE NOT EXISTS ( - SELECT 1 FROM note_extractions extraction - WHERE extraction.user_id = notes.user_id - AND extraction.note_id = notes.id - AND extraction.key = json_extract(filter.value, '$.key') - AND extraction.value = json_extract(filter.value, '$.value') - ) - ) - ORDER BY updated_at DESC - LIMIT ? - "#, - ) - .bind(&self.user_id) - .bind(filter.archived) - .bind(filter.note_type) - .bind(filter.note_type) - .bind(filter.project_id) - .bind(filter.project_id) - .bind(keywords_json.clone()) - .bind(keywords_json) - .bind(extractions_json) - .bind(limit) - .fetch_all(&self.db.pool) - .await?) - } - - async fn insert_note(&self, req: &InsertNoteReq<'_>) -> Result { - sqlx::query(SQ_INSERT) - .bind(req.id) - .bind(&self.user_id) - .bind(req.note_type) - .bind(req.status) - .bind(req.title) - .bind(req.content) - .bind(req.metadata) - .bind(req.project_id) - .bind(req.now) - .bind(req.now) - .execute(&self.db.pool) - .await?; - Ok(InsertedNote { - uuid: req.id.to_string(), - short_id: None, - }) - } - - async fn update_note_content( - &self, - id: &str, - content: &str, - requeue: bool, - ) -> Result<(), CliError> { - let now = chrono::Utc::now().to_rfc3339(); - sqlx::query(SQ_UPDATE_CONTENT) - .bind(content) - .bind(requeue) - .bind(now) - .bind(&self.user_id) - .bind(id) - .execute(&self.db.pool) - .await?; - Ok(()) - } - - async fn set_note_deleted_at( - &self, - id: &str, - deleted_at: Option<&str>, - now: &str, - ) -> Result<(), CliError> { - if let Some(ts) = deleted_at { - sqlx::query(SQ_SET_DELETED_AT) - .bind(ts) - .bind(now) - .bind(&self.user_id) - .bind(id) - .execute(&self.db.pool) - .await?; - } else { - sqlx::query(SQ_SET_DELETED_AT_NULL) - .bind(now) - .bind(&self.user_id) - .bind(id) - .execute(&self.db.pool) - .await?; - } - Ok(()) - } - - async fn undo_last_delete(&self) -> Result<(), CliError> { - let now = chrono::Utc::now().to_rfc3339(); - sqlx::query(SQ_UNDO_DELETE) - .bind(now) - .bind(&self.user_id) - .execute(&self.db.pool) - .await?; - Ok(()) - } - - async fn find_project_by_name(&self, name: &str) -> Result, CliError> { - Ok(sqlx::query_scalar::<_, String>(SQ_FIND_PROJECT) - .bind(&self.user_id) - .bind(name) - .fetch_optional(&self.db.pool) - .await?) - } - - async fn find_project_name_by_id(&self, project_id: &str) -> Result, CliError> { - Ok(sqlx::query_scalar::<_, String>(SQ_FIND_PROJECT_NAME) - .bind(&self.user_id) - .bind(project_id) - .fetch_optional(&self.db.pool) - .await?) - } - - async fn list_projects(&self, archived: bool) -> Result, CliError> { - let sql = if archived { - SQ_LIST_PROJECTS_ARCHIVED - } else { - SQ_LIST_PROJECTS_ACTIVE - }; - Ok(sqlx::query_as::<_, Project>(sql) - .bind(&self.user_id) - .fetch_all(&self.db.pool) - .await?) - } - - async fn create_project(&self, name: &str) -> Result { - let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - sqlx::query(SQ_CREATE_PROJECT) - .bind(&id) - .bind(&self.user_id) - .bind(name) - .bind(now) - .execute(&self.db.pool) - .await?; - Ok(id) - } - - async fn move_note_to_project( - &self, - note_id: &str, - new_project_id: &str, - old_project_id: Option<&str>, - ) -> Result, CliError> { - let now = chrono::Utc::now().to_rfc3339(); - let mut tx = self.db.pool.begin().await?; - let exists = sqlx::query_scalar::<_, i64>( - "SELECT 1 FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1", - ) - .bind(&self.user_id) - .bind(note_id) - .fetch_optional(&mut *tx) - .await? - .is_some(); - if !exists { - return Err(CliError::NoteNotFound { - id: note_id.to_string(), - }); - } - - sqlx::query(SQ_UPDATE_PROJECT) - .bind(new_project_id) - .bind(now) - .bind(&self.user_id) - .bind(note_id) - .execute(&mut *tx) - .await?; - - let Some(old_pid) = old_project_id else { - tx.commit().await?; - return Ok(None); - }; - - let count = sqlx::query_scalar::<_, i64>(SQ_COUNT_PROJECT_NOTES) - .bind(&self.user_id) - .bind(old_pid) - .fetch_one(&mut *tx) - .await?; - - if count != 0 { - tx.commit().await?; - return Ok(None); - } - - let old_name = sqlx::query_scalar::<_, String>(SQ_FIND_PROJECT_NAME) - .bind(&self.user_id) - .bind(old_pid) - .fetch_optional(&mut *tx) - .await?; - sqlx::query(SQ_DELETE_PROJECT) - .bind(&self.user_id) - .bind(old_pid) - .execute(&mut *tx) - .await?; - tx.commit().await?; - Ok(old_name) - } - - async fn find_project(&self, id: &str) -> Result { - sqlx::query_as::<_, Project>(SQ_FIND_PROJECT_BY_ID) - .bind(&self.user_id) - .bind(id) - .fetch_optional(&self.db.pool) - .await? - .ok_or_else(|| CliError::Other(format!("Project not found: {id}"))) - } - - async fn resolve_project_id(&self, prefix: &str) -> Result { - resolve_sqlite_uuid_id( - &self.db.pool, - SQ_RESOLVE_PROJECT, - &self.user_id, - prefix, - || CliError::Other(format!("Project not found: {prefix}")), - ) - .await - } - - async fn update_project(&self, id: &str, color: Option>) -> Result<(), CliError> { - let update_color = color.is_some(); - if !update_color { - return Ok(()); - } - - let color_value = color.flatten(); - sqlx::query!( - r#" - UPDATE projects SET - color = CASE WHEN ? THEN ? ELSE color END - WHERE user_id = ? AND id = ? - "#, - update_color, - color_value, - self.user_id, - id, - ) - .execute(&self.db.pool) - .await?; - Ok(()) - } - - async fn delete_project(&self, id: &str) -> Result<(), CliError> { - if !sqlite_exists( - &self.db.pool, - "SELECT 1 FROM projects WHERE user_id = ? AND id = ? LIMIT 1", - &self.user_id, - id, - ) - .await? - { - return Err(CliError::Other(format!("Project not found: {id}"))); - } - sqlx::query(SQ_ARCHIVE_PROJECT) - .bind(&self.user_id) - .bind(id) - .execute(&self.db.pool) - .await?; - Ok(()) - } - - async fn update_note_title(&self, id: &str, title: &str) -> Result<(), CliError> { - let now = chrono::Utc::now().to_rfc3339(); - if !sqlite_exists( - &self.db.pool, - "SELECT 1 FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1", - &self.user_id, - id, - ) - .await? - { - return Err(CliError::NoteNotFound { id: id.to_string() }); - } - sqlx::query(SQ_UPDATE_TITLE) - .bind(title) - .bind(now) - .bind(&self.user_id) - .bind(id) - .execute(&self.db.pool) - .await?; - Ok(()) - } - - async fn update_note_flagged(&self, id: &str, flagged: bool) -> Result<(), CliError> { - let now = chrono::Utc::now().to_rfc3339(); - let val: i64 = if flagged { 1 } else { 0 }; - if !sqlite_exists( - &self.db.pool, - "SELECT 1 FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1", - &self.user_id, - id, - ) - .await? - { - return Err(CliError::NoteNotFound { id: id.to_string() }); - } - sqlx::query(SQ_UPDATE_FLAGGED) - .bind(val) - .bind(now) - .bind(&self.user_id) - .bind(id) - .execute(&self.db.pool) - .await?; - Ok(()) - } - - async fn count_notes(&self, filter: &NoteFilter<'_>) -> Result { - let count = sqlx::query_scalar!( - r#" - SELECT COUNT(*) as "count!: i64" - FROM notes - WHERE user_id = ? - AND (deleted_at IS NOT NULL) = ? - AND (? IS NULL OR type = ?) - AND (? IS NULL OR project_id = ?) - "#, - self.user_id, - filter.archived, - filter.note_type, - filter.note_type, - filter.project_id, - filter.project_id, - ) - .fetch_one(&self.db.pool) - .await?; - count - .try_into() - .map_err(|_| CliError::Other(format!("unexpected negative count: {count}"))) - } - - async fn list_note_topics( - &self, - note_ids: &[&str], - ) -> Result>, CliError> { - let extractions = self - .list_note_extractions(note_ids, &[TOPIC_EXTRACTION_KEY]) - .await?; - let mut map = std::collections::HashMap::new(); - for (note_id, pairs) in extractions { - map.insert(note_id, pairs.into_iter().map(|(_, value)| value).collect()); - } - Ok(map) - } - async fn list_note_extractions( - &self, - note_ids: &[&str], - extraction_keys: &[&str], - ) -> Result>, CliError> { - if note_ids.is_empty() || extraction_keys.is_empty() { - return Ok(std::collections::HashMap::new()); - } - let note_ids_json = serde_json::to_string(note_ids)?; - let keys_json = serde_json::to_string(extraction_keys)?; - let rows = sqlx::query_as::<_, (String, String, String)>(SQ_LIST_EXTRACTIONS) - .bind(&self.user_id) - .bind(keys_json) - .bind(note_ids_json) - .fetch_all(&self.db.pool) - .await?; - let mut map: std::collections::HashMap> = - std::collections::HashMap::new(); - for (note_id, ext_type, value) in rows { - map.entry(note_id).or_default().push((ext_type, value)); - } - Ok(map) - } - - async fn list_extraction_values( - &self, - extraction_keys: &[&str], - archived: bool, - ) -> Result, CliError> { - if extraction_keys.is_empty() { - return Ok(Vec::new()); - } - let keys_json = serde_json::to_string(extraction_keys)?; - Ok(sqlx::query_scalar::<_, String>(SQ_LIST_EXTRACTION_VALUES) - .bind(&self.user_id) - .bind(keys_json) - .bind(archived) - .fetch_all(&self.db.pool) - .await?) - } - async fn set_note_extractions( - &self, - note_id: &str, - extraction_key: &str, - values: &[String], - ) -> Result<(), CliError> { - // Delete all existing rows for this note + key. - sqlx::query(SQ_CLEAR_EXTRACTIONS) - .bind(&self.user_id) - .bind(note_id) - .bind(extraction_key) - .execute(&self.db.pool) - .await?; - // Insert new values - for value in values { - let id = uuid::Uuid::new_v4().to_string(); - sqlx::query(SQ_INSERT_EXTRACTION) - .bind(id) - .bind(note_id) - .bind(&self.user_id) - .bind(extraction_key) - .bind(value) - .execute(&self.db.pool) - .await?; - } - Ok(()) - } -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── +pub use local::LocalPowerSyncBackend; #[cfg(test)] #[cfg(feature = "powersync")] -mod tests { - use super::*; - - async fn make_backend() -> SqliteBackend { - use crate::config::{Config, ConfigPaths}; - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - let config = Config { - supabase_url: String::new(), - supabase_anon_key: String::new(), - powersync_url: String::new(), - api_url: String::new(), - web_url: None, - paths: ConfigPaths { - config_dir: dir.path().to_path_buf(), - data_dir: dir.path().to_path_buf(), - config_file: dir.path().join("config.json"), - session_file: dir.path().join("session.json"), - db_file: dir.path().join("test.db"), - log_file: dir.path().join("test.log"), - }, - }; - - let db = Database::open_local(&config).await.unwrap(); - let user_id = "test-user-id".to_string(); - - // Keep dir alive by leaking it — acceptable in tests - std::mem::forget(dir); - - SqliteBackend { db, user_id } - } - - #[tokio::test] - async fn test_sqlite_backend_insert_and_find() { - let backend = make_backend().await; - let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - - let inserted = backend - .insert_note(&InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: Some("Hello world"), - content: Some("# Hello world\n\nContent here."), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - - assert_eq!(inserted.uuid, id); - assert_eq!(inserted.short_id, None); - - // Find by full id - let note = backend.find_note(&id).await.unwrap(); - assert_eq!(note.id, id); - assert_eq!(note.title, Some("Hello world".to_string())); - - // Find by full UUID compatibility path - let resolved = backend.resolve_note_id(&id).await.unwrap(); - assert_eq!(resolved, id); - - sqlx::query("UPDATE notes SET short_id = 42 WHERE id = ?") - .bind(&id) - .execute(&backend.db.pool) - .await - .unwrap(); - let resolved = backend.resolve_note_id("42").await.unwrap(); - assert_eq!(resolved, id); - - // UUID prefixes are not accepted for notes; use short IDs or full UUIDs. - let prefix = &id[..8]; - let err = backend.resolve_note_id(prefix).await.unwrap_err(); - assert!(matches!(err, CliError::NoteNotFound { .. })); - - // Find content - let content = backend.find_note_content(&id).await.unwrap(); - assert_eq!(content, Some("# Hello world\n\nContent here.".to_string())); - } - - #[tokio::test] - async fn test_numeric_short_id_ref_does_not_fallback_to_short_uuid_prefix() { - let backend = make_backend().await; - let id = "42000000-e29b-41d4-a716-446655440000".to_string(); - let now = chrono::Utc::now().to_rfc3339(); - - backend - .insert_note(&InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: Some("Numeric prefix note"), - content: Some("content"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - - let err = backend.resolve_note_id("42").await.unwrap_err(); - assert!(matches!(err, CliError::NoteNotFound { .. })); - } - - #[tokio::test] - async fn test_eight_digit_uuid_prefix_does_not_resolve_note() { - let backend = make_backend().await; - let id = "12345678-e29b-41d4-a716-446655440000".to_string(); - let now = chrono::Utc::now().to_rfc3339(); - - backend - .insert_note(&InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: Some("Eight digit prefix note"), - content: Some("content"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - - let err = backend.resolve_note_id("12345678").await.unwrap_err(); - assert!(matches!(err, CliError::NoteNotFound { .. })); - } - - #[tokio::test] - async fn test_resolved_note_id_can_update_content_and_extractions() { - let backend = make_backend().await; - let id = "11fa49a2-6ac4-421e-94bf-240ee4197bb7".to_string(); - let now = chrono::Utc::now().to_rfc3339(); - - backend - .insert_note(&InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: Some("Editable note"), - content: Some("hello"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - sqlx::query("UPDATE notes SET short_id = 1172 WHERE id = ?") - .bind(&id) - .execute(&backend.db.pool) - .await - .unwrap(); - - let from_uuid = backend.resolve_note_id(&id).await.unwrap(); - backend - .update_note_content(&from_uuid, "hi from uuid", true) - .await - .unwrap(); - assert_eq!( - backend.find_note_content(&id).await.unwrap(), - Some("hi from uuid".to_string()) - ); - - let from_short_id = backend.resolve_note_id("1172").await.unwrap(); - backend - .update_note_content(&from_short_id, "hi from short id", true) - .await - .unwrap(); - assert_eq!( - backend.find_note_content(&id).await.unwrap(), - Some("hi from short id".to_string()) - ); - - backend - .set_note_extractions( - &from_short_id, - TOPIC_EXTRACTION_KEY, - &["orientation".to_string(), "cli".to_string()], - ) - .await - .unwrap(); - let extractions = backend - .list_note_extractions(&[&id], &[TOPIC_EXTRACTION_KEY]) - .await - .unwrap(); - assert_eq!( - extractions.get(&id), - Some(&vec![ - (TOPIC_EXTRACTION_KEY.to_string(), "cli".to_string()), - (TOPIC_EXTRACTION_KEY.to_string(), "orientation".to_string()) - ]) - ); - } - - #[tokio::test] - async fn test_sqlite_backend_list_filter() { - let backend = make_backend().await; - let now = chrono::Utc::now().to_rfc3339(); - - // Create two projects - let proj_a = backend.create_project("Project A").await.unwrap(); - let proj_b = backend.create_project("Project B").await.unwrap(); - - // Insert notes in different projects - let id_a = uuid::Uuid::new_v4().to_string(); - backend - .insert_note(&InsertNoteReq { - id: &id_a, - note_type: "normal", - status: "ai_queued", - title: Some("Note A"), - content: Some("content a"), - metadata: None, - project_id: Some(&proj_a), - now: &now, - }) - .await - .unwrap(); - - let id_b = uuid::Uuid::new_v4().to_string(); - backend - .insert_note(&InsertNoteReq { - id: &id_b, - note_type: "normal", - status: "ai_queued", - title: Some("Note B"), - content: Some("content b"), - metadata: None, - project_id: Some(&proj_b), - now: &now, - }) - .await - .unwrap(); - - // List by project A - let notes = backend - .list_notes(&NoteFilter { - project_id: Some(&proj_a), - note_type: None, - archived: false, - limit: 20, - }) - .await - .unwrap(); - assert_eq!(notes.len(), 1); - assert_eq!(notes[0].id, id_a); - } - - #[tokio::test] - async fn test_sqlite_backend_search_notes() { - let backend = make_backend().await; - let now = chrono::Utc::now().to_rfc3339(); - - let id = uuid::Uuid::new_v4().to_string(); - backend - .insert_note(&InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: Some("Unique searchable title"), - content: Some("some body text"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - - let results = backend - .search_notes( - &["Unique".to_string()], - &NoteFilter { - project_id: None, - note_type: None, - archived: false, - limit: 20, - }, - ) - .await - .unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].id, id); - - // Empty keywords should return Err - let err = backend - .search_notes( - &[], - &NoteFilter { - project_id: None, - note_type: None, - archived: false, - limit: 20, - }, - ) - .await; - assert!(err.is_err()); - } - - #[tokio::test] - async fn test_sqlite_backend_search_notes_matches_all_extraction_filters() { - let backend = make_backend().await; - let now = chrono::Utc::now().to_rfc3339(); - - let matching_id = uuid::Uuid::new_v4().to_string(); - backend - .insert_note(&InsertNoteReq { - id: &matching_id, - note_type: "normal", - status: "ai_queued", - title: Some("Whisper pipeline"), - content: Some("ASR notes"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - backend - .set_note_extractions( - &matching_id, - TOPIC_EXTRACTION_KEY, - &["ASR".to_string(), "AI".to_string()], - ) - .await - .unwrap(); - backend - .set_note_extractions(&matching_id, "::person", &["瓜子".to_string()]) - .await - .unwrap(); - - let topic_only_id = uuid::Uuid::new_v4().to_string(); - backend - .insert_note(&InsertNoteReq { - id: &topic_only_id, - note_type: "normal", - status: "ai_queued", - title: Some("Whisper without person"), - content: Some("ASR notes"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - backend - .set_note_extractions(&topic_only_id, TOPIC_EXTRACTION_KEY, &["ASR".to_string()]) - .await - .unwrap(); - - let results = backend - .search_notes_structured( - &NoteSearch { - keywords: vec!["Whisper".to_string()], - extractions: vec![ - MetadataFilter { - key: TOPIC_EXTRACTION_KEY.to_string(), - value: "ASR".to_string(), - }, - MetadataFilter { - key: "::person".to_string(), - value: "瓜子".to_string(), - }, - ], - }, - &NoteFilter { - project_id: None, - note_type: None, - archived: false, - limit: 20, - }, - ) - .await - .unwrap(); - - assert_eq!(results.len(), 1); - assert_eq!(results[0].id, matching_id); - } - - #[tokio::test] - async fn test_sqlite_backend_search_notes_accepts_structured_only_query() { - let backend = make_backend().await; - let now = chrono::Utc::now().to_rfc3339(); - let id = uuid::Uuid::new_v4().to_string(); - - backend - .insert_note(&InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: Some("No keyword match here"), - content: Some("body"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - backend - .set_note_extractions(&id, "::company", &["OpenAI".to_string()]) - .await - .unwrap(); - - let results = backend - .search_notes_structured( - &NoteSearch { - keywords: Vec::new(), - extractions: vec![MetadataFilter { - key: "::company".to_string(), - value: "OpenAI".to_string(), - }], - }, - &NoteFilter { - project_id: None, - note_type: None, - archived: false, - limit: 20, - }, - ) - .await - .unwrap(); - - assert_eq!(results.len(), 1); - assert_eq!(results[0].id, id); - } - - #[tokio::test] - async fn test_sqlite_backend_list_extraction_values_dedupes_and_sorts() { - let backend = make_backend().await; - let now = chrono::Utc::now().to_rfc3339(); - - for value in ["ASR", "AI", "ASR"] { - let id = uuid::Uuid::new_v4().to_string(); - backend - .insert_note(&InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: Some(value), - content: Some("body"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - backend - .set_note_extractions(&id, TOPIC_EXTRACTION_KEY, &[value.to_string()]) - .await - .unwrap(); - } - - let values = backend - .list_extraction_values(&[TOPIC_EXTRACTION_KEY], false) - .await - .unwrap(); - - assert_eq!(values, vec!["AI".to_string(), "ASR".to_string()]); - } - - #[tokio::test] - async fn test_sqlite_backend_search_respects_type_filter() { - let backend = make_backend().await; - let now = chrono::Utc::now().to_rfc3339(); - - let normal_id = uuid::Uuid::new_v4().to_string(); - backend - .insert_note(&InsertNoteReq { - id: &normal_id, - note_type: "normal", - status: "ai_queued", - title: Some("Shared searchable title"), - content: Some("normal body"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - - let link_id = uuid::Uuid::new_v4().to_string(); - backend - .insert_note(&InsertNoteReq { - id: &link_id, - note_type: "link", - status: "ai_queued", - title: Some("Shared searchable title"), - content: Some("link body"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - - let results = backend - .search_notes( - &["Shared".to_string()], - &NoteFilter { - project_id: None, - note_type: Some("link"), - archived: false, - limit: 20, - }, - ) - .await - .unwrap(); - - assert_eq!(results.len(), 1); - assert_eq!(results[0].id, link_id); - } - - #[tokio::test] - async fn test_sqlite_backend_archive() { - let backend = make_backend().await; - let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - - backend - .insert_note(&InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: Some("To archive"), - content: Some("content"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - - // Verify it appears in active list - let active = backend - .list_notes(&NoteFilter { - project_id: None, - note_type: None, - archived: false, - limit: 20, - }) - .await - .unwrap(); - assert!(active.iter().any(|n| n.id == id)); - - // Archive it - backend - .set_note_deleted_at(&id, Some(&now), &now) - .await - .unwrap(); - - // Should be gone from active - let active_after = backend - .list_notes(&NoteFilter { - project_id: None, - note_type: None, - archived: false, - limit: 20, - }) - .await - .unwrap(); - assert!(!active_after.iter().any(|n| n.id == id)); - - // Should appear in archived - let archived = backend - .list_notes(&NoteFilter { - project_id: None, - note_type: None, - archived: true, - limit: 20, - }) - .await - .unwrap(); - assert!(archived.iter().any(|n| n.id == id)); - - // Unarchive - backend.set_note_deleted_at(&id, None, &now).await.unwrap(); - let active_restored = backend - .list_notes(&NoteFilter { - project_id: None, - note_type: None, - archived: false, - limit: 20, - }) - .await - .unwrap(); - assert!(active_restored.iter().any(|n| n.id == id)); - } - - #[tokio::test] - async fn test_find_archived_note() { - let backend = make_backend().await; - let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - - backend - .insert_note(&InsertNoteReq { - id: &id, - note_type: "normal", - status: "ai_queued", - title: Some("Archived note"), - content: Some("content"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - - // Not findable via find_archived_note before archiving - assert!(backend.find_archived_note(&id).await.is_err()); - - // Archive it - backend - .set_note_deleted_at(&id, Some(&now), &now) - .await - .unwrap(); - - // Now findable via find_archived_note - let note = backend.find_archived_note(&id).await.unwrap(); - assert_eq!(note.id, id); - assert_eq!(note.title, Some("Archived note".to_string())); - assert!(note.deleted_at.is_some()); - - // No longer findable via find_note (active-only) - assert!(backend.find_note(&id).await.is_err()); - } - - // ─── Fix: PowerSync view-UPDATE zero affected rows ──────────────────── - - #[tokio::test] - async fn test_move_note_to_project_ok() { - let backend = make_backend().await; - let now = chrono::Utc::now().to_rfc3339(); - let note_id = uuid::Uuid::new_v4().to_string(); - let proj_a = backend.create_project("Proj-A").await.unwrap(); - let proj_b = backend.create_project("Proj-B").await.unwrap(); - - backend - .insert_note(&InsertNoteReq { - id: ¬e_id, - note_type: "normal", - status: "ai_queued", - title: Some("Test note"), - content: Some("body"), - metadata: None, - project_id: Some(&proj_a), - now: &now, - }) - .await - .unwrap(); - - // Move to proj_b — should succeed (not return NoteNotFound) - let result = backend - .move_note_to_project(¬e_id, &proj_b, Some(&proj_a)) - .await - .unwrap(); - // This note was the only one in proj_a, so proj_a gets deleted - assert_eq!(result.as_deref(), Some("Proj-A")); - - // Verify the note is now in proj_b - let note = backend.find_note(¬e_id).await.unwrap(); - assert_eq!(note.project_id.as_deref(), Some(proj_b.as_str())); - } - - #[tokio::test] - async fn test_move_note_to_project_missing_returns_err() { - let backend = make_backend().await; - let fake_id = uuid::Uuid::new_v4().to_string(); - let proj_a = backend.create_project("Proj-A").await.unwrap(); - let proj_b = backend.create_project("Proj-B").await.unwrap(); - - let err = backend - .move_note_to_project(&fake_id, &proj_b, Some(&proj_a)) - .await - .unwrap_err(); - match err { - CliError::NoteNotFound { id } => assert_eq!(id, fake_id), - _ => panic!("expected NoteNotFound, got {:?}", err), - } - } - - #[tokio::test] - async fn test_move_note_to_project_same_project_noop() { - let backend = make_backend().await; - let now = chrono::Utc::now().to_rfc3339(); - let note_id = uuid::Uuid::new_v4().to_string(); - let proj_x = backend.create_project("Proj-X").await.unwrap(); - - backend - .insert_note(&InsertNoteReq { - id: ¬e_id, - note_type: "normal", - status: "ai_queued", - title: Some("Same-project note"), - content: Some("body"), - metadata: None, - project_id: Some(&proj_x), - now: &now, - }) - .await - .unwrap(); - - // Same source and target — should be idempotent, return Ok(None), - // not delete the project (it still holds the note). - let result = backend - .move_note_to_project(¬e_id, &proj_x, Some(&proj_x)) - .await - .unwrap(); - assert_eq!(result, None, "same-project move should not delete project"); - - // Verify project still exists and note is still in it - let note = backend.find_note(¬e_id).await.unwrap(); - assert_eq!(note.project_id.as_deref(), Some(proj_x.as_str())); - let active = backend.list_projects(false).await.unwrap(); - assert!( - active.iter().any(|p| p.id == proj_x), - "project should still exist" - ); - } - - #[tokio::test] - async fn test_update_note_title_ok() { - let backend = make_backend().await; - let now = chrono::Utc::now().to_rfc3339(); - let note_id = uuid::Uuid::new_v4().to_string(); - - backend - .insert_note(&InsertNoteReq { - id: ¬e_id, - note_type: "normal", - status: "ai_queued", - title: Some("Old title"), - content: Some("body"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - - backend - .update_note_title(¬e_id, "New title") - .await - .unwrap(); - let note = backend.find_note(¬e_id).await.unwrap(); - assert_eq!(note.title, Some("New title".to_string())); - } - - #[tokio::test] - async fn test_update_note_flagged_ok() { - let backend = make_backend().await; - let now = chrono::Utc::now().to_rfc3339(); - let note_id = uuid::Uuid::new_v4().to_string(); - - backend - .insert_note(&InsertNoteReq { - id: ¬e_id, - note_type: "normal", - status: "ai_queued", - title: Some("Flag me"), - content: Some("body"), - metadata: None, - project_id: None, - now: &now, - }) - .await - .unwrap(); - - backend.update_note_flagged(¬e_id, true).await.unwrap(); - let note = backend.find_note(¬e_id).await.unwrap(); - assert_eq!(note.is_flagged, Some(1)); - - backend.update_note_flagged(¬e_id, false).await.unwrap(); - let note = backend.find_note(¬e_id).await.unwrap(); - assert_eq!(note.is_flagged, Some(0)); - } - - #[tokio::test] - async fn test_delete_project_archives() { - let backend = make_backend().await; - let proj_id = backend.create_project("ToDelete").await.unwrap(); - - // Verify project exists - let proj = backend.find_project(&proj_id).await.unwrap(); - assert_eq!(proj.name, "ToDelete"); - - backend.delete_project(&proj_id).await.unwrap(); - - // After archive, project should not appear in active list - let active = backend.list_projects(false).await.unwrap(); - assert!( - !active.iter().any(|p| p.id == proj_id), - "deleted project should not appear in active list" - ); - - // Archived list should contain it - let archived = backend.list_projects(true).await.unwrap(); - assert!( - archived.iter().any(|p| p.id == proj_id), - "deleted project should appear in archived list" - ); - } - - #[tokio::test] - async fn test_delete_project_missing_returns_err() { - let backend = make_backend().await; - let fake_id = uuid::Uuid::new_v4().to_string(); - - let err = backend.delete_project(&fake_id).await.unwrap_err(); - match err { - CliError::Other(msg) => assert!(msg.contains("not found"), "got: {msg}"), - _ => panic!("expected Other error, got {:?}", err), - } - } - - #[tokio::test] - async fn test_project_resolver_rejects_uuid_prefixes() { - let backend = make_backend().await; - - let project_id = backend.create_project("Exact Project").await.unwrap(); - - assert_eq!( - backend.resolve_project_id(&project_id).await.unwrap(), - project_id - ); - - let project_prefix = &project_id[..8]; - - assert!(backend.resolve_project_id(project_prefix).await.is_err()); - } -} +mod tests; diff --git a/flicknote-core/src/backend/local.rs b/flicknote-core/src/backend/local.rs new file mode 100644 index 0000000..ffa18c4 --- /dev/null +++ b/flicknote-core/src/backend/local.rs @@ -0,0 +1,808 @@ +use async_trait::async_trait; +use powersync::PowerSyncDatabase; +use rusqlite::{Connection, OptionalExtension, Params, Row, params}; + +use crate::TOPIC_EXTRACTION_KEY; +use crate::error::CliError; +use crate::types::{Note, Project}; + +use super::{ + InsertNoteReq, InsertedNote, NoteDb, NoteFilter, NoteLookup, NoteSearch, parse_note_lookup, +}; + +// ─── LocalPowerSyncBackend ─────────────────────────────────────────────────── + +pub struct LocalPowerSyncBackend { + db: PowerSyncDatabase, + user_id: String, +} +impl LocalPowerSyncBackend { + pub fn new(db: PowerSyncDatabase, user_id: String) -> Self { + Self { db, user_id } + } + + #[cfg(test)] + pub(crate) fn database(&self) -> &PowerSyncDatabase { + &self.db + } +} + +// SQLite SQL constants — all scope by user_id. +// id column is TEXT in SQLite schema, so LIKE works directly. + +const SQ_RESOLVE_UUID: &str = + "SELECT id FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1"; +const SQ_RESOLVE_SHORT_ID: &str = + "SELECT id FROM notes WHERE user_id = ? AND short_id = ? AND deleted_at IS NULL LIMIT 1"; +const SQ_RESOLVE_ARCHIVED_UUID: &str = + "SELECT id FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NOT NULL LIMIT 1"; +const SQ_RESOLVE_ARCHIVED_SHORT_ID: &str = + "SELECT id FROM notes WHERE user_id = ? AND short_id = ? AND deleted_at IS NOT NULL LIMIT 1"; +const SQ_FIND: &str = "SELECT id, short_id, user_id, type, status, title, content, summary, is_flagged, \ + project_id, metadata, source, created_at, updated_at, deleted_at \ + FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1"; +const SQ_FIND_ARCHIVED: &str = "SELECT id, short_id, user_id, type, status, title, content, summary, is_flagged, \ + project_id, metadata, source, created_at, updated_at, deleted_at \ + FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NOT NULL LIMIT 1"; +const SQ_FIND_CONTENT: &str = + "SELECT content FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1"; +const SQ_INSERT: &str = "INSERT INTO notes \ + (id, user_id, type, status, title, content, metadata, project_id, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; +const SQ_UPDATE_CONTENT: &str = "UPDATE notes \ + SET content = ?, status = CASE WHEN ? THEN 'ai_queued' ELSE status END, updated_at = ? \ + WHERE user_id = ? AND id = ?"; +const SQ_SET_DELETED_AT: &str = + "UPDATE notes SET deleted_at = ?, updated_at = ? WHERE user_id = ? AND id = ?"; +const SQ_SET_DELETED_AT_NULL: &str = + "UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE user_id = ? AND id = ?"; +const SQ_UPDATE_PROJECT: &str = + "UPDATE notes SET project_id = ?, updated_at = ? WHERE user_id = ? AND id = ?"; + +const SQ_FIND_PROJECT: &str = "SELECT id FROM projects WHERE user_id = ? AND name = ? \ + AND (is_archived = 0 OR is_archived IS NULL) LIMIT 1"; +const SQ_FIND_PROJECT_NAME: &str = "SELECT name FROM projects WHERE user_id = ? AND id = ? LIMIT 1"; +const SQ_LIST_PROJECTS_ACTIVE: &str = "SELECT id, user_id, name, color, is_archived, created_at FROM projects \ + WHERE user_id = ? AND (is_archived = 0 OR is_archived IS NULL) ORDER BY name"; +const SQ_LIST_PROJECTS_ARCHIVED: &str = "SELECT id, user_id, name, color, is_archived, created_at FROM projects \ + WHERE user_id = ? AND is_archived = 1 ORDER BY name"; +const SQ_CREATE_PROJECT: &str = + "INSERT INTO projects (id, user_id, name, is_archived, created_at) VALUES (?, ?, ?, 0, ?)"; +const SQ_COUNT_PROJECT_NOTES: &str = + "SELECT COUNT(*) FROM notes WHERE user_id = ? AND project_id = ? AND deleted_at IS NULL"; +const SQ_DELETE_PROJECT: &str = "DELETE FROM projects WHERE user_id = ? AND id = ?"; + +const SQ_UNDO_DELETE: &str = "UPDATE notes SET deleted_at = NULL, updated_at = ? \ + WHERE id = (SELECT id FROM notes WHERE deleted_at IS NOT NULL AND user_id = ? \ + ORDER BY deleted_at DESC LIMIT 1)"; + +const SQ_UPDATE_TITLE: &str = + "UPDATE notes SET title = ?, updated_at = ? WHERE user_id = ? AND id = ?"; +const SQ_UPDATE_FLAGGED: &str = + "UPDATE notes SET is_flagged = ?, updated_at = ? WHERE user_id = ? AND id = ?"; +const SQ_LIST_EXTRACTIONS: &str = "SELECT note_id, key, value FROM note_extractions \ + WHERE user_id = ? AND key IN (SELECT value FROM json_each(?)) \ + AND note_id IN (SELECT value FROM json_each(?)) \ + ORDER BY key, value"; +const SQ_LIST_EXTRACTION_VALUES: &str = "SELECT DISTINCT e.value FROM note_extractions e \ + JOIN notes n ON n.id = e.note_id AND n.user_id = e.user_id \ + WHERE e.user_id = ? AND e.key IN (SELECT value FROM json_each(?)) \ + AND (n.deleted_at IS NOT NULL) = ? \ + ORDER BY e.value"; +const SQ_CLEAR_EXTRACTIONS: &str = "DELETE FROM note_extractions \ + WHERE user_id = ? AND note_id = ? AND key = ?"; +// PowerSync managed tables expose an implicit text `id` column for row identity. +// We write it so extraction rows sync, but reads/deletes use the domain key. +const SQ_INSERT_EXTRACTION: &str = + "INSERT INTO note_extractions (id, note_id, user_id, key, value) VALUES (?, ?, ?, ?, ?)"; + +const SQ_FIND_PROJECT_BY_ID: &str = "SELECT id, user_id, name, color, is_archived, created_at FROM projects WHERE user_id = ? AND id = ? LIMIT 1"; +const SQ_RESOLVE_PROJECT: &str = "SELECT id FROM projects WHERE user_id = ? AND id = ? LIMIT 1"; +const SQ_ARCHIVE_PROJECT: &str = "UPDATE projects SET is_archived = 1 WHERE user_id = ? AND id = ?"; +async fn resolve_sqlite_uuid_id( + db: &PowerSyncDatabase, + sql: &str, + user_id: &str, + input: &str, + missing: impl FnOnce() -> CliError, +) -> Result { + if uuid::Uuid::parse_str(input).is_err() { + return Err(missing()); + } + let reader = db.reader().await?; + let mut statement = reader.prepare(sql)?; + let rows = statement + .query_map(params![user_id, input], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + + match rows.as_slice() { + [id] => Ok(id.clone()), + [] => Err(missing()), + [_, _, ..] => unreachable!("exact UUID lookup returns at most one row"), + } +} + +async fn resolve_sqlite_note_id( + db: &PowerSyncDatabase, + user_id: &str, + input: &str, + uuid_sql: &str, + short_id_sql: &str, +) -> Result { + match parse_note_lookup(input)? { + NoteLookup::ShortId(short_id) => { + let reader = db.reader().await?; + if let Some(id) = reader + .query_row(short_id_sql, params![user_id, short_id], |row| { + row.get::<_, String>(0) + }) + .optional()? + { + return Ok(id); + } + Err(CliError::NoteNotFound { + id: input.to_string(), + }) + } + NoteLookup::Uuid(uuid) => { + let reader = db.reader().await?; + reader + .query_row(uuid_sql, params![user_id, uuid], |row| { + row.get::<_, String>(0) + }) + .optional()? + .ok_or_else(|| CliError::NoteNotFound { + id: input.to_string(), + }) + } + } +} + +async fn sqlite_exists( + db: &PowerSyncDatabase, + sql: &str, + user_id: &str, + id: &str, +) -> Result { + let reader = db.reader().await?; + let exists = reader + .query_row(sql, params![user_id, id], |row| row.get::<_, i64>(0)) + .optional()?; + Ok(exists.is_some()) +} + +fn decode_note(row: &Row<'_>) -> rusqlite::Result { + Ok(Note { + id: row.get("id")?, + short_id: row.get("short_id")?, + user_id: row.get("user_id")?, + r#type: row.get("type")?, + status: row.get("status")?, + title: row.get("title")?, + content: row.get("content")?, + summary: row.get("summary")?, + is_flagged: row.get("is_flagged")?, + project_id: row.get("project_id")?, + metadata: row.get("metadata")?, + source: row.get("source")?, + created_at: row.get("created_at")?, + updated_at: row.get("updated_at")?, + deleted_at: row.get("deleted_at")?, + }) +} + +fn decode_project(row: &Row<'_>) -> rusqlite::Result { + Ok(Project { + id: row.get("id")?, + user_id: row.get("user_id")?, + name: row.get("name")?, + color: row.get("color")?, + is_archived: row.get("is_archived")?, + created_at: row.get("created_at")?, + }) +} + +fn query_notes( + connection: &Connection, + sql: &str, + parameters: impl Params, +) -> Result, CliError> { + let mut statement = connection.prepare(sql)?; + Ok(statement + .query_map(parameters, decode_note)? + .collect::, _>>()?) +} + +fn query_projects( + connection: &Connection, + sql: &str, + parameters: impl Params, +) -> Result, CliError> { + let mut statement = connection.prepare(sql)?; + Ok(statement + .query_map(parameters, decode_project)? + .collect::, _>>()?) +} +#[async_trait] +impl NoteDb for LocalPowerSyncBackend { + fn user_id(&self) -> &str { + &self.user_id + } + + async fn resolve_note_id(&self, prefix: &str) -> Result { + resolve_sqlite_note_id( + &self.db, + &self.user_id, + prefix, + SQ_RESOLVE_UUID, + SQ_RESOLVE_SHORT_ID, + ) + .await + } + + async fn resolve_archived_note_id(&self, prefix: &str) -> Result { + resolve_sqlite_note_id( + &self.db, + &self.user_id, + prefix, + SQ_RESOLVE_ARCHIVED_UUID, + SQ_RESOLVE_ARCHIVED_SHORT_ID, + ) + .await + } + + async fn find_note(&self, id: &str) -> Result { + let reader = self.db.reader().await?; + reader + .query_row(SQ_FIND, params![self.user_id, id], decode_note) + .optional()? + .ok_or_else(|| CliError::NoteNotFound { id: id.to_string() }) + } + + async fn find_archived_note(&self, id: &str) -> Result { + let reader = self.db.reader().await?; + reader + .query_row(SQ_FIND_ARCHIVED, params![self.user_id, id], decode_note) + .optional()? + .ok_or_else(|| CliError::NoteNotFound { id: id.to_string() }) + } + + async fn find_note_content(&self, id: &str) -> Result, CliError> { + let reader = self.db.reader().await?; + reader + .query_row(SQ_FIND_CONTENT, params![self.user_id, id], |row| row.get(0)) + .optional()? + .ok_or_else(|| CliError::NoteNotFound { id: id.to_string() }) + } + + async fn list_notes(&self, filter: &NoteFilter<'_>) -> Result, CliError> { + let limit = i64::from(filter.limit); + let reader = self.db.reader().await?; + query_notes( + &reader, + r#" + SELECT + id, + short_id, + user_id, + type, + status, + title, + content, + summary, + is_flagged, + project_id, + metadata, + source, + created_at, + updated_at, + deleted_at + FROM notes + WHERE user_id = ? + AND (deleted_at IS NOT NULL) = ? + AND (? IS NULL OR type = ?) + AND (? IS NULL OR project_id = ?) + ORDER BY created_at DESC + LIMIT ? + "#, + params![ + self.user_id, + filter.archived, + filter.note_type, + filter.note_type, + filter.project_id, + filter.project_id, + limit, + ], + ) + } + + async fn search_notes( + &self, + keywords: &[String], + filter: &NoteFilter<'_>, + ) -> Result, CliError> { + if keywords.is_empty() { + return Err(CliError::Other( + "search_notes requires at least one keyword".into(), + )); + } + let limit = i64::from(filter.limit); + let keywords_json = serde_json::to_string(keywords)?; + let reader = self.db.reader().await?; + query_notes( + &reader, + r#" + SELECT + id, + short_id, + user_id, + type, + status, + title, + content, + summary, + is_flagged, + project_id, + metadata, + source, + created_at, + updated_at, + deleted_at + FROM notes + WHERE user_id = ? + AND (deleted_at IS NOT NULL) = ? + AND (? IS NULL OR type = ?) + AND (? IS NULL OR project_id = ?) + AND EXISTS ( + SELECT 1 FROM json_each(?) AS kw + WHERE title LIKE '%' || kw.value || '%' + OR content LIKE '%' || kw.value || '%' + OR summary LIKE '%' || kw.value || '%' + ) + ORDER BY updated_at DESC + LIMIT ? + "#, + params![ + self.user_id, + filter.archived, + filter.note_type, + filter.note_type, + filter.project_id, + filter.project_id, + keywords_json, + limit, + ], + ) + } + + async fn search_notes_structured( + &self, + search: &NoteSearch, + filter: &NoteFilter<'_>, + ) -> Result, CliError> { + if search.keywords.is_empty() && search.extractions.is_empty() { + return Err(CliError::Other( + "search_notes_structured requires at least one keyword or structured filter".into(), + )); + } + let limit = i64::from(filter.limit); + let keywords_json = serde_json::to_string(&search.keywords)?; + let extractions_json = serde_json::to_string( + &search + .extractions + .iter() + .map(|filter| { + serde_json::json!({ + "key": filter.key, + "value": filter.value, + }) + }) + .collect::>(), + )?; + let reader = self.db.reader().await?; + query_notes( + &reader, + r#" + SELECT + id, + short_id, + user_id, + type, + status, + title, + content, + summary, + is_flagged, + project_id, + metadata, + source, + created_at, + updated_at, + deleted_at + FROM notes + WHERE user_id = ? + AND (deleted_at IS NOT NULL) = ? + AND (? IS NULL OR type = ?) + AND (? IS NULL OR project_id = ?) + AND ( + json_array_length(?) = 0 OR EXISTS ( + SELECT 1 FROM json_each(?) AS kw + WHERE title LIKE '%' || kw.value || '%' + OR content LIKE '%' || kw.value || '%' + OR summary LIKE '%' || kw.value || '%' + ) + ) + AND NOT EXISTS ( + SELECT 1 FROM json_each(?) AS filter + WHERE NOT EXISTS ( + SELECT 1 FROM note_extractions extraction + WHERE extraction.user_id = notes.user_id + AND extraction.note_id = notes.id + AND extraction.key = json_extract(filter.value, '$.key') + AND extraction.value = json_extract(filter.value, '$.value') + ) + ) + ORDER BY updated_at DESC + LIMIT ? + "#, + params![ + self.user_id, + filter.archived, + filter.note_type, + filter.note_type, + filter.project_id, + filter.project_id, + keywords_json, + keywords_json, + extractions_json, + limit, + ], + ) + } + + async fn insert_note(&self, req: &InsertNoteReq<'_>) -> Result { + let writer = self.db.writer().await?; + writer.execute( + SQ_INSERT, + params![ + req.id, + self.user_id, + req.note_type, + req.status, + req.title, + req.content, + req.metadata, + req.project_id, + req.now, + req.now, + ], + )?; + Ok(InsertedNote { + uuid: req.id.to_string(), + short_id: None, + }) + } + + async fn update_note_content( + &self, + id: &str, + content: &str, + requeue: bool, + ) -> Result<(), CliError> { + let now = chrono::Utc::now().to_rfc3339(); + let writer = self.db.writer().await?; + writer.execute( + SQ_UPDATE_CONTENT, + params![content, requeue, now, self.user_id, id], + )?; + Ok(()) + } + + async fn set_note_deleted_at( + &self, + id: &str, + deleted_at: Option<&str>, + now: &str, + ) -> Result<(), CliError> { + let writer = self.db.writer().await?; + if let Some(ts) = deleted_at { + writer.execute(SQ_SET_DELETED_AT, params![ts, now, self.user_id, id])?; + } else { + writer.execute(SQ_SET_DELETED_AT_NULL, params![now, self.user_id, id])?; + } + Ok(()) + } + + async fn undo_last_delete(&self) -> Result<(), CliError> { + let now = chrono::Utc::now().to_rfc3339(); + let writer = self.db.writer().await?; + writer.execute(SQ_UNDO_DELETE, params![now, self.user_id])?; + Ok(()) + } + + async fn find_project_by_name(&self, name: &str) -> Result, CliError> { + let reader = self.db.reader().await?; + Ok(reader + .query_row(SQ_FIND_PROJECT, params![self.user_id, name], |row| { + row.get(0) + }) + .optional()?) + } + + async fn find_project_name_by_id(&self, project_id: &str) -> Result, CliError> { + let reader = self.db.reader().await?; + Ok(reader + .query_row( + SQ_FIND_PROJECT_NAME, + params![self.user_id, project_id], + |row| row.get(0), + ) + .optional()?) + } + + async fn list_projects(&self, archived: bool) -> Result, CliError> { + let sql = if archived { + SQ_LIST_PROJECTS_ARCHIVED + } else { + SQ_LIST_PROJECTS_ACTIVE + }; + let reader = self.db.reader().await?; + query_projects(&reader, sql, params![self.user_id]) + } + + async fn create_project(&self, name: &str) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + let writer = self.db.writer().await?; + writer.execute(SQ_CREATE_PROJECT, params![id, self.user_id, name, now])?; + Ok(id) + } + + async fn move_note_to_project( + &self, + note_id: &str, + new_project_id: &str, + old_project_id: Option<&str>, + ) -> Result, CliError> { + let now = chrono::Utc::now().to_rfc3339(); + let mut writer = self.db.writer().await?; + let tx = writer.transaction()?; + let exists = tx + .query_row( + "SELECT 1 FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1", + params![self.user_id, note_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .is_some(); + if !exists { + return Err(CliError::NoteNotFound { + id: note_id.to_string(), + }); + } + + tx.execute( + SQ_UPDATE_PROJECT, + params![new_project_id, now, self.user_id, note_id], + )?; + + let Some(old_pid) = old_project_id else { + tx.commit()?; + return Ok(None); + }; + + let count = tx.query_row( + SQ_COUNT_PROJECT_NOTES, + params![self.user_id, old_pid], + |row| row.get::<_, i64>(0), + )?; + + if count != 0 { + tx.commit()?; + return Ok(None); + } + + let old_name = tx + .query_row( + SQ_FIND_PROJECT_NAME, + params![self.user_id, old_pid], + |row| row.get::<_, String>(0), + ) + .optional()?; + tx.execute(SQ_DELETE_PROJECT, params![self.user_id, old_pid])?; + tx.commit()?; + Ok(old_name) + } + + async fn find_project(&self, id: &str) -> Result { + let reader = self.db.reader().await?; + reader + .query_row( + SQ_FIND_PROJECT_BY_ID, + params![self.user_id, id], + decode_project, + ) + .optional()? + .ok_or_else(|| CliError::Other(format!("Project not found: {id}"))) + } + + async fn resolve_project_id(&self, prefix: &str) -> Result { + resolve_sqlite_uuid_id(&self.db, SQ_RESOLVE_PROJECT, &self.user_id, prefix, || { + CliError::Other(format!("Project not found: {prefix}")) + }) + .await + } + + async fn update_project(&self, id: &str, color: Option>) -> Result<(), CliError> { + let update_color = color.is_some(); + if !update_color { + return Ok(()); + } + + let color_value = color.flatten(); + let writer = self.db.writer().await?; + writer.execute( + r#" + UPDATE projects SET + color = CASE WHEN ? THEN ? ELSE color END + WHERE user_id = ? AND id = ? + "#, + params![update_color, color_value, self.user_id, id], + )?; + Ok(()) + } + + async fn delete_project(&self, id: &str) -> Result<(), CliError> { + if !sqlite_exists( + &self.db, + "SELECT 1 FROM projects WHERE user_id = ? AND id = ? LIMIT 1", + &self.user_id, + id, + ) + .await? + { + return Err(CliError::Other(format!("Project not found: {id}"))); + } + let writer = self.db.writer().await?; + writer.execute(SQ_ARCHIVE_PROJECT, params![self.user_id, id])?; + Ok(()) + } + + async fn update_note_title(&self, id: &str, title: &str) -> Result<(), CliError> { + let now = chrono::Utc::now().to_rfc3339(); + if !sqlite_exists( + &self.db, + "SELECT 1 FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1", + &self.user_id, + id, + ) + .await? + { + return Err(CliError::NoteNotFound { id: id.to_string() }); + } + let writer = self.db.writer().await?; + writer.execute(SQ_UPDATE_TITLE, params![title, now, self.user_id, id])?; + Ok(()) + } + + async fn update_note_flagged(&self, id: &str, flagged: bool) -> Result<(), CliError> { + let now = chrono::Utc::now().to_rfc3339(); + let val: i64 = if flagged { 1 } else { 0 }; + if !sqlite_exists( + &self.db, + "SELECT 1 FROM notes WHERE user_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1", + &self.user_id, + id, + ) + .await? + { + return Err(CliError::NoteNotFound { id: id.to_string() }); + } + let writer = self.db.writer().await?; + writer.execute(SQ_UPDATE_FLAGGED, params![val, now, self.user_id, id])?; + Ok(()) + } + + async fn count_notes(&self, filter: &NoteFilter<'_>) -> Result { + let reader = self.db.reader().await?; + let count = reader.query_row( + r#" + SELECT COUNT(*) + FROM notes + WHERE user_id = ? + AND (deleted_at IS NOT NULL) = ? + AND (? IS NULL OR type = ?) + AND (? IS NULL OR project_id = ?) + "#, + params![ + self.user_id, + filter.archived, + filter.note_type, + filter.note_type, + filter.project_id, + filter.project_id, + ], + |row| row.get::<_, i64>(0), + )?; + count + .try_into() + .map_err(|_| CliError::Other(format!("unexpected negative count: {count}"))) + } + + async fn list_note_topics( + &self, + note_ids: &[&str], + ) -> Result>, CliError> { + let extractions = self + .list_note_extractions(note_ids, &[TOPIC_EXTRACTION_KEY]) + .await?; + let mut map = std::collections::HashMap::new(); + for (note_id, pairs) in extractions { + map.insert(note_id, pairs.into_iter().map(|(_, value)| value).collect()); + } + Ok(map) + } + async fn list_note_extractions( + &self, + note_ids: &[&str], + extraction_keys: &[&str], + ) -> Result>, CliError> { + if note_ids.is_empty() || extraction_keys.is_empty() { + return Ok(std::collections::HashMap::new()); + } + let note_ids_json = serde_json::to_string(note_ids)?; + let keys_json = serde_json::to_string(extraction_keys)?; + let reader = self.db.reader().await?; + let mut statement = reader.prepare(SQ_LIST_EXTRACTIONS)?; + let rows = statement + .query_map(params![self.user_id, keys_json, note_ids_json], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })? + .collect::, _>>()?; + let mut map: std::collections::HashMap> = + std::collections::HashMap::new(); + for (note_id, ext_type, value) in rows { + map.entry(note_id).or_default().push((ext_type, value)); + } + Ok(map) + } + + async fn list_extraction_values( + &self, + extraction_keys: &[&str], + archived: bool, + ) -> Result, CliError> { + if extraction_keys.is_empty() { + return Ok(Vec::new()); + } + let keys_json = serde_json::to_string(extraction_keys)?; + let reader = self.db.reader().await?; + let mut statement = reader.prepare(SQ_LIST_EXTRACTION_VALUES)?; + Ok(statement + .query_map(params![self.user_id, keys_json, archived], |row| row.get(0))? + .collect::, _>>()?) + } + async fn set_note_extractions( + &self, + note_id: &str, + extraction_key: &str, + values: &[String], + ) -> Result<(), CliError> { + let mut writer = self.db.writer().await?; + let transaction = writer.transaction()?; + transaction.execute( + SQ_CLEAR_EXTRACTIONS, + params![self.user_id, note_id, extraction_key], + )?; + for value in values { + let id = uuid::Uuid::new_v4().to_string(); + transaction.execute( + SQ_INSERT_EXTRACTION, + params![id, note_id, self.user_id, extraction_key, value], + )?; + } + transaction.commit()?; + Ok(()) + } +} diff --git a/flicknote-core/src/backend/tests.rs b/flicknote-core/src/backend/tests.rs new file mode 100644 index 0000000..a0a325a --- /dev/null +++ b/flicknote-core/src/backend/tests.rs @@ -0,0 +1,944 @@ +use super::*; +use crate::TOPIC_EXTRACTION_KEY; +use rusqlite::params; +use std::ops::Deref; + +struct BackendFixture { + backend: LocalPowerSyncBackend, + _database: powersync::PowerSyncDatabase, + _directory: tempfile::TempDir, +} + +impl Deref for BackendFixture { + type Target = LocalPowerSyncBackend; + + fn deref(&self) -> &Self::Target { + &self.backend + } +} + +async fn make_powersync_backend() -> ( + tempfile::TempDir, + powersync::PowerSyncDatabase, + LocalPowerSyncBackend, +) { + struct NoHttp; + + #[async_trait::async_trait] + impl powersync::http::HttpClient for NoHttp { + async fn send( + &self, + _request: powersync::http::Request, + ) -> Result { + panic!("local backend tests must not make HTTP requests") + } + } + + use powersync::{ConnectionPool, PowerSyncDatabase, env::PowerSyncEnvironment}; + + PowerSyncEnvironment::powersync_auto_extension().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let pool = ConnectionPool::open(directory.path().join("test-powersync.db")).unwrap(); + let environment = + PowerSyncEnvironment::custom(NoHttp, pool, PowerSyncEnvironment::tokio_timer()); + let db = PowerSyncDatabase::new(environment, crate::schema::app_schema()); + let backend = LocalPowerSyncBackend::new(db.clone(), "test-user-id".to_string()); + (directory, db, backend) +} + +#[tokio::test] +async fn powersync_backend_writes_to_the_supplied_database() { + let (_directory, db, backend) = make_powersync_backend().await; + let id = uuid::Uuid::new_v4().to_string(); + + backend + .insert_note(&InsertNoteReq { + id: &id, + note_type: "normal", + status: "ai_queued", + title: Some("Shared database"), + content: Some("visible immediately"), + metadata: None, + project_id: None, + now: "2026-08-10T00:00:00Z", + }) + .await + .unwrap(); + + assert_eq!( + backend.find_note_content(&id).await.unwrap().as_deref(), + Some("visible immediately") + ); + let transaction = db.next_crud_transaction().await.unwrap().unwrap(); + assert_eq!(transaction.crud.len(), 1); + assert_eq!(transaction.crud[0].table, "notes"); + assert_eq!(transaction.crud[0].id, id); +} + +#[tokio::test] +async fn replacing_extractions_rolls_back_on_insert_failure() { + let (_directory, _db, backend) = make_powersync_backend().await; + let id = uuid::Uuid::new_v4().to_string(); + backend + .insert_note(&InsertNoteReq { + id: &id, + note_type: "normal", + status: "ai_queued", + title: Some("Atomic extractions"), + content: Some("body"), + metadata: None, + project_id: None, + now: "2026-08-10T00:00:00Z", + }) + .await + .unwrap(); + backend + .set_note_extractions(&id, TOPIC_EXTRACTION_KEY, &["old".to_string()]) + .await + .unwrap(); + + let writer = backend.database().writer().await.unwrap(); + writer + .execute_batch( + r#" + CREATE TRIGGER fail_test_extraction + INSTEAD OF INSERT ON note_extractions + WHEN NEW.value = 'fail' + BEGIN + SELECT RAISE(ABORT, 'forced extraction failure'); + END; + "#, + ) + .unwrap(); + drop(writer); + + assert!( + backend + .set_note_extractions( + &id, + TOPIC_EXTRACTION_KEY, + &["new".to_string(), "fail".to_string()], + ) + .await + .is_err() + ); + assert_eq!( + backend.list_note_topics(&[&id]).await.unwrap().get(&id), + Some(&vec!["old".to_string()]) + ); +} + +async fn make_backend() -> BackendFixture { + let (directory, database, backend) = make_powersync_backend().await; + BackendFixture { + backend, + _database: database, + _directory: directory, + } +} + +#[tokio::test] +async fn local_backend_insert_and_find() { + let backend = make_backend().await; + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + let inserted = backend + .insert_note(&InsertNoteReq { + id: &id, + note_type: "normal", + status: "ai_queued", + title: Some("Hello world"), + content: Some("# Hello world\n\nContent here."), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + + assert_eq!(inserted.uuid, id); + assert_eq!(inserted.short_id, None); + + // Find by full id + let note = backend.find_note(&id).await.unwrap(); + assert_eq!(note.id, id); + assert_eq!(note.title, Some("Hello world".to_string())); + + // Find by full UUID compatibility path + let resolved = backend.resolve_note_id(&id).await.unwrap(); + assert_eq!(resolved, id); + + let writer = backend.database().writer().await.unwrap(); + writer + .execute( + "UPDATE notes SET short_id = ? WHERE id = ?", + params![42, id], + ) + .unwrap(); + drop(writer); + let resolved = backend.resolve_note_id("42").await.unwrap(); + assert_eq!(resolved, id); + + // UUID prefixes are not accepted for notes; use short IDs or full UUIDs. + let prefix = &id[..8]; + let err = backend.resolve_note_id(prefix).await.unwrap_err(); + assert!(matches!(err, CliError::NoteNotFound { .. })); + + // Find content + let content = backend.find_note_content(&id).await.unwrap(); + assert_eq!(content, Some("# Hello world\n\nContent here.".to_string())); +} + +#[tokio::test] +async fn test_numeric_short_id_ref_does_not_fallback_to_short_uuid_prefix() { + let backend = make_backend().await; + let id = "42000000-e29b-41d4-a716-446655440000".to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + backend + .insert_note(&InsertNoteReq { + id: &id, + note_type: "normal", + status: "ai_queued", + title: Some("Numeric prefix note"), + content: Some("content"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + + let err = backend.resolve_note_id("42").await.unwrap_err(); + assert!(matches!(err, CliError::NoteNotFound { .. })); +} + +#[tokio::test] +async fn test_eight_digit_uuid_prefix_does_not_resolve_note() { + let backend = make_backend().await; + let id = "12345678-e29b-41d4-a716-446655440000".to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + backend + .insert_note(&InsertNoteReq { + id: &id, + note_type: "normal", + status: "ai_queued", + title: Some("Eight digit prefix note"), + content: Some("content"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + + let err = backend.resolve_note_id("12345678").await.unwrap_err(); + assert!(matches!(err, CliError::NoteNotFound { .. })); +} + +#[tokio::test] +async fn test_resolved_note_id_can_update_content_and_extractions() { + let backend = make_backend().await; + let id = "11fa49a2-6ac4-421e-94bf-240ee4197bb7".to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + backend + .insert_note(&InsertNoteReq { + id: &id, + note_type: "normal", + status: "ai_queued", + title: Some("Editable note"), + content: Some("hello"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + let writer = backend.database().writer().await.unwrap(); + writer + .execute( + "UPDATE notes SET short_id = ? WHERE id = ?", + params![1172, id], + ) + .unwrap(); + drop(writer); + + let from_uuid = backend.resolve_note_id(&id).await.unwrap(); + backend + .update_note_content(&from_uuid, "hi from uuid", true) + .await + .unwrap(); + assert_eq!( + backend.find_note_content(&id).await.unwrap(), + Some("hi from uuid".to_string()) + ); + + let from_short_id = backend.resolve_note_id("1172").await.unwrap(); + backend + .update_note_content(&from_short_id, "hi from short id", true) + .await + .unwrap(); + assert_eq!( + backend.find_note_content(&id).await.unwrap(), + Some("hi from short id".to_string()) + ); + + backend + .set_note_extractions( + &from_short_id, + TOPIC_EXTRACTION_KEY, + &["orientation".to_string(), "cli".to_string()], + ) + .await + .unwrap(); + let extractions = backend + .list_note_extractions(&[&id], &[TOPIC_EXTRACTION_KEY]) + .await + .unwrap(); + assert_eq!( + extractions.get(&id), + Some(&vec![ + (TOPIC_EXTRACTION_KEY.to_string(), "cli".to_string()), + (TOPIC_EXTRACTION_KEY.to_string(), "orientation".to_string()) + ]) + ); +} + +#[tokio::test] +async fn local_backend_list_filter() { + let backend = make_backend().await; + let now = chrono::Utc::now().to_rfc3339(); + + // Create two projects + let proj_a = backend.create_project("Project A").await.unwrap(); + let proj_b = backend.create_project("Project B").await.unwrap(); + + // Insert notes in different projects + let id_a = uuid::Uuid::new_v4().to_string(); + backend + .insert_note(&InsertNoteReq { + id: &id_a, + note_type: "normal", + status: "ai_queued", + title: Some("Note A"), + content: Some("content a"), + metadata: None, + project_id: Some(&proj_a), + now: &now, + }) + .await + .unwrap(); + + let id_b = uuid::Uuid::new_v4().to_string(); + backend + .insert_note(&InsertNoteReq { + id: &id_b, + note_type: "normal", + status: "ai_queued", + title: Some("Note B"), + content: Some("content b"), + metadata: None, + project_id: Some(&proj_b), + now: &now, + }) + .await + .unwrap(); + + // List by project A + let notes = backend + .list_notes(&NoteFilter { + project_id: Some(&proj_a), + note_type: None, + archived: false, + limit: 20, + }) + .await + .unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].id, id_a); +} + +#[tokio::test] +async fn local_backend_search_notes() { + let backend = make_backend().await; + let now = chrono::Utc::now().to_rfc3339(); + + let id = uuid::Uuid::new_v4().to_string(); + backend + .insert_note(&InsertNoteReq { + id: &id, + note_type: "normal", + status: "ai_queued", + title: Some("Unique searchable title"), + content: Some("some body text"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + + let results = backend + .search_notes( + &["Unique".to_string()], + &NoteFilter { + project_id: None, + note_type: None, + archived: false, + limit: 20, + }, + ) + .await + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, id); + + // Empty keywords should return Err + let err = backend + .search_notes( + &[], + &NoteFilter { + project_id: None, + note_type: None, + archived: false, + limit: 20, + }, + ) + .await; + assert!(err.is_err()); +} + +#[tokio::test] +async fn local_backend_search_notes_matches_all_extraction_filters() { + let backend = make_backend().await; + let now = chrono::Utc::now().to_rfc3339(); + + let matching_id = uuid::Uuid::new_v4().to_string(); + backend + .insert_note(&InsertNoteReq { + id: &matching_id, + note_type: "normal", + status: "ai_queued", + title: Some("Whisper pipeline"), + content: Some("ASR notes"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + backend + .set_note_extractions( + &matching_id, + TOPIC_EXTRACTION_KEY, + &["ASR".to_string(), "AI".to_string()], + ) + .await + .unwrap(); + backend + .set_note_extractions(&matching_id, "::person", &["瓜子".to_string()]) + .await + .unwrap(); + + let topic_only_id = uuid::Uuid::new_v4().to_string(); + backend + .insert_note(&InsertNoteReq { + id: &topic_only_id, + note_type: "normal", + status: "ai_queued", + title: Some("Whisper without person"), + content: Some("ASR notes"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + backend + .set_note_extractions(&topic_only_id, TOPIC_EXTRACTION_KEY, &["ASR".to_string()]) + .await + .unwrap(); + + let results = backend + .search_notes_structured( + &NoteSearch { + keywords: vec!["Whisper".to_string()], + extractions: vec![ + MetadataFilter { + key: TOPIC_EXTRACTION_KEY.to_string(), + value: "ASR".to_string(), + }, + MetadataFilter { + key: "::person".to_string(), + value: "瓜子".to_string(), + }, + ], + }, + &NoteFilter { + project_id: None, + note_type: None, + archived: false, + limit: 20, + }, + ) + .await + .unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, matching_id); +} + +#[tokio::test] +async fn local_backend_search_notes_accepts_structured_only_query() { + let backend = make_backend().await; + let now = chrono::Utc::now().to_rfc3339(); + let id = uuid::Uuid::new_v4().to_string(); + + backend + .insert_note(&InsertNoteReq { + id: &id, + note_type: "normal", + status: "ai_queued", + title: Some("No keyword match here"), + content: Some("body"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + backend + .set_note_extractions(&id, "::company", &["OpenAI".to_string()]) + .await + .unwrap(); + + let results = backend + .search_notes_structured( + &NoteSearch { + keywords: Vec::new(), + extractions: vec![MetadataFilter { + key: "::company".to_string(), + value: "OpenAI".to_string(), + }], + }, + &NoteFilter { + project_id: None, + note_type: None, + archived: false, + limit: 20, + }, + ) + .await + .unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, id); +} + +#[tokio::test] +async fn local_backend_list_extraction_values_dedupes_and_sorts() { + let backend = make_backend().await; + let now = chrono::Utc::now().to_rfc3339(); + + for value in ["ASR", "AI", "ASR"] { + let id = uuid::Uuid::new_v4().to_string(); + backend + .insert_note(&InsertNoteReq { + id: &id, + note_type: "normal", + status: "ai_queued", + title: Some(value), + content: Some("body"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + backend + .set_note_extractions(&id, TOPIC_EXTRACTION_KEY, &[value.to_string()]) + .await + .unwrap(); + } + + let values = backend + .list_extraction_values(&[TOPIC_EXTRACTION_KEY], false) + .await + .unwrap(); + + assert_eq!(values, vec!["AI".to_string(), "ASR".to_string()]); +} + +#[tokio::test] +async fn local_backend_search_respects_type_filter() { + let backend = make_backend().await; + let now = chrono::Utc::now().to_rfc3339(); + + let normal_id = uuid::Uuid::new_v4().to_string(); + backend + .insert_note(&InsertNoteReq { + id: &normal_id, + note_type: "normal", + status: "ai_queued", + title: Some("Shared searchable title"), + content: Some("normal body"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + + let link_id = uuid::Uuid::new_v4().to_string(); + backend + .insert_note(&InsertNoteReq { + id: &link_id, + note_type: "link", + status: "ai_queued", + title: Some("Shared searchable title"), + content: Some("link body"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + + let results = backend + .search_notes( + &["Shared".to_string()], + &NoteFilter { + project_id: None, + note_type: Some("link"), + archived: false, + limit: 20, + }, + ) + .await + .unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, link_id); +} + +#[tokio::test] +async fn local_backend_archive() { + let backend = make_backend().await; + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + backend + .insert_note(&InsertNoteReq { + id: &id, + note_type: "normal", + status: "ai_queued", + title: Some("To archive"), + content: Some("content"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + + // Verify it appears in active list + let active = backend + .list_notes(&NoteFilter { + project_id: None, + note_type: None, + archived: false, + limit: 20, + }) + .await + .unwrap(); + assert!(active.iter().any(|n| n.id == id)); + + // Archive it + backend + .set_note_deleted_at(&id, Some(&now), &now) + .await + .unwrap(); + + // Should be gone from active + let active_after = backend + .list_notes(&NoteFilter { + project_id: None, + note_type: None, + archived: false, + limit: 20, + }) + .await + .unwrap(); + assert!(!active_after.iter().any(|n| n.id == id)); + + // Should appear in archived + let archived = backend + .list_notes(&NoteFilter { + project_id: None, + note_type: None, + archived: true, + limit: 20, + }) + .await + .unwrap(); + assert!(archived.iter().any(|n| n.id == id)); + + // Unarchive + backend.set_note_deleted_at(&id, None, &now).await.unwrap(); + let active_restored = backend + .list_notes(&NoteFilter { + project_id: None, + note_type: None, + archived: false, + limit: 20, + }) + .await + .unwrap(); + assert!(active_restored.iter().any(|n| n.id == id)); +} + +#[tokio::test] +async fn test_find_archived_note() { + let backend = make_backend().await; + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + backend + .insert_note(&InsertNoteReq { + id: &id, + note_type: "normal", + status: "ai_queued", + title: Some("Archived note"), + content: Some("content"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + + // Not findable via find_archived_note before archiving + assert!(backend.find_archived_note(&id).await.is_err()); + + // Archive it + backend + .set_note_deleted_at(&id, Some(&now), &now) + .await + .unwrap(); + + // Now findable via find_archived_note + let note = backend.find_archived_note(&id).await.unwrap(); + assert_eq!(note.id, id); + assert_eq!(note.title, Some("Archived note".to_string())); + assert!(note.deleted_at.is_some()); + + // No longer findable via find_note (active-only) + assert!(backend.find_note(&id).await.is_err()); +} + +// ─── Fix: PowerSync view-UPDATE zero affected rows ──────────────────── + +#[tokio::test] +async fn test_move_note_to_project_ok() { + let backend = make_backend().await; + let now = chrono::Utc::now().to_rfc3339(); + let note_id = uuid::Uuid::new_v4().to_string(); + let proj_a = backend.create_project("Proj-A").await.unwrap(); + let proj_b = backend.create_project("Proj-B").await.unwrap(); + + backend + .insert_note(&InsertNoteReq { + id: ¬e_id, + note_type: "normal", + status: "ai_queued", + title: Some("Test note"), + content: Some("body"), + metadata: None, + project_id: Some(&proj_a), + now: &now, + }) + .await + .unwrap(); + + // Move to proj_b — should succeed (not return NoteNotFound) + let result = backend + .move_note_to_project(¬e_id, &proj_b, Some(&proj_a)) + .await + .unwrap(); + // This note was the only one in proj_a, so proj_a gets deleted + assert_eq!(result.as_deref(), Some("Proj-A")); + + // Verify the note is now in proj_b + let note = backend.find_note(¬e_id).await.unwrap(); + assert_eq!(note.project_id.as_deref(), Some(proj_b.as_str())); +} + +#[tokio::test] +async fn test_move_note_to_project_missing_returns_err() { + let backend = make_backend().await; + let fake_id = uuid::Uuid::new_v4().to_string(); + let proj_a = backend.create_project("Proj-A").await.unwrap(); + let proj_b = backend.create_project("Proj-B").await.unwrap(); + + let err = backend + .move_note_to_project(&fake_id, &proj_b, Some(&proj_a)) + .await + .unwrap_err(); + match err { + CliError::NoteNotFound { id } => assert_eq!(id, fake_id), + _ => panic!("expected NoteNotFound, got {:?}", err), + } +} + +#[tokio::test] +async fn test_move_note_to_project_same_project_noop() { + let backend = make_backend().await; + let now = chrono::Utc::now().to_rfc3339(); + let note_id = uuid::Uuid::new_v4().to_string(); + let proj_x = backend.create_project("Proj-X").await.unwrap(); + + backend + .insert_note(&InsertNoteReq { + id: ¬e_id, + note_type: "normal", + status: "ai_queued", + title: Some("Same-project note"), + content: Some("body"), + metadata: None, + project_id: Some(&proj_x), + now: &now, + }) + .await + .unwrap(); + + // Same source and target — should be idempotent, return Ok(None), + // not delete the project (it still holds the note). + let result = backend + .move_note_to_project(¬e_id, &proj_x, Some(&proj_x)) + .await + .unwrap(); + assert_eq!(result, None, "same-project move should not delete project"); + + // Verify project still exists and note is still in it + let note = backend.find_note(¬e_id).await.unwrap(); + assert_eq!(note.project_id.as_deref(), Some(proj_x.as_str())); + let active = backend.list_projects(false).await.unwrap(); + assert!( + active.iter().any(|p| p.id == proj_x), + "project should still exist" + ); +} + +#[tokio::test] +async fn test_update_note_title_ok() { + let backend = make_backend().await; + let now = chrono::Utc::now().to_rfc3339(); + let note_id = uuid::Uuid::new_v4().to_string(); + + backend + .insert_note(&InsertNoteReq { + id: ¬e_id, + note_type: "normal", + status: "ai_queued", + title: Some("Old title"), + content: Some("body"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + + backend + .update_note_title(¬e_id, "New title") + .await + .unwrap(); + let note = backend.find_note(¬e_id).await.unwrap(); + assert_eq!(note.title, Some("New title".to_string())); +} + +#[tokio::test] +async fn test_update_note_flagged_ok() { + let backend = make_backend().await; + let now = chrono::Utc::now().to_rfc3339(); + let note_id = uuid::Uuid::new_v4().to_string(); + + backend + .insert_note(&InsertNoteReq { + id: ¬e_id, + note_type: "normal", + status: "ai_queued", + title: Some("Flag me"), + content: Some("body"), + metadata: None, + project_id: None, + now: &now, + }) + .await + .unwrap(); + + backend.update_note_flagged(¬e_id, true).await.unwrap(); + let note = backend.find_note(¬e_id).await.unwrap(); + assert_eq!(note.is_flagged, Some(1)); + + backend.update_note_flagged(¬e_id, false).await.unwrap(); + let note = backend.find_note(¬e_id).await.unwrap(); + assert_eq!(note.is_flagged, Some(0)); +} + +#[tokio::test] +async fn test_delete_project_archives() { + let backend = make_backend().await; + let proj_id = backend.create_project("ToDelete").await.unwrap(); + + // Verify project exists + let proj = backend.find_project(&proj_id).await.unwrap(); + assert_eq!(proj.name, "ToDelete"); + + backend.delete_project(&proj_id).await.unwrap(); + + // After archive, project should not appear in active list + let active = backend.list_projects(false).await.unwrap(); + assert!( + !active.iter().any(|p| p.id == proj_id), + "deleted project should not appear in active list" + ); + + // Archived list should contain it + let archived = backend.list_projects(true).await.unwrap(); + assert!( + archived.iter().any(|p| p.id == proj_id), + "deleted project should appear in archived list" + ); +} + +#[tokio::test] +async fn test_delete_project_missing_returns_err() { + let backend = make_backend().await; + let fake_id = uuid::Uuid::new_v4().to_string(); + + let err = backend.delete_project(&fake_id).await.unwrap_err(); + match err { + CliError::Other(msg) => assert!(msg.contains("not found"), "got: {msg}"), + _ => panic!("expected Other error, got {:?}", err), + } +} + +#[tokio::test] +async fn test_project_resolver_rejects_uuid_prefixes() { + let backend = make_backend().await; + + let project_id = backend.create_project("Exact Project").await.unwrap(); + + assert_eq!( + backend.resolve_project_id(&project_id).await.unwrap(), + project_id + ); + + let project_prefix = &project_id[..8]; + + assert!(backend.resolve_project_id(project_prefix).await.is_err()); +} diff --git a/flicknote-core/src/db.rs b/flicknote-core/src/db.rs deleted file mode 100644 index fff4dbd..0000000 --- a/flicknote-core/src/db.rs +++ /dev/null @@ -1,46 +0,0 @@ -use powersync::env::PowerSyncEnvironment; -use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions}; - -use crate::config::Config; -use crate::schema::app_schema; - -pub struct Database { - pub pool: SqlitePool, -} - -impl Database { - /// Open the database for local-only use (no sync connection). - pub async fn open_local(config: &Config) -> Result { - PowerSyncEnvironment::powersync_auto_extension().map_err(|e| { - crate::error::CliError::Other(format!("Failed to load PowerSync extension: {e}")) - })?; - - let options = SqliteConnectOptions::new() - .filename(&config.paths.db_file) - .create_if_missing(true) - .journal_mode(SqliteJournalMode::Wal) - .pragma("journal_size_limit", "6291456") - .pragma("busy_timeout", "30000") - .pragma("cache_size", "51200"); - - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect_with(options) - .await?; - - sqlx::query("SELECT powersync_update_hooks('install')") - .execute(&pool) - .await?; - sqlx::query("SELECT powersync_init()") - .execute(&pool) - .await?; - - let schema = serde_json::to_string(&app_schema())?; - sqlx::query("SELECT powersync_replace_schema(?)") - .bind(schema) - .execute(&pool) - .await?; - - Ok(Self { pool }) - } -} diff --git a/flicknote-core/src/error.rs b/flicknote-core/src/error.rs index acec17b..7113497 100644 --- a/flicknote-core/src/error.rs +++ b/flicknote-core/src/error.rs @@ -26,8 +26,9 @@ pub enum CliError { #[error("PowerSync error: {0}")] PowerSync(#[from] powersync::error::PowerSyncError), + #[cfg(feature = "powersync")] #[error("Database error: {0}")] - Sqlx(#[from] sqlx::Error), + Sqlite(#[from] rusqlite::Error), #[error("HTTP error: {0}")] Http(String), diff --git a/flicknote-core/src/lib.rs b/flicknote-core/src/lib.rs index 5fe9e7c..a0c68a4 100644 --- a/flicknote-core/src/lib.rs +++ b/flicknote-core/src/lib.rs @@ -1,7 +1,5 @@ pub mod backend; pub mod config; -#[cfg(feature = "powersync")] -pub mod db; pub mod error; #[cfg(feature = "powersync")] pub mod schema; diff --git a/flicknote-core/src/services/note.rs b/flicknote-core/src/services/note.rs index c2a4ead..cd49a20 100644 --- a/flicknote-core/src/services/note.rs +++ b/flicknote-core/src/services/note.rs @@ -636,7 +636,7 @@ mod tests { async fn append_separates_content_and_does_not_requeue() { let backend = make_backend().await; let id = insert_normal_note(&backend, "existing", "synced").await; - let service = NoteService::new(&backend); + let service = NoteService::new(&*backend); let result = service.append(&id, "added").await.unwrap(); @@ -662,7 +662,7 @@ mod tests { .headings[0] .id .clone(); - let service = NoteService::new(&backend); + let service = NoteService::new(&*backend); let result = service .replace_section(&id, §ion, "# Replacement\nnew") @@ -682,7 +682,7 @@ mod tests { async fn modify_rejects_ambiguous_before_without_writing() { let backend = make_backend().await; let id = insert_normal_note(&backend, "same\n\nsame", "synced").await; - let service = NoteService::new(&backend); + let service = NoteService::new(&*backend); let error = service .modify(NoteModifyInput { @@ -706,7 +706,7 @@ mod tests { async fn archive_and_restore_target_the_explicit_note() { let backend = make_backend().await; let id = insert_normal_note(&backend, "body", "synced").await; - let service = NoteService::new(&backend); + let service = NoteService::new(&*backend); let archived = service.archive(&id).await.unwrap(); assert!(archived.archived); @@ -726,7 +726,7 @@ mod tests { .move_note_to_project(&id, &project_id, None) .await .unwrap(); - let service = NoteService::new(&backend); + let service = NoteService::new(&*backend); let notes = service .list(NoteListInput { @@ -747,7 +747,7 @@ mod tests { async fn get_returns_editable_content_and_section_tree() { let backend = make_backend().await; let id = insert_normal_note(&backend, "## Part\nBody", "synced").await; - let service = NoteService::new(&backend); + let service = NoteService::new(&*backend); let detail = service.get(&id, false).await.unwrap(); @@ -764,7 +764,7 @@ mod tests { let section = crate::services::markdown::parse_markdown(original).headings[0] .id .clone(); - let service = NoteService::new(&backend); + let service = NoteService::new(&*backend); let renamed = service .rename_section(&id, §ion, "Renamed") @@ -786,7 +786,7 @@ mod tests { let section = crate::services::markdown::parse_markdown(original).headings[0] .id .clone(); - let service = NoteService::new(&backend); + let service = NoteService::new(&*backend); service .insert(&id, §ion, InsertPosition::After, "## New\nnew") @@ -808,7 +808,7 @@ mod tests { .set_note_extractions(&id, "::topic", &["Rust".to_string()]) .await .unwrap(); - let service = NoteService::new(&backend); + let service = NoteService::new(&*backend); let found = service .find(NoteFindInput { @@ -845,7 +845,7 @@ mod tests { let section = crate::services::markdown::parse_markdown(original).headings[0] .id .clone(); - let service = NoteService::new(&backend); + let service = NoteService::new(&*backend); let result = service.get_section(&id, §ion).await.unwrap(); @@ -858,7 +858,7 @@ mod tests { async fn get_section_accepts_an_id_returned_by_get_when_title_matches_heading() { let backend = make_backend().await; let id = insert_normal_note(&backend, "# Test note\nBody", "synced").await; - let service = NoteService::new(&backend); + let service = NoteService::new(&*backend); let detail = service.get(&id, false).await.unwrap(); let section_id = detail.sections[0].id.clone(); @@ -910,7 +910,7 @@ mod tests { #[tokio::test] async fn add_reports_structured_partial_when_summary_read_fails_after_create() { let backend = make_backend().await; - let error = NoteService::new(&backend) + let error = NoteService::new(&*backend) .add( &DetachedCreator, NoteAddInput { @@ -943,10 +943,10 @@ mod tests { async fn add_normalizes_h1_before_calling_creator() { let backend = make_backend().await; let creator = DbCreator { - db: &backend, + db: &*backend, request: std::sync::Mutex::new(None), }; - let service = NoteService::new(&backend); + let service = NoteService::new(&*backend); let created = service .add( @@ -974,11 +974,11 @@ mod tests { async fn add_only_treats_a_pure_http_value_as_a_link() { let backend = make_backend().await; let creator = DbCreator { - db: &backend, + db: &*backend, request: std::sync::Mutex::new(None), }; - NoteService::new(&backend) + NoteService::new(&*backend) .add( &creator, NoteAddInput { @@ -1005,13 +1005,15 @@ mod tests { async fn source_reads_archived_notes_through_the_shared_parser() { let backend = make_backend().await; let id = insert_normal_note(&backend, "body", "synced").await; - sqlx::query("UPDATE notes SET source = ? WHERE id = ?") - .bind(r#"{"link":{"content":"one\ntwo"}}"#) - .bind(&id) - .execute(&backend.db.pool) - .await + let writer = backend.database().writer().await.unwrap(); + writer + .execute( + "UPDATE notes SET source = ? WHERE id = ?", + rusqlite::params![r#"{"link":{"content":"one\ntwo"}}"#, id], + ) .unwrap(); - let service = NoteService::new(&backend); + drop(writer); + let service = NoteService::new(&*backend); service.archive(&id).await.unwrap(); let result = service @@ -1068,13 +1070,16 @@ mod tests { async fn share_and_open_resolve_note_identity_before_side_effects() { let backend = make_backend().await; let id = insert_normal_note(&backend, "body", "synced").await; - sqlx::query("UPDATE notes SET short_id = 42 WHERE id = ?") - .bind(&id) - .execute(&backend.db.pool) - .await + let writer = backend.database().writer().await.unwrap(); + writer + .execute( + "UPDATE notes SET short_id = ? WHERE id = ?", + rusqlite::params![42, id], + ) .unwrap(); + drop(writer); let side_effects = FakeSideEffects::default(); - let service = NoteService::new(&backend); + let service = NoteService::new(&*backend); let shared = service.share(&side_effects, &id).await.unwrap(); assert_eq!(shared.url, format!("https://share.example/{id}")); diff --git a/flicknote-core/src/services/project.rs b/flicknote-core/src/services/project.rs index c753c10..e05d4d9 100644 --- a/flicknote-core/src/services/project.rs +++ b/flicknote-core/src/services/project.rs @@ -133,7 +133,7 @@ mod tests { #[tokio::test] async fn add_get_modify_and_archive_share_one_typed_contract() { let backend = make_backend().await; - let service = ProjectService::new(&backend); + let service = ProjectService::new(&*backend); let created = service .add(ProjectAddInput { @@ -196,7 +196,7 @@ mod tests { let backend = make_backend().await; let id = backend.create_project("work").await.unwrap(); let gateway = FakeGateway::default(); - let service = ProjectService::new(&backend); + let service = ProjectService::new(&*backend); assert_eq!( service.share(&gateway, &id).await.unwrap().url, @@ -215,7 +215,7 @@ mod tests { #[tokio::test] async fn project_lookup_uses_domain_error_code() { let backend = make_backend().await; - let service = ProjectService::new(&backend); + let service = ProjectService::new(&*backend); let missing = service .get("550e8400-e29b-41d4-a716-446655440000") diff --git a/flicknote-core/src/services/test_support.rs b/flicknote-core/src/services/test_support.rs index 9d257ec..7bd4e44 100644 --- a/flicknote-core/src/services/test_support.rs +++ b/flicknote-core/src/services/test_support.rs @@ -1,34 +1,52 @@ -use crate::backend::{InsertNoteReq, NoteDb, SqliteBackend}; -use crate::config::{Config, ConfigPaths}; -use crate::db::Database; +use crate::backend::{InsertNoteReq, LocalPowerSyncBackend, NoteDb}; +use std::ops::Deref; -pub(crate) async fn make_backend() -> SqliteBackend { +pub(crate) struct BackendFixture { + backend: LocalPowerSyncBackend, + _database: powersync::PowerSyncDatabase, + _directory: tempfile::TempDir, +} + +impl Deref for BackendFixture { + type Target = LocalPowerSyncBackend; + + fn deref(&self) -> &Self::Target { + &self.backend + } +} + +pub(crate) async fn make_backend() -> BackendFixture { + struct NoHttp; + + #[async_trait::async_trait] + impl powersync::http::HttpClient for NoHttp { + async fn send( + &self, + _request: powersync::http::Request, + ) -> Result { + panic!("local service tests must not make HTTP requests") + } + } + + use powersync::{ConnectionPool, PowerSyncDatabase, env::PowerSyncEnvironment}; + + PowerSyncEnvironment::powersync_auto_extension().unwrap(); let directory = tempfile::tempdir().unwrap(); - let config = Config { - supabase_url: String::new(), - supabase_anon_key: String::new(), - powersync_url: String::new(), - api_url: String::new(), - web_url: None, - paths: ConfigPaths { - config_dir: directory.path().to_path_buf(), - data_dir: directory.path().to_path_buf(), - config_file: directory.path().join("config.json"), - session_file: directory.path().join("session.json"), - db_file: directory.path().join("test.db"), - log_file: directory.path().join("test.log"), - }, - }; - let db = Database::open_local(&config).await.unwrap(); - std::mem::forget(directory); - SqliteBackend { - db, - user_id: "test-user-id".to_string(), + let pool = ConnectionPool::open(directory.path().join("test.db")).unwrap(); + let environment = + PowerSyncEnvironment::custom(NoHttp, pool, PowerSyncEnvironment::tokio_timer()); + let db = PowerSyncDatabase::new(environment, crate::schema::app_schema()); + db.writer().await.unwrap(); + let backend = LocalPowerSyncBackend::new(db.clone(), "test-user-id".to_string()); + BackendFixture { + backend, + _database: db, + _directory: directory, } } pub(crate) async fn insert_normal_note( - backend: &SqliteBackend, + backend: &LocalPowerSyncBackend, content: &str, status: &str, ) -> String { diff --git a/flicknote-core/src/types.rs b/flicknote-core/src/types.rs index 8a975fa..11971e6 100644 --- a/flicknote-core/src/types.rs +++ b/flicknote-core/src/types.rs @@ -1,11 +1,10 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Note { pub id: String, pub short_id: Option, pub user_id: String, - #[sqlx(rename = "type")] pub r#type: String, pub status: String, pub title: Option, @@ -30,7 +29,7 @@ impl Note { .map(std::string::ToString::to_string) } } -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Project { pub id: String, pub user_id: String, diff --git a/flicknote-sync/src/app/mod.rs b/flicknote-sync/src/app/mod.rs index 75ebac2..a582f91 100644 --- a/flicknote-sync/src/app/mod.rs +++ b/flicknote-sync/src/app/mod.rs @@ -14,7 +14,6 @@ pub struct Application { creator: Arc, share_gateway: Arc, web_url: Option, - write_signal: Option>, } impl Application { @@ -28,7 +27,6 @@ impl Application { creator, share_gateway, web_url: None, - write_signal: None, } } @@ -37,23 +35,8 @@ impl Application { self } - pub fn with_write_signal(mut self, signal: tokio::sync::mpsc::Sender<()>) -> Self { - self.write_signal = Some(signal); - self - } - pub async fn handle(&self, request: AppRequest) -> Result { - let may_write = request.may_write(); - let result = self.handle_inner(request).await; - if may_write - && let Some(signal) = &self.write_signal - && signal.try_send(()).is_err() - { - log::debug!( - "Upload trigger channel full or closed; startup/next write will drain CRUD" - ); - } - result + self.handle_inner(request).await } async fn handle_inner(&self, request: AppRequest) -> Result { diff --git a/flicknote-sync/src/connector.rs b/flicknote-sync/src/connector.rs index 7a7e8c4..730d2c2 100644 --- a/flicknote-sync/src/connector.rs +++ b/flicknote-sync/src/connector.rs @@ -19,10 +19,7 @@ impl BackendConnector for FlickNoteConnector { } async fn upload_data(&self) -> Result<(), PowerSyncError> { - let _guard = self.upload_guard.lock().await; let token = self.get_token().await?; - // Ignore the bool — checkpoint is only safe to call from the serialized drain path, - // not here (SDK callback fires during active sync alongside the download actor). run_upload( &self.db, &self.http_client, diff --git a/flicknote-sync/src/runtime.rs b/flicknote-sync/src/runtime.rs index c07fbb8..874746c 100644 --- a/flicknote-sync/src/runtime.rs +++ b/flicknote-sync/src/runtime.rs @@ -3,20 +3,19 @@ use std::sync::Arc; use flicknote_auth::client::GoTrueClient; use flicknote_core::{ - backend::{NoteDb, SqliteBackend}, + backend::{LocalPowerSyncBackend, NoteDb}, config::Config, - db::Database, schema::app_schema, services::ports::{NoteCreator, ShareGateway}, }; use powersync::{ConnectionPool, PowerSyncDatabase, SyncOptions, env::PowerSyncEnvironment}; -use tokio::{net::UnixListener, sync::mpsc}; +use tokio::net::UnixListener; use crate::app::Application; use crate::ipc; use crate::remote::{RemoteNoteCreator, RemoteShareGateway}; use crate::storage_maintenance::{WalCheckpointMode, checkpoint_wal_standalone}; -use crate::upload::{FlickNoteConnector, retry_upload_until_success}; +use crate::upload::FlickNoteConnector; fn pid_path(config: &Config) -> PathBuf { PathBuf::from(&config.paths.data_dir).join("sync.pid") @@ -87,7 +86,6 @@ fn check_and_write_pid(path: &Path) -> Result, checkpoint: tokio::task::JoinHandle<()>, socket: tokio::task::JoinHandle<()>, } @@ -104,22 +102,18 @@ pub async fn run() -> Result<(), Box> { &config.supabase_anon_key, &config.paths.session_file, )); - let upload_guard = Arc::new(tokio::sync::Mutex::new(())); - let connector = build_connector(&db, &auth, &upload_guard, &config); + let connector = build_connector(&db, &auth, &config); startup_checkpoint(config.paths.db_file.clone()).await; - let backend = open_local_backend(&config).await?; + let backend = open_local_backend(&db, &config)?; log::info!("Sync daemon connecting (pid {})", std::process::id()); - let (trigger_tx, trigger_rx) = mpsc::channel::<()>(16); - let upload_worker = upload_worker(&connector, trigger_rx, config.paths.db_file.clone()); db.connect(SyncOptions::new(connector)).await; log::info!("Sync daemon connected (pid {})", std::process::id()); let mut actors = ActorHandles { - upload: tokio::spawn(upload_worker), checkpoint: spawn_checkpoint_worker(config.paths.db_file.clone()), - socket: spawn_socket_server(socket_listener, backend, &db, &auth, &config, trigger_tx), + socket: spawn_socket_server(socket_listener, backend, &db, &auth, &config), }; let result = wait_for_shutdown(&mut actors).await; shutdown_daemon(&mut actors, &db, config.paths.db_file.clone()).await; @@ -144,13 +138,11 @@ fn open_powersync_database( fn build_connector( db: &PowerSyncDatabase, auth: &Arc, - upload_guard: &Arc>, config: &Config, ) -> FlickNoteConnector { FlickNoteConnector { db: db.clone(), auth: Arc::clone(auth), - upload_guard: Arc::clone(upload_guard), http_client: reqwest::Client::new(), powersync_url: config.powersync_url.clone(), supabase_url: config.supabase_url.clone(), @@ -169,70 +161,12 @@ async fn startup_checkpoint(db_path: PathBuf) { } } -async fn open_local_backend( +fn open_local_backend( + db: &PowerSyncDatabase, config: &Config, ) -> Result, Box> { let user_id = flicknote_core::session::get_user_id(config)?; - Ok(Arc::new(SqliteBackend { - db: Database::open_local(config).await?, - user_id, - })) -} - -fn upload_worker( - connector: &FlickNoteConnector, - mut trigger_rx: mpsc::Receiver<()>, - db_path: PathBuf, -) -> impl std::future::Future + Send + 'static { - let db = connector.db.clone(); - let client = connector.http_client.clone(); - let auth = Arc::clone(&connector.auth); - let guard = Arc::clone(&connector.upload_guard); - let supabase_url = connector.supabase_url.clone(); - let anon_key = connector.supabase_anon_key.clone(); - async move { - retry_upload_until_success( - &db, - &client, - &auth, - &guard, - &supabase_url, - &anon_key, - "Startup upload", - &db_path, - ) - .await; - - while wait_for_upload_trigger(&mut trigger_rx).await { - retry_upload_until_success( - &db, - &client, - &auth, - &guard, - &supabase_url, - &anon_key, - "Upload", - &db_path, - ) - .await; - } - } -} - -async fn wait_for_upload_trigger(trigger_rx: &mut mpsc::Receiver<()>) -> bool { - if trigger_rx.recv().await.is_none() { - return false; - } - loop { - tokio::select! { - _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => return true, - value = trigger_rx.recv() => { - if value.is_none() { - return false; - } - } - } - } + Ok(Arc::new(LocalPowerSyncBackend::new(db.clone(), user_id))) } fn spawn_checkpoint_worker(db_path: PathBuf) -> tokio::task::JoinHandle<()> { @@ -259,7 +193,6 @@ fn spawn_socket_server( db: &PowerSyncDatabase, auth: &Arc, config: &Arc, - trigger_tx: mpsc::Sender<()>, ) -> tokio::task::JoinHandle<()> { let http = reqwest::Client::new(); let creator: Arc = Arc::new(RemoteNoteCreator::new( @@ -273,11 +206,8 @@ fn spawn_socket_server( Arc::clone(auth), Arc::clone(config), )); - let app = Arc::new( - Application::new(backend, creator, gateway) - .with_web_url(config.web_url.clone()) - .with_write_signal(trigger_tx), - ); + let app = + Arc::new(Application::new(backend, creator, gateway).with_web_url(config.web_url.clone())); tokio::spawn(async move { if let Err(error) = ipc::serve_app(listener, app, ipc::ServerInfo::current()).await { log::error!("Application socket server failed: {error}"); @@ -288,7 +218,6 @@ fn spawn_socket_server( async fn wait_for_shutdown(actors: &mut ActorHandles) -> Result<(), String> { tokio::select! { _ = tokio::signal::ctrl_c() => Ok(()), - result = &mut actors.upload => result.map_err(|error| error.to_string()), result = &mut actors.checkpoint => { if let Err(error) = &result { log::error!("Checkpoint task panicked: {error}"); @@ -309,7 +238,6 @@ async fn wait_for_shutdown(actors: &mut ActorHandles) -> Result<(), String> { } async fn shutdown_daemon(actors: &mut ActorHandles, db: &PowerSyncDatabase, db_path: PathBuf) { - actors.upload.abort(); actors.checkpoint.abort(); actors.socket.abort(); db.disconnect().await; diff --git a/flicknote-sync/src/upload.rs b/flicknote-sync/src/upload.rs index 5f39445..e41ddf7 100644 --- a/flicknote-sync/src/upload.rs +++ b/flicknote-sync/src/upload.rs @@ -1,19 +1,15 @@ -use std::future::Future; -use std::path::Path; use std::sync::Arc; use flicknote_auth::client::GoTrueClient; use futures_lite::StreamExt; use powersync::{CrudEntry, PowerSyncDatabase, UpdateType, error::PowerSyncError}; -use crate::storage_maintenance::{WalCheckpointMode, checkpoint_wal_standalone}; - #[cfg(test)] mod tests; /// Helper to convert arbitrary errors into PowerSyncError. pub(crate) fn ps_err(msg: impl std::fmt::Display) -> PowerSyncError { - std::io::Error::other(msg.to_string()).into() + PowerSyncError::upload_error(std::io::Error::other(msg.to_string())) } /// Postgres/PostgREST error codes that will never succeed on retry. @@ -89,7 +85,6 @@ async fn classify_response( pub(crate) struct FlickNoteConnector { pub(crate) db: PowerSyncDatabase, pub(crate) auth: Arc, - pub(crate) upload_guard: Arc>, pub(crate) http_client: reqwest::Client, pub(crate) powersync_url: String, pub(crate) supabase_url: String, @@ -235,13 +230,6 @@ async fn upload_crud( Ok(classify_response(response, operation, &table, &id).await) } -/// Inner upload logic shared by the BackendConnector and application-triggered drain. -/// Caller is responsible for holding `upload_guard` before calling. -/// -/// Returns `true` if at least one CRUD transaction was processed and committed, -/// `false` if ps_crud was empty. Callers may use this to decide whether to -/// run a WAL checkpoint after upload. -/// /// The token is fetched once per call by the caller. Supabase tokens are typically /// valid for 1 hour, so any realistic upload batch completes well within the window. pub(crate) async fn run_upload( @@ -250,9 +238,8 @@ pub(crate) async fn run_upload( token: &str, supabase_url: &str, supabase_anon_key: &str, -) -> Result { +) -> Result<(), PowerSyncError> { let mut transactions = db.crud_transactions(); - let mut did_upload = false; while let Some(mut tx) = transactions.try_next().await? { let mut fatal_msg: Option = None; @@ -283,7 +270,6 @@ pub(crate) async fn run_upload( "Failed to discard fatal transaction (original: {msg}): {e}" )) })?; // discard entire transaction atomically - did_upload = true; continue; // next transaction } if let Some(msg) = transient_msg { @@ -296,104 +282,7 @@ pub(crate) async fn run_upload( // prior entries on the next cycle, causing phantom DELETEs (404) and // duplicate PUTs. tx.complete().await?; - did_upload = true; } - Ok(did_upload) -} - -/// Acquire the upload guard, get a fresh token, run_upload, and checkpoint. -/// Shared by the startup drain and application-triggered drain. -/// `context` is used as a log prefix (e.g. "Startup upload", "Upload"). -/// -/// A PASSIVE checkpoint is run after a successful upload to reclaim WAL space -/// freed by crud deletions. PASSIVE never acquires PENDING/EXCLUSIVE locks so it -/// is safe to call alongside active pool connections and the download actor. -/// -/// The checkpoint call uses `spawn_blocking` since `checkpoint_wal_standalone` -/// does blocking I/O (rusqlite open). -#[allow(clippy::too_many_arguments)] -async fn try_upload_and_checkpoint( - db: &PowerSyncDatabase, - client: &reqwest::Client, - auth: &GoTrueClient, - guard: &tokio::sync::Mutex<()>, - supabase_url: &str, - supabase_anon_key: &str, - context: &str, - db_path: &Path, -) -> bool { - let _guard = guard.lock().await; - - let token = match auth.get_session().await { - Ok(s) => s.access_token, - Err(e) => { - log::warn!("{context}: auth error: {e}"); - return false; - } - }; - match run_upload(db, client, &token, supabase_url, supabase_anon_key).await { - Ok(_) => { - // Post-upload PASSIVE checkpoint: reclaim crud deletion frames without - // acquiring any locks that could contend with active pool connections. - let post_path = db_path.to_path_buf(); - if let Err(e) = tokio::task::spawn_blocking(move || { - checkpoint_wal_standalone(&post_path, "post-upload", WalCheckpointMode::Passive) - }) - .await - { - log::error!("Post-upload WAL checkpoint task panicked: {e}"); - } - true - } - Err(e) => { - log::warn!("{context}: upload failed: {e}"); - false - } - } -} - -async fn retry_with_backoff( - mut attempt: F, - initial_delay: std::time::Duration, - maximum_delay: std::time::Duration, -) where - F: FnMut() -> Fut, - Fut: Future, -{ - let mut delay = initial_delay; - while !attempt().await { - tokio::time::sleep(delay).await; - delay = delay.saturating_mul(2).min(maximum_delay); - } -} - -#[allow(clippy::too_many_arguments)] -pub(crate) async fn retry_upload_until_success( - db: &PowerSyncDatabase, - client: &reqwest::Client, - auth: &GoTrueClient, - guard: &tokio::sync::Mutex<()>, - supabase_url: &str, - supabase_anon_key: &str, - context: &str, - db_path: &Path, -) { - retry_with_backoff( - || { - try_upload_and_checkpoint( - db, - client, - auth, - guard, - supabase_url, - supabase_anon_key, - context, - db_path, - ) - }, - std::time::Duration::from_secs(1), - std::time::Duration::from_secs(30), - ) - .await; + Ok(()) } diff --git a/flicknote-sync/src/upload/tests.rs b/flicknote-sync/src/upload/tests.rs index 2c3d282..0a17cc9 100644 --- a/flicknote-sync/src/upload/tests.rs +++ b/flicknote-sync/src/upload/tests.rs @@ -1,27 +1,152 @@ use std::sync::atomic::{AtomicUsize, Ordering}; -use flicknote_core::{REMOTE_COMMITTED_INSERT_METADATA, schema::app_schema}; +use async_trait::async_trait; +use flicknote_core::{ + REMOTE_COMMITTED_INSERT_METADATA, + backend::{InsertNoteReq, LocalPowerSyncBackend, NoteDb}, + schema::app_schema, +}; +use powersync::{BackendConnector, PowerSyncCredentials, SyncOptions}; use rusqlite::params; use super::*; use crate::test_support::*; -#[tokio::test] -async fn failed_upload_is_retried_without_a_second_write_trigger() { - let attempts = Arc::new(AtomicUsize::new(0)); - let attempt_counter = Arc::clone(&attempts); - - retry_with_backoff( - move || { - let attempt_counter = Arc::clone(&attempt_counter); - async move { attempt_counter.fetch_add(1, Ordering::SeqCst) > 0 } - }, - std::time::Duration::from_millis(1), - std::time::Duration::from_millis(2), - ) +#[derive(Clone)] +struct ActorTestConnector { + db: PowerSyncDatabase, + attempts: Arc, + failures_remaining: Arc, +} + +impl ActorTestConnector { + fn new(db: PowerSyncDatabase, failures: usize) -> Self { + Self { + db, + attempts: Arc::new(AtomicUsize::new(0)), + failures_remaining: Arc::new(AtomicUsize::new(failures)), + } + } + + fn attempts(&self) -> usize { + self.attempts.load(Ordering::SeqCst) + } +} + +#[async_trait] +impl BackendConnector for ActorTestConnector { + async fn fetch_credentials(&self) -> Result { + Ok(PowerSyncCredentials { + endpoint: "http://127.0.0.1:1".to_string(), + token: "test-token".to_string(), + }) + } + + async fn upload_data(&self) -> Result<(), PowerSyncError> { + self.attempts.fetch_add(1, Ordering::SeqCst); + if self + .failures_remaining + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return Err(ps_err("transient test failure")); + } + + while let Some(transaction) = self.db.next_crud_transaction().await? { + transaction.complete().await?; + } + Ok(()) + } +} + +async fn insert_actor_test_note(db: &PowerSyncDatabase, id: &str) { + let backend = LocalPowerSyncBackend::new(db.clone(), "user-1".to_string()); + backend + .insert_note(&InsertNoteReq { + id, + note_type: "normal", + status: "ready", + title: Some("Actor test"), + content: Some("Body"), + metadata: None, + project_id: None, + now: "2026-08-10T00:00:00Z", + }) + .await + .unwrap(); +} + +async fn crud_count(db: &PowerSyncDatabase) -> i64 { + let reader = db.reader().await.unwrap(); + reader + .query_row("SELECT COUNT(*) FROM ps_crud", [], |row| row.get(0)) + .unwrap() +} + +async fn wait_for_actor_upload(connector: &ActorTestConnector, expected_attempts: usize) { + let result = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if connector.attempts() >= expected_attempts && crud_count(&connector.db).await == 0 { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) .await; + assert!( + result.is_ok(), + "timed out with {} upload attempt(s) and {} queued CRUD row(s)", + connector.attempts(), + crud_count(&connector.db).await, + ); +} - assert_eq!(attempts.load(Ordering::SeqCst), 2); +async fn connect_actor(db: &PowerSyncDatabase, connector: ActorTestConnector) { + let mut options = SyncOptions::new(connector); + options.with_retry_delay(std::time::Duration::from_millis(5)); + db.connect(options).await; +} + +#[tokio::test] +async fn powersync_actor_uploads_a_local_backend_write_without_an_app_signal() { + let (_directory, db) = test_powersync_db().await; + db.async_tasks().spawn_with_tokio(); + let connector = ActorTestConnector::new(db.clone(), 0); + connect_actor(&db, connector.clone()).await; + + insert_actor_test_note(&db, "live-write").await; + + wait_for_actor_upload(&connector, 1).await; + db.disconnect().await; +} + +#[tokio::test] +async fn powersync_actor_drains_crud_that_existed_before_connect() { + let (_directory, db) = test_powersync_db().await; + insert_actor_test_note(&db, "startup-backlog").await; + assert_eq!(crud_count(&db).await, 1); + db.async_tasks().spawn_with_tokio(); + let connector = ActorTestConnector::new(db.clone(), 0); + + connect_actor(&db, connector.clone()).await; + + wait_for_actor_upload(&connector, 1).await; + db.disconnect().await; +} + +#[tokio::test] +async fn powersync_actor_retries_a_transient_upload_failure() { + let (_directory, db) = test_powersync_db().await; + db.async_tasks().spawn_with_tokio(); + let connector = ActorTestConnector::new(db.clone(), 1); + connect_actor(&db, connector.clone()).await; + + insert_actor_test_note(&db, "retry-write").await; + + wait_for_actor_upload(&connector, 2).await; + db.disconnect().await; } #[tokio::test] @@ -181,17 +306,15 @@ async fn existing_database_retires_keyterm_schema_without_losing_projects() { } let (server_url, server) = spawn_capture_server(1); - assert!( - run_upload( - &upgraded_db, - &reqwest::Client::new(), - "token", - &server_url, - "anon-key", - ) - .await - .unwrap() - ); + run_upload( + &upgraded_db, + &reqwest::Client::new(), + "token", + &server_url, + "anon-key", + ) + .await + .unwrap(); assert!(upgraded_db.next_crud_transaction().await.unwrap().is_none()); let requests = server.join().unwrap(); assert_eq!(requests.len(), 1); @@ -207,7 +330,7 @@ async fn remote_committed_put_completes_without_http_request() { let (_directory, db) = test_powersync_db().await; insert_marked_note(&db).await; - let uploaded = run_upload( + run_upload( &db, &reqwest::Client::new(), "token", @@ -217,7 +340,6 @@ async fn remote_committed_put_completes_without_http_request() { .await .unwrap(); - assert!(uploaded); assert!(db.next_crud_transaction().await.unwrap().is_none()); } diff --git a/flicknote-sync/tests/app_contract.rs b/flicknote-sync/tests/app_contract.rs index 2791428..30054c0 100644 --- a/flicknote-sync/tests/app_contract.rs +++ b/flicknote-sync/tests/app_contract.rs @@ -1,10 +1,10 @@ use std::sync::Arc; use async_trait::async_trait; -use flicknote_core::backend::{InsertNoteReq, InsertedNote, NoteDb, SqliteBackend}; +use flicknote_core::backend::{InsertNoteReq, InsertedNote, LocalPowerSyncBackend, NoteDb}; use flicknote_core::config::{Config, ConfigPaths}; -use flicknote_core::db::Database; -use flicknote_core::services::dto::{NoteListInput, Patch, ProjectAddInput, ProjectModifyInput}; +use flicknote_core::schema::app_schema; +use flicknote_core::services::dto::{NoteListInput, ProjectAddInput}; use flicknote_core::services::error::ServiceError; use flicknote_core::services::ports::{ CreateNote, CreatedNote, NoteCreator, ShareGateway, ShareResource, @@ -14,6 +14,7 @@ use flicknote_sync::ipc::{ AppRequest, AppResponse, DaemonClient, DaemonRequest, DaemonResponse, ServerInfo, serve_app_once, socket_path, }; +use powersync::{ConnectionPool, PowerSyncDatabase, env::PowerSyncEnvironment}; fn test_config(directory: &std::path::Path) -> Config { Config { @@ -33,44 +34,24 @@ fn test_config(directory: &std::path::Path) -> Config { } } +fn test_backend(config: &Config) -> Arc { + PowerSyncEnvironment::powersync_auto_extension().unwrap(); + let pool = ConnectionPool::open(&config.paths.db_file).unwrap(); + let environment = PowerSyncEnvironment::custom( + reqwest::Client::new(), + pool, + PowerSyncEnvironment::tokio_timer(), + ); + let db = PowerSyncDatabase::new(environment, app_schema()); + Arc::new(LocalPowerSyncBackend::new(db, "user-1".to_string())) +} + #[test] fn application_is_safe_to_share_between_daemon_request_tasks() { fn assert_send_sync() {} assert_send_sync::(); } -#[tokio::test] -async fn application_signals_every_may_write_request_even_when_it_fails() { - let directory = tempfile::tempdir().unwrap(); - let config = test_config(directory.path()); - let backend = Arc::new(SqliteBackend { - db: Database::open_local(&config).await.unwrap(), - user_id: "user-1".to_string(), - }); - let (signal, mut receiver) = tokio::sync::mpsc::channel(4); - let app = test_app(backend).with_write_signal(signal); - - app.handle(AppRequest::NoteList(NoteListInput { - note_type: None, - project: None, - archived: false, - limit: 20, - })) - .await - .unwrap(); - assert!(receiver.try_recv().is_err()); - - let error = app - .handle(AppRequest::ProjectModify(ProjectModifyInput { - id: "missing".to_string(), - color: Patch::Missing, - })) - .await - .unwrap_err(); - assert_eq!(error.code, "nothing_to_modify"); - receiver.try_recv().unwrap(); -} - struct RecordingCreator { db: Arc, request: std::sync::Mutex>, @@ -130,10 +111,7 @@ async fn app_preserves_created_identity_when_editor_or_attachment_summary_fails( let config = test_config(directory.path()); let attachment = directory.path().join("report.pdf"); std::fs::write(&attachment, b"pdf").unwrap(); - let backend = Arc::new(SqliteBackend { - db: Database::open_local(&config).await.unwrap(), - user_id: "user-1".to_string(), - }); + let backend = test_backend(&config); let app = app_with_creator(backend, Arc::new(DetachedCreator)); for request in [ @@ -161,10 +139,7 @@ async fn app_routes_note_list_and_append_through_services() { const NOTE_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; let directory = tempfile::tempdir().unwrap(); let config = test_config(directory.path()); - let backend = Arc::new(SqliteBackend { - db: Database::open_local(&config).await.unwrap(), - user_id: "user-1".to_string(), - }); + let backend = test_backend(&config); backend .insert_note(&InsertNoteReq { id: NOTE_ID, @@ -228,10 +203,7 @@ async fn app_routes_note_list_and_append_through_services() { async fn app_owns_project_and_catalog_domain_operations() { let directory = tempfile::tempdir().unwrap(); let config = test_config(directory.path()); - let backend = Arc::new(SqliteBackend { - db: Database::open_local(&config).await.unwrap(), - user_id: "user-1".to_string(), - }); + let backend = test_backend(&config); let app = test_app(backend); let project = app @@ -264,10 +236,7 @@ async fn app_owns_project_and_catalog_domain_operations() { async fn versioned_socket_routes_client_requests_through_application() { let directory = tempfile::tempdir().unwrap(); let config = test_config(directory.path()); - let backend = Arc::new(SqliteBackend { - db: Database::open_local(&config).await.unwrap(), - user_id: "user-1".to_string(), - }); + let backend = test_backend(&config); let app = Arc::new(test_app(backend)); let listener = tokio::net::UnixListener::bind(flicknote_sync::ipc::socket_path(&config)).unwrap(); @@ -283,10 +252,7 @@ async fn versioned_socket_routes_client_requests_through_application() { tokio::net::UnixListener::bind(flicknote_sync::ipc::socket_path(&config)).unwrap(); let directory2 = tempfile::tempdir().unwrap(); let config2 = test_config(directory2.path()); - let backend = Arc::new(SqliteBackend { - db: Database::open_local(&config2).await.unwrap(), - user_id: "user-1".to_string(), - }); + let backend = test_backend(&config2); let app = Arc::new(test_app(backend)); let server = tokio::spawn(serve_app_once(listener, app, ServerInfo::current())); let response = client @@ -308,12 +274,8 @@ async fn protocol_v1_app_request_is_rejected_before_application_dispatch() { let directory = tempfile::tempdir().unwrap(); let config = test_config(directory.path()); - let backend = Arc::new(SqliteBackend { - db: Database::open_local(&config).await.unwrap(), - user_id: "user-1".to_string(), - }); - let (signal, mut receiver) = tokio::sync::mpsc::channel(1); - let app = Arc::new(test_app(backend).with_write_signal(signal)); + let backend = test_backend(&config); + let app = Arc::new(test_app(backend)); let listener = tokio::net::UnixListener::bind(socket_path(&config)).unwrap(); let server = tokio::spawn(serve_app_once(listener, app, ServerInfo::current())); @@ -339,7 +301,6 @@ async fn protocol_v1_app_request_is_rejected_before_application_dispatch() { panic!("expected protocol mismatch") }; assert_eq!(error.code, "daemon_protocol_mismatch"); - assert!(receiver.try_recv().is_err()); server.await.unwrap().unwrap(); } @@ -349,10 +310,7 @@ async fn local_app_owns_attachment_normalization_and_creator_call() { let config = test_config(directory.path()); let path = directory.path().join("report.pdf"); std::fs::write(&path, b"pdf").unwrap(); - let backend = Arc::new(SqliteBackend { - db: Database::open_local(&config).await.unwrap(), - user_id: "user-1".to_string(), - }); + let backend = test_backend(&config); let creator = Arc::new(RecordingCreator { db: backend.clone(), request: std::sync::Mutex::new(None), @@ -379,10 +337,7 @@ async fn local_app_owns_attachment_normalization_and_creator_call() { async fn app_owns_editable_document_parsing_and_persistence() { let directory = tempfile::tempdir().unwrap(); let config = test_config(directory.path()); - let backend = Arc::new(SqliteBackend { - db: Database::open_local(&config).await.unwrap(), - user_id: "user-1".to_string(), - }); + let backend = test_backend(&config); let creator = Arc::new(RecordingCreator { db: backend.clone(), request: std::sync::Mutex::new(None), diff --git a/justfile b/justfile index bdc7c65..61b5edc 100644 --- a/justfile +++ b/justfile @@ -28,10 +28,6 @@ fmt: clippy: cargo clippy --workspace --all-targets --all-features -- -D warnings -# Refresh SQLx offline metadata. -sqlx-prepare: - ./scripts/sqlx-prepare.sh - # Install the Rust CLI and sync daemon. install: install-rust diff --git a/lefthook.yml b/lefthook.yml index ee3ed3e..f9bea28 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -3,22 +3,12 @@ pre-commit: commands: cargo-fmt: run: cargo fmt --all --check - sqlx-metadata: - run: | - if git diff --cached -U0 -- ':(glob)**/*.rs' | grep -Eq '^\+.*sqlx::(query!|query_as!|query_scalar!)'; then - if ! git diff --cached --name-only --diff-filter=ACMR -- '.sqlx/*.json' | grep -q .; then - echo "sqlx macro changes detected without staged .sqlx metadata." - echo "Run: just sqlx-prepare" - echo "Then stage the generated .sqlx/*.json files." - exit 1 - fi - fi pre-push: parallel: true commands: - cargo-sqlx-offline-check: - run: SQLX_OFFLINE=true cargo check --workspace --all-targets --all-features + cargo-check: + run: cargo check --workspace --all-targets --all-features cargo-clippy: run: cargo clippy --workspace --all-targets --all-features -- -D warnings cargo-deny: diff --git a/osv-scanner.toml b/osv-scanner.toml deleted file mode 100644 index 48d3768..0000000 --- a/osv-scanner.toml +++ /dev/null @@ -1,9 +0,0 @@ -# RUSTSEC-2023-0071 is reported for rsa 0.9.10 in Cargo.lock. -# -# rsa is pulled in only through sqlx's optional MySQL support -# (sqlx -> sqlx-macros-core -> sqlx-mysql -> rsa). FlickNote enables SQLite -# and Postgres only, and does not enable or use sqlx-mysql. Reassess this -# ignore before adding MySQL support or a direct runtime rsa dependency. -[[IgnoredVulns]] -id = "RUSTSEC-2023-0071" -reason = "rsa is present only via inactive sqlx-mysql optional metadata in Cargo.lock; FlickNote enables SQLite/Postgres only and does not use MySQL authentication paths." diff --git a/scripts/sqlx-prepare.sh b/scripts/sqlx-prepare.sh deleted file mode 100755 index 8d59cfd..0000000 --- a/scripts/sqlx-prepare.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -META_DIR="$ROOT/.sqlx" -WORK_DIR="$ROOT/target/sqlx" -SQLITE_DB="$WORK_DIR/flicknote-sqlx.sqlite" - -require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "missing required command: $1" >&2 - exit 1 - fi -} - -prepare_sqlite() { - require_cmd sqlite3 - rm -f "$SQLITE_DB" - sqlite3 "$SQLITE_DB" <"$ROOT/scripts/sqlx-sqlite-schema.sql" - - rm -rf "$META_DIR" - cargo sqlx prepare --workspace -D "sqlite://$SQLITE_DB" -- \ - -p flicknote-core \ - --no-default-features \ - --features powersync \ - --all-targets - -} - -mkdir -p "$WORK_DIR" -prepare_sqlite diff --git a/scripts/sqlx-sqlite-schema.sql b/scripts/sqlx-sqlite-schema.sql deleted file mode 100644 index 924fd04..0000000 --- a/scripts/sqlx-sqlite-schema.sql +++ /dev/null @@ -1,34 +0,0 @@ -CREATE TABLE notes ( - id TEXT PRIMARY KEY, - short_id INTEGER, - user_id TEXT, - type TEXT, - status TEXT, - title TEXT, - content TEXT, - summary TEXT, - is_flagged INTEGER, - project_id TEXT, - metadata TEXT, - source TEXT, - created_at TEXT, - updated_at TEXT, - deleted_at TEXT -); - -CREATE TABLE projects ( - id TEXT PRIMARY KEY, - user_id TEXT, - name TEXT, - color TEXT, - is_archived INTEGER, - created_at TEXT -); - -CREATE TABLE note_extractions ( - id TEXT, - note_id TEXT, - user_id TEXT, - key TEXT, - value TEXT -);