From 52e8d43f696d81720adc5f51e395054a21248c01 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Thu, 14 May 2026 12:26:36 +0800 Subject: [PATCH] feat(poc/storage): add sqlite-storage spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proof-of-concept proving the SQLite-storage exit criterion from docs/architecture/proof-of-concept-plan.md §2: "Schema, migration, and repository pattern are demonstrated." Also satisfies the SRS-089 acceptance criteria explicitly: "Storage implementation uses an embedded local data store and migration mechanism." Implements: - A forward-only Migrator over a fixed Migration list, tracking the applied version via PRAGMA user_version. Each migration is applied inside an IMMEDIATE transaction; rolled back on failure. - Three canonical migrations (initial schema, add nickname, add last_connected_at) demonstrating ALTER TABLE flows. - A LocalDatabaseRepository implementing both BookmarkRepository and SettingsRepository traits. - Bookmark.identity_ref is a reference to a secret name, never a secret value (cross-checked by the secure-storage spike's SS-AUD-001/002 scans). This is the SAD-067 separation. Test suite (11/11 PASS on 2026-05-13): - migrator brings fresh DB to latest version - migrator is idempotent (no-op when already current) - migrator applies only pending versions (catch-up upgrade) - migrator rejects out-of-order versions - migrator rejects DB newer than known migrations (downgrade guard) - failed migration rolls back atomically - bookmark CRUD round-trip - bookmark list ordered by recency - bookmark UNIQUE(host, identity_ref) enforcement - settings upsert + delete - open creates file and persists across reopen Surfaced finding for the decision register: DEC-013 does not pin a SQLite crate. The PoC uses rusqlite with the bundled feature (no system libsqlite3 dependency); production code needs an owner ruling on rusqlite vs. sqlx vs. sea-orm. Authority: PoC plan §2, SRS-089, SDD-077, SAD-067, SysDes-033/036/049/091. Not product code; not promoted into chanora_storage. --- poc/sqlite-storage-spike/Cargo.lock | 746 ++++++++++++++++++++++ poc/sqlite-storage-spike/Cargo.toml | 27 + poc/sqlite-storage-spike/README.md | 85 +++ poc/sqlite-storage-spike/VERIFICATION.md | 94 +++ poc/sqlite-storage-spike/src/lib.rs | 40 ++ poc/sqlite-storage-spike/src/main.rs | 66 ++ poc/sqlite-storage-spike/src/migrate.rs | 145 +++++ poc/sqlite-storage-spike/src/repo.rs | 218 +++++++ poc/sqlite-storage-spike/tests/storage.rs | 237 +++++++ 9 files changed, 1658 insertions(+) create mode 100644 poc/sqlite-storage-spike/Cargo.lock create mode 100644 poc/sqlite-storage-spike/Cargo.toml create mode 100644 poc/sqlite-storage-spike/README.md create mode 100644 poc/sqlite-storage-spike/VERIFICATION.md create mode 100644 poc/sqlite-storage-spike/src/lib.rs create mode 100644 poc/sqlite-storage-spike/src/main.rs create mode 100644 poc/sqlite-storage-spike/src/migrate.rs create mode 100644 poc/sqlite-storage-spike/src/repo.rs create mode 100644 poc/sqlite-storage-spike/tests/storage.rs diff --git a/poc/sqlite-storage-spike/Cargo.lock b/poc/sqlite-storage-spike/Cargo.lock new file mode 100644 index 0000000..8592e7c --- /dev/null +++ b/poc/sqlite-storage-spike/Cargo.lock @@ -0,0 +1,746 @@ +# This file is automatically @generated by Cargo. +# 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[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 = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "sqlite-storage-spike" +version = "0.1.0" +dependencies = [ + "rusqlite", + "tempfile", + "thiserror", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/poc/sqlite-storage-spike/Cargo.toml b/poc/sqlite-storage-spike/Cargo.toml new file mode 100644 index 0000000..cb7409c --- /dev/null +++ b/poc/sqlite-storage-spike/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "sqlite-storage-spike" +version = "0.1.0" +edition = "2021" +publish = false +description = "Chanora PoC: SQLite-backed LocalDatabaseRepository with a forward-only migration mechanism and repository pattern." + +# Not product code. See docs/architecture/proof-of-concept-plan.md §4. +# Authority: SRS-089 (acceptance criteria: embedded store + migration mechanism), +# SDD-077, SysDes-033/036/049/091. + +[lib] +name = "sqlite_storage_spike" +path = "src/lib.rs" + +[[bin]] +name = "sqlite-storage-cli" +path = "src/main.rs" + +[dependencies] +rusqlite = { version = "0.32", features = ["bundled"] } +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tempfile = "3" + +[dev-dependencies] diff --git a/poc/sqlite-storage-spike/README.md b/poc/sqlite-storage-spike/README.md new file mode 100644 index 0000000..1561b8b --- /dev/null +++ b/poc/sqlite-storage-spike/README.md @@ -0,0 +1,85 @@ +# SQLite Storage Spike + +Chanora proof-of-concept. **Not product code.** + +| Field | Value | +|---|---| +| PoC name | `sqlite-storage-spike` | +| PoC plan | [`docs/architecture/proof-of-concept-plan.md`](../../docs/architecture/proof-of-concept-plan.md) §2 | +| Purpose | Prove embedded local non-secret persistence | +| Exit criterion | "Schema, migration, and repository pattern are demonstrated" | +| Authority | SRS-089 (acceptance criteria: embedded data store + migration mechanism), SDD-077, SAD-067 | + +## What it proves + +- A **schema** managed entirely inside SQLite (`bookmarks`, `settings`) + with a UNIQUE index, secondary indices, and `ON CONFLICT` upsert + semantics for the settings table. +- A **forward-only migration mechanism** keyed on + `PRAGMA user_version`. Migrations are applied atomically inside an + IMMEDIATE transaction and rolled back on failure. +- A **repository pattern**: `BookmarkRepository` and + `SettingsRepository` traits, implemented once by + `LocalDatabaseRepository`. The rest of the Rust core can depend on + the traits and avoid leaking SQLite-specific types upward + (SAD-067, SDD-077). +- **Cross-spike contract**: `Bookmark.identity_ref` is a *name*, not a + secret. The secret behind that name lives in + `SecretStorageRepository` (see `secure-storage-spike`). + +## Layout + +```text +sqlite-storage-spike/ + src/ + lib.rs # crate root, re-exports + migrate.rs # Migrator + canonical migrations (v1..v3) + repo.rs # traits, DTOs, LocalDatabaseRepository + main.rs # sqlite-storage-cli driver + tests/ + storage.rs # 11 tests — migration + repo + persistence + Cargo.toml +``` + +## Canonical migrations + +| Version | Description | +|---|---| +| 1 | Initial `bookmarks` table + UNIQUE index on (server_host, identity_ref); `settings` key/value table. | +| 2 | `ALTER TABLE bookmarks ADD COLUMN nickname TEXT`. | +| 3 | `ALTER TABLE bookmarks ADD COLUMN last_connected_at INTEGER` + descending index. | + +Future migrations are appended only. The migrator refuses to start if +the DB's `user_version` exceeds the highest known migration (downgrade +guard). + +## Reproduce + +Requires Rust stable (developed against 1.95). `rusqlite` is built with +the `bundled` feature, so no system `libsqlite3` is needed. + +```bash +cargo test +cargo run --bin sqlite-storage-cli +``` + +## Scope boundaries + +- **Non-secret data only.** Any secret material (identity private + keys, server passwords) belongs to `secure-storage-spike` / + `SecretStorageRepository`. This is the SAD-067 separation. +- **Single-process access.** Connection pooling, multi-process + contention, and concurrent transactions are not exercised here. + Production code will likely run on a single Rust core worker. +- **No encryption at rest.** SQLCipher and similar are not in scope. + Secrets must not land in this DB in the first place. +- **No backup / restore.** +- **No reverse migrations.** Forward-only is the documented policy; + rollbacks require a fresh DB. +- The migration-engine choice (`refinery`, `sqlx::migrate!`, etc.) + remains open for the product crate. The PoC's ~50-line custom + mechanism is intentional, to keep the audit surface explainable. + +## Verification log + +See `VERIFICATION.md` in this directory. diff --git a/poc/sqlite-storage-spike/VERIFICATION.md b/poc/sqlite-storage-spike/VERIFICATION.md new file mode 100644 index 0000000..64aede8 --- /dev/null +++ b/poc/sqlite-storage-spike/VERIFICATION.md @@ -0,0 +1,94 @@ +# Verification record — `sqlite-storage-spike` + +## Result + +PASS. The PoC exit criterion (from +`docs/architecture/proof-of-concept-plan.md` §2: "Schema, migration, +and repository pattern are demonstrated") is met. + +## Environment + +| Field | Value | +|---|---| +| Date | 2026-05-13 | +| Host OS | Linux (Arch, kernel 7.0.5-arch1-1, x86_64) | +| Rust toolchain | stable 1.95.0 | +| `rusqlite` | 0.32.1 (bundled SQLite) | +| `thiserror` | 2 | + +## Reproduction + +```bash +cargo test +cargo run --bin sqlite-storage-cli +``` + +## Test run + +``` +running 11 tests +test migrator_rejects_out_of_order_versions ... ok +test migrator_rejects_db_newer_than_known_migrations ... ok +test failed_migration_rolls_back_atomically ... ok +test bookmark_repo_crud_round_trip ... ok +test bookmark_repo_unique_host_identity_is_enforced ... ok +test settings_repo_upsert_and_delete ... ok +test bookmark_repo_list_orders_recent_first ... ok +test migrator_applies_only_pending_versions ... ok +test migrator_is_idempotent ... ok +test open_creates_file_and_persists_across_reopen ... ok +test migrator_brings_fresh_db_to_latest_version ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; + 0 filtered out; finished in 0.01s +``` + +## CLI run + +``` +INFO spike: opening /tmp/.tmp.../chanora.sqlite +INFO spike: applying migration v1 "initial bookmarks + settings" +INFO spike: applying migration v2 "add bookmarks.nickname" +INFO spike: applying migration v3 "add bookmarks.last_connected_at" +INFO spike: schema version = 3 +INFO spike: inserted bookmark ids: 1, 2 + +Bookmarks (most-recently-connected first): + [1] Vigorous Pro @ cn.teamspeak.app (identity_ref=identity.primary, + last_connected_at=Some(1715000000)) + [2] Local Lab @ ts.example.invalid (identity_ref=identity.lab, + last_connected_at=None) + +Settings.audio.aec = Some("true") + +OK — schema v3 loaded, repository round-trip verified. +``` + +## Coverage matrix + +| Concern | Test(s) | Result | +|---|---|---| +| Schema present (SDD-077) | All tests open the schema | PASS | +| Migration mechanism exists (SRS-089) | `migrator_*` (6 tests) | PASS | +| Idempotency | `migrator_is_idempotent`, `open_creates_file_and_persists_across_reopen` | PASS | +| Partial-upgrade catch-up | `migrator_applies_only_pending_versions` | PASS | +| Out-of-order migrations rejected | `migrator_rejects_out_of_order_versions` | PASS | +| Downgrade-guard | `migrator_rejects_db_newer_than_known_migrations` | PASS | +| Atomic rollback on failure | `failed_migration_rolls_back_atomically` | PASS | +| Repository pattern (CRUD) | `bookmark_repo_crud_round_trip`, `settings_repo_upsert_and_delete` | PASS | +| Ordering invariants | `bookmark_repo_list_orders_recent_first` | PASS | +| Constraint enforcement | `bookmark_repo_unique_host_identity_is_enforced` | PASS | +| On-disk persistence | `open_creates_file_and_persists_across_reopen` | PASS | +| SAD-067 separation (no secrets in DB) | DTO design (`identity_ref` is a *name*); cross-checked in `secure-storage-spike` SS-AUD-001/002 | PASS by construction | + +## What this spike does NOT validate + +- Concurrent access from multiple processes or threads. +- WAL checkpoint behaviour under sustained writes. +- Database growth / vacuum policy. +- Encrypted-at-rest variants (SQLCipher / SEE). +- Behaviour with corrupted or partially-written DB files. +- Mobile-specific storage paths (Android scoped storage, iOS app + group containers). +- The eventual production choice of migration engine. +- Performance characteristics under realistic dataset sizes. diff --git a/poc/sqlite-storage-spike/src/lib.rs b/poc/sqlite-storage-spike/src/lib.rs new file mode 100644 index 0000000..7628bbb --- /dev/null +++ b/poc/sqlite-storage-spike/src/lib.rs @@ -0,0 +1,40 @@ +//! Chanora PoC — SQLite storage spike. +//! +//! Authority: +//! * `docs/architecture/proof-of-concept-plan.md` §2 — SQLite storage spike. +//! Exit criterion: "Schema, migration, and repository pattern are demonstrated." +//! * SRS-089 — "The software shall use SQLite or an equivalent embedded +//! data store for non-secret local data." Acceptance criteria: +//! "Storage implementation uses an embedded local data store and +//! migration mechanism." +//! * SDD-077 — `LocalDatabaseRepository` persists non-secret local state +//! through SQLite or equivalent. +//! * SAD-067 — separation of non-secret local persistence (DB repository +//! layer) from secret persistence (platform secure storage). +//! +//! The PoC models the production shape: +//! +//! ┌───────────────────────────┐ ┌──────────────────────────┐ +//! │ BookmarkRepository (trait)│ │ SettingsRepository (trait)│ +//! └─────────────┬─────────────┘ └─────────────┬────────────┘ +//! │ │ +//! ▼ ▼ +//! ┌────────────────────────────────────────┐ +//! │ LocalDatabaseRepository (SQLite) │ +//! │ ────────────────────────────────── │ +//! │ forward-only schema migrations │ +//! │ tracked via PRAGMA user_version │ +//! └────────────────────────────────────────┘ +//! +//! Out of scope here: server passwords, identity secrets, any secret +//! material whatsoever (those belong to `secure-storage-spike` and to +//! the production `SecretStorageRepository`). + +pub mod migrate; +pub mod repo; + +pub use migrate::{Migrator, MigrationError}; +pub use repo::{ + Bookmark, BookmarkRepository, LocalDatabaseRepository, RepoError, Setting, + SettingsRepository, +}; diff --git a/poc/sqlite-storage-spike/src/main.rs b/poc/sqlite-storage-spike/src/main.rs new file mode 100644 index 0000000..2720582 --- /dev/null +++ b/poc/sqlite-storage-spike/src/main.rs @@ -0,0 +1,66 @@ +//! CLI driver — exercises the full repository surface against an +//! on-disk SQLite file in a temporary directory. + +use std::process::ExitCode; + +use sqlite_storage_spike::{ + repo::NewBookmark, BookmarkRepository, LocalDatabaseRepository, SettingsRepository, +}; +use tracing::info; + +fn main() -> ExitCode { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + if let Err(e) = run() { + eprintln!("error: {e}"); + return ExitCode::from(1); + } + ExitCode::SUCCESS +} + +fn run() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let db_path = tmp.path().join("chanora.sqlite"); + info!(target: "spike", "opening {}", db_path.display()); + + let repo = LocalDatabaseRepository::open(&db_path)?; + info!(target: "spike", "schema version = {}", repo.schema_version()?); + + let id_a = repo.insert(NewBookmark { + server_name: "Vigorous Pro", + server_host: "cn.teamspeak.app", + identity_ref: "identity.primary", + nickname: Some("ChanoraPoC"), + })?; + let id_b = repo.insert(NewBookmark { + server_name: "Local Lab", + server_host: "ts.example.invalid", + identity_ref: "identity.lab", + nickname: None, + })?; + info!(target: "spike", "inserted bookmark ids: {id_a}, {id_b}"); + + repo.touch_connected(id_a, 1_715_000_000)?; + SettingsRepository::put(&repo, "audio.aec", "true")?; + SettingsRepository::put(&repo, "audio.ns", "true")?; + + let list = BookmarkRepository::list_ordered(&repo)?; + println!("\nBookmarks (most-recently-connected first):"); + for b in &list { + println!( + " [{}] {} @ {} (identity_ref={}, last_connected_at={:?})", + b.id, b.server_name, b.server_host, b.identity_ref, b.last_connected_at + ); + } + + let aec = SettingsRepository::get(&repo, "audio.aec")?; + println!("\nSettings.audio.aec = {aec:?}"); + + println!("\nOK — schema v{} loaded, repository round-trip verified.", repo.schema_version()?); + Ok(()) +} diff --git a/poc/sqlite-storage-spike/src/migrate.rs b/poc/sqlite-storage-spike/src/migrate.rs new file mode 100644 index 0000000..ee56543 --- /dev/null +++ b/poc/sqlite-storage-spike/src/migrate.rs @@ -0,0 +1,145 @@ +//! Forward-only schema migration mechanism. +//! +//! Approach: each migration is a `&'static str` SQL script applied in +//! order. The current schema version is tracked in SQLite's +//! `PRAGMA user_version`. Running the migrator is idempotent — already- +//! applied versions are skipped. +//! +//! Why not an external migration framework? At this stage we want the +//! mechanism in the audit surface to be explainable in ~50 lines and +//! to have no transitive crate dependencies. Production code may +//! promote this to `refinery`, `sqlx::migrate!`, or similar if the +//! complexity warrants. + +use rusqlite::{Connection, TransactionBehavior}; +use thiserror::Error; +use tracing::info; + +/// A single migration step. `version` is monotonically increasing +/// starting at 1. `sql` is applied inside a transaction. +#[derive(Debug, Clone, Copy)] +pub struct Migration { + pub version: u32, + pub name: &'static str, + pub sql: &'static str, +} + +#[derive(Debug, Error)] +pub enum MigrationError { + #[error("sqlite error during migration: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("migrations must be ordered by ascending version; got {got} after {prev}")] + OutOfOrder { prev: u32, got: u32 }, + #[error("database is at version {db}, which is newer than the highest known migration {max}")] + FromTheFuture { db: u32, max: u32 }, +} + +/// Forward-only migrator over a fixed list of `Migration` steps. +pub struct Migrator { + migrations: Vec, +} + +impl Migrator { + pub fn new(migrations: Vec) -> Result { + let mut prev = 0u32; + for m in &migrations { + if m.version <= prev { + return Err(MigrationError::OutOfOrder { prev, got: m.version }); + } + prev = m.version; + } + Ok(Self { migrations }) + } + + pub fn highest_version(&self) -> u32 { + self.migrations.last().map(|m| m.version).unwrap_or(0) + } + + /// Apply every pending migration to `conn`. Idempotent: if the DB + /// is already at the highest version, this is a no-op. Each + /// migration is wrapped in an IMMEDIATE transaction; on failure + /// the transaction is rolled back and the DB version is unchanged. + pub fn run(&self, conn: &mut Connection) -> Result { + let current: u32 = + conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))? as u32; + + if current > self.highest_version() { + return Err(MigrationError::FromTheFuture { + db: current, + max: self.highest_version(), + }); + } + + let pending: Vec<&Migration> = self + .migrations + .iter() + .filter(|m| m.version > current) + .collect(); + + if pending.is_empty() { + info!(target: "spike", "no migrations to apply (already at v{current})"); + return Ok(current); + } + + for m in pending { + info!(target: "spike", "applying migration v{} {:?}", m.version, m.name); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute_batch(m.sql)?; + // PRAGMA user_version cannot be parameterised; the version + // is from our own static migration list and is therefore + // safe to interpolate. + tx.execute_batch(&format!("PRAGMA user_version = {}", m.version))?; + tx.commit()?; + } + + let final_v: u32 = + conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))? as u32; + Ok(final_v) + } +} + +/// Canonical Chanora PoC migration list. +/// +/// Each version is intentionally small and self-contained. Migrations +/// in the production crate will live in `migrations/` as separate +/// files; the PoC keeps them inline for visibility. +pub fn canonical_migrations() -> Vec { + vec![ + Migration { + version: 1, + name: "initial bookmarks + settings", + sql: r#" + CREATE TABLE bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_name TEXT NOT NULL, + server_host TEXT NOT NULL, + identity_ref TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + ); + CREATE UNIQUE INDEX ix_bookmarks_host_identity + ON bookmarks(server_host, identity_ref); + + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + "#, + }, + Migration { + version: 2, + name: "add bookmarks.nickname", + sql: r#" + ALTER TABLE bookmarks ADD COLUMN nickname TEXT; + "#, + }, + Migration { + version: 3, + name: "add bookmarks.last_connected_at", + sql: r#" + ALTER TABLE bookmarks ADD COLUMN last_connected_at INTEGER; + CREATE INDEX ix_bookmarks_last_connected + ON bookmarks(last_connected_at DESC); + "#, + }, + ] +} diff --git a/poc/sqlite-storage-spike/src/repo.rs b/poc/sqlite-storage-spike/src/repo.rs new file mode 100644 index 0000000..4f10d24 --- /dev/null +++ b/poc/sqlite-storage-spike/src/repo.rs @@ -0,0 +1,218 @@ +//! Repository pattern over the SQLite schema. +//! +//! `LocalDatabaseRepository` is the single concrete type that owns the +//! `Connection`. Repository traits (`BookmarkRepository`, +//! `SettingsRepository`) describe the API surface the rest of the Rust +//! core depends on. This mirrors the production split where the +//! Chanora core consumes traits rather than the concrete SQLite type +//! (SAD-067, SDD-077). + +use std::path::Path; + +use rusqlite::{params, Connection, OptionalExtension}; +use thiserror::Error; + +use crate::migrate::{canonical_migrations, Migrator}; + +#[derive(Debug, Error)] +pub enum RepoError { + #[error("sqlite error: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("migration error: {0}")] + Migration(#[from] crate::migrate::MigrationError), + #[error("not found")] + NotFound, +} + +// ---------- DTOs ---------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bookmark { + pub id: i64, + pub server_name: String, + pub server_host: String, + /// Stable reference to a secret in platform secure storage. + /// **Not** the secret itself (SAD-067, see secure-storage-spike). + pub identity_ref: String, + pub nickname: Option, + /// Unix epoch seconds; `None` if never connected. + pub last_connected_at: Option, + pub created_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Setting { + pub key: String, + pub value: String, +} + +// ---------- Traits ---------- + +pub trait BookmarkRepository { + fn insert(&self, b: NewBookmark<'_>) -> Result; + fn get(&self, id: i64) -> Result; + fn list_ordered(&self) -> Result, RepoError>; + fn touch_connected(&self, id: i64, at_epoch_secs: i64) -> Result<(), RepoError>; + fn delete(&self, id: i64) -> Result<(), RepoError>; +} + +pub trait SettingsRepository { + fn put(&self, key: &str, value: &str) -> Result<(), RepoError>; + fn get(&self, key: &str) -> Result, RepoError>; + fn delete(&self, key: &str) -> Result; +} + +#[derive(Debug, Clone)] +pub struct NewBookmark<'a> { + pub server_name: &'a str, + pub server_host: &'a str, + pub identity_ref: &'a str, + pub nickname: Option<&'a str>, +} + +// ---------- Concrete repository ---------- + +pub struct LocalDatabaseRepository { + conn: Connection, +} + +impl LocalDatabaseRepository { + /// Open (or create) a SQLite database at `path` and bring the + /// schema up to date via the migrator. + pub fn open(path: &Path) -> Result { + let mut conn = Connection::open(path)?; + Self::pragmas(&conn)?; + let migrator = Migrator::new(canonical_migrations()).map_err(RepoError::Migration)?; + migrator.run(&mut conn).map_err(RepoError::Migration)?; + Ok(Self { conn }) + } + + /// Open an in-memory DB for tests. + pub fn open_in_memory() -> Result { + let mut conn = Connection::open_in_memory()?; + Self::pragmas(&conn)?; + let migrator = Migrator::new(canonical_migrations()).map_err(RepoError::Migration)?; + migrator.run(&mut conn).map_err(RepoError::Migration)?; + Ok(Self { conn }) + } + + fn pragmas(conn: &Connection) -> Result<(), RepoError> { + // Durability + concurrency defaults that match production + // expectations for a desktop/mobile embedded DB. + conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON;", + )?; + Ok(()) + } + + /// Current schema version (PRAGMA user_version). + pub fn schema_version(&self) -> Result { + let v: i64 = self + .conn + .query_row("PRAGMA user_version", [], |r| r.get(0))?; + Ok(v as u32) + } +} + +impl BookmarkRepository for LocalDatabaseRepository { + fn insert(&self, b: NewBookmark<'_>) -> Result { + self.conn.execute( + "INSERT INTO bookmarks (server_name, server_host, identity_ref, nickname) + VALUES (?1, ?2, ?3, ?4)", + params![b.server_name, b.server_host, b.identity_ref, b.nickname], + )?; + Ok(self.conn.last_insert_rowid()) + } + + fn get(&self, id: i64) -> Result { + self.conn + .query_row( + "SELECT id, server_name, server_host, identity_ref, nickname, + last_connected_at, created_at + FROM bookmarks WHERE id = ?1", + params![id], + row_to_bookmark, + ) + .optional()? + .ok_or(RepoError::NotFound) + } + + fn list_ordered(&self) -> Result, RepoError> { + let mut stmt = self.conn.prepare( + "SELECT id, server_name, server_host, identity_ref, nickname, + last_connected_at, created_at + FROM bookmarks + ORDER BY last_connected_at DESC NULLS LAST, server_name ASC", + )?; + let rows = stmt.query_map([], row_to_bookmark)?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + fn touch_connected(&self, id: i64, at_epoch_secs: i64) -> Result<(), RepoError> { + let n = self.conn.execute( + "UPDATE bookmarks SET last_connected_at = ?2 WHERE id = ?1", + params![id, at_epoch_secs], + )?; + if n == 0 { + return Err(RepoError::NotFound); + } + Ok(()) + } + + fn delete(&self, id: i64) -> Result<(), RepoError> { + let n = self + .conn + .execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?; + if n == 0 { + return Err(RepoError::NotFound); + } + Ok(()) + } +} + +impl SettingsRepository for LocalDatabaseRepository { + fn put(&self, key: &str, value: &str) -> Result<(), RepoError> { + self.conn.execute( + "INSERT INTO settings (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + )?; + Ok(()) + } + + fn get(&self, key: &str) -> Result, RepoError> { + Ok(self + .conn + .query_row( + "SELECT value FROM settings WHERE key = ?1", + params![key], + |r| r.get(0), + ) + .optional()?) + } + + fn delete(&self, key: &str) -> Result { + let n = self + .conn + .execute("DELETE FROM settings WHERE key = ?1", params![key])?; + Ok(n > 0) + } +} + +fn row_to_bookmark(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Bookmark { + id: row.get(0)?, + server_name: row.get(1)?, + server_host: row.get(2)?, + identity_ref: row.get(3)?, + nickname: row.get(4)?, + last_connected_at: row.get(5)?, + created_at: row.get(6)?, + }) +} diff --git a/poc/sqlite-storage-spike/tests/storage.rs b/poc/sqlite-storage-spike/tests/storage.rs new file mode 100644 index 0000000..8f8b1bf --- /dev/null +++ b/poc/sqlite-storage-spike/tests/storage.rs @@ -0,0 +1,237 @@ +//! Verification tests mapped to SRS-089's acceptance criteria: +//! "Storage implementation uses an embedded local data store and +//! migration mechanism." +//! +//! And to the PoC plan §2 exit criterion: +//! "Schema, migration, and repository pattern are demonstrated." + +use rusqlite::Connection; +use sqlite_storage_spike::{ + migrate::{canonical_migrations, Migration, Migrator}, + repo::NewBookmark, + BookmarkRepository, LocalDatabaseRepository, SettingsRepository, +}; + +// ---------- Migration mechanism ---------- + +#[test] +fn migrator_brings_fresh_db_to_latest_version() { + let mut conn = Connection::open_in_memory().unwrap(); + let migrator = Migrator::new(canonical_migrations()).unwrap(); + + let final_v = migrator.run(&mut conn).unwrap(); + assert_eq!(final_v, migrator.highest_version()); +} + +#[test] +fn migrator_is_idempotent() { + let mut conn = Connection::open_in_memory().unwrap(); + let migrator = Migrator::new(canonical_migrations()).unwrap(); + let v1 = migrator.run(&mut conn).unwrap(); + let v2 = migrator.run(&mut conn).unwrap(); + let v3 = migrator.run(&mut conn).unwrap(); + assert_eq!(v1, v2); + assert_eq!(v2, v3); +} + +#[test] +fn migrator_applies_only_pending_versions() { + let mut conn = Connection::open_in_memory().unwrap(); + + // Apply only v1 first. + let first = Migrator::new(vec![canonical_migrations()[0]]).unwrap(); + assert_eq!(first.run(&mut conn).unwrap(), 1); + + // Now apply the full list — must catch up from v1 to v3 cleanly. + let full = Migrator::new(canonical_migrations()).unwrap(); + assert_eq!(full.run(&mut conn).unwrap(), 3); + + // Schema reflects v2 + v3 column additions. + let cols: Vec = conn + .prepare("SELECT name FROM pragma_table_info('bookmarks')") + .unwrap() + .query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .collect::>() + .unwrap(); + assert!(cols.contains(&"nickname".to_string())); + assert!(cols.contains(&"last_connected_at".to_string())); +} + +#[test] +fn migrator_rejects_out_of_order_versions() { + let bogus = vec![ + Migration { version: 2, name: "a", sql: "" }, + Migration { version: 1, name: "b", sql: "" }, + ]; + assert!(Migrator::new(bogus).is_err()); +} + +#[test] +fn migrator_rejects_db_newer_than_known_migrations() { + let mut conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA user_version = 99").unwrap(); + let migrator = Migrator::new(canonical_migrations()).unwrap(); + assert!(migrator.run(&mut conn).is_err()); +} + +#[test] +fn failed_migration_rolls_back_atomically() { + let mut conn = Connection::open_in_memory().unwrap(); + + // v1 ok; v2 deliberately broken. + let broken = vec![ + canonical_migrations()[0], + Migration { + version: 2, + name: "broken", + sql: "ALTER TABLE not_a_table ADD COLUMN x TEXT;", + }, + ]; + let m = Migrator::new(broken).unwrap(); + assert!(m.run(&mut conn).is_err()); + + // We should still be on v1, not partially upgraded. + let v: i64 = conn + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(v, 1); +} + +// ---------- Repository pattern ---------- + +#[test] +fn bookmark_repo_crud_round_trip() { + let repo = LocalDatabaseRepository::open_in_memory().unwrap(); + let id = BookmarkRepository::insert( + &repo, + NewBookmark { + server_name: "Vigorous", + server_host: "cn.teamspeak.app", + identity_ref: "identity.primary", + nickname: Some("ChanoraPoC"), + }, + ) + .unwrap(); + + let got = BookmarkRepository::get(&repo, id).unwrap(); + assert_eq!(got.server_name, "Vigorous"); + assert_eq!(got.server_host, "cn.teamspeak.app"); + assert_eq!(got.identity_ref, "identity.primary"); + assert_eq!(got.nickname.as_deref(), Some("ChanoraPoC")); + assert!(got.last_connected_at.is_none()); + + BookmarkRepository::touch_connected(&repo, id, 1_700_000_000).unwrap(); + let again = BookmarkRepository::get(&repo, id).unwrap(); + assert_eq!(again.last_connected_at, Some(1_700_000_000)); + + BookmarkRepository::delete(&repo, id).unwrap(); + assert!(matches!( + BookmarkRepository::get(&repo, id), + Err(sqlite_storage_spike::RepoError::NotFound) + )); +} + +#[test] +fn bookmark_repo_list_orders_recent_first() { + let repo = LocalDatabaseRepository::open_in_memory().unwrap(); + let a = BookmarkRepository::insert( + &repo, + NewBookmark { server_name: "A", server_host: "a.example", identity_ref: "i.a", nickname: None }, + ) + .unwrap(); + let b = BookmarkRepository::insert( + &repo, + NewBookmark { server_name: "B", server_host: "b.example", identity_ref: "i.b", nickname: None }, + ) + .unwrap(); + let _c = BookmarkRepository::insert( + &repo, + NewBookmark { server_name: "C", server_host: "c.example", identity_ref: "i.c", nickname: None }, + ) + .unwrap(); + BookmarkRepository::touch_connected(&repo, b, 200).unwrap(); + BookmarkRepository::touch_connected(&repo, a, 100).unwrap(); + // c never connected. + + let list = BookmarkRepository::list_ordered(&repo).unwrap(); + let names: Vec<_> = list.iter().map(|x| x.server_name.as_str()).collect(); + // b (200), a (100), then c (NULL last) — c sorts after by name fallback. + assert_eq!(names, vec!["B", "A", "C"]); +} + +#[test] +fn bookmark_repo_unique_host_identity_is_enforced() { + let repo = LocalDatabaseRepository::open_in_memory().unwrap(); + BookmarkRepository::insert( + &repo, + NewBookmark { + server_name: "Vigorous", + server_host: "cn.teamspeak.app", + identity_ref: "identity.primary", + nickname: None, + }, + ) + .unwrap(); + let dup = BookmarkRepository::insert( + &repo, + NewBookmark { + server_name: "Vigorous Again", + server_host: "cn.teamspeak.app", + identity_ref: "identity.primary", + nickname: None, + }, + ); + assert!(dup.is_err(), "expected UNIQUE violation"); +} + +#[test] +fn settings_repo_upsert_and_delete() { + let repo = LocalDatabaseRepository::open_in_memory().unwrap(); + SettingsRepository::put(&repo, "audio.aec", "true").unwrap(); + assert_eq!( + SettingsRepository::get(&repo, "audio.aec").unwrap().as_deref(), + Some("true") + ); + + // Upsert (same key, new value). + SettingsRepository::put(&repo, "audio.aec", "false").unwrap(); + assert_eq!( + SettingsRepository::get(&repo, "audio.aec").unwrap().as_deref(), + Some("false") + ); + + assert!(SettingsRepository::delete(&repo, "audio.aec").unwrap()); + assert!(SettingsRepository::get(&repo, "audio.aec").unwrap().is_none()); + assert!(!SettingsRepository::delete(&repo, "audio.aec").unwrap()); +} + +// ---------- End-to-end on disk ---------- + +#[test] +fn open_creates_file_and_persists_across_reopen() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("chanora.sqlite"); + + { + let repo = LocalDatabaseRepository::open(&path).unwrap(); + assert_eq!(repo.schema_version().unwrap(), 3); + BookmarkRepository::insert( + &repo, + NewBookmark { + server_name: "Persist", + server_host: "persist.example", + identity_ref: "i.persist", + nickname: None, + }, + ) + .unwrap(); + } + + // Reopen and verify durability + migrator no-ops. + let repo2 = LocalDatabaseRepository::open(&path).unwrap(); + assert_eq!(repo2.schema_version().unwrap(), 3); + let list = BookmarkRepository::list_ordered(&repo2).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].server_name, "Persist"); +}