feat(poc/storage): add sqlite-storage spike

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.
This commit is contained in:
EdisonJwa
2026-05-14 12:26:36 +08:00
parent 50c95b61ad
commit 52e8d43f69
9 changed files with 1658 additions and 0 deletions
+746
View File
@@ -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"
+27
View File
@@ -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]
+85
View File
@@ -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.
+94
View File
@@ -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.
+40
View File
@@ -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,
};
+66
View File
@@ -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<dyn std::error::Error>> {
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(())
}
+145
View File
@@ -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<Migration>,
}
impl Migrator {
pub fn new(migrations: Vec<Migration>) -> Result<Self, MigrationError> {
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<u32, MigrationError> {
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<Migration> {
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);
"#,
},
]
}
+218
View File
@@ -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<String>,
/// Unix epoch seconds; `None` if never connected.
pub last_connected_at: Option<i64>,
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<i64, RepoError>;
fn get(&self, id: i64) -> Result<Bookmark, RepoError>;
fn list_ordered(&self) -> Result<Vec<Bookmark>, 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<Option<String>, RepoError>;
fn delete(&self, key: &str) -> Result<bool, RepoError>;
}
#[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<Self, RepoError> {
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<Self, RepoError> {
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<u32, RepoError> {
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<i64, RepoError> {
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<Bookmark, RepoError> {
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<Vec<Bookmark>, 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<Option<String>, 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<bool, RepoError> {
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<Bookmark> {
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)?,
})
}
+237
View File
@@ -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<String> = conn
.prepare("SELECT name FROM pragma_table_info('bookmarks')")
.unwrap()
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.collect::<Result<_, _>>()
.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");
}