use chrono::{SecondsFormat, TimeZone, Utc};
pub fn format_rfc3339_millis(unix_millis: i64) -> String {
Utc.timestamp_millis_opt(unix_millis)
.single()
.expect("valid millis")
.to_rfc3339_opts(SecondsFormat::Millis, true)
}
use crate::store::models::StoredClip;
use crate::store::{queries, Store, StoreError};
use ulid::Ulid;
pub const MAX_UNSYNCED: usize = 1000;
pub fn enqueue_local(
store: &Store,
source: &str,
_label: &str,
content_type: &str,
raw: Vec<u8>,
byte_size: i64,
) -> Result<String, StoreError> {
let clip_id = format!("local-{}", Ulid::new());
let stored = StoredClip {
id: clip_id.clone(),
source: source.to_string(),
source_key: None,
content_type: content_type.to_string(),
content: Some(raw),
media_path: None,
byte_size,
created_at: chrono::Utc::now().timestamp_millis(),
pinned: false,
pinned_at: None,
synced: false,
};
queries::insert_clip(store, &stored)?;
queries::enforce_offline_cap(store, MAX_UNSYNCED)?;
Ok(clip_id)
}
use crate::crypto;
use crate::http::{HttpError, RestClient};
use crate::rest::PushRequest;
pub async fn flush_once(
store: &crate::store::Store,
client: &RestClient,
enc_key: [u8; 32],
) -> Result<FlushReport, FlushError> {
let pending = queries::list_unsynced_clips(store)?;
let total = pending.len();
let mut report = FlushReport::default();
for clip in pending {
let plaintext = match clip.content.clone() {
Some(b) => b,
None => {
queries::delete_clip(store, &clip.id)?;
report.dropped += 1;
continue;
}
};
let ciphertext = crypto::encrypt(&enc_key, &plaintext).map_err(FlushError::Crypto)?;
let req = PushRequest {
content: ciphertext,
content_type: clip.content_type.clone(),
label: String::new(),
source: clip.source.clone(),
media_path: None,
byte_size: clip.byte_size,
encrypted: true,
target_device_id: None,
client_created_at: Some(format_rfc3339_millis(clip.created_at)),
idempotency_key: Some(clip.id.clone()),
};
match client.push_clip_json(&req).await {
Ok(resp) => {
queries::replace_id_and_mark_synced(store, &clip.id, &resp.clip_id)?;
report.flushed += 1;
}
Err(e) if is_transient(&e) => {
report.remaining = total - report.flushed - report.dropped;
return Ok(report);
}
Err(e) => {
log::warn!(
"backlog: dropping clip {} on permanent error: {}",
clip.id,
e
);
queries::delete_clip(store, &clip.id)?;
report.dropped += 1;
}
}
}
Ok(report)
}
fn is_transient(e: &HttpError) -> bool {
match e {
HttpError::Network(_) => true,
HttpError::Relay { status, .. } => (500..600).contains(status),
HttpError::Unauthorized | HttpError::Decode(_) | HttpError::Build(_) => false,
}
}
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct FlushReport {
pub flushed: usize,
pub dropped: usize,
pub remaining: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum FlushError {
#[error("encryption failed: {0}")]
Crypto(String),
#[error("store error: {0}")]
Store(#[from] crate::store::StoreError),
}
pub struct FlushGate(AtomicBool);
impl FlushGate {
pub const fn new() -> Self {
Self(AtomicBool::new(false))
}
pub fn try_enter(&self) -> Option<FlushGuard<'_>> {
if self.0.swap(true, Ordering::AcqRel) {
None
} else {
Some(FlushGuard(&self.0))
}
}
}
impl Default for FlushGate {
fn default() -> Self {
Self::new()
}
}
pub struct FlushGuard<'a>(&'a AtomicBool);
impl Drop for FlushGuard<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::queries;
use crate::store::Store;
fn fresh_store() -> Store {
Store::open(std::path::Path::new(":memory:")).unwrap()
}
#[test]
fn format_rfc3339_millis_emits_z_suffix_with_millis() {
assert_eq!(
format_rfc3339_millis(1_700_000_000_123),
"2023-11-14T22:13:20.123Z"
);
}
#[test]
fn enqueue_local_persists_unsynced_with_local_prefix() {
let store = fresh_store();
let id = enqueue_local(&store, "remote:host", "label", "text", b"hello".to_vec(), 5)
.expect("enqueue");
assert!(
id.starts_with("local-"),
"id must start with 'local-' marker, got {id}"
);
let rows = queries::list_unsynced_clips(&store).unwrap();
assert_eq!(rows.len(), 1);
let row = &rows[0];
assert_eq!(row.id, id);
assert!(!row.synced);
assert_eq!(row.source, "remote:host");
assert_eq!(row.content.as_deref(), Some(&b"hello"[..]));
assert_eq!(row.content_type, "text");
assert_eq!(row.byte_size, 5);
}
#[test]
fn enqueue_local_three_under_cap_all_survive() {
let store = fresh_store();
for _ in 0..3 {
enqueue_local(&store, "s", "", "text", b"x".to_vec(), 1).unwrap();
}
let rows = queries::list_unsynced_clips(&store).unwrap();
assert_eq!(
rows.len(),
3,
"default cap is 1000; 3 enqueued must all survive"
);
}
#[test]
fn flush_gate_blocks_second_entrant_until_first_drops() {
let gate = FlushGate::new();
let g1 = gate.try_enter().unwrap();
assert!(
gate.try_enter().is_none(),
"second try_enter must be blocked"
);
drop(g1);
assert!(
gate.try_enter().is_some(),
"after guard drop, new entrant ok"
);
}
fn seed_three_unsynced(store: &Store) {
use crate::store::models::StoredClip;
for (id, ts, content) in [
("local-a", 10i64, b"first" as &[u8]),
("local-b", 20, b"second"),
("local-c", 30, b"third"),
] {
let c = StoredClip {
id: id.into(),
source: "s".into(),
source_key: None,
content_type: "text".into(),
content: Some(content.to_vec()),
media_path: None,
byte_size: content.len() as i64,
created_at: ts,
pinned: false,
pinned_at: None,
synced: false,
};
queries::insert_clip(store, &c).unwrap();
}
}
#[tokio::test]
async fn flush_once_pushes_in_chronological_order_and_swaps_ids() {
let store = std::sync::Arc::new(fresh_store());
seed_three_unsynced(&store);
let key = [9u8; 32];
let client = crate::http::RestClient::for_test_recording();
let report = flush_once(&store, &client, key).await.expect("flush_once");
assert_eq!(report.flushed, 3);
assert_eq!(report.dropped, 0);
assert_eq!(report.remaining, 0);
let pending = queries::list_unsynced_clips(&store).unwrap();
assert!(pending.is_empty(), "all rows should be synced");
let calls = client.recorded_pushes();
assert_eq!(calls.len(), 3);
let keys: Vec<_> = calls.iter().map(|r| r.idempotency_key.clone()).collect();
assert_eq!(
keys,
vec![
Some("local-a".into()),
Some("local-b".into()),
Some("local-c".into())
]
);
for call in &calls {
assert!(call.client_created_at.is_some());
assert!(call.encrypted);
}
}
#[tokio::test]
async fn flush_once_stops_on_transient_and_reports_remaining() {
let store = std::sync::Arc::new(fresh_store());
seed_three_unsynced(&store);
let key = [9u8; 32];
let client = crate::http::RestClient::for_test_with_failures(vec![
crate::http::FakePush::Ok,
crate::http::FakePush::Network,
]);
let report = flush_once(&store, &client, key).await.unwrap();
assert_eq!(report.flushed, 1);
assert_eq!(report.dropped, 0);
assert_eq!(report.remaining, 2);
assert_eq!(queries::list_unsynced_clips(&store).unwrap().len(), 2);
}
#[tokio::test]
async fn flush_once_drops_on_permanent_and_continues() {
let store = std::sync::Arc::new(fresh_store());
seed_three_unsynced(&store);
let key = [9u8; 32];
let client = crate::http::RestClient::for_test_with_failures(vec![
crate::http::FakePush::Ok,
crate::http::FakePush::Relay {
status: 413,
msg: "payload too large".into(),
},
crate::http::FakePush::Ok,
]);
let report = flush_once(&store, &client, key).await.unwrap();
assert_eq!(report.flushed, 2);
assert_eq!(report.dropped, 1);
assert_eq!(queries::list_unsynced_clips(&store).unwrap().len(), 0);
}
#[tokio::test]
async fn flush_once_drops_rows_with_null_content() {
use crate::store::models::StoredClip;
let store = std::sync::Arc::new(fresh_store());
let c = StoredClip {
id: "local-nocontent".into(),
source: "s".into(),
source_key: None,
content_type: "text".into(),
content: None,
media_path: Some("/tmp/clip.png".into()),
byte_size: 0,
created_at: 10,
pinned: false,
pinned_at: None,
synced: false,
};
queries::insert_clip(&store, &c).unwrap();
let key = [9u8; 32];
let client = crate::http::RestClient::for_test_recording();
let report = flush_once(&store, &client, key).await.unwrap();
assert_eq!(report.flushed, 0);
assert_eq!(report.dropped, 1);
assert!(queries::list_unsynced_clips(&store).unwrap().is_empty());
assert!(
client.recorded_pushes().is_empty(),
"no relay call for null-content row"
);
}
}