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:
Generated
+746
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
"#,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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)?,
|
||||
})
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user