host: the drafts vault (CAS revisions, append-only history, archive, export, capture budget) with the app's VaultRules; feature vault

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-10-01 16:08:40 +07:00
parent 4e44d8aaa0
commit fc08bc89cb
4 changed files with 582 additions and 0 deletions

View file

@ -77,6 +77,8 @@ pub mod tabs;
pub mod testkit;
pub mod theme;
pub mod update;
#[cfg(feature = "vault")]
pub mod vault;
pub mod window_fit;
pub mod workspaces;
pub mod zoom;

View file

@ -0,0 +1,492 @@
//! The drafts vault: an app's local SQLite store of drafts with CAS revisions, an append-only revision
//! history, archive/restore (nothing is ever deleted), the observations table and one consistent
//! export. "Saved" means acknowledged: a write returns only after its SQLite transaction committed
//! (`synchronous=FULL`, WAL), and a refused write (stale revision, other account, over the budget)
//! leaves no trace.
//!
//! What is the app's is a [`VaultRules`] value: the draft kinds and sources, the size limits, the
//! identifier and context checks, and who owns captured drafts (account, source, budget). The schema
//! is version 1 and its text is derived from the rules, so a database an app created before the
//! vault moved here opens unchanged (pinned by the app's persistence tests).
use chrono::{SecondsFormat, Utc};
use rusqlite::{params, Connection, OptionalExtension, Row, TransactionBehavior};
use serde::{Deserialize, Serialize};
use std::{path::Path, time::Duration};
pub type Result<T> = std::result::Result<T, String>;
/// Who owns captured drafts and how much of them the vault keeps.
#[derive(Clone, Copy, Debug)]
pub struct CaptureRules {
/// The account every captured draft belongs to (page-reported text never picks it).
pub account: &'static str,
/// The `source` of captured drafts.
pub source: &'static str,
pub budget_bytes: i64,
pub budget_revisions: i64,
}
/// What an app decides about its drafts.
#[derive(Clone, Copy)]
pub struct VaultRules {
pub kinds: &'static [&'static str],
pub sources: &'static [&'static str],
pub max_title_utf16: usize,
pub max_text_bytes: usize,
pub valid_identifier: fn(&str) -> bool,
/// Checked on every save (an empty context is the app's call).
pub validate_context: fn(&str) -> Result<()>,
pub capture: CaptureRules,
}
fn quoted(values: &[&str]) -> String {
values.iter().map(|value| format!("'{value}'")).collect::<Vec<_>>().join(",")
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Draft {
pub id: String,
pub account_id: String,
pub kind: String,
pub title: String,
pub text: String,
pub context_url: String,
pub revision: i64,
pub status: String,
pub created_at: String,
pub updated_at: String,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DraftRevision {
pub draft_id: String,
pub revision: i64,
pub title: String,
pub text: String,
pub context_url: String,
pub source: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SaveDraftInput {
pub id: String,
pub account_id: String,
pub kind: String,
pub title: String,
pub text: String,
pub context_url: String,
pub expected_revision: i64,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Observation {
pub id: String,
pub account_id: String,
pub user_id: String,
pub following: Option<bool>,
pub followed_by: Option<bool>,
pub observed_at: String,
pub source: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportData {
pub version: u8,
pub exported_at: String,
pub drafts: Vec<Draft>,
pub revisions: Vec<DraftRevision>,
pub observations: Vec<Observation>,
}
const SELECT_DRAFT: &str = "SELECT id,account_id,kind,title,text,context_url,revision,status,created_at,updated_at,source FROM drafts";
pub fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
fn sql_error(error: rusqlite::Error) -> String {
// Database errors never include SQL parameters (draft contents).
format!("DATABASE: {error}")
}
fn draft_row(row: &Row<'_>) -> rusqlite::Result<Draft> {
Ok(Draft {
id: row.get(0)?,
account_id: row.get(1)?,
kind: row.get(2)?,
title: row.get(3)?,
text: row.get(4)?,
context_url: row.get(5)?,
revision: row.get(6)?,
status: row.get(7)?,
created_at: row.get(8)?,
updated_at: row.get(9)?,
source: row.get(10)?,
})
}
fn revision_row(row: &Row<'_>) -> rusqlite::Result<DraftRevision> {
Ok(DraftRevision {
draft_id: row.get(0)?,
revision: row.get(1)?,
title: row.get(2)?,
text: row.get(3)?,
context_url: row.get(4)?,
source: row.get(5)?,
created_at: row.get(6)?,
})
}
fn validate_input(rules: &VaultRules, input: &SaveDraftInput) -> Result<()> {
if !(rules.valid_identifier)(&input.id) || !(rules.valid_identifier)(&input.account_id) {
return Err("VALIDATION: invalid draft or account identifier".into());
}
if !rules.kinds.contains(&input.kind.as_str())
|| !rules.sources.contains(&input.source.as_str())
|| !(0..9_007_199_254_740_991).contains(&input.expected_revision)
{
return Err("VALIDATION: invalid draft kind, source, or revision".into());
}
if input.title.encode_utf16().count() > rules.max_title_utf16 || input.text.len() > rules.max_text_bytes {
return Err("VALIDATION: draft size limit exceeded".into());
}
(rules.validate_context)(&input.context_url)
}
pub struct Vault {
rules: &'static VaultRules,
connection: Connection,
}
impl Vault {
/// Opens (creating) the database at `path` for `rules`. A database made by a newer schema is refused.
pub fn open(path: impl AsRef<Path>, rules: &'static VaultRules) -> Result<Self> {
let mut connection = Connection::open(path).map_err(sql_error)?;
connection
.busy_timeout(Duration::from_secs(5))
.map_err(sql_error)?;
connection
.execute_batch(
"PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON;",
)
.map_err(sql_error)?;
let version: i64 = connection
.pragma_query_value(None, "user_version", |row| row.get(0))
.map_err(sql_error)?;
if version > 1 {
return Err("DATABASE: newer schema; use a newer application version".into());
}
if version == 0 {
let tx = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(sql_error)?;
tx.execute_batch(&format!(
"CREATE TABLE IF NOT EXISTS drafts (
id TEXT PRIMARY KEY, account_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ({kinds})),
title TEXT NOT NULL, text TEXT NOT NULL, context_url TEXT NOT NULL,
revision INTEGER NOT NULL CHECK(revision > 0),
status TEXT NOT NULL CHECK(status IN ('active','archived')),
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
source TEXT NOT NULL CHECK(source IN ({sources}))
);
CREATE INDEX IF NOT EXISTS drafts_updated ON drafts(updated_at DESC);
CREATE TABLE IF NOT EXISTS revisions (
draft_id TEXT NOT NULL REFERENCES drafts(id), revision INTEGER NOT NULL,
title TEXT NOT NULL, text TEXT NOT NULL, context_url TEXT NOT NULL,
source TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY(draft_id,revision)
);
CREATE TABLE IF NOT EXISTS observations (
id TEXT PRIMARY KEY, account_id TEXT NOT NULL, user_id TEXT NOT NULL,
following INTEGER CHECK(following IN (0,1)),
followed_by INTEGER CHECK(followed_by IN (0,1)),
observed_at TEXT NOT NULL, source TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS capture_usage (
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
stored_bytes INTEGER NOT NULL, revision_count INTEGER NOT NULL
);
INSERT OR IGNORE INTO capture_usage VALUES(1,0,0);
PRAGMA user_version=1;",
kinds = quoted(rules.kinds),
sources = quoted(rules.sources),
))
.map_err(sql_error)?;
tx.commit().map_err(sql_error)?;
}
Ok(Self { rules, connection })
}
pub fn list_drafts(&self) -> Result<Vec<Draft>> {
let mut statement = self
.connection
.prepare(&format!("{SELECT_DRAFT} ORDER BY updated_at DESC,id"))
.map_err(sql_error)?;
let rows = statement.query_map([], draft_row).map_err(sql_error)?;
rows.collect::<rusqlite::Result<_>>().map_err(sql_error)
}
pub fn get_draft(&self, id: &str) -> Result<Option<Draft>> {
self.connection
.query_row(&format!("{SELECT_DRAFT} WHERE id=?1"), [id], draft_row)
.optional()
.map_err(sql_error)
}
pub fn save_draft(&mut self, input: &SaveDraftInput) -> Result<Draft> {
self.save(input, false)
}
/// Saves a draft the app's capture owns: only its account and source, under the capture budget.
pub fn save_capture(&mut self, input: &SaveDraftInput) -> Result<Draft> {
if input.account_id != self.rules.capture.account || input.source != self.rules.capture.source {
return Err("FORBIDDEN: capture account/source are native-owned".into());
}
self.save(input, true)
}
fn save(&mut self, input: &SaveDraftInput, capture: bool) -> Result<Draft> {
validate_input(self.rules, input)?;
let tx = self
.connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(sql_error)?;
let old = tx
.query_row(
&format!("{SELECT_DRAFT} WHERE id=?1"),
[&input.id],
draft_row,
)
.optional()
.map_err(sql_error)?;
match &old {
Some(draft) if draft.revision != input.expected_revision => {
return Err("CONFLICT: draft changed; reload before saving".into())
}
Some(draft) if draft.account_id != input.account_id => {
return Err("FORBIDDEN: draft account cannot change".into())
}
Some(draft) if capture && (draft.status != "active" || draft.source != self.rules.capture.source) => {
return Err("CONFLICT: captured draft was edited or archived locally".into())
}
None if input.expected_revision != 0 => {
return Err("CONFLICT: draft no longer matches expected revision".into())
}
_ => {}
}
if capture {
let bytes =
(input.text.len() + input.title.len() + input.context_url.len() + 256) as i64;
let changed = tx.execute(
"UPDATE capture_usage SET stored_bytes=stored_bytes+?1,revision_count=revision_count+1 WHERE singleton=1 AND stored_bytes+?1<=?2 AND revision_count+1<=?3",
params![bytes, self.rules.capture.budget_bytes, self.rules.capture.budget_revisions],
).map_err(sql_error)?;
if changed != 1 {
return Err(
"QUOTA: capture storage limit reached; existing drafts are preserved".into(),
);
}
}
let stamp = now();
let draft = Draft {
id: input.id.clone(),
account_id: input.account_id.clone(),
kind: input.kind.clone(),
title: input.title.clone(),
text: input.text.clone(),
context_url: input.context_url.clone(),
revision: input.expected_revision + 1,
status: old
.as_ref()
.map_or("active", |draft| draft.status.as_str())
.to_string(),
created_at: old.map_or_else(|| stamp.clone(), |draft| draft.created_at),
updated_at: stamp,
source: input.source.clone(),
};
tx.execute(
"INSERT INTO drafts(id,account_id,kind,title,text,context_url,revision,status,created_at,updated_at,source)
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)
ON CONFLICT(id) DO UPDATE SET kind=excluded.kind,title=excluded.title,text=excluded.text,
context_url=excluded.context_url,revision=excluded.revision,updated_at=excluded.updated_at,source=excluded.source",
params![draft.id,draft.account_id,draft.kind,draft.title,draft.text,draft.context_url,draft.revision,draft.status,draft.created_at,draft.updated_at,draft.source],
).map_err(sql_error)?;
Self::append_revision(&tx, &draft)?;
tx.commit().map_err(sql_error)?;
Ok(draft)
}
fn append_revision(connection: &Connection, draft: &Draft) -> Result<()> {
connection.execute(
"INSERT INTO revisions(draft_id,revision,title,text,context_url,source,created_at) VALUES(?1,?2,?3,?4,?5,?6,?7)",
params![draft.id,draft.revision,draft.title,draft.text,draft.context_url,draft.source,draft.updated_at],
).map_err(sql_error)?;
Ok(())
}
pub fn set_status(&mut self, id: &str, expected_revision: i64, status: &str) -> Result<Draft> {
if !(self.rules.valid_identifier)(id)
|| !["active", "archived"].contains(&status)
|| expected_revision < 1
|| expected_revision >= 9_007_199_254_740_991
{
return Err("VALIDATION: invalid status update".into());
}
let tx = self
.connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(sql_error)?;
let mut draft = tx
.query_row(&format!("{SELECT_DRAFT} WHERE id=?1"), [id], draft_row)
.optional()
.map_err(sql_error)?
.ok_or("CONFLICT: draft not found")?;
if draft.revision != expected_revision {
return Err("CONFLICT: draft changed before status update".into());
}
draft.status = status.into();
draft.revision += 1;
draft.updated_at = now();
tx.execute(
"UPDATE drafts SET status=?1,revision=?2,updated_at=?3 WHERE id=?4",
params![draft.status, draft.revision, draft.updated_at, id],
)
.map_err(sql_error)?;
Self::append_revision(&tx, &draft)?;
tx.commit().map_err(sql_error)?;
Ok(draft)
}
pub fn revisions(&self, id: &str) -> Result<Vec<DraftRevision>> {
if !(self.rules.valid_identifier)(id) {
return Err("VALIDATION: invalid draft identifier".into());
}
let mut statement = self.connection.prepare("SELECT draft_id,revision,title,text,context_url,source,created_at FROM revisions WHERE draft_id=?1 ORDER BY revision DESC").map_err(sql_error)?;
let rows = statement.query_map([id], revision_row).map_err(sql_error)?;
rows.collect::<rusqlite::Result<_>>().map_err(sql_error)
}
pub fn observations(&self) -> Result<Vec<Observation>> {
// No remote observation ingestion yet: account identity has not been verified.
let mut statement = self.connection.prepare("SELECT id,account_id,user_id,following,followed_by,observed_at,source FROM observations ORDER BY observed_at DESC,id").map_err(sql_error)?;
let rows = statement
.query_map([], |row| {
Ok(Observation {
id: row.get(0)?,
account_id: row.get(1)?,
user_id: row.get(2)?,
following: row.get(3)?,
followed_by: row.get(4)?,
observed_at: row.get(5)?,
source: row.get(6)?,
})
})
.map_err(sql_error)?;
rows.collect::<rusqlite::Result<_>>().map_err(sql_error)
}
pub fn export(&mut self) -> Result<ExportData> {
// A deferred transaction gives all export tables one SQLite read snapshot.
self.connection
.execute_batch("BEGIN DEFERRED")
.map_err(sql_error)?;
let result = (|| {
let drafts = self.list_drafts()?;
let observations = self.observations()?;
let mut statement = self.connection.prepare("SELECT draft_id,revision,title,text,context_url,source,created_at FROM revisions ORDER BY draft_id,revision").map_err(sql_error)?;
let rows = statement.query_map([], revision_row).map_err(sql_error)?;
let revisions = rows
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(sql_error)?;
Ok(ExportData {
version: 1,
exported_at: now(),
drafts,
revisions,
observations,
})
})();
match result {
Ok(data) => {
self.connection.execute_batch("COMMIT").map_err(sql_error)?;
Ok(data)
}
Err(error) => {
let _ = self.connection.execute_batch("ROLLBACK");
Err(error)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// A rule set that is nothing like X's: the rules really are parameters.
static NOTES: VaultRules = VaultRules {
kinds: &["note", "todo"],
sources: &["me", "sync"],
max_title_utf16: 10,
max_text_bytes: 100,
valid_identifier: |id| !id.is_empty() && id.len() <= 16 && id.bytes().all(|b| b.is_ascii_alphanumeric()),
validate_context: |context| if context.is_empty() { Ok(()) } else { Err("VALIDATION: no contexts here".into()) },
capture: CaptureRules { account: "syncaccount", source: "sync", budget_bytes: 600, budget_revisions: 2 },
};
fn input(id: &str, revision: i64, text: &str) -> SaveDraftInput {
SaveDraftInput { id: id.into(), account_id: "me1".into(), kind: "note".into(), title: "t".into(), text: text.into(), context_url: String::new(), expected_revision: revision, source: "me".into() }
}
#[test]
fn the_rules_decide_kinds_sources_limits_and_the_schema() {
let mut vault = Vault::open(":memory:", &NOTES).unwrap();
assert!(vault.save_draft(&SaveDraftInput { kind: "post".into(), ..input("a", 0, "x") }).unwrap_err().starts_with("VALIDATION"));
assert!(vault.save_draft(&SaveDraftInput { source: "local".into(), ..input("a", 0, "x") }).unwrap_err().starts_with("VALIDATION"));
assert!(vault.save_draft(&input("a", 0, &"x".repeat(101))).unwrap_err().starts_with("VALIDATION"));
assert!(vault.save_draft(&SaveDraftInput { context_url: "https://x.example".into(), ..input("a", 0, "x") }).unwrap_err().starts_with("VALIDATION"));
assert!(vault.save_draft(&input("bad id", 0, "x")).is_err());
assert!(vault.list_drafts().unwrap().is_empty(), "refused writes leave nothing");
// The database itself refuses a kind the rules did not allow, even past the Rust check.
let sql: String = vault.connection.query_row("SELECT sql FROM sqlite_master WHERE name='drafts'", [], |row| row.get(0)).unwrap();
assert!(sql.contains("kind IN ('note','todo')") && sql.contains("source IN ('me','sync')"));
assert_eq!(vault.save_draft(&input("a", 0, "x")).unwrap().revision, 1);
}
#[test]
fn a_stale_revision_is_refused_and_every_save_appends_a_revision() {
let mut vault = Vault::open(":memory:", &NOTES).unwrap();
let first = vault.save_draft(&input("a", 0, "one")).unwrap();
vault.save_draft(&input("a", first.revision, "two")).unwrap();
assert!(vault.save_draft(&input("a", first.revision, "stale")).unwrap_err().starts_with("CONFLICT"));
assert_eq!(vault.get_draft("a").unwrap().unwrap().text, "two");
let texts: Vec<_> = vault.revisions("a").unwrap().into_iter().map(|revision| revision.text).collect();
assert_eq!(texts, ["two", "one"]);
let archived = vault.set_status("a", 2, "archived").unwrap();
assert_eq!((archived.revision, archived.text.as_str()), (3, "two"));
assert_eq!(vault.export().unwrap().revisions.len(), 3);
}
#[test]
fn capture_belongs_to_the_rules_account_and_stops_at_the_budget() {
let mut vault = Vault::open(":memory:", &NOTES).unwrap();
let capture = |id: &str, revision: i64, text: &str| SaveDraftInput { account_id: "syncaccount".into(), source: "sync".into(), ..input(id, revision, text) };
assert!(vault.save_capture(&input("a", 0, "x")).unwrap_err().starts_with("FORBIDDEN"), "not the capture account");
let first = vault.save_capture(&capture("a", 0, "one")).unwrap();
vault.save_capture(&capture("a", first.revision, "two")).unwrap();
// Two revisions were allowed; the third is refused and the draft keeps its text.
assert!(vault.save_capture(&capture("a", 2, "three")).unwrap_err().starts_with("QUOTA"));
assert_eq!(vault.get_draft("a").unwrap().unwrap().text, "two");
assert_eq!(vault.revisions("a").unwrap().len(), 2);
}
}