1use chrono::{SecondsFormat, TimeZone, Utc};
6
7pub fn format_rfc3339_millis(unix_millis: i64) -> String {
10 Utc.timestamp_millis_opt(unix_millis)
11 .single()
12 .expect("valid millis")
13 .to_rfc3339_opts(SecondsFormat::Millis, true)
14}
15
16use crate::store::models::StoredClip;
17use crate::store::{queries, Store, StoreError};
18use ulid::Ulid;
19
20pub const MAX_UNSYNCED: usize = 1000;
23
24pub fn enqueue_local(
29 store: &Store,
30 source: &str,
31 _label: &str,
32 content_type: &str,
33 raw: Vec<u8>,
34 byte_size: i64,
35) -> Result<String, StoreError> {
36 let clip_id = format!("local-{}", Ulid::new());
37 let stored = StoredClip {
38 id: clip_id.clone(),
39 source: source.to_string(),
40 source_key: None,
41 content_type: content_type.to_string(),
42 content: Some(raw),
43 media_path: None,
44 byte_size,
45 created_at: chrono::Utc::now().timestamp_millis(),
46 pinned: false,
47 pinned_at: None,
48 synced: false,
49 };
50 queries::insert_clip(store, &stored)?;
51 queries::enforce_offline_cap(store, MAX_UNSYNCED)?;
52 Ok(clip_id)
53}
54
55use crate::crypto;
56use crate::http::{HttpError, RestClient};
57use crate::rest::PushRequest;
58
59pub async fn flush_once(
66 store: &crate::store::Store,
67 client: &RestClient,
68 enc_key: [u8; 32],
69) -> Result<FlushReport, FlushError> {
70 let pending = queries::list_unsynced_clips(store)?;
71 let total = pending.len();
72 let mut report = FlushReport::default();
73
74 for clip in pending {
75 let plaintext = match clip.content.clone() {
76 Some(b) => b,
77 None => {
78 queries::delete_clip(store, &clip.id)?;
79 report.dropped += 1;
80 continue;
81 }
82 };
83
84 let ciphertext = crypto::encrypt(&enc_key, &plaintext).map_err(FlushError::Crypto)?;
85 let req = PushRequest {
86 content: ciphertext,
87 content_type: clip.content_type.clone(),
88 label: String::new(),
89 source: clip.source.clone(),
90 media_path: None,
91 byte_size: clip.byte_size,
92 encrypted: true,
93 target_device_id: None,
94 client_created_at: Some(format_rfc3339_millis(clip.created_at)),
95 idempotency_key: Some(clip.id.clone()),
96 };
97
98 match client.push_clip_json(&req).await {
99 Ok(resp) => {
100 queries::replace_id_and_mark_synced(store, &clip.id, &resp.clip_id)?;
101 report.flushed += 1;
102 }
103 Err(e) if is_transient(&e) => {
104 report.remaining = total - report.flushed - report.dropped;
105 return Ok(report);
106 }
107 Err(e) => {
108 log::warn!(
109 "backlog: dropping clip {} on permanent error: {}",
110 clip.id,
111 e
112 );
113 queries::delete_clip(store, &clip.id)?;
114 report.dropped += 1;
115 }
116 }
117 }
118
119 Ok(report)
120}
121
122fn is_transient(e: &HttpError) -> bool {
123 match e {
124 HttpError::Network(_) => true,
125 HttpError::Relay { status, .. } => (500..600).contains(status),
126 HttpError::Unauthorized | HttpError::Decode(_) | HttpError::Build(_) => false,
127 }
128}
129
130use std::sync::atomic::{AtomicBool, Ordering};
131
132#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
133pub struct FlushReport {
134 pub flushed: usize,
135 pub dropped: usize,
136 pub remaining: usize,
137}
138
139#[derive(Debug, thiserror::Error)]
140pub enum FlushError {
141 #[error("encryption failed: {0}")]
142 Crypto(String),
143 #[error("store error: {0}")]
144 Store(#[from] crate::store::StoreError),
145}
146
147pub struct FlushGate(AtomicBool);
151
152impl FlushGate {
153 pub const fn new() -> Self {
154 Self(AtomicBool::new(false))
155 }
156 pub fn try_enter(&self) -> Option<FlushGuard<'_>> {
157 if self.0.swap(true, Ordering::AcqRel) {
158 None
159 } else {
160 Some(FlushGuard(&self.0))
161 }
162 }
163}
164
165impl Default for FlushGate {
166 fn default() -> Self {
167 Self::new()
168 }
169}
170
171pub struct FlushGuard<'a>(&'a AtomicBool);
172
173impl Drop for FlushGuard<'_> {
174 fn drop(&mut self) {
175 self.0.store(false, Ordering::Release);
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use crate::store::queries;
183 use crate::store::Store;
184
185 fn fresh_store() -> Store {
186 Store::open(std::path::Path::new(":memory:")).unwrap()
187 }
188
189 #[test]
190 fn format_rfc3339_millis_emits_z_suffix_with_millis() {
191 assert_eq!(
192 format_rfc3339_millis(1_700_000_000_123),
193 "2023-11-14T22:13:20.123Z"
194 );
195 }
196
197 #[test]
198 fn enqueue_local_persists_unsynced_with_local_prefix() {
199 let store = fresh_store();
200 let id = enqueue_local(&store, "remote:host", "label", "text", b"hello".to_vec(), 5)
201 .expect("enqueue");
202 assert!(
203 id.starts_with("local-"),
204 "id must start with 'local-' marker, got {id}"
205 );
206 let rows = queries::list_unsynced_clips(&store).unwrap();
207 assert_eq!(rows.len(), 1);
208 let row = &rows[0];
209 assert_eq!(row.id, id);
210 assert!(!row.synced);
211 assert_eq!(row.source, "remote:host");
212 assert_eq!(row.content.as_deref(), Some(&b"hello"[..]));
213 assert_eq!(row.content_type, "text");
214 assert_eq!(row.byte_size, 5);
215 }
216
217 #[test]
218 fn enqueue_local_three_under_cap_all_survive() {
219 let store = fresh_store();
220 for _ in 0..3 {
221 enqueue_local(&store, "s", "", "text", b"x".to_vec(), 1).unwrap();
222 }
223 let rows = queries::list_unsynced_clips(&store).unwrap();
224 assert_eq!(
225 rows.len(),
226 3,
227 "default cap is 1000; 3 enqueued must all survive"
228 );
229 }
230
231 #[test]
232 fn flush_gate_blocks_second_entrant_until_first_drops() {
233 let gate = FlushGate::new();
234 let g1 = gate.try_enter().unwrap();
235 assert!(
236 gate.try_enter().is_none(),
237 "second try_enter must be blocked"
238 );
239 drop(g1);
240 assert!(
241 gate.try_enter().is_some(),
242 "after guard drop, new entrant ok"
243 );
244 }
245
246 fn seed_three_unsynced(store: &Store) {
247 use crate::store::models::StoredClip;
248 for (id, ts, content) in [
249 ("local-a", 10i64, b"first" as &[u8]),
250 ("local-b", 20, b"second"),
251 ("local-c", 30, b"third"),
252 ] {
253 let c = StoredClip {
254 id: id.into(),
255 source: "s".into(),
256 source_key: None,
257 content_type: "text".into(),
258 content: Some(content.to_vec()),
259 media_path: None,
260 byte_size: content.len() as i64,
261 created_at: ts,
262 pinned: false,
263 pinned_at: None,
264 synced: false,
265 };
266 queries::insert_clip(store, &c).unwrap();
267 }
268 }
269
270 #[tokio::test]
271 async fn flush_once_pushes_in_chronological_order_and_swaps_ids() {
272 let store = std::sync::Arc::new(fresh_store());
273 seed_three_unsynced(&store);
274 let key = [9u8; 32];
275 let client = crate::http::RestClient::for_test_recording();
276 let report = flush_once(&store, &client, key).await.expect("flush_once");
277 assert_eq!(report.flushed, 3);
278 assert_eq!(report.dropped, 0);
279 assert_eq!(report.remaining, 0);
280
281 let pending = queries::list_unsynced_clips(&store).unwrap();
283 assert!(pending.is_empty(), "all rows should be synced");
284
285 let calls = client.recorded_pushes();
287 assert_eq!(calls.len(), 3);
288 let keys: Vec<_> = calls.iter().map(|r| r.idempotency_key.clone()).collect();
289 assert_eq!(
290 keys,
291 vec![
292 Some("local-a".into()),
293 Some("local-b".into()),
294 Some("local-c".into())
295 ]
296 );
297
298 for call in &calls {
300 assert!(call.client_created_at.is_some());
301 assert!(call.encrypted);
302 }
303 }
304
305 #[tokio::test]
306 async fn flush_once_stops_on_transient_and_reports_remaining() {
307 let store = std::sync::Arc::new(fresh_store());
308 seed_three_unsynced(&store);
309 let key = [9u8; 32];
310 let client = crate::http::RestClient::for_test_with_failures(vec![
311 crate::http::FakePush::Ok,
312 crate::http::FakePush::Network,
313 ]);
314 let report = flush_once(&store, &client, key).await.unwrap();
315 assert_eq!(report.flushed, 1);
316 assert_eq!(report.dropped, 0);
317 assert_eq!(report.remaining, 2);
318 assert_eq!(queries::list_unsynced_clips(&store).unwrap().len(), 2);
319 }
320
321 #[tokio::test]
322 async fn flush_once_drops_on_permanent_and_continues() {
323 let store = std::sync::Arc::new(fresh_store());
324 seed_three_unsynced(&store);
325 let key = [9u8; 32];
326 let client = crate::http::RestClient::for_test_with_failures(vec![
327 crate::http::FakePush::Ok,
328 crate::http::FakePush::Relay {
329 status: 413,
330 msg: "payload too large".into(),
331 },
332 crate::http::FakePush::Ok,
333 ]);
334 let report = flush_once(&store, &client, key).await.unwrap();
335 assert_eq!(report.flushed, 2);
336 assert_eq!(report.dropped, 1);
337 assert_eq!(queries::list_unsynced_clips(&store).unwrap().len(), 0);
338 }
339
340 #[tokio::test]
341 async fn flush_once_drops_rows_with_null_content() {
342 use crate::store::models::StoredClip;
343 let store = std::sync::Arc::new(fresh_store());
344 let c = StoredClip {
345 id: "local-nocontent".into(),
346 source: "s".into(),
347 source_key: None,
348 content_type: "text".into(),
349 content: None,
350 media_path: Some("/tmp/clip.png".into()),
351 byte_size: 0,
352 created_at: 10,
353 pinned: false,
354 pinned_at: None,
355 synced: false,
356 };
357 queries::insert_clip(&store, &c).unwrap();
358 let key = [9u8; 32];
359 let client = crate::http::RestClient::for_test_recording();
360 let report = flush_once(&store, &client, key).await.unwrap();
361 assert_eq!(report.flushed, 0);
362 assert_eq!(report.dropped, 1);
363 assert!(queries::list_unsynced_clips(&store).unwrap().is_empty());
364 assert!(
365 client.recorded_pushes().is_empty(),
366 "no relay call for null-content row"
367 );
368 }
369}