feat(desktop): bundled renderer, modules and instance accounts (#3254)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 16:48:07 +02:00
1 parent c748c8af4e
commit 7c5fa2180a
1275 files changed
+215074 -81246

No files matched your search

@@ -0,0 +1,585 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::model::{
CapacityBudget, MAX_ACCOUNT_BYTES, MAX_ACCOUNTS, MAX_INSTANCE_BYTES, MAX_KNOWN_INSTANCE_BYTES,
MAX_KNOWN_INSTANCES, StoreError, WriteRefusal, check_lookup_key,
};
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_json::value::RawValue;
#[derive(Deserialize, Serialize)]
pub struct AccountEntry {
#[serde(rename = "storageKey")]
pub storage_key: String,
pub record: Box<RawValue>,
}
#[derive(Deserialize)]
pub struct AccountCompareAndSwap {
pub expected: AccountEntry,
pub replacement: AccountEntry,
}
#[derive(Deserialize, Serialize)]
pub struct KnownInstanceEntry {
pub key: String,
pub record: Box<RawValue>,
}
pub enum AccountWrite {
Written { instance_usable: bool },
Refused(WriteRefusal),
}
struct AccountFacts {
user_id: String,
last_active: f64,
instance_usable: bool,
}
fn account_record_fields(
record: &RawValue,
) -> Result<serde_json::Map<String, Value>, WriteRefusal> {
match serde_json::from_str::<Value>(record.get()) {
Ok(Value::Object(fields)) => Ok(fields),
_ => Err(WriteRefusal::Invalid(
"account record must be a JSON object",
)),
}
}
fn account_facts(record: &RawValue) -> Result<AccountFacts, WriteRefusal> {
let fields = account_record_fields(record)?;
Ok(AccountFacts {
user_id: fields
.get("userId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
last_active: numeric_field(&fields, "lastActive"),
instance_usable: instance_is_usable(&fields),
})
}
fn numeric_field(fields: &serde_json::Map<String, Value>, name: &str) -> f64 {
fields
.get(name)
.and_then(Value::as_f64)
.filter(|value| value.is_finite())
.unwrap_or_default()
}
fn instance_is_usable(fields: &serde_json::Map<String, Value>) -> bool {
let Some(Value::Object(instance)) = fields.get("instance") else {
return false;
};
let endpoint_is_present = instance
.get("apiEndpoint")
.and_then(Value::as_str)
.is_some_and(|endpoint| !endpoint.is_empty());
endpoint_is_present
&& serde_json::to_string(instance)
.is_ok_and(|serialized| serialized.len() <= MAX_INSTANCE_BYTES)
}
pub fn account_budget(conn: &Connection) -> Result<CapacityBudget, StoreError> {
CapacityBudget::read(
conn,
"accounts",
"SELECT COUNT(*), COALESCE(SUM(length(CAST(storage_key AS BLOB)) + length(CAST(record_json AS BLOB))), 0) FROM accounts",
[],
MAX_ACCOUNTS,
MAX_ACCOUNT_BYTES,
)
}
pub fn write_account(
conn: &Connection,
budget: &mut CapacityBudget,
entry: &AccountEntry,
) -> Result<AccountWrite, StoreError> {
if let Some(refusal) = check_lookup_key(&entry.storage_key, "account storageKey is unusable") {
return Ok(AccountWrite::Refused(refusal));
}
let facts = match account_facts(&entry.record) {
Ok(facts) => facts,
Err(refusal) => return Ok(AccountWrite::Refused(refusal)),
};
let incoming = (entry.storage_key.len() + entry.record.get().len()) as i64;
let replaces = stored_bytes(
conn,
"SELECT length(CAST(storage_key AS BLOB)) + length(CAST(record_json AS BLOB)) FROM accounts WHERE storage_key = ?1",
params![entry.storage_key],
)?;
if let Err(refusal) = budget.check(replaces, incoming) {
return Ok(AccountWrite::Refused(refusal.into()));
}
budget.commit(replaces, incoming);
conn.execute(
"INSERT INTO accounts(storage_key, user_id, last_active, record_json) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(storage_key) DO UPDATE SET
user_id = excluded.user_id,
last_active = excluded.last_active,
record_json = excluded.record_json",
params![
entry.storage_key,
facts.user_id,
facts.last_active,
entry.record.get()
],
)?;
Ok(AccountWrite::Written {
instance_usable: facts.instance_usable,
})
}
pub fn upsert_account(conn: &Connection, entry: &AccountEntry) -> Result<(), StoreError> {
let mut budget = account_budget(conn)?;
match write_account(conn, &mut budget, entry)? {
AccountWrite::Written { .. } => Ok(()),
AccountWrite::Refused(refusal) => Err(refusal.into()),
}
}
pub fn compare_and_swap_account(
conn: &mut Connection,
request: &AccountCompareAndSwap,
) -> Result<bool, StoreError> {
if request.expected.storage_key != request.replacement.storage_key {
return Err(StoreError::Refused(
"account compare-and-swap records must use the same storageKey",
));
}
if let Some(refusal) = check_lookup_key(
&request.expected.storage_key,
"account storageKey is unusable",
) {
return Err(refusal.into());
}
let expected = account_record_fields(&request.expected.record)?;
let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let stored: Option<String> = transaction
.query_row(
"SELECT record_json FROM accounts WHERE storage_key = ?1",
params![request.expected.storage_key],
|row| row.get(0),
)
.optional()?;
let Some(stored) = stored else {
transaction.commit()?;
return Ok(false);
};
let stored = RawValue::from_string(stored)?;
let stored = account_record_fields(&stored)?;
if stored != expected {
transaction.commit()?;
return Ok(false);
}
let mut budget = account_budget(&transaction)?;
match write_account(&transaction, &mut budget, &request.replacement)? {
AccountWrite::Written { .. } => {}
AccountWrite::Refused(refusal) => return Err(refusal.into()),
}
transaction.commit()?;
Ok(true)
}
pub fn all_accounts(conn: &Connection) -> Result<Vec<AccountEntry>, StoreError> {
let mut statement = conn.prepare(
"SELECT storage_key, record_json FROM accounts ORDER BY last_active DESC, storage_key ASC",
)?;
let mut rows = statement.query([])?;
let mut accounts = Vec::new();
while let Some(row) = rows.next()? {
accounts.push(AccountEntry {
storage_key: row.get(0)?,
record: RawValue::from_string(row.get(1)?)?,
});
}
Ok(accounts)
}
pub fn account(conn: &Connection, storage_key: &str) -> Result<Option<AccountEntry>, StoreError> {
let record: Option<String> = conn
.query_row(
"SELECT record_json FROM accounts WHERE storage_key = ?1",
params![storage_key],
|row| row.get(0),
)
.optional()?;
record
.map(|record| {
Ok(AccountEntry {
storage_key: storage_key.to_owned(),
record: RawValue::from_string(record)?,
})
})
.transpose()
}
pub fn delete_account_rows(conn: &Connection, storage_key: &str) -> Result<(), StoreError> {
conn.execute(
"DELETE FROM accounts WHERE storage_key = ?1",
params![storage_key],
)?;
conn.execute(
"DELETE FROM scoped_storage WHERE scope = ?1",
params![storage_key],
)?;
Ok(())
}
pub fn delete_account(conn: &Connection, storage_key: &str) -> Result<(), StoreError> {
let transaction = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
delete_account_rows(&transaction, storage_key)?;
transaction.commit()?;
Ok(())
}
pub fn known_instance_budget(conn: &Connection) -> Result<CapacityBudget, StoreError> {
CapacityBudget::read(
conn,
"known instances",
"SELECT COUNT(*), COALESCE(SUM(length(CAST(key AS BLOB)) + length(CAST(record_json AS BLOB))), 0) FROM known_instances",
[],
MAX_KNOWN_INSTANCES,
MAX_KNOWN_INSTANCE_BYTES,
)
}
pub fn upsert_known_instance(
conn: &Connection,
entry: &KnownInstanceEntry,
) -> Result<(), StoreError> {
if let Some(refusal) = check_lookup_key(&entry.key, "known instance key is unusable") {
return Err(refusal.into());
}
let Ok(Value::Object(fields)) = serde_json::from_str::<Value>(entry.record.get()) else {
return Err(WriteRefusal::Invalid("known instance record must be a JSON object").into());
};
let budget = known_instance_budget(conn)?;
let incoming = (entry.key.len() + entry.record.get().len()) as i64;
let replaces = stored_bytes(
conn,
"SELECT length(CAST(key AS BLOB)) + length(CAST(record_json AS BLOB)) FROM known_instances WHERE key = ?1",
params![entry.key],
)?;
budget.check(replaces, incoming)?;
conn.execute(
"INSERT INTO known_instances(key, last_used, record_json) VALUES (?1, ?2, ?3)
ON CONFLICT(key) DO UPDATE SET
last_used = excluded.last_used,
record_json = excluded.record_json",
params![
entry.key,
numeric_field(&fields, "lastUsed"),
entry.record.get()
],
)?;
Ok(())
}
pub fn all_known_instances(conn: &Connection) -> Result<Vec<KnownInstanceEntry>, StoreError> {
let mut statement = conn
.prepare("SELECT key, record_json FROM known_instances ORDER BY last_used DESC, key ASC")?;
let mut rows = statement.query([])?;
let mut instances = Vec::new();
while let Some(row) = rows.next()? {
instances.push(KnownInstanceEntry {
key: row.get(0)?,
record: RawValue::from_string(row.get(1)?)?,
});
}
Ok(instances)
}
pub fn delete_known_instance(conn: &Connection, key: &str) -> Result<(), StoreError> {
conn.execute("DELETE FROM known_instances WHERE key = ?1", params![key])?;
Ok(())
}
fn stored_bytes(
conn: &Connection,
sql: &str,
args: impl rusqlite::Params,
) -> Result<Option<i64>, StoreError> {
Ok(conn.query_row(sql, args, |row| row.get(0)).optional()?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Store, temporary_store_path};
use crate::scoped_storage::{ScopedEntry, entry as scoped_entry, scope_entries, set_entry};
fn entry(storage_key: &str, record: &str) -> AccountEntry {
AccountEntry {
storage_key: storage_key.to_owned(),
record: RawValue::from_string(record.to_owned()).expect("valid JSON"),
}
}
#[test]
fn a_record_with_unknown_keys_round_trips_byte_exactly() {
let (_dir, path) = temporary_store_path();
let store = Store::open(path, None).expect("a fresh store opens");
let written = r#"{"userId":"1","instance":{"apiEndpoint":"https://a.example/api","futureKey":[1,2]},"unknownTopLevel":{"nested":true},"lastActive":7}"#;
upsert_account(store.connection(), &entry("a.example::1", written))
.expect("the account is stored");
let read = account(store.connection(), "a.example::1")
.expect("the account is readable")
.expect("the account is present");
assert_eq!(
read.record.get(),
written,
"a record written by a newer renderer must survive an older shell byte-exactly"
);
assert_eq!(read.storage_key, "a.example::1");
}
#[test]
fn accounts_are_ordered_by_last_active_then_storage_key() {
let (_dir, path) = temporary_store_path();
let store = Store::open(path, None).expect("a fresh store opens");
for (key, last_active) in [("b::2", 5.0), ("a::1", 9.0), ("a::3", 5.0)] {
upsert_account(
store.connection(),
&entry(
key,
&format!(r#"{{"userId":"u","lastActive":{last_active}}}"#),
),
)
.expect("the account is stored");
}
let keys = all_accounts(store.connection())
.expect("the accounts are readable")
.into_iter()
.map(|account| account.storage_key)
.collect::<Vec<_>>();
assert_eq!(keys, vec!["a::1", "a::3", "b::2"]);
}
#[test]
fn a_record_the_addon_cannot_use_is_still_stored() {
let (_dir, path) = temporary_store_path();
let store = Store::open(path, None).expect("a fresh store opens");
let deployed_shape =
r#"{"userId":"1","instance":{"gifProvider":"tenor","apiCodeVersion":9}}"#;
upsert_account(store.connection(), &entry("legacy::1", deployed_shape))
.expect("a record with an unusable instance is still stored");
let mut budget = account_budget(store.connection());
let write = write_account(
store.connection(),
budget.as_mut().expect("the budget is readable"),
&entry("legacy::1", deployed_shape),
)
.expect("the write succeeds");
assert!(matches!(
write,
AccountWrite::Written {
instance_usable: false
}
));
assert_eq!(
account(store.connection(), "legacy::1")
.expect("the account is readable")
.expect("the account is present")
.record
.get(),
deployed_shape
);
}
#[test]
fn a_storage_key_crosses_the_boundary_opaquely() {
let (_dir, path) = temporary_store_path();
let store = Store::open(path, None).expect("a fresh store opens");
let opaque = "HTTPS://Mixed.Case.Example:8443/api::user::12345";
upsert_account(store.connection(), &entry(opaque, r#"{"userId":"12345"}"#))
.expect("the account is stored");
assert!(
account(store.connection(), opaque)
.expect("the account is readable")
.is_some(),
"the addon must not normalise the storage key it was given"
);
}
#[test]
fn a_record_that_is_not_a_json_object_is_refused_rather_than_stored() {
let (_dir, path) = temporary_store_path();
let store = Store::open(path, None).expect("a fresh store opens");
assert!(upsert_account(store.connection(), &entry("a::1", "[1,2,3]")).is_err());
assert!(
all_accounts(store.connection())
.expect("the accounts are readable")
.is_empty()
);
}
#[test]
fn deleting_an_account_takes_its_scoped_storage_with_it() {
let (_dir, path) = temporary_store_path();
let store = Store::open(path, None).expect("a fresh store opens");
for key in ["a::1", "a::2"] {
upsert_account(store.connection(), &entry(key, r#"{"userId":"1"}"#))
.expect("the account is stored");
}
for scope in ["a::1", "a::2", "global"] {
set_entry(
store.connection(),
&ScopedEntry {
store: "app".to_owned(),
scope: scope.to_owned(),
key: "theme".to_owned(),
value: "\"dark\"".to_owned(),
updated_at: 1.0,
},
)
.expect("the entry is stored");
}
delete_account(store.connection(), "a::1").expect("the account is deleted");
assert!(
account(store.connection(), "a::1")
.expect("the account is readable")
.is_none()
);
assert!(
scope_entries(store.connection(), "app", "a::1")
.expect("entries are readable")
.is_empty(),
"a deleted account must not leave its scoped storage behind"
);
assert!(
scoped_entry(store.connection(), "app", "a::2", "theme")
.expect("the entry is readable")
.is_some()
);
assert!(
scoped_entry(store.connection(), "app", "global", "theme")
.expect("the entry is readable")
.is_some()
);
}
#[test]
fn a_compare_and_swap_lands_once_and_refuses_the_stale_retry() {
let (_dir, path) = temporary_store_path();
let mut store = Store::open(path, None).expect("a fresh store opens");
let first = r#"{"userId":"1","token":"first"}"#;
let second = r#"{"userId":"1","token":"second"}"#;
let third = r#"{"userId":"1","token":"third"}"#;
upsert_account(store.connection(), &entry("a.example::1", first))
.expect("the account is stored");
assert!(
compare_and_swap_account(
store.connection_mut(),
&AccountCompareAndSwap {
expected: entry("a.example::1", first),
replacement: entry("a.example::1", second),
},
)
.expect("the swap runs")
);
assert_eq!(
account(store.connection(), "a.example::1")
.expect("the account is readable")
.expect("the account is present")
.record
.get(),
second
);
assert!(
!compare_and_swap_account(
store.connection_mut(),
&AccountCompareAndSwap {
expected: entry("a.example::1", first),
replacement: entry("a.example::1", third),
},
)
.expect("the swap runs"),
"a swap holding a record the store has already moved past must report that it did not land"
);
assert_eq!(
account(store.connection(), "a.example::1")
.expect("the account is readable")
.expect("the account is present")
.record
.get(),
second,
"a refused swap must leave the newer record untouched"
);
}
#[test]
fn a_compare_and_swap_across_two_storage_keys_is_refused() {
let (_dir, path) = temporary_store_path();
let mut store = Store::open(path, None).expect("a fresh store opens");
let record = r#"{"userId":"1"}"#;
upsert_account(store.connection(), &entry("a.example::1", record))
.expect("the account is stored");
let outcome = compare_and_swap_account(
store.connection_mut(),
&AccountCompareAndSwap {
expected: entry("a.example::1", record),
replacement: entry("a.example::2", record),
},
);
assert!(matches!(outcome, Err(StoreError::Refused(_))));
assert!(
account(store.connection(), "a.example::2")
.expect("the accounts are readable")
.is_none()
);
}
#[test]
fn known_instances_are_stored_opaquely_and_ordered_by_last_used() {
let (_dir, path) = temporary_store_path();
let store = Store::open(path, None).expect("a fresh store opens");
for (key, last_used) in [("beta.example", 1.0), ("alpha.example", 4.0)] {
upsert_known_instance(
store.connection(),
&KnownInstanceEntry {
key: key.to_owned(),
record: RawValue::from_string(format!(
r#"{{"lastUsed":{last_used},"displayName":"{key}"}}"#
))
.expect("valid JSON"),
},
)
.expect("the known instance is stored");
}
let stored = all_known_instances(store.connection()).expect("the instances are readable");
assert_eq!(
stored
.iter()
.map(|row| row.key.as_str())
.collect::<Vec<_>>(),
vec!["alpha.example", "beta.example"]
);
delete_known_instance(store.connection(), "alpha.example")
.expect("the instance is deleted");
assert_eq!(
all_known_instances(store.connection())
.expect("the instances are readable")
.len(),
1
);
}
}
@@ -0,0 +1,479 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::accounts::{
AccountEntry, AccountWrite, account_budget, delete_account_rows, write_account,
};
use crate::model::{Store, StoreError, write_metadata};
use crate::scoped_storage::{EntryBudgets, ScopedEntry, write_entry};
use rusqlite::TransactionBehavior;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Deserialize)]
pub struct Marker {
pub key: String,
pub value: String,
}
#[derive(Deserialize)]
pub struct AccountImport {
pub records: Vec<AccountEntry>,
#[serde(default)]
pub marker: Option<Marker>,
}
#[derive(Deserialize)]
pub struct EntryImport {
pub entries: Vec<ScopedEntry>,
#[serde(default)]
pub marker: Option<Marker>,
}
#[derive(Deserialize)]
pub struct PruneRequest {
#[serde(rename = "knownStorageKeys")]
pub known_storage_keys: Vec<String>,
#[serde(rename = "listIsAuthoritative")]
pub list_is_authoritative: bool,
}
#[derive(Serialize)]
pub struct SkippedRecord {
pub key: String,
pub reason: String,
}
#[derive(Serialize)]
pub struct AccountImportReport {
pub imported: u32,
pub skipped: Vec<SkippedRecord>,
#[serde(rename = "unusableInstances")]
pub unusable_instances: Vec<String>,
}
#[derive(Serialize)]
pub struct EntryImportReport {
pub imported: u32,
pub skipped: Vec<SkippedRecord>,
}
#[derive(Serialize)]
pub struct PruneReport {
pub pruned: Vec<String>,
#[serde(rename = "refusedReason")]
pub refused_reason: Option<&'static str>,
}
pub fn import_accounts(
store: &mut Store,
request: AccountImport,
) -> Result<AccountImportReport, StoreError> {
let transaction = store
.connection_mut()
.transaction_with_behavior(TransactionBehavior::Immediate)?;
let mut budget = account_budget(&transaction)?;
let mut report = AccountImportReport {
imported: 0,
skipped: Vec::new(),
unusable_instances: Vec::new(),
};
for entry in &request.records {
match write_account(&transaction, &mut budget, entry)? {
AccountWrite::Written { instance_usable } => {
report.imported += 1;
if !instance_usable {
report.unusable_instances.push(entry.storage_key.clone());
}
}
AccountWrite::Refused(refusal) => report.skipped.push(SkippedRecord {
key: entry.storage_key.clone(),
reason: refusal.to_string(),
}),
}
}
if let Some(marker) = &request.marker {
write_metadata(&transaction, &marker.key, &marker.value)?;
}
transaction.commit()?;
Ok(report)
}
pub fn import_entries(
store: &mut Store,
request: EntryImport,
) -> Result<EntryImportReport, StoreError> {
let transaction = store
.connection_mut()
.transaction_with_behavior(TransactionBehavior::Immediate)?;
let mut budgets = EntryBudgets::read(&transaction)?;
let mut report = EntryImportReport {
imported: 0,
skipped: Vec::new(),
};
for entry in &request.entries {
match write_entry(&transaction, &mut budgets, entry)? {
None => report.imported += 1,
Some(refusal) => report.skipped.push(SkippedRecord {
key: format!("{}/{}/{}", entry.store, entry.scope, entry.key),
reason: refusal.to_string(),
}),
}
}
if let Some(marker) = &request.marker {
write_metadata(&transaction, &marker.key, &marker.value)?;
}
transaction.commit()?;
Ok(report)
}
pub fn prune(store: &mut Store, request: PruneRequest) -> Result<PruneReport, StoreError> {
if !request.list_is_authoritative {
return Ok(refused("the known account list was not authoritative"));
}
let transaction = store
.connection_mut()
.transaction_with_behavior(TransactionBehavior::Immediate)?;
let stored = {
let mut statement = transaction.prepare("SELECT storage_key FROM accounts")?;
let mut rows = statement.query([])?;
let mut stored = Vec::new();
while let Some(row) = rows.next()? {
stored.push(row.get::<_, String>(0)?);
}
stored
};
if request.known_storage_keys.is_empty() && !stored.is_empty() {
return Ok(refused(
"the known account list was empty while the store holds accounts",
));
}
let known = request
.known_storage_keys
.iter()
.map(String::as_str)
.collect::<HashSet<_>>();
let mut pruned = Vec::new();
for storage_key in stored {
if known.contains(storage_key.as_str()) {
continue;
}
delete_account_rows(&transaction, &storage_key)?;
pruned.push(storage_key);
}
transaction.commit()?;
Ok(PruneReport {
pruned,
refused_reason: None,
})
}
fn refused(reason: &'static str) -> PruneReport {
PruneReport {
pruned: Vec::new(),
refused_reason: Some(reason),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accounts::{account, all_accounts};
use crate::model::{MAX_SCOPED_VALUE_BYTES, temporary_store_path};
use crate::scoped_storage::{entry, scope_entries};
use serde_json::value::RawValue;
fn open() -> (tempfile::TempDir, Store) {
let (dir, path) = temporary_store_path();
let store = Store::open(path, None).expect("a fresh store opens");
(dir, store)
}
fn account_entry(storage_key: &str, record: &str) -> AccountEntry {
AccountEntry {
storage_key: storage_key.to_owned(),
record: RawValue::from_string(record.to_owned()).expect("valid JSON"),
}
}
fn scoped_entry(scope: &str, key: &str, value: &str) -> ScopedEntry {
ScopedEntry {
store: "app".to_owned(),
scope: scope.to_owned(),
key: key.to_owned(),
value: value.to_owned(),
updated_at: 1.0,
}
}
#[test]
fn importing_accounts_commits_the_records_and_the_phase_marker_together() {
let (_dir, mut store) = open();
let request = AccountImport {
records: vec![
account_entry(
"a::1",
r#"{"userId":"1","lastActive":2,"instance":{"apiEndpoint":"https://a/api"}}"#,
),
account_entry("a::2", r#"{"userId":"2","lastActive":1}"#),
],
marker: Some(Marker {
key: "legacy_import.v1".to_owned(),
value: r#"{"phase":"entries"}"#.to_owned(),
}),
};
let report = import_accounts(&mut store, request).expect("the import commits");
assert_eq!(report.imported, 2);
assert!(report.skipped.is_empty());
assert_eq!(report.unusable_instances, vec!["a::2".to_owned()]);
assert_eq!(
store
.metadata("legacy_import.v1")
.expect("metadata is readable"),
Some(r#"{"phase":"entries"}"#.to_owned())
);
let again = import_accounts(
&mut store,
AccountImport {
records: vec![account_entry("a::1", r#"{"userId":"1","lastActive":9}"#)],
marker: None,
},
)
.expect("a repeated import commits");
assert_eq!(again.imported, 1);
assert_eq!(
all_accounts(store.connection())
.expect("the accounts are readable")
.len(),
2,
"re-importing a record must replace it, never duplicate it"
);
}
#[test]
fn importing_accounts_skips_and_reports_a_bad_record_without_losing_the_batch() {
let (_dir, mut store) = open();
let report = import_accounts(
&mut store,
AccountImport {
records: vec![
account_entry("a::1", r#"{"userId":"1"}"#),
account_entry("", r#"{"userId":"2"}"#),
account_entry("a::3", r#""not-an-object""#),
account_entry("a::4", r#"{"userId":"4"}"#),
],
marker: None,
},
)
.expect("the import commits");
assert_eq!(report.imported, 2);
assert_eq!(
report
.skipped
.iter()
.map(|skipped| skipped.key.as_str())
.collect::<Vec<_>>(),
vec!["", "a::3"]
);
assert!(
account(store.connection(), "a::4")
.expect("the account is readable")
.is_some()
);
}
#[test]
fn importing_entries_skips_and_reports_a_bad_record_and_commits_the_rest() {
let (_dir, mut store) = open();
let oversized = "x".repeat(MAX_SCOPED_VALUE_BYTES + 1);
let report = import_entries(
&mut store,
EntryImport {
entries: vec![
scoped_entry("a::1", "theme", "dark"),
scoped_entry("a::1", "", "no key"),
scoped_entry("a::1", "huge", &oversized),
scoped_entry("a::1", "zoom", "1.25"),
],
marker: Some(Marker {
key: "legacy_import.v1".to_owned(),
value: r#"{"phase":"session"}"#.to_owned(),
}),
},
)
.expect("the import commits");
assert_eq!(report.imported, 2);
assert_eq!(report.skipped.len(), 2);
assert!(report.skipped[0].reason.contains("key is unusable"));
assert!(report.skipped[1].reason.contains("too large"));
assert_eq!(
scope_entries(store.connection(), "app", "a::1")
.expect("entries are readable")
.len(),
2,
"one oversized entry must not abort the batch"
);
assert_eq!(
store
.metadata("legacy_import.v1")
.expect("metadata is readable"),
Some(r#"{"phase":"session"}"#.to_owned())
);
}
#[test]
fn a_failure_inside_a_phase_rolls_back_the_data_and_the_marker_together() {
let (_dir, mut store) = open();
store
.set_metadata("legacy_import.v1", r#"{"phase":"accounts"}"#)
.expect("metadata is writable");
store
.connection()
.execute_batch("DROP TABLE accounts")
.expect("the table is dropped");
let failure = import_accounts(
&mut store,
AccountImport {
records: vec![account_entry("a::1", r#"{"userId":"1"}"#)],
marker: Some(Marker {
key: "legacy_import.v1".to_owned(),
value: r#"{"phase":"entries"}"#.to_owned(),
}),
},
);
assert!(failure.is_err());
assert_eq!(
store
.metadata("legacy_import.v1")
.expect("metadata is readable"),
Some(r#"{"phase":"accounts"}"#.to_owned()),
"an interrupted phase must re-run rather than be marked complete"
);
}
#[test]
fn pruning_refuses_a_non_authoritative_or_transiently_empty_list() {
let (_dir, mut store) = open();
import_accounts(
&mut store,
AccountImport {
records: vec![account_entry("a::1", r#"{"userId":"1"}"#)],
marker: None,
},
)
.expect("the import commits");
let not_authoritative = prune(
&mut store,
PruneRequest {
known_storage_keys: vec!["a::1".to_owned()],
list_is_authoritative: false,
},
)
.expect("prune completes");
assert_eq!(
not_authoritative.refused_reason,
Some("the known account list was not authoritative")
);
let transiently_empty = prune(
&mut store,
PruneRequest {
known_storage_keys: Vec::new(),
list_is_authoritative: true,
},
)
.expect("prune completes");
assert_eq!(
transiently_empty.refused_reason,
Some("the known account list was empty while the store holds accounts")
);
assert_eq!(
all_accounts(store.connection())
.expect("the accounts are readable")
.len(),
1,
"a refused prune must delete nothing"
);
}
#[test]
fn pruning_an_authoritative_list_removes_orphans_and_their_scoped_rows() {
let (_dir, mut store) = open();
import_accounts(
&mut store,
AccountImport {
records: vec![
account_entry("a::1", r#"{"userId":"1"}"#),
account_entry("a::2", r#"{"userId":"2"}"#),
],
marker: None,
},
)
.expect("the import commits");
import_entries(
&mut store,
EntryImport {
entries: vec![
scoped_entry("a::1", "theme", "dark"),
scoped_entry("a::2", "theme", "light"),
scoped_entry("global", "theme", "system"),
],
marker: None,
},
)
.expect("the import commits");
let report = prune(
&mut store,
PruneRequest {
known_storage_keys: vec!["a::1".to_owned()],
list_is_authoritative: true,
},
)
.expect("prune completes");
assert_eq!(report.pruned, vec!["a::2".to_owned()]);
assert_eq!(report.refused_reason, None);
assert!(
account(store.connection(), "a::2")
.expect("the account is readable")
.is_none()
);
assert!(
scope_entries(store.connection(), "app", "a::2")
.expect("entries are readable")
.is_empty()
);
assert!(
entry(store.connection(), "app", "global", "theme")
.expect("the entry is readable")
.is_some()
);
assert!(
entry(store.connection(), "app", "a::1", "theme")
.expect("the entry is readable")
.is_some()
);
}
#[test]
fn pruning_an_empty_store_against_an_empty_list_is_allowed() {
let (_dir, mut store) = open();
let report = prune(
&mut store,
PruneRequest {
known_storage_keys: Vec::new(),
list_is_authoritative: true,
},
)
.expect("prune completes");
assert_eq!(report.refused_reason, None);
assert!(report.pruned.is_empty());
}
}
+333
View File
@@ -0,0 +1,333 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
mod accounts;
mod import;
mod model;
mod scoped_storage;
use crate::accounts::{AccountCompareAndSwap, AccountEntry, KnownInstanceEntry};
use crate::import::{AccountImport, EntryImport, PruneRequest};
use crate::model::{OpenOutcome, Store, StoreError};
use crate::scoped_storage::ScopedEntry;
use napi::Task;
use napi::bindgen_prelude::{AsyncTask, Env, Error, Result, Status, ToNapiValue, TypeName};
use napi_derive::napi;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
const MAX_PAYLOAD_BYTES: usize = 64 * 1024 * 1024;
impl From<StoreError> for Error {
fn from(error: StoreError) -> Self {
Self::new(Status::GenericFailure, error.to_string())
}
}
#[napi(object)]
pub struct AppStoreOptions {
pub path: String,
pub derivation_version: Option<String>,
}
#[derive(Serialize)]
struct Initialization<'a> {
path: &'a str,
#[serde(rename = "schemaVersion")]
schema_version: i32,
#[serde(rename = "previousSchemaVersion")]
previous_schema_version: i32,
#[serde(rename = "appliedMigrations")]
applied_migrations: u32,
#[serde(rename = "quarantinedPath")]
quarantined_path: Option<&'a str>,
#[serde(rename = "quarantineReason")]
quarantine_reason: Option<&'a str>,
}
#[derive(Deserialize)]
struct ClearStoreExceptRequest {
store: String,
#[serde(rename = "keysToKeep")]
keys_to_keep: Vec<String>,
}
type StoreJob<T> = Box<dyn FnOnce(&mut Store) -> Result<T> + Send>;
pub struct StoreTask<T> {
store: Arc<Mutex<Option<Store>>>,
job: Option<StoreJob<T>>,
}
impl<T: Send + ToNapiValue + TypeName + 'static> Task for StoreTask<T> {
type Output = T;
type JsValue = T;
fn compute(&mut self) -> Result<Self::Output> {
let job = self.job.take().ok_or_else(|| {
Error::new(
Status::GenericFailure,
"app store operation was already executed",
)
})?;
let mut guard = self.store.lock().map_err(|_| {
Error::new(
Status::GenericFailure,
"app store lock was poisoned by an earlier panic",
)
})?;
let store = guard
.as_mut()
.ok_or_else(|| Error::new(Status::GenericFailure, "app store is closed"))?;
job(store)
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
#[napi]
pub struct AppStore {
store: Arc<Mutex<Option<Store>>>,
initialization: String,
}
impl AppStore {
fn task<T: Send + ToNapiValue + TypeName + 'static>(
&self,
job: impl FnOnce(&mut Store) -> Result<T> + Send + 'static,
) -> AsyncTask<StoreTask<T>> {
AsyncTask::new(StoreTask {
store: Arc::clone(&self.store),
job: Some(Box::new(job)),
})
}
}
#[napi]
impl AppStore {
#[napi(constructor)]
pub fn new(options: AppStoreOptions) -> Result<Self> {
let store = Store::open(
PathBuf::from(options.path),
options.derivation_version.as_deref(),
)?;
let initialization = describe(store.path().to_string_lossy().as_ref(), store.outcome())?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
initialization,
})
}
#[napi(getter)]
pub fn initialization(&self) -> String {
self.initialization.clone()
}
#[napi]
pub fn close(&self) -> Result<()> {
let mut guard = self.store.lock().map_err(|_| {
Error::new(
Status::GenericFailure,
"app store lock was poisoned by an earlier panic",
)
})?;
*guard = None;
Ok(())
}
#[napi]
pub fn get_metadata(&self, key: String) -> AsyncTask<StoreTask<String>> {
self.task(move |app_store| json(&app_store.metadata(&key)?))
}
#[napi]
pub fn set_metadata(&self, key: String, value: String) -> AsyncTask<StoreTask<()>> {
self.task(move |app_store| Ok(app_store.set_metadata(&key, &value)?))
}
#[napi]
pub fn get_all_accounts(&self) -> AsyncTask<StoreTask<String>> {
self.task(|app_store| json(&accounts::all_accounts(app_store.connection())?))
}
#[napi]
pub fn get_account(&self, storage_key: String) -> AsyncTask<StoreTask<String>> {
self.task(move |app_store| json(&accounts::account(app_store.connection(), &storage_key)?))
}
#[napi]
pub fn upsert_account(&self, payload: String) -> AsyncTask<StoreTask<()>> {
self.task(move |app_store| {
let entry: AccountEntry = parse(&payload)?;
Ok(accounts::upsert_account(app_store.connection(), &entry)?)
})
}
#[napi]
pub fn compare_and_swap_account(&self, payload: String) -> AsyncTask<StoreTask<bool>> {
self.task(move |app_store| {
let request: AccountCompareAndSwap = parse(&payload)?;
Ok(accounts::compare_and_swap_account(
app_store.connection_mut(),
&request,
)?)
})
}
#[napi]
pub fn delete_account(&self, storage_key: String) -> AsyncTask<StoreTask<()>> {
self.task(move |app_store| {
Ok(accounts::delete_account(
app_store.connection(),
&storage_key,
)?)
})
}
#[napi]
pub fn import_accounts(&self, payload: String) -> AsyncTask<StoreTask<String>> {
self.task(move |app_store| {
let request: AccountImport = parse(&payload)?;
json(&import::import_accounts(app_store, request)?)
})
}
#[napi]
pub fn get_all_known_instances(&self) -> AsyncTask<StoreTask<String>> {
self.task(|app_store| json(&accounts::all_known_instances(app_store.connection())?))
}
#[napi]
pub fn upsert_known_instance(&self, payload: String) -> AsyncTask<StoreTask<()>> {
self.task(move |app_store| {
let entry: KnownInstanceEntry = parse(&payload)?;
Ok(accounts::upsert_known_instance(
app_store.connection(),
&entry,
)?)
})
}
#[napi]
pub fn delete_known_instance(&self, key: String) -> AsyncTask<StoreTask<()>> {
self.task(move |app_store| {
Ok(accounts::delete_known_instance(
app_store.connection(),
&key,
)?)
})
}
#[napi]
pub fn get_entries(&self, store: String, scope: String) -> AsyncTask<StoreTask<String>> {
self.task(move |app_store| {
json(&scoped_storage::scope_entries(
app_store.connection(),
&store,
&scope,
)?)
})
}
#[napi]
pub fn get_entry(
&self,
store: String,
scope: String,
key: String,
) -> AsyncTask<StoreTask<String>> {
self.task(move |app_store| {
json(&scoped_storage::entry(
app_store.connection(),
&store,
&scope,
&key,
)?)
})
}
#[napi]
pub fn set_entry(&self, payload: String) -> AsyncTask<StoreTask<()>> {
self.task(move |app_store| {
let entry: ScopedEntry = parse(&payload)?;
Ok(scoped_storage::set_entry(app_store.connection(), &entry)?)
})
}
#[napi]
pub fn delete_entry(
&self,
store: String,
scope: String,
key: String,
) -> AsyncTask<StoreTask<()>> {
self.task(move |app_store| {
Ok(scoped_storage::delete_entry(
app_store.connection(),
&store,
&scope,
&key,
)?)
})
}
#[napi]
pub fn clear_scope(&self, scope: String) -> AsyncTask<StoreTask<()>> {
self.task(move |app_store| Ok(scoped_storage::clear_scope(app_store.connection(), &scope)?))
}
#[napi]
pub fn clear_store_except(&self, payload: String) -> AsyncTask<StoreTask<()>> {
self.task(move |app_store| {
let request: ClearStoreExceptRequest = parse(&payload)?;
Ok(scoped_storage::clear_store_except(
app_store.connection(),
&request.store,
&request.keys_to_keep,
)?)
})
}
#[napi]
pub fn import_entries(&self, payload: String) -> AsyncTask<StoreTask<String>> {
self.task(move |app_store| {
let request: EntryImport = parse(&payload)?;
json(&import::import_entries(app_store, request)?)
})
}
#[napi]
pub fn prune(&self, payload: String) -> AsyncTask<StoreTask<String>> {
self.task(move |app_store| {
let request: PruneRequest = parse(&payload)?;
json(&import::prune(app_store, request)?)
})
}
}
fn describe(path: &str, outcome: &OpenOutcome) -> Result<String> {
json(&Initialization {
path,
schema_version: outcome.schema_version,
previous_schema_version: outcome.previous_schema_version,
applied_migrations: outcome.applied_migrations,
quarantined_path: outcome.quarantined_path.as_deref(),
quarantine_reason: outcome.quarantine_reason,
})
}
fn json<T: Serialize>(value: &T) -> Result<String> {
Ok(serde_json::to_string(value).map_err(StoreError::from)?)
}
fn parse<T: DeserializeOwned>(payload: &str) -> Result<T> {
if payload.len() > MAX_PAYLOAD_BYTES {
return Err(Error::new(
Status::GenericFailure,
format!("app store payload exceeds {MAX_PAYLOAD_BYTES} bytes"),
));
}
Ok(serde_json::from_str(payload).map_err(StoreError::from)?)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,372 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::model::{
CapacityBudget, MAX_SCOPE_BYTES, MAX_SCOPE_ENTRIES, MAX_SCOPED_BYTES, MAX_SCOPED_ENTRIES,
MAX_SCOPED_VALUE_BYTES, StoreError, WriteRefusal, check_lookup_key, require_lookup_key,
};
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Deserialize, Serialize)]
pub struct ScopedEntry {
pub store: String,
pub scope: String,
pub key: String,
pub value: String,
#[serde(rename = "updatedAt")]
pub updated_at: f64,
}
pub struct EntryBudgets {
global: CapacityBudget,
scopes: HashMap<(String, String), CapacityBudget>,
}
impl EntryBudgets {
pub fn read(conn: &Connection) -> Result<Self, StoreError> {
Ok(Self {
global: CapacityBudget::read(
conn,
"scoped storage",
"SELECT COUNT(*), COALESCE(SUM(length(CAST(store AS BLOB)) + length(CAST(scope AS BLOB)) + length(CAST(entry_key AS BLOB)) + length(CAST(value AS BLOB))), 0) FROM scoped_storage",
[],
MAX_SCOPED_ENTRIES,
MAX_SCOPED_BYTES,
)?,
scopes: HashMap::new(),
})
}
fn scope(
&mut self,
conn: &Connection,
store: &str,
scope: &str,
) -> Result<&mut CapacityBudget, StoreError> {
let identity = (store.to_owned(), scope.to_owned());
if !self.scopes.contains_key(&identity) {
let budget = CapacityBudget::read(
conn,
"scoped storage store/scope",
"SELECT COUNT(*), COALESCE(SUM(length(CAST(entry_key AS BLOB)) + length(CAST(value AS BLOB))), 0) FROM scoped_storage WHERE store = ?1 AND scope = ?2",
params![store, scope],
MAX_SCOPE_ENTRIES,
MAX_SCOPE_BYTES,
)?;
self.scopes.insert(identity.clone(), budget);
}
Ok(self
.scopes
.get_mut(&identity)
.expect("the scope budget was just inserted"))
}
}
fn check_entry(entry: &ScopedEntry) -> Option<WriteRefusal> {
check_lookup_key(&entry.store, "scoped storage store is unusable")
.or_else(|| check_lookup_key(&entry.scope, "scoped storage scope is unusable"))
.or_else(|| check_lookup_key(&entry.key, "scoped storage key is unusable"))
.or_else(|| {
(entry.value.len() > MAX_SCOPED_VALUE_BYTES)
.then_some(WriteRefusal::Invalid("scoped storage value is too large"))
})
}
pub fn write_entry(
conn: &Connection,
budgets: &mut EntryBudgets,
entry: &ScopedEntry,
) -> Result<Option<WriteRefusal>, StoreError> {
if let Some(refusal) = check_entry(entry) {
return Ok(Some(refusal));
}
let replaces: Option<i64> = conn
.query_row(
"SELECT length(CAST(entry_key AS BLOB)) + length(CAST(value AS BLOB)) FROM scoped_storage WHERE store = ?1 AND scope = ?2 AND entry_key = ?3",
params![entry.store, entry.scope, entry.key],
|row| row.get(0),
)
.optional()?;
let scope_incoming = (entry.key.len() + entry.value.len()) as i64;
let global_incoming = (entry.store.len() + entry.scope.len()) as i64 + scope_incoming;
let global_replaces =
replaces.map(|bytes| bytes + (entry.store.len() + entry.scope.len()) as i64);
if let Err(refusal) = budgets.global.check(global_replaces, global_incoming) {
return Ok(Some(refusal.into()));
}
let scope_budget = budgets.scope(conn, &entry.store, &entry.scope)?;
if let Err(refusal) = scope_budget.check(replaces, scope_incoming) {
return Ok(Some(refusal.into()));
}
scope_budget.commit(replaces, scope_incoming);
budgets.global.commit(global_replaces, global_incoming);
conn.execute(
"INSERT INTO scoped_storage(store, scope, entry_key, value, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(store, scope, entry_key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at",
params![
entry.store,
entry.scope,
entry.key,
entry.value,
entry.updated_at
],
)?;
Ok(None)
}
pub fn set_entry(conn: &Connection, entry: &ScopedEntry) -> Result<(), StoreError> {
let mut budgets = EntryBudgets::read(conn)?;
match write_entry(conn, &mut budgets, entry)? {
None => Ok(()),
Some(refusal) => Err(refusal.into()),
}
}
pub fn scope_entries(
conn: &Connection,
store: &str,
scope: &str,
) -> Result<Vec<ScopedEntry>, StoreError> {
require_lookup_key(store, "scoped storage store is unusable")?;
require_lookup_key(scope, "scoped storage scope is unusable")?;
let mut statement = conn.prepare(
"SELECT entry_key, value, updated_at FROM scoped_storage WHERE store = ?1 AND scope = ?2 ORDER BY entry_key ASC",
)?;
let mut rows = statement.query(params![store, scope])?;
let mut entries = Vec::new();
while let Some(row) = rows.next()? {
entries.push(ScopedEntry {
store: store.to_owned(),
scope: scope.to_owned(),
key: row.get(0)?,
value: row.get(1)?,
updated_at: row.get(2)?,
});
}
Ok(entries)
}
pub fn entry(
conn: &Connection,
store: &str,
scope: &str,
key: &str,
) -> Result<Option<ScopedEntry>, StoreError> {
Ok(conn
.query_row(
"SELECT value, updated_at FROM scoped_storage WHERE store = ?1 AND scope = ?2 AND entry_key = ?3",
params![store, scope, key],
|row| {
Ok(ScopedEntry {
store: store.to_owned(),
scope: scope.to_owned(),
key: key.to_owned(),
value: row.get(0)?,
updated_at: row.get(1)?,
})
},
)
.optional()?)
}
pub fn delete_entry(
conn: &Connection,
store: &str,
scope: &str,
key: &str,
) -> Result<(), StoreError> {
conn.execute(
"DELETE FROM scoped_storage WHERE store = ?1 AND scope = ?2 AND entry_key = ?3",
params![store, scope, key],
)?;
Ok(())
}
pub fn clear_scope(conn: &Connection, scope: &str) -> Result<(), StoreError> {
require_lookup_key(scope, "scoped storage scope is unusable")?;
conn.execute(
"DELETE FROM scoped_storage WHERE scope = ?1",
params![scope],
)?;
Ok(())
}
pub fn clear_store_except(
conn: &Connection,
store: &str,
keys_to_keep: &[String],
) -> Result<(), StoreError> {
require_lookup_key(store, "scoped storage store is unusable")?;
if keys_to_keep.is_empty() {
conn.execute(
"DELETE FROM scoped_storage WHERE store = ?1",
params![store],
)?;
return Ok(());
}
let placeholders = vec!["?"; keys_to_keep.len()].join(", ");
let mut args: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(keys_to_keep.len() + 1);
args.push(&store);
for key in keys_to_keep {
args.push(key);
}
conn.execute(
&format!(
"DELETE FROM scoped_storage WHERE store = ? AND entry_key NOT IN ({placeholders})"
),
args.as_slice(),
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Store, temporary_store_path};
fn entry(store: &str, scope: &str, key: &str, value: &str) -> ScopedEntry {
ScopedEntry {
store: store.to_owned(),
scope: scope.to_owned(),
key: key.to_owned(),
value: value.to_owned(),
updated_at: 1.0,
}
}
#[test]
fn entries_round_trip_and_are_ordered_by_key() {
let (_dir, path) = temporary_store_path();
let store = Store::open(path, None).expect("a fresh store opens");
for key in ["zeta", "alpha", "mu"] {
set_entry(store.connection(), &entry("app", "a::1", key, key))
.expect("the entry is stored");
}
let stored =
scope_entries(store.connection(), "app", "a::1").expect("entries are readable");
assert_eq!(
stored
.iter()
.map(|row| row.key.as_str())
.collect::<Vec<_>>(),
vec!["alpha", "mu", "zeta"]
);
assert_eq!(
entry_value(store.connection(), "app", "a::1", "mu"),
Some("mu".to_owned())
);
set_entry(store.connection(), &entry("app", "a::1", "mu", "updated"))
.expect("the entry is replaced");
assert_eq!(
entry_value(store.connection(), "app", "a::1", "mu"),
Some("updated".to_owned())
);
assert_eq!(
scope_entries(store.connection(), "app", "a::1")
.expect("entries are readable")
.len(),
3,
"a replacement must not grow the scope"
);
delete_entry(store.connection(), "app", "a::1", "mu").expect("the entry is deleted");
assert_eq!(entry_value(store.connection(), "app", "a::1", "mu"), None);
}
#[test]
fn clearing_a_scope_leaves_every_other_scope_alone() {
let (_dir, path) = temporary_store_path();
let store = Store::open(path, None).expect("a fresh store opens");
set_entry(store.connection(), &entry("app", "a::1", "k", "mine"))
.expect("the entry is stored");
set_entry(store.connection(), &entry("voice", "a::1", "k", "mine"))
.expect("the entry is stored");
set_entry(store.connection(), &entry("app", "global", "k", "shared"))
.expect("the entry is stored");
clear_scope(store.connection(), "a::1").expect("the scope is cleared");
assert!(
scope_entries(store.connection(), "app", "a::1")
.expect("entries are readable")
.is_empty()
);
assert!(
scope_entries(store.connection(), "voice", "a::1")
.expect("entries are readable")
.is_empty()
);
assert_eq!(
entry_value(store.connection(), "app", "global", "k"),
Some("shared".to_owned())
);
}
#[test]
fn clearing_a_store_except_some_keys_spans_every_scope() {
let (_dir, path) = temporary_store_path();
let store = Store::open(path, None).expect("a fresh store opens");
for (scope, key) in [("a::1", "keep"), ("a::1", "drop"), ("global", "drop")] {
set_entry(store.connection(), &entry("app", scope, key, "v"))
.expect("the entry is stored");
}
set_entry(store.connection(), &entry("voice", "a::1", "drop", "v"))
.expect("the entry is stored");
clear_store_except(store.connection(), "app", &["keep".to_owned()])
.expect("the store is cleared");
assert_eq!(
entry_value(store.connection(), "app", "a::1", "keep"),
Some("v".to_owned())
);
assert_eq!(entry_value(store.connection(), "app", "a::1", "drop"), None);
assert_eq!(
entry_value(store.connection(), "app", "global", "drop"),
None
);
assert_eq!(
entry_value(store.connection(), "voice", "a::1", "drop"),
Some("v".to_owned()),
"another store must be untouched"
);
clear_store_except(store.connection(), "voice", &[]).expect("the store is cleared");
assert_eq!(
entry_value(store.connection(), "voice", "a::1", "drop"),
None
);
}
#[test]
fn an_oversized_value_is_refused_before_it_is_inserted() {
let (_dir, path) = temporary_store_path();
let store = Store::open(path, None).expect("a fresh store opens");
let oversized = "x".repeat(MAX_SCOPED_VALUE_BYTES + 1);
let mut budgets = EntryBudgets::read(store.connection()).expect("budgets are readable");
let refusal = write_entry(
store.connection(),
&mut budgets,
&entry("app", "a::1", "big", &oversized),
)
.expect("the write completes")
.expect("the write is refused");
assert!(refusal.to_string().contains("too large"));
assert!(
scope_entries(store.connection(), "app", "a::1")
.expect("entries are readable")
.is_empty(),
"a refused entry must never reach the table"
);
}
fn entry_value(conn: &Connection, store: &str, scope: &str, key: &str) -> Option<String> {
super::entry(conn, store, scope, key)
.expect("the entry is readable")
.map(|row| row.value)
}
}