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:
parent
4e44d8aaa0
commit
fc08bc89cb
4 changed files with 582 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
492
crates/waybetter-host/src/vault.rs
Normal file
492
crates/waybetter-host/src/vault.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue