mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
persistent entity cache, streaming batche
This commit is contained in:
1 parent
2336ad9e87
commit
a5e03ad8da
33 files changed
+1936
-65
No files matched your search
@@ -9,8 +9,10 @@
|
||||
use keyring::Entry;
|
||||
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
||||
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||
use proton_sdk::cache::EncryptedCacheRepository;
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||
use proton_sdk::telemetry::TracingTelemetry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT};
|
||||
@@ -220,6 +222,21 @@ pub fn load() -> Result<StoredSession> {
|
||||
|
||||
/// Forget the persisted session (best-effort; absent entry is not an error).
|
||||
pub fn logout() -> Result<()> {
|
||||
// The persisted entity cache describes the account being logged out of. It
|
||||
// is encrypted under that account's mailbox password, but leaving it behind
|
||||
// would also mean the next account inherits a store it can only ever read as
|
||||
// misses. Best-effort: failing to remove a cache must not block the logout.
|
||||
if let Ok(dirs) = AppDirs::new() {
|
||||
let path = dirs.state_dir().join("sdk_cache.db");
|
||||
if let Some(cache) = crate::sdkcache::SdkCache::opened()
|
||||
&& let Err(e) = cache.clear_now()
|
||||
{
|
||||
tracing::warn!(error = %e, "clearing the SDK entity cache on logout failed");
|
||||
}
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
||||
}
|
||||
}
|
||||
match keyring_entry()?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
@@ -259,10 +276,50 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
||||
|
||||
register_refresh_handler(&session, password.clone(), key_salts.clone());
|
||||
|
||||
let client = ProtonDriveClient::with_key_salts(&session, password.into_bytes(), key_salts);
|
||||
let client = tune(
|
||||
ProtonDriveClient::with_key_salts(&session, password.clone().into_bytes(), key_salts),
|
||||
password.as_bytes(),
|
||||
);
|
||||
Ok((client, session))
|
||||
}
|
||||
|
||||
/// The client settings every front-end wants, applied wherever a
|
||||
/// [`ProtonDriveClient`] is built for real work.
|
||||
///
|
||||
/// - **Telemetry** into `tracing`, so the SDK's own spans (transfers, block
|
||||
/// storage, per-request timings) land in the daemon's log next to the
|
||||
/// daemon's, instead of being dropped by the default no-op sink.
|
||||
/// - **Small-file uploads**: a file that fits in one block is uploaded as a
|
||||
/// single atomic request instead of the multi-step draft/block/commit dance.
|
||||
/// The SDK makes this opt-in because it has no remote feature-flag provider;
|
||||
/// the drain queue writes plenty of small files, and the shorter path is one
|
||||
/// failure point per write instead of three.
|
||||
/// - **A persistent entity cache** ([`crate::sdkcache`]), so a restart does not
|
||||
/// re-fetch and re-decrypt the tree the previous run already walked. It is
|
||||
/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by the mailbox
|
||||
/// password, so the decrypted node names it holds are not readable from the
|
||||
/// file alone — and a password change simply reads as a cold cache, since the
|
||||
/// SDK treats an undecryptable entry as a miss and clears the store.
|
||||
///
|
||||
/// Opening it is best-effort: a store this process cannot open costs cache
|
||||
/// hits, not the session, so the client falls back to the SDK's in-memory
|
||||
/// default.
|
||||
fn tune(client: ProtonDriveClient, mailbox_password: &[u8]) -> ProtonDriveClient {
|
||||
let client = client
|
||||
.with_telemetry(TracingTelemetry::shared())
|
||||
.with_small_file_upload(true);
|
||||
match AppDirs::new().and_then(|dirs| crate::sdkcache::SdkCache::shared(&dirs)) {
|
||||
Ok(cache) => client.with_entity_repository(EncryptedCacheRepository::shared(
|
||||
cache,
|
||||
mailbox_password.to_vec(),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "persistent SDK entity cache unavailable; using memory");
|
||||
client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the session's current tokens back to the keyring.
|
||||
///
|
||||
/// The session does not carry the mailbox password (it is only needed to rebuild
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Per-node outcomes of the SDK's batch node operations.
|
||||
//!
|
||||
//! `trash_nodes`, `restore_nodes`, `delete_nodes` and `move_nodes` report one
|
||||
//! outcome per node instead of failing the whole call: a node the server
|
||||
//! rejects leaves the rest of the batch applied. The daemon's callers fall into
|
||||
//! two shapes, and both are here so neither has to re-derive the fold:
|
||||
//!
|
||||
//! - one node in, one answer out — [`into_unit`], which collapses the single
|
||||
//! outcome back to a `Result`;
|
||||
//! - a user-supplied list — [`split`], which keeps the successes and hands back
|
||||
//! the failures to report.
|
||||
|
||||
use proton_sdk::error::ProtonError;
|
||||
use proton_sdk::ids::NodeUid;
|
||||
|
||||
/// One node's outcome, as the SDK batch operations report it.
|
||||
pub type Outcome = (NodeUid, Result<(), ProtonError>);
|
||||
|
||||
/// Collapse a batch result to the first per-node failure, for the callers that
|
||||
/// pass exactly one node and want the old all-or-nothing answer.
|
||||
///
|
||||
/// An empty outcome list is `Ok`: the server accepted the request and reported
|
||||
/// on nothing, which is not an error the caller can act on.
|
||||
pub fn into_unit(outcomes: Vec<Outcome>) -> Result<(), ProtonError> {
|
||||
for (_, outcome) in outcomes {
|
||||
outcome?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split a batch result into the nodes that succeeded and the ones that failed.
|
||||
pub fn split(outcomes: Vec<Outcome>) -> (Vec<NodeUid>, Vec<(NodeUid, ProtonError)>) {
|
||||
let mut done = Vec::with_capacity(outcomes.len());
|
||||
let mut failed = Vec::new();
|
||||
for (uid, outcome) in outcomes {
|
||||
match outcome {
|
||||
Ok(()) => done.push(uid),
|
||||
Err(e) => failed.push((uid, e)),
|
||||
}
|
||||
}
|
||||
(done, failed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_sdk::ids::{LinkId, VolumeId};
|
||||
|
||||
fn uid(link: &str) -> NodeUid {
|
||||
NodeUid::new(VolumeId::new("vol"), LinkId::new(link))
|
||||
}
|
||||
|
||||
fn failure() -> ProtonError {
|
||||
ProtonError::invalid_operation("nope")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_unit_is_ok_when_every_node_succeeded() {
|
||||
assert!(into_unit(vec![(uid("a"), Ok(())), (uid("b"), Ok(()))]).is_ok());
|
||||
assert!(
|
||||
into_unit(Vec::new()).is_ok(),
|
||||
"nothing reported is not a failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_unit_surfaces_a_failed_node() {
|
||||
let outcomes = vec![(uid("a"), Ok(())), (uid("b"), Err(failure()))];
|
||||
assert!(into_unit(outcomes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_keeps_successes_and_failures_apart() {
|
||||
let outcomes = vec![
|
||||
(uid("a"), Ok(())),
|
||||
(uid("b"), Err(failure())),
|
||||
(uid("c"), Ok(())),
|
||||
];
|
||||
let (done, failed) = split(outcomes);
|
||||
assert_eq!(done, vec![uid("a"), uid("c")]);
|
||||
assert_eq!(failed.len(), 1);
|
||||
assert_eq!(failed[0].0, uid("b"));
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,10 @@ pub enum Request {
|
||||
/// timeline. Like `kind`, the offset is relative to the filtered set.
|
||||
#[serde(default)]
|
||||
range: Option<(i64, i64)>,
|
||||
/// Restrict the page to favourited photos. Older front-ends omit it and
|
||||
/// get the whole timeline, as before.
|
||||
#[serde(default)]
|
||||
favorites: bool,
|
||||
},
|
||||
/// The months the timeline spans (newest first, with per-month counts) so a
|
||||
/// front-end can build a date scrubber without paging the whole library.
|
||||
@@ -91,6 +95,14 @@ pub enum Request {
|
||||
PhotoThumbs { uids: Vec<String> },
|
||||
/// Download a photo's full content into the cache; replies with its path.
|
||||
OpenPhoto { uid: String },
|
||||
/// Add or remove Proton's `Favorite` tag on a photo. Replies with
|
||||
/// [`Response::Ok`].
|
||||
///
|
||||
/// Favouriting a photo that is *not* on this account's own photos volume —
|
||||
/// one shared with us, or one that lives only in an album — needs it
|
||||
/// re-encrypted for our timeline, which the SDK does not implement yet; the
|
||||
/// daemon reports that as an error rather than silently doing nothing.
|
||||
SetPhotoFavorite { uid: String, favorite: bool },
|
||||
/// Upload the photo at `source_path` under the given name and media type.
|
||||
///
|
||||
/// A path, not the bytes: this protocol is line-delimited JSON, and
|
||||
@@ -325,6 +337,39 @@ pub enum Request {
|
||||
/// The by-uid twin of [`Request::RemovePublicLink`].
|
||||
RemovePublicLinkByUid { uid: String, id: String },
|
||||
|
||||
// ---- revisions --------------------------------------------------------
|
||||
/// List the version history of the file at mountpoint-relative `path`,
|
||||
/// newest first. Replies with [`Response::Revisions`].
|
||||
ListRevisions { path: String },
|
||||
/// The by-uid twin of [`Request::ListRevisions`].
|
||||
ListRevisionsByUid { uid: String },
|
||||
/// Make revision `revision_id` of the file at `path` current again. The
|
||||
/// server applies this asynchronously, so the restored content may take a
|
||||
/// moment to appear. Replies with [`Response::Ok`].
|
||||
RestoreRevision { path: String, revision_id: String },
|
||||
/// The by-uid twin of [`Request::RestoreRevision`].
|
||||
RestoreRevisionByUid { uid: String, revision_id: String },
|
||||
/// Permanently delete revision `revision_id` of the file at `path`. The
|
||||
/// content is unrecoverable, and the server refuses to delete the revision
|
||||
/// that is currently active. Replies with [`Response::Ok`].
|
||||
DeleteRevision { path: String, revision_id: String },
|
||||
/// The by-uid twin of [`Request::DeleteRevision`].
|
||||
DeleteRevisionByUid { uid: String, revision_id: String },
|
||||
/// Write revision `revision_id` of the file at `path` to the absolute local
|
||||
/// path `dest`, leaving the file's current content untouched. Replies with
|
||||
/// [`Response::Ok`].
|
||||
SaveRevisionAs {
|
||||
path: String,
|
||||
revision_id: String,
|
||||
dest: String,
|
||||
},
|
||||
/// The by-uid twin of [`Request::SaveRevisionAs`].
|
||||
SaveRevisionAsByUid {
|
||||
uid: String,
|
||||
revision_id: String,
|
||||
dest: String,
|
||||
},
|
||||
|
||||
// ---- shared by me -----------------------------------------------------
|
||||
/// List the nodes I have shared with others — collaborative shares that still
|
||||
/// have members, pending invitations or a public link. Replies with
|
||||
@@ -608,6 +653,36 @@ pub struct SharedItem {
|
||||
pub link: Option<PublicLinkInfo>,
|
||||
}
|
||||
|
||||
/// One entry of a file's version history (in [`Response::Revisions`]).
|
||||
///
|
||||
/// The size and modification time are the ones the *uploader* claimed in the
|
||||
/// revision's extended attributes, so an old client that wrote none leaves them
|
||||
/// `None`; `size_on_storage` is always known but counts ciphertext, which is
|
||||
/// larger than the file. A front-end shows the claimed size when it has one and
|
||||
/// falls back to the storage size.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct RevisionInfo {
|
||||
/// Server-assigned id — the handle for [`Request::RestoreRevision`],
|
||||
/// [`Request::DeleteRevision`] and [`Request::SaveRevisionAs`].
|
||||
pub id: String,
|
||||
/// True for the revision that is the file's current content. It cannot be
|
||||
/// deleted, and restoring it is a no-op.
|
||||
pub is_active: bool,
|
||||
/// When the revision was created, epoch seconds.
|
||||
pub created: i64,
|
||||
/// Encrypted size on Proton's storage, in bytes.
|
||||
pub size_on_storage: i64,
|
||||
/// Plaintext size as claimed by the uploader, when it wrote one.
|
||||
pub claimed_size: Option<i64>,
|
||||
/// Modification time as claimed by the uploader (ISO-8601, verbatim).
|
||||
pub claimed_modified: Option<String>,
|
||||
/// The address that signed the revision, when it was not signed anonymously
|
||||
/// by the node key.
|
||||
pub signed_by: Option<String>,
|
||||
/// Whether the revision carries thumbnails.
|
||||
pub has_thumbnails: bool,
|
||||
}
|
||||
|
||||
/// What happened, in an [`ActivityEntry`]. Kept coarse: a front-end maps each to
|
||||
/// an icon and a verb, and the human detail lives in the entry's fields.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -1014,6 +1089,11 @@ pub struct PhotoItem {
|
||||
/// the split omit it; a front-end then treats everything as a still photo.
|
||||
#[serde(default = "default_photo_kind")]
|
||||
pub kind: PhotoKind,
|
||||
/// Whether the photo is favourited. Older daemons omit it, which a front-end
|
||||
/// reads as "not favourited" — the same thing it would show for a photo whose
|
||||
/// tags it has not learned yet.
|
||||
#[serde(default)]
|
||||
pub favorite: bool,
|
||||
}
|
||||
|
||||
/// A daemon too old to classify a timeline entry is assumed to have served a
|
||||
@@ -1209,6 +1289,9 @@ pub enum Response {
|
||||
AccountQuota { max_space: i64, used_space: i64 },
|
||||
/// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]).
|
||||
SharedByMe { items: Vec<SharedItem> },
|
||||
/// A file's version history, newest first (reply to
|
||||
/// [`Request::ListRevisions`]).
|
||||
Revisions { items: Vec<RevisionInfo> },
|
||||
/// The daemon's recent activity, newest first (reply to
|
||||
/// [`Request::ListActivity`]).
|
||||
Activity { items: Vec<ActivityEntry> },
|
||||
@@ -1858,6 +1941,101 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The revision requests come in path and by-uid pairs like the share ones,
|
||||
/// and carry a revision id that must survive the round-trip verbatim: acting
|
||||
/// on the wrong revision deletes content the user meant to keep.
|
||||
#[test]
|
||||
fn revision_requests_roundtrip_in_both_addressing_forms() {
|
||||
let cases = [
|
||||
(
|
||||
Request::ListRevisions { path: "a/b".into() },
|
||||
r#"{"ListRevisions":{"path":"a/b"}}"#,
|
||||
),
|
||||
(
|
||||
Request::ListRevisionsByUid {
|
||||
uid: "vol~link".into(),
|
||||
},
|
||||
r#"{"ListRevisionsByUid":{"uid":"vol~link"}}"#,
|
||||
),
|
||||
(
|
||||
Request::RestoreRevision {
|
||||
path: "a/b".into(),
|
||||
revision_id: "rev-1".into(),
|
||||
},
|
||||
r#"{"RestoreRevision":{"path":"a/b","revision_id":"rev-1"}}"#,
|
||||
),
|
||||
(
|
||||
Request::RestoreRevisionByUid {
|
||||
uid: "vol~link".into(),
|
||||
revision_id: "rev-1".into(),
|
||||
},
|
||||
r#"{"RestoreRevisionByUid":{"uid":"vol~link","revision_id":"rev-1"}}"#,
|
||||
),
|
||||
(
|
||||
Request::DeleteRevision {
|
||||
path: "a/b".into(),
|
||||
revision_id: "rev-2".into(),
|
||||
},
|
||||
r#"{"DeleteRevision":{"path":"a/b","revision_id":"rev-2"}}"#,
|
||||
),
|
||||
(
|
||||
Request::DeleteRevisionByUid {
|
||||
uid: "vol~link".into(),
|
||||
revision_id: "rev-2".into(),
|
||||
},
|
||||
r#"{"DeleteRevisionByUid":{"uid":"vol~link","revision_id":"rev-2"}}"#,
|
||||
),
|
||||
(
|
||||
Request::SaveRevisionAs {
|
||||
path: "a/b".into(),
|
||||
revision_id: "rev-3".into(),
|
||||
dest: "/tmp/out.bin".into(),
|
||||
},
|
||||
r#"{"SaveRevisionAs":{"path":"a/b","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#,
|
||||
),
|
||||
(
|
||||
Request::SaveRevisionAsByUid {
|
||||
uid: "vol~link".into(),
|
||||
revision_id: "rev-3".into(),
|
||||
dest: "/tmp/out.bin".into(),
|
||||
},
|
||||
r#"{"SaveRevisionAsByUid":{"uid":"vol~link","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#,
|
||||
),
|
||||
];
|
||||
for (request, wire) in cases {
|
||||
assert_eq!(serde_json::to_string(&request).unwrap(), wire);
|
||||
let decoded: Request = serde_json::from_str(wire).unwrap();
|
||||
assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
|
||||
}
|
||||
}
|
||||
|
||||
/// A front-end that predates the favourites filter sends no `favorites`
|
||||
/// field, and must keep getting the whole timeline rather than an empty page.
|
||||
#[test]
|
||||
fn timeline_requests_without_the_favorites_filter_still_decode() {
|
||||
let decoded: Request =
|
||||
serde_json::from_str(r#"{"PhotosTimeline":{"offset":0,"limit":50}}"#).unwrap();
|
||||
assert!(matches!(
|
||||
decoded,
|
||||
Request::PhotosTimeline {
|
||||
favorites: false,
|
||||
kind: None,
|
||||
range: None,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let wire = serde_json::to_string(&Request::SetPhotoFavorite {
|
||||
uid: "vol~link".into(),
|
||||
favorite: true,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
wire,
|
||||
r#"{"SetPhotoFavorite":{"uid":"vol~link","favorite":true}}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_share_requests_keep_their_existing_wire_shape() {
|
||||
let cases = [
|
||||
|
||||
@@ -178,6 +178,10 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
// Album rows have no favourite state of their own: an album
|
||||
// photo on someone else's volume is not in our timeline, and
|
||||
// favouriting one is not supported yet anyway.
|
||||
favorite: false,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
@@ -218,6 +222,10 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
// Album rows have no favourite state of their own: an album
|
||||
// photo on someone else's volume is not in our timeline, and
|
||||
// favouriting one is not supported yet anyway.
|
||||
favorite: false,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 19;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 20;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -98,6 +98,9 @@ impl Db {
|
||||
if current < 19 {
|
||||
tx.execute_batch(MIGRATION_V19)?;
|
||||
}
|
||||
if current < 20 {
|
||||
tx.execute_batch(MIGRATION_V20)?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -511,3 +514,16 @@ CREATE TABLE album_photos (
|
||||
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
||||
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
||||
";
|
||||
|
||||
/// V20: whether a photo is a favourite.
|
||||
///
|
||||
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
||||
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
||||
/// server's own classification, and the Photos page already derives its tabs
|
||||
/// from the media type). Defaulting to 0 means an existing timeline stays
|
||||
/// intact and each row learns its real value on the next refresh, the same way
|
||||
/// `media_type` did.
|
||||
const MIGRATION_V20: &str = "
|
||||
ALTER TABLE photos ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_favorite ON photos(favorite, seq);
|
||||
";
|
||||
@@ -49,7 +49,7 @@ pub use ops::{
|
||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
|
||||
PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||
pub use trash::StoredTrash;
|
||||
|
||||
|
||||
@@ -32,13 +32,21 @@ pub struct StoredPhoto {
|
||||
/// Which Photos-page tab this entry belongs to, derived from its name and
|
||||
/// media type when the timeline was last replaced.
|
||||
pub kind: crate::control::PhotoKind,
|
||||
/// Whether the photo carries Proton's `Favorite` tag.
|
||||
pub favorite: bool,
|
||||
}
|
||||
|
||||
/// One row of a timeline replacement: `(uid, capture_time, name, media_type,
|
||||
/// favorite)`. The two trailing options are "what this refresh learned" — a
|
||||
/// `None` keeps whatever is already stored rather than clearing it.
|
||||
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>);
|
||||
|
||||
impl Db {
|
||||
pub fn photos_replace(
|
||||
&self,
|
||||
items: &[(String, i64, Option<String>, Option<String>)],
|
||||
) -> Result<()> {
|
||||
/// Replace the timeline wholesale. Each item is
|
||||
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None`
|
||||
/// when the refresh could not resolve that photo's node, in which case the
|
||||
/// flag already stored is kept rather than silently cleared.
|
||||
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
|
||||
@@ -46,10 +54,14 @@ impl Db {
|
||||
// not know a photo's media type yet when it replaces the timeline. Keep
|
||||
// any previously learned value so the Photos/Videos/Raw split survives a
|
||||
// refresh instead of collapsing back to name-extension guesses.
|
||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
|
||||
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?;
|
||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = {
|
||||
let mut stmt =
|
||||
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
(r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0),
|
||||
))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
@@ -58,16 +70,16 @@ impl Db {
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO photos
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)?;
|
||||
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state, learned_media) =
|
||||
learned
|
||||
.get(uid)
|
||||
.cloned()
|
||||
.unwrap_or((None, THUMB_UNKNOWN, None));
|
||||
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state, learned_media, learned_favorite) = learned
|
||||
.get(uid)
|
||||
.cloned()
|
||||
.unwrap_or((None, THUMB_UNKNOWN, None, false));
|
||||
let media_type = media_type.clone().or(learned_media);
|
||||
let favorite = favorite.unwrap_or(learned_favorite);
|
||||
// The tab this photo lands in is derived here, once, so a page or
|
||||
// count query is a plain indexed `WHERE kind = ?` rather than a
|
||||
// reclassification of every row.
|
||||
@@ -82,6 +94,7 @@ impl Db {
|
||||
seq as i64,
|
||||
media_type,
|
||||
kind.as_i64(),
|
||||
favorite as i64,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -93,18 +106,21 @@ impl Db {
|
||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
||||
/// `favorites` restricts the page to favourited photos.
|
||||
pub fn photos_page(
|
||||
&self,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
range: Option<(i64, i64)>,
|
||||
favorites: bool,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.conn.lock();
|
||||
// Built up so any combination of the optional filters is one indexed
|
||||
// query rather than a statement per case.
|
||||
let mut sql =
|
||||
String::from("SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos");
|
||||
let mut sql = String::from(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
|
||||
);
|
||||
let mut binds: Vec<i64> = Vec::new();
|
||||
let mut conds: Vec<String> = Vec::new();
|
||||
if let Some(k) = kind {
|
||||
@@ -117,6 +133,9 @@ impl Db {
|
||||
binds.push(to);
|
||||
conds.push(format!("capture_time < ?{}", binds.len()));
|
||||
}
|
||||
if favorites {
|
||||
conds.push("favorite = 1".to_string());
|
||||
}
|
||||
if !conds.is_empty() {
|
||||
sql.push_str(" WHERE ");
|
||||
sql.push_str(&conds.join(" AND "));
|
||||
@@ -139,6 +158,7 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
favorite: r.get::<_, i64>(6)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
@@ -206,7 +226,7 @@ impl Db {
|
||||
let conn = self.conn.lock();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
|
||||
WHERE uid IN ({placeholders})"
|
||||
))?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
|
||||
@@ -217,6 +237,7 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
favorite: r.get::<_, i64>(6)? != 0,
|
||||
})
|
||||
})?;
|
||||
let mut photos = Vec::new();
|
||||
@@ -226,6 +247,18 @@ impl Db {
|
||||
Ok(photos)
|
||||
}
|
||||
|
||||
/// Record a photo's favourite flag locally, after the server accepted the
|
||||
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
||||
/// someone else's volume is never in our own `photos` table.
|
||||
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE photos SET favorite = ?2 WHERE uid = ?1",
|
||||
params![uid, favorite as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record what a thumbnail attempt learned: whether the photo now has one
|
||||
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
|
||||
/// seen. A `None` ratio leaves any previously learned one alone.
|
||||
|
||||
@@ -113,9 +113,9 @@ fn node_from(parent: serde_json::Value, link: &str, name: &str, kind: serde_json
|
||||
fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
("p1".into(), 300, None, Some("video/mp4".into())),
|
||||
("p2".into(), 200, None, None),
|
||||
("p3".into(), 100, None, None),
|
||||
("p1".into(), 300, None, Some("video/mp4".into()), Some(true)),
|
||||
("p2".into(), 200, None, None, None),
|
||||
("p3".into(), 100, None, None, None),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
@@ -126,13 +126,13 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
// The next refresh brings a new photo, keeps p1 and p2, and loses p3.
|
||||
// p1's media type arrives as `None` this time and must not be forgotten.
|
||||
db.photos_replace(&[
|
||||
("p0".into(), 400, Some("new.jpg".into()), None),
|
||||
("p1".into(), 300, None, None),
|
||||
("p2".into(), 200, None, None),
|
||||
("p0".into(), 400, Some("new.jpg".into()), None, Some(false)),
|
||||
("p1".into(), 300, None, None, None),
|
||||
("p2".into(), 200, None, None, None),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let page = db.photos_page(0, 10, None, None).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p0", "p1", "p2"],
|
||||
@@ -155,7 +155,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
// The counts break down by tab, and a filtered page returns only its tab.
|
||||
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
|
||||
let videos = db
|
||||
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None)
|
||||
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None, false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
@@ -167,7 +167,9 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
|
||||
// A date-range page keeps only the window's photos: [150, 350) is p1+p2,
|
||||
// not p0 at 400. Combined with a kind filter both conditions apply.
|
||||
let ranged = db.photos_page(0, 10, None, Some((150, 350))).unwrap();
|
||||
let ranged = db
|
||||
.photos_page(0, 10, None, Some((150, 350)), false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p1", "p2"]
|
||||
@@ -178,6 +180,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
10,
|
||||
Some(crate::control::PhotoKind::Video),
|
||||
Some((150, 350)),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -193,20 +196,52 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
assert_eq!(months[0].2, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
("p1".into(), 300, None, None, Some(true)),
|
||||
("p2".into(), 200, None, None, Some(false)),
|
||||
])
|
||||
.unwrap();
|
||||
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
||||
assert_eq!(
|
||||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p1"],
|
||||
"only the favourited photo is in a favourites page"
|
||||
);
|
||||
|
||||
// A local toggle survives a refresh that could not resolve the photo's node
|
||||
// (`None`), and loses to one that could.
|
||||
db.photos_set_favorite("p2", true).unwrap();
|
||||
db.photos_replace(&[
|
||||
("p1".into(), 300, None, None, Some(false)),
|
||||
("p2".into(), 200, None, None, None),
|
||||
])
|
||||
.unwrap();
|
||||
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
||||
assert_eq!(
|
||||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p2"],
|
||||
"the server's answer wins where it has one; the local flag holds where it doesn't"
|
||||
);
|
||||
assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_page_slices_the_timeline_in_order() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let items: Vec<_> = (0..5)
|
||||
.map(|i| (format!("p{i}"), 500 - i as i64, None, None::<String>))
|
||||
.map(|i| (format!("p{i}"), 500 - i as i64, None, None::<String>, None))
|
||||
.collect();
|
||||
db.photos_replace(&items).unwrap();
|
||||
|
||||
let page = db.photos_page(2, 2, None, None).unwrap();
|
||||
let page = db.photos_page(2, 2, None, None, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p2", "p3"]
|
||||
);
|
||||
assert!(db.photos_page(9, 2, None, None).unwrap().is_empty());
|
||||
assert!(db.photos_page(9, 2, None, None, false).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1336,6 +1371,8 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
|
||||
DROP TABLE share_access;
|
||||
DROP TABLE album_photos;
|
||||
DROP TABLE albums;
|
||||
DROP INDEX idx_photos_favorite;
|
||||
ALTER TABLE photos DROP COLUMN favorite;
|
||||
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1402,6 +1439,18 @@ fn migration_v18_projects_v17_sync_folders_and_cascades_deletes() {
|
||||
VALUES
|
||||
(41, '/home/me/Existing', 'vol~existing', 'share-existing',
|
||||
'ondemand', 'syncing', 123);
|
||||
-- The timeline as V17 held it: later migrations add columns to it,
|
||||
-- so a fixture without it isn't a database any release produced.
|
||||
CREATE TABLE photos (
|
||||
uid TEXT PRIMARY KEY,
|
||||
capture_time INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
ratio REAL,
|
||||
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL,
|
||||
media_type TEXT,
|
||||
kind INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE share_access (
|
||||
root_uid TEXT PRIMARY KEY,
|
||||
access TEXT NOT NULL
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub mod access;
|
||||
pub mod auth;
|
||||
pub mod batch;
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod control;
|
||||
@@ -12,6 +13,7 @@ pub mod error;
|
||||
pub mod localindex;
|
||||
pub mod mounts;
|
||||
pub mod profile;
|
||||
pub mod sdkcache;
|
||||
pub mod search;
|
||||
pub mod service;
|
||||
pub mod shell;
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! A persistent [`CacheRepository`] for the SDK's Drive entity cache.
|
||||
//!
|
||||
//! The SDK caches decrypted node metadata (name, size, parent, the share whose
|
||||
//! membership signs operations on it) so navigating a tree does not re-fetch and
|
||||
//! re-decrypt what it already resolved. That cache defaults to memory, which
|
||||
//! means every daemon restart pays for the whole walk again. This backs it with
|
||||
//! SQLite so it survives one.
|
||||
//!
|
||||
//! **Its own database file, not `cache.db`.** The daemon's `Db` is a single
|
||||
//! `Mutex<Connection>` shared by every FUSE thread and the control socket; SDK
|
||||
//! cache traffic is frequent, small, and entirely reconstructible, so putting it
|
||||
//! on that mutex would add contention to the daemon's hottest lock (and to a
|
||||
//! write-ahead log whose growth is already sensitive to how long that lock is
|
||||
//! held) for no durability benefit. A separate file also means no schema
|
||||
//! migration: this store can be deleted and rebuilt at any time.
|
||||
//!
|
||||
//! **What it holds.** Decrypted metadata, never key material — the SDK keeps
|
||||
//! node keys and hash keys in a separate in-memory secret cache that nothing
|
||||
//! here can reach. The same names and sizes are already persisted in `cache.db`
|
||||
//! (`nodes`), so this file widens no exposure that the state directory did not
|
||||
//! already have; `AppDirs::ensure` is what keeps that directory private.
|
||||
//!
|
||||
//! **Staleness.** A persisted cache outlives the process, so an entry can
|
||||
//! describe a node another client has since changed. The daemon's event loop is
|
||||
//! what closes that: it resumes from a persisted cursor and calls the SDK's
|
||||
//! `invalidate_caches_for_event` for every event it replays, including the ones
|
||||
//! that happened while the daemon was down. The one case that leaves no trail is
|
||||
//! a *seeded* cursor — a first-ever mount, or a cursor the daemon lost — where
|
||||
//! nothing says what changed, so the caller [`clear`](SdkCache::clear)s this
|
||||
//! store instead of trusting it.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::Mutex;
|
||||
use proton_sdk::cache::CacheRepository;
|
||||
use proton_sdk::error::{ProtonError, Result as SdkResult};
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use crate::config::AppDirs;
|
||||
use crate::error::Result;
|
||||
|
||||
/// Process-wide handle, so the daemon's event loop can clear the same store the
|
||||
/// client is writing through without threading it down every call.
|
||||
static SHARED: OnceLock<Arc<SdkCache>> = OnceLock::new();
|
||||
|
||||
/// SQLite-backed entity cache for the SDK.
|
||||
pub struct SdkCache {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl SdkCache {
|
||||
/// Open (creating if needed) the entity cache under the app's state
|
||||
/// directory. The first call wins; later calls return the same handle, which
|
||||
/// is what lets the event loop clear the store the Drive client is using.
|
||||
pub fn shared(dirs: &AppDirs) -> Result<Arc<Self>> {
|
||||
if let Some(existing) = SHARED.get() {
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
let cache = Arc::new(Self::open(&dirs.state_dir().join("sdk_cache.db"))?);
|
||||
Ok(SHARED.get_or_init(|| cache).clone())
|
||||
}
|
||||
|
||||
/// The already-open shared cache, if one was opened in this process.
|
||||
pub fn opened() -> Option<Arc<Self>> {
|
||||
SHARED.get().cloned()
|
||||
}
|
||||
|
||||
/// Open the store at `path`, creating its schema.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
// Tags cascade with their entry, so a `set` that replaces a value cannot
|
||||
// leave the old value's tags behind pointing at the new one.
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS entry (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tag (
|
||||
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (key, tag)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tag_tag ON tag(tag);",
|
||||
)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop every entry. Used when the daemon cannot prove the cache is current
|
||||
/// — a seeded event cursor — and on logout.
|
||||
pub fn clear_now(&self) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute_batch("DELETE FROM tag; DELETE FROM entry;")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_now(&self, key: &str, value: &str, tags: &[String]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO entry (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)?;
|
||||
tx.execute("DELETE FROM tag WHERE key = ?1", params![key])?;
|
||||
{
|
||||
let mut stmt = tx.prepare("INSERT INTO tag (key, tag) VALUES (?1, ?2)")?;
|
||||
for tag in tags {
|
||||
stmt.execute(params![key, tag])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_now(&self, key: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare("SELECT value FROM entry WHERE key = ?1")?;
|
||||
let mut rows = stmt.query(params![key])?;
|
||||
match rows.next()? {
|
||||
Some(row) => Ok(Some(row.get(0)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_now(&self, key: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute("DELETE FROM entry WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_by_tag_now(&self, tag: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM entry WHERE key IN (SELECT key FROM tag WHERE tag = ?1)",
|
||||
params![tag],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Entries carrying **all** of `tags` — the set intersection the SDK's
|
||||
/// `get_by_tags` contract specifies, not a union.
|
||||
fn get_by_tags_now(&self, tags: &[String]) -> Result<Vec<(String, String)>> {
|
||||
if tags.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let placeholders = vec!["?"; tags.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT e.key, e.value FROM entry e
|
||||
JOIN tag t ON t.key = e.key
|
||||
WHERE t.tag IN ({placeholders})
|
||||
GROUP BY e.key
|
||||
HAVING COUNT(DISTINCT t.tag) = {}",
|
||||
tags.len()
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(tags), |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A cache failure is never fatal to the operation that triggered it — the SDK
|
||||
/// treats this repository as best-effort — but it still has to come back as the
|
||||
/// SDK's error type.
|
||||
fn sdk_error(e: crate::error::Error) -> ProtonError {
|
||||
ProtonError::invalid_operation(format!("sdk entity cache: {e}"))
|
||||
}
|
||||
|
||||
// Every method is a local SQLite statement on its own connection: microseconds,
|
||||
// no network, no shared lock with the daemon's database. Running them inline on
|
||||
// the caller's task is cheaper than the thread hop that moving them off would
|
||||
// cost.
|
||||
#[async_trait]
|
||||
impl CacheRepository for SdkCache {
|
||||
async fn set(&self, key: &str, value: &str, tags: &[String]) -> SdkResult<()> {
|
||||
self.set_now(key, value, tags).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn get(&self, key: &str) -> SdkResult<Option<String>> {
|
||||
self.get_now(key).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn remove(&self, key: &str) -> SdkResult<()> {
|
||||
self.remove_now(key).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn remove_by_tag(&self, tag: &str) -> SdkResult<()> {
|
||||
self.remove_by_tag_now(tag).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn clear(&self) -> SdkResult<()> {
|
||||
self.clear_now().map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn get_by_tags(&self, tags: &[String]) -> SdkResult<Vec<(String, String)>> {
|
||||
self.get_by_tags_now(tags).map_err(sdk_error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cache() -> SdkCache {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE entry (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE tag (
|
||||
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (key, tag)
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
SdkCache {
|
||||
conn: Mutex::new(conn),
|
||||
}
|
||||
}
|
||||
|
||||
fn tags(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|t| t.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_replaces_the_value_and_its_tags() {
|
||||
let cache = cache();
|
||||
cache.set_now("k", "one", &tags(&["a", "b"])).unwrap();
|
||||
cache.set_now("k", "two", &tags(&["b"])).unwrap();
|
||||
|
||||
assert_eq!(cache.get_now("k").unwrap().as_deref(), Some("two"));
|
||||
// The tag dropped by the second write must not still select the entry —
|
||||
// that is how a stale invalidation would miss it.
|
||||
assert!(cache.get_by_tags_now(&tags(&["a"])).unwrap().is_empty());
|
||||
assert_eq!(cache.get_by_tags_now(&tags(&["b"])).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_tags_intersects_rather_than_unions() {
|
||||
let cache = cache();
|
||||
cache.set_now("both", "1", &tags(&["x", "y"])).unwrap();
|
||||
cache.set_now("one", "2", &tags(&["x"])).unwrap();
|
||||
|
||||
let hits = cache.get_by_tags_now(&tags(&["x", "y"])).unwrap();
|
||||
assert_eq!(hits, vec![("both".to_string(), "1".to_string())]);
|
||||
// An empty tag list selects nothing, matching the SDK contract.
|
||||
assert!(cache.get_by_tags_now(&[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_by_tag_takes_the_entry_and_its_tags() {
|
||||
let cache = cache();
|
||||
cache.set_now("k", "v", &tags(&["gone"])).unwrap();
|
||||
cache.remove_by_tag_now("gone").unwrap();
|
||||
|
||||
assert!(cache.get_now("k").unwrap().is_none());
|
||||
assert!(cache.get_by_tags_now(&tags(&["gone"])).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_empties_the_store() {
|
||||
let cache = cache();
|
||||
cache.set_now("a", "1", &tags(&["t"])).unwrap();
|
||||
cache.set_now("b", "2", &[]).unwrap();
|
||||
cache.clear_now().unwrap();
|
||||
|
||||
assert!(cache.get_now("a").unwrap().is_none());
|
||||
assert!(cache.get_now("b").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user