mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(desktop): bundled renderer, modules and instance accounts (#3254)
This commit is contained in:
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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user