Add kv-wasm: the core compiled to WebAssembly for the web vault

New crate crates/kv-wasm runs the same crypto, item model and sync engine in the browser:
- WebStore: in-memory replica of kv-store's Store, each changed entry written to IndexedDB
  (ciphertext only) before the JS call resolves.
- Device key: non-extractable WebCrypto ECDSA P-256 key in IndexedDB (raw r||s signatures).
- WebCore mirrors kv-ffi's VaultCore; wasm-bindgen surface in camelCase with TypeScript types,
  plus a Web Worker wrapper (connectVault) so Argon2id never blocks the page.
- Tests: Chrome (IndexedDB, WebCrypto, RFC 9421 verify), shared envelope fixtures both ways
  with kv-ffi, native sync with kv-ffi via the test server, Node smoke of the built package.

Small changes to shared crates so they build for wasm32:
- kv-store: SqliteStore moved to a native-only module.
- kv-sync: rustls/pinning/tokio native only (fetch on wasm); DeviceSigner::sign_async (default
  wraps sign) used by ApiClient; unix_now() instead of SystemTime/now_utc.
- kv-model: Public Suffix List behind a default `psl` feature (kv-ffi keeps it; the web build
  drops ~0.9 MB of code and matches same-host only).
- wasm-release profile (opt-level z, Argon2 at 3) and a wasm CI job.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 23:58:07 +09:00
co-authored by Claude Opus 5.5
parent f20380d293
commit b5ced32be3
35 changed files with 4488 additions and 363 deletions
+3
View File
@@ -11,6 +11,9 @@ kv-proto.workspace = true
uuid.workspace = true
thiserror.workspace = true
serde_json.workspace = true
# SQLite does not build for wasm32; the web vault (kv-wasm) brings its own IndexedDB-backed store.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rusqlite.workspace = true
[dev-dependencies]
+7 -326
View File
@@ -1,5 +1,7 @@
//! Local encrypted replica (milestone M1, kestrove-spec/docs/07).
//!
//! [`SqliteStore`] is native only; on wasm32 the web vault (`kv-wasm`) implements [`Store`] over IndexedDB.
//!
//! The store holds the same `Envelope` bytes the server holds, plus sync
//! bookkeeping. It never sees plaintext. Schema (rusqlite, bundled):
//!
@@ -22,14 +24,17 @@
use std::{
collections::{BTreeMap, HashMap},
path::Path,
sync::Mutex,
};
use kv_proto::{Envelope, ItemRecord};
use rusqlite::{params, Connection, OptionalExtension};
use uuid::Uuid;
#[cfg(not(target_arch = "wasm32"))]
mod sqlite;
#[cfg(not(target_arch = "wasm32"))]
pub use sqlite::SqliteStore;
/// Versions of an item kept in history.
pub const HISTORY_LIMIT: usize = 10;
@@ -41,12 +46,6 @@ pub enum StoreError {
NotFound,
}
impl From<rusqlite::Error> for StoreError {
fn from(e: rusqlite::Error) -> Self {
StoreError::Io(e.to_string())
}
}
impl From<serde_json::Error> for StoreError {
fn from(e: serde_json::Error) -> Self {
StoreError::Io(format!("corrupt envelope: {e}"))
@@ -164,324 +163,6 @@ pub trait Store: Send + Sync {
}
}
// ---------------------------------------------------------------- SQLite
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value BLOB NOT NULL);
CREATE TABLE IF NOT EXISTS vault (
vault_id BLOB PRIMARY KEY, sealed_key TEXT NOT NULL, meta TEXT,
cursor INTEGER NOT NULL DEFAULT 0, manifest_version INTEGER NOT NULL DEFAULT 0, manifest_hash BLOB,
integrity_alarm INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS item (
vault_id BLOB NOT NULL, item_id BLOB NOT NULL, revision INTEGER NOT NULL, deleted INTEGER NOT NULL,
wrapped_item_key TEXT, payload TEXT, updated_by BLOB NOT NULL, updated_at TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('clean','pending')),
base_revision INTEGER NOT NULL DEFAULT 0, base_deleted INTEGER, base_wrapped_item_key TEXT, base_payload TEXT,
base_updated_by BLOB, base_updated_at TEXT,
PRIMARY KEY (vault_id, item_id));
CREATE TABLE IF NOT EXISTS history (
seq INTEGER PRIMARY KEY AUTOINCREMENT, vault_id BLOB NOT NULL, item_id BLOB NOT NULL, revision INTEGER NOT NULL,
wrapped_item_key TEXT NOT NULL, payload TEXT NOT NULL, saved_at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS history_by_item ON history (vault_id, item_id, seq);
";
const SCHEMA_VERSION: i64 = 1;
pub struct SqliteStore {
conn: Mutex<Connection>,
}
impl SqliteStore {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
Self::init(Connection::open(path)?)
}
pub fn open_in_memory() -> Result<Self> {
Self::init(Connection::open_in_memory()?)
}
fn init(conn: Connection) -> Result<Self> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "FULL")?;
conn.pragma_update(None, "secure_delete", "ON")?;
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
if version > SCHEMA_VERSION {
return Err(StoreError::Io(format!("database schema {version} is newer than this app")));
}
conn.execute_batch(SCHEMA)?;
conn.pragma_update(None, "user_version", SCHEMA_VERSION)?;
Ok(Self { conn: Mutex::new(conn) })
}
fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
self.conn.lock().unwrap_or_else(|p| p.into_inner())
}
}
fn env_to_sql(e: &Option<Envelope>) -> Result<Option<String>> {
e.as_ref().map(serde_json::to_string).transpose().map_err(Into::into)
}
fn env_from_sql(s: Option<String>) -> Result<Option<Envelope>> {
s.as_deref().map(serde_json::from_str).transpose().map_err(Into::into)
}
fn uuid_from_sql(b: Vec<u8>) -> Result<Uuid> {
Uuid::from_slice(&b).map_err(|e| StoreError::Io(e.to_string()))
}
fn hash_from_sql(b: Option<Vec<u8>>) -> Result<Option<[u8; 32]>> {
b.map(|b| b.try_into().map_err(|_| StoreError::Io("bad manifest hash".into()))).transpose()
}
const ITEM_COLS: &str = "item_id, revision, deleted, wrapped_item_key, payload, updated_by, updated_at, state,
base_revision, base_deleted, base_wrapped_item_key, base_payload, base_updated_by, base_updated_at";
type RawItem = (
Vec<u8>,
i64,
bool,
Option<String>,
Option<String>,
Vec<u8>,
String,
String,
i64,
Option<bool>,
Option<String>,
Option<String>,
Option<Vec<u8>>,
Option<String>,
);
fn raw_item(r: &rusqlite::Row<'_>) -> rusqlite::Result<RawItem> {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
r.get(7)?,
r.get(8)?,
r.get(9)?,
r.get(10)?,
r.get(11)?,
r.get(12)?,
r.get(13)?,
))
}
fn item_from_raw(raw: RawItem) -> Result<LocalItem> {
let (id, rev, deleted, wik, payload, by, at, state, brev, bdel, bwik, bpayload, bby, bat) = raw;
let item_id = uuid_from_sql(id)?;
let record = ItemRecord {
item_id,
revision: rev as u64,
deleted,
wrapped_item_key: env_from_sql(wik)?,
payload: env_from_sql(payload)?,
updated_by: uuid_from_sql(by)?,
updated_at: at,
};
let base = if brev > 0 {
Some(ItemRecord {
item_id,
revision: brev as u64,
deleted: bdel.unwrap_or(false),
wrapped_item_key: env_from_sql(bwik)?,
payload: env_from_sql(bpayload)?,
updated_by: bby.map(uuid_from_sql).transpose()?.unwrap_or_default(),
updated_at: bat.unwrap_or_default(),
})
} else {
None
};
let state = if state == "pending" { SyncState::Pending } else { SyncState::Clean };
Ok(LocalItem { record, state, base })
}
fn vault_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<RawVault> {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?))
}
type RawVault = (Vec<u8>, String, Option<String>, i64, i64, Option<Vec<u8>>, bool);
fn vault_from_raw(raw: RawVault) -> Result<VaultRow> {
let (id, sealed, meta, cursor, mver, mhash, alarm) = raw;
Ok(VaultRow {
vault_id: uuid_from_sql(id)?,
sealed_key: serde_json::from_str(&sealed)?,
meta: env_from_sql(meta)?,
cursor: cursor as u64,
manifest_version: mver as u64,
manifest_hash: hash_from_sql(mhash)?,
integrity_alarm: alarm,
})
}
const VAULT_COLS: &str = "vault_id, sealed_key, meta, cursor, manifest_version, manifest_hash, integrity_alarm";
impl Store for SqliteStore {
fn get_meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
Ok(self.conn().query_row("SELECT value FROM meta WHERE key = ?", [key], |r| r.get(0)).optional()?)
}
fn put_meta(&self, key: &str, value: &[u8]) -> Result<()> {
self.conn().execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", params![key, value])?;
Ok(())
}
fn delete_meta(&self, key: &str) -> Result<()> {
self.conn().execute("DELETE FROM meta WHERE key = ?", [key])?;
Ok(())
}
fn put_vault(&self, v: &VaultRow) -> Result<()> {
self.conn().execute(
&format!("INSERT OR REPLACE INTO vault ({VAULT_COLS}) VALUES (?,?,?,?,?,?,?)"),
params![
v.vault_id.as_bytes().as_slice(),
serde_json::to_string(&v.sealed_key)?,
env_to_sql(&v.meta)?,
v.cursor as i64,
v.manifest_version as i64,
v.manifest_hash.as_ref().map(|h| h.as_slice()),
v.integrity_alarm,
],
)?;
Ok(())
}
fn vault(&self, vault: Uuid) -> Result<Option<VaultRow>> {
let raw = self
.conn()
.query_row(
&format!("SELECT {VAULT_COLS} FROM vault WHERE vault_id = ?"),
[vault.as_bytes().as_slice()],
vault_from_row,
)
.optional()?;
raw.map(vault_from_raw).transpose()
}
fn vaults(&self) -> Result<Vec<VaultRow>> {
let conn = self.conn();
let mut stmt = conn.prepare(&format!("SELECT {VAULT_COLS} FROM vault ORDER BY rowid"))?;
let raws = stmt.query_map([], vault_from_row)?.collect::<rusqlite::Result<Vec<_>>>()?;
raws.into_iter().map(vault_from_raw).collect()
}
fn put(&self, vault: Uuid, item: &LocalItem) -> Result<()> {
let r = &item.record;
let b = item.base.as_ref();
self.conn().execute(
&format!("INSERT OR REPLACE INTO item (vault_id, {ITEM_COLS}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"),
params![
vault.as_bytes().as_slice(),
r.item_id.as_bytes().as_slice(),
r.revision as i64,
r.deleted,
env_to_sql(&r.wrapped_item_key)?,
env_to_sql(&r.payload)?,
r.updated_by.as_bytes().as_slice(),
r.updated_at,
match item.state {
SyncState::Clean => "clean",
SyncState::Pending => "pending",
},
b.map_or(0, |b| b.revision as i64),
b.map(|b| b.deleted),
env_to_sql(&b.and_then(|b| b.wrapped_item_key.clone()))?,
env_to_sql(&b.and_then(|b| b.payload.clone()))?,
b.map(|b| b.updated_by.as_bytes().to_vec()),
b.map(|b| b.updated_at.clone()),
],
)?;
Ok(())
}
fn get(&self, vault: Uuid, item: Uuid) -> Result<Option<LocalItem>> {
let raw = self
.conn()
.query_row(
&format!("SELECT {ITEM_COLS} FROM item WHERE vault_id = ? AND item_id = ?"),
[vault.as_bytes().as_slice(), item.as_bytes().as_slice()],
raw_item,
)
.optional()?;
raw.map(item_from_raw).transpose()
}
fn list(&self, vault: Uuid) -> Result<Vec<LocalItem>> {
let conn = self.conn();
let mut stmt = conn.prepare(&format!("SELECT {ITEM_COLS} FROM item WHERE vault_id = ? ORDER BY item_id"))?;
let raws = stmt.query_map([vault.as_bytes().as_slice()], raw_item)?.collect::<rusqlite::Result<Vec<_>>>()?;
raws.into_iter().map(item_from_raw).collect()
}
fn remove(&self, vault: Uuid, item: Uuid) -> Result<()> {
self.conn().execute(
"DELETE FROM item WHERE vault_id = ? AND item_id = ?",
[vault.as_bytes().as_slice(), item.as_bytes().as_slice()],
)?;
Ok(())
}
fn push_history(&self, vault: Uuid, item: Uuid, e: &HistoryEntry) -> Result<()> {
let mut conn = self.conn();
let tx = conn.transaction()?;
let (v, i) = (vault.as_bytes().as_slice(), item.as_bytes().as_slice());
tx.execute(
"INSERT INTO history (vault_id, item_id, revision, wrapped_item_key, payload, saved_at) VALUES (?,?,?,?,?,?)",
params![
v,
i,
e.revision as i64,
serde_json::to_string(&e.wrapped_item_key)?,
serde_json::to_string(&e.payload)?,
e.saved_at
],
)?;
tx.execute(
"DELETE FROM history WHERE vault_id = ?1 AND item_id = ?2 AND seq NOT IN
(SELECT seq FROM history WHERE vault_id = ?1 AND item_id = ?2 ORDER BY seq DESC LIMIT ?3)",
params![v, i, HISTORY_LIMIT as i64],
)?;
tx.commit()?;
Ok(())
}
fn history(&self, vault: Uuid, item: Uuid) -> Result<Vec<HistoryEntry>> {
let conn = self.conn();
let mut stmt = conn.prepare(
"SELECT revision, wrapped_item_key, payload, saved_at FROM history
WHERE vault_id = ? AND item_id = ? ORDER BY seq DESC",
)?;
let raws = stmt
.query_map([vault.as_bytes().as_slice(), item.as_bytes().as_slice()], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, i64>(3)?))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
raws.into_iter()
.map(|(rev, wik, payload, at)| {
Ok(HistoryEntry {
revision: rev as u64,
wrapped_item_key: serde_json::from_str(&wik)?,
payload: serde_json::from_str(&payload)?,
saved_at: at,
})
})
.collect()
}
fn wipe(&self) -> Result<()> {
self.conn().execute_batch("DELETE FROM history; DELETE FROM item; DELETE FROM vault; DELETE FROM meta;")?;
Ok(())
}
}
// ---------------------------------------------------------------- in-memory
#[derive(Default)]
+331
View File
@@ -0,0 +1,331 @@
//! SQLite backend (devices). Not built for wasm32.
use std::{path::Path, sync::Mutex};
use kv_proto::{Envelope, ItemRecord};
use rusqlite::{params, Connection, OptionalExtension};
use uuid::Uuid;
use crate::{HistoryEntry, LocalItem, Result, Store, StoreError, SyncState, VaultRow, HISTORY_LIMIT};
impl From<rusqlite::Error> for StoreError {
fn from(e: rusqlite::Error) -> Self {
StoreError::Io(e.to_string())
}
}
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value BLOB NOT NULL);
CREATE TABLE IF NOT EXISTS vault (
vault_id BLOB PRIMARY KEY, sealed_key TEXT NOT NULL, meta TEXT,
cursor INTEGER NOT NULL DEFAULT 0, manifest_version INTEGER NOT NULL DEFAULT 0, manifest_hash BLOB,
integrity_alarm INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS item (
vault_id BLOB NOT NULL, item_id BLOB NOT NULL, revision INTEGER NOT NULL, deleted INTEGER NOT NULL,
wrapped_item_key TEXT, payload TEXT, updated_by BLOB NOT NULL, updated_at TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('clean','pending')),
base_revision INTEGER NOT NULL DEFAULT 0, base_deleted INTEGER, base_wrapped_item_key TEXT, base_payload TEXT,
base_updated_by BLOB, base_updated_at TEXT,
PRIMARY KEY (vault_id, item_id));
CREATE TABLE IF NOT EXISTS history (
seq INTEGER PRIMARY KEY AUTOINCREMENT, vault_id BLOB NOT NULL, item_id BLOB NOT NULL, revision INTEGER NOT NULL,
wrapped_item_key TEXT NOT NULL, payload TEXT NOT NULL, saved_at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS history_by_item ON history (vault_id, item_id, seq);
";
const SCHEMA_VERSION: i64 = 1;
pub struct SqliteStore {
conn: Mutex<Connection>,
}
impl SqliteStore {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
Self::init(Connection::open(path)?)
}
pub fn open_in_memory() -> Result<Self> {
Self::init(Connection::open_in_memory()?)
}
fn init(conn: Connection) -> Result<Self> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "FULL")?;
conn.pragma_update(None, "secure_delete", "ON")?;
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
if version > SCHEMA_VERSION {
return Err(StoreError::Io(format!("database schema {version} is newer than this app")));
}
conn.execute_batch(SCHEMA)?;
conn.pragma_update(None, "user_version", SCHEMA_VERSION)?;
Ok(Self { conn: Mutex::new(conn) })
}
pub(crate) fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
self.conn.lock().unwrap_or_else(|p| p.into_inner())
}
}
fn env_to_sql(e: &Option<Envelope>) -> Result<Option<String>> {
e.as_ref().map(serde_json::to_string).transpose().map_err(Into::into)
}
fn env_from_sql(s: Option<String>) -> Result<Option<Envelope>> {
s.as_deref().map(serde_json::from_str).transpose().map_err(Into::into)
}
fn uuid_from_sql(b: Vec<u8>) -> Result<Uuid> {
Uuid::from_slice(&b).map_err(|e| StoreError::Io(e.to_string()))
}
fn hash_from_sql(b: Option<Vec<u8>>) -> Result<Option<[u8; 32]>> {
b.map(|b| b.try_into().map_err(|_| StoreError::Io("bad manifest hash".into()))).transpose()
}
const ITEM_COLS: &str = "item_id, revision, deleted, wrapped_item_key, payload, updated_by, updated_at, state,
base_revision, base_deleted, base_wrapped_item_key, base_payload, base_updated_by, base_updated_at";
type RawItem = (
Vec<u8>,
i64,
bool,
Option<String>,
Option<String>,
Vec<u8>,
String,
String,
i64,
Option<bool>,
Option<String>,
Option<String>,
Option<Vec<u8>>,
Option<String>,
);
fn raw_item(r: &rusqlite::Row<'_>) -> rusqlite::Result<RawItem> {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
r.get(7)?,
r.get(8)?,
r.get(9)?,
r.get(10)?,
r.get(11)?,
r.get(12)?,
r.get(13)?,
))
}
fn item_from_raw(raw: RawItem) -> Result<LocalItem> {
let (id, rev, deleted, wik, payload, by, at, state, brev, bdel, bwik, bpayload, bby, bat) = raw;
let item_id = uuid_from_sql(id)?;
let record = ItemRecord {
item_id,
revision: rev as u64,
deleted,
wrapped_item_key: env_from_sql(wik)?,
payload: env_from_sql(payload)?,
updated_by: uuid_from_sql(by)?,
updated_at: at,
};
let base = if brev > 0 {
Some(ItemRecord {
item_id,
revision: brev as u64,
deleted: bdel.unwrap_or(false),
wrapped_item_key: env_from_sql(bwik)?,
payload: env_from_sql(bpayload)?,
updated_by: bby.map(uuid_from_sql).transpose()?.unwrap_or_default(),
updated_at: bat.unwrap_or_default(),
})
} else {
None
};
let state = if state == "pending" { SyncState::Pending } else { SyncState::Clean };
Ok(LocalItem { record, state, base })
}
fn vault_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<RawVault> {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?))
}
type RawVault = (Vec<u8>, String, Option<String>, i64, i64, Option<Vec<u8>>, bool);
fn vault_from_raw(raw: RawVault) -> Result<VaultRow> {
let (id, sealed, meta, cursor, mver, mhash, alarm) = raw;
Ok(VaultRow {
vault_id: uuid_from_sql(id)?,
sealed_key: serde_json::from_str(&sealed)?,
meta: env_from_sql(meta)?,
cursor: cursor as u64,
manifest_version: mver as u64,
manifest_hash: hash_from_sql(mhash)?,
integrity_alarm: alarm,
})
}
const VAULT_COLS: &str = "vault_id, sealed_key, meta, cursor, manifest_version, manifest_hash, integrity_alarm";
impl Store for SqliteStore {
fn get_meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
Ok(self.conn().query_row("SELECT value FROM meta WHERE key = ?", [key], |r| r.get(0)).optional()?)
}
fn put_meta(&self, key: &str, value: &[u8]) -> Result<()> {
self.conn().execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", params![key, value])?;
Ok(())
}
fn delete_meta(&self, key: &str) -> Result<()> {
self.conn().execute("DELETE FROM meta WHERE key = ?", [key])?;
Ok(())
}
fn put_vault(&self, v: &VaultRow) -> Result<()> {
self.conn().execute(
&format!("INSERT OR REPLACE INTO vault ({VAULT_COLS}) VALUES (?,?,?,?,?,?,?)"),
params![
v.vault_id.as_bytes().as_slice(),
serde_json::to_string(&v.sealed_key)?,
env_to_sql(&v.meta)?,
v.cursor as i64,
v.manifest_version as i64,
v.manifest_hash.as_ref().map(|h| h.as_slice()),
v.integrity_alarm,
],
)?;
Ok(())
}
fn vault(&self, vault: Uuid) -> Result<Option<VaultRow>> {
let raw = self
.conn()
.query_row(
&format!("SELECT {VAULT_COLS} FROM vault WHERE vault_id = ?"),
[vault.as_bytes().as_slice()],
vault_from_row,
)
.optional()?;
raw.map(vault_from_raw).transpose()
}
fn vaults(&self) -> Result<Vec<VaultRow>> {
let conn = self.conn();
let mut stmt = conn.prepare(&format!("SELECT {VAULT_COLS} FROM vault ORDER BY rowid"))?;
let raws = stmt.query_map([], vault_from_row)?.collect::<rusqlite::Result<Vec<_>>>()?;
raws.into_iter().map(vault_from_raw).collect()
}
fn put(&self, vault: Uuid, item: &LocalItem) -> Result<()> {
let r = &item.record;
let b = item.base.as_ref();
self.conn().execute(
&format!("INSERT OR REPLACE INTO item (vault_id, {ITEM_COLS}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"),
params![
vault.as_bytes().as_slice(),
r.item_id.as_bytes().as_slice(),
r.revision as i64,
r.deleted,
env_to_sql(&r.wrapped_item_key)?,
env_to_sql(&r.payload)?,
r.updated_by.as_bytes().as_slice(),
r.updated_at,
match item.state {
SyncState::Clean => "clean",
SyncState::Pending => "pending",
},
b.map_or(0, |b| b.revision as i64),
b.map(|b| b.deleted),
env_to_sql(&b.and_then(|b| b.wrapped_item_key.clone()))?,
env_to_sql(&b.and_then(|b| b.payload.clone()))?,
b.map(|b| b.updated_by.as_bytes().to_vec()),
b.map(|b| b.updated_at.clone()),
],
)?;
Ok(())
}
fn get(&self, vault: Uuid, item: Uuid) -> Result<Option<LocalItem>> {
let raw = self
.conn()
.query_row(
&format!("SELECT {ITEM_COLS} FROM item WHERE vault_id = ? AND item_id = ?"),
[vault.as_bytes().as_slice(), item.as_bytes().as_slice()],
raw_item,
)
.optional()?;
raw.map(item_from_raw).transpose()
}
fn list(&self, vault: Uuid) -> Result<Vec<LocalItem>> {
let conn = self.conn();
let mut stmt = conn.prepare(&format!("SELECT {ITEM_COLS} FROM item WHERE vault_id = ? ORDER BY item_id"))?;
let raws = stmt.query_map([vault.as_bytes().as_slice()], raw_item)?.collect::<rusqlite::Result<Vec<_>>>()?;
raws.into_iter().map(item_from_raw).collect()
}
fn remove(&self, vault: Uuid, item: Uuid) -> Result<()> {
self.conn().execute(
"DELETE FROM item WHERE vault_id = ? AND item_id = ?",
[vault.as_bytes().as_slice(), item.as_bytes().as_slice()],
)?;
Ok(())
}
fn push_history(&self, vault: Uuid, item: Uuid, e: &HistoryEntry) -> Result<()> {
let mut conn = self.conn();
let tx = conn.transaction()?;
let (v, i) = (vault.as_bytes().as_slice(), item.as_bytes().as_slice());
tx.execute(
"INSERT INTO history (vault_id, item_id, revision, wrapped_item_key, payload, saved_at) VALUES (?,?,?,?,?,?)",
params![
v,
i,
e.revision as i64,
serde_json::to_string(&e.wrapped_item_key)?,
serde_json::to_string(&e.payload)?,
e.saved_at
],
)?;
tx.execute(
"DELETE FROM history WHERE vault_id = ?1 AND item_id = ?2 AND seq NOT IN
(SELECT seq FROM history WHERE vault_id = ?1 AND item_id = ?2 ORDER BY seq DESC LIMIT ?3)",
params![v, i, HISTORY_LIMIT as i64],
)?;
tx.commit()?;
Ok(())
}
fn history(&self, vault: Uuid, item: Uuid) -> Result<Vec<HistoryEntry>> {
let conn = self.conn();
let mut stmt = conn.prepare(
"SELECT revision, wrapped_item_key, payload, saved_at FROM history
WHERE vault_id = ? AND item_id = ? ORDER BY seq DESC",
)?;
let raws = stmt
.query_map([vault.as_bytes().as_slice(), item.as_bytes().as_slice()], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, i64>(3)?))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
raws.into_iter()
.map(|(rev, wik, payload, at)| {
Ok(HistoryEntry {
revision: rev as u64,
wrapped_item_key: serde_json::from_str(&wik)?,
payload: serde_json::from_str(&payload)?,
saved_at: at,
})
})
.collect()
}
fn wipe(&self) -> Result<()> {
self.conn().execute_batch("DELETE FROM history; DELETE FROM item; DELETE FROM vault; DELETE FROM meta;")?;
Ok(())
}
}