Skip to main content

aven_core/sync/
persistence.rs

1use std::collections::HashSet;
2use std::path::Path;
3use std::time::Instant;
4
5use anyhow::{Context, Result, bail};
6use sqlx::{QueryBuilder, Sqlite, SqliteConnection};
7
8use super::apply::apply_remote_change;
9use super::wire::{
10    AttachmentAddPayload, ChangeRow, ChangeWire, PushAck, SyncRequest, SyncResponse,
11};
12use crate::change_log::op_type;
13use crate::db::{Database, begin_immediate, get_meta, set_meta};
14
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct SyncPersistenceStatus {
17    pub pinned_server: Option<String>,
18    pub pending_changes: i64,
19    pub pending_attachment_uploads: i64,
20    pub pending_attachment_upload_bytes: i64,
21    pub conflicts: i64,
22    pub sync_cursor: Option<String>,
23    pub local_sequence: Option<String>,
24    pub last_attempt: Option<String>,
25    pub last_success: Option<String>,
26    pub last_error: Option<String>,
27    pub last_pushed: Option<String>,
28    pub last_pulled: Option<String>,
29    pub last_cursor: Option<String>,
30}
31
32#[derive(Debug, Clone)]
33pub struct ClientSyncPage {
34    pub request: SyncRequest,
35    pub pending: usize,
36}
37
38#[derive(Debug)]
39pub struct ApplySyncPage {
40    pub request: SyncRequest,
41    pub response: SyncResponse,
42    pub attempted_at: String,
43    pub previous_pushed: i64,
44    pub previous_pulled: usize,
45}
46
47#[derive(Debug)]
48pub struct ServerSyncPage {
49    pub request: SyncRequest,
50}
51
52#[derive(Debug)]
53pub struct ServerSyncResult {
54    pub accepted_count: i64,
55    pub push_acks: Vec<PushAck>,
56    pub changes: Vec<ChangeWire>,
57    pub has_more: bool,
58    pub blob_prepare_ms: u128,
59    pub assign_ms: u128,
60    pub pull_query_ms: u128,
61}
62
63impl Database {
64    pub async fn sync_persistence_status(&self) -> Result<SyncPersistenceStatus> {
65        let mut conn = self.acquire_reader().await?;
66        let pending_changes =
67            sqlx::query_scalar("SELECT count(*) FROM changes WHERE server_seq IS NULL")
68                .fetch_one(&mut *conn)
69                .await?;
70        let (pending_attachment_uploads, pending_attachment_upload_bytes): (i64, i64) =
71            sqlx::query_as(
72                "SELECT COUNT(*), COALESCE(SUM(byte_size), 0)
73                 FROM (
74                   SELECT json_extract(payload, '$.workspace_id') AS workspace_id,
75                          json_extract(payload, '$.sha256') AS sha256,
76                          MAX(CAST(json_extract(payload, '$.byte_size') AS INTEGER)) AS byte_size
77                   FROM changes
78                   WHERE server_seq IS NULL AND op_type = 'attachment_add'
79                   GROUP BY workspace_id, sha256
80                 )",
81            )
82            .fetch_one(&mut *conn)
83            .await?;
84        let conflicts = sqlx::query_scalar("SELECT count(*) FROM conflicts WHERE resolved = 0")
85            .fetch_one(&mut *conn)
86            .await?;
87        Ok(SyncPersistenceStatus {
88            pinned_server: get_meta(&mut conn, "sync_server_url").await?,
89            pending_changes,
90            pending_attachment_uploads,
91            pending_attachment_upload_bytes,
92            conflicts,
93            sync_cursor: get_meta(&mut conn, "sync_cursor").await?,
94            local_sequence: get_meta(&mut conn, "local_seq").await?,
95            last_attempt: get_meta(&mut conn, "sync_last_attempt_at").await?,
96            last_success: get_meta(&mut conn, "sync_last_success_at").await?,
97            last_error: get_meta(&mut conn, "sync_last_error").await?,
98            last_pushed: get_meta(&mut conn, "sync_last_pushed").await?,
99            last_pulled: get_meta(&mut conn, "sync_last_pulled").await?,
100            last_cursor: get_meta(&mut conn, "sync_last_cursor").await?,
101        })
102    }
103
104    pub async fn begin_sync_attempt(&self, attempted_at: String) -> Result<()> {
105        let mut conn = self.acquire_writer().await?;
106        set_meta(&mut conn, "sync_last_attempt_at", &attempted_at).await
107    }
108
109    pub async fn record_sync_error(&self, error: String) -> Result<()> {
110        let mut conn = self.acquire_writer().await?;
111        set_meta(&mut conn, "sync_last_error", &error).await
112    }
113
114    pub(super) async fn pending_sync_change_count(&self) -> Result<i64> {
115        let mut conn = self.acquire_reader().await?;
116        Ok(
117            sqlx::query_scalar("SELECT COUNT(*) FROM changes WHERE server_seq IS NULL")
118                .fetch_one(&mut *conn)
119                .await?,
120        )
121    }
122
123    pub(super) async fn pending_blob_counts(
124        &self,
125        known_server_blobs: &HashSet<String>,
126    ) -> Result<crate::attachments::lifecycle::ByteCount> {
127        let mut conn = self.acquire_reader().await?;
128        let known = serde_json::to_string(known_server_blobs)?;
129        let (count, bytes): (i64, i64) = sqlx::query_as(
130            "WITH known(sha256) AS (
131               SELECT value FROM json_each(?)
132             ), pending AS (
133               SELECT json_extract(payload, '$.workspace_id') AS workspace_id,
134                      json_extract(payload, '$.sha256') AS sha256,
135                      MAX(CAST(json_extract(payload, '$.byte_size') AS INTEGER)) AS byte_size
136               FROM changes
137               WHERE server_seq IS NULL AND op_type = 'attachment_add'
138               GROUP BY workspace_id, sha256
139             )
140             SELECT COUNT(*), COALESCE(SUM(byte_size), 0)
141             FROM pending LEFT JOIN known USING (sha256)
142             WHERE known.sha256 IS NULL",
143        )
144        .bind(known)
145        .fetch_one(&mut *conn)
146        .await?;
147        Ok(crate::attachments::lifecycle::ByteCount {
148            count: u64::try_from(count)?,
149            bytes: u64::try_from(bytes)?,
150        })
151    }
152
153    pub async fn prepare_client_sync_page(
154        &self,
155        server: String,
156        push_limit: usize,
157        pull_limit: u32,
158    ) -> Result<ClientSyncPage> {
159        let mut conn = self.acquire_writer().await?;
160        validate_sync_server(&mut conn, &server).await?;
161        let client_id = get_meta(&mut conn, "client_id")
162            .await?
163            .context("missing client id")?;
164        let after = sync_cursor(&mut conn).await?;
165        let changes = load_unsynced_changes(&mut conn, push_limit).await?;
166        let pending = changes.len();
167        Ok(ClientSyncPage {
168            request: SyncRequest {
169                protocol_version: Some(super::wire::SYNC_PROTOCOL_VERSION),
170                client_id,
171                after,
172                pull_limit: Some(pull_limit),
173                changes,
174            },
175            pending,
176        })
177    }
178
179    pub async fn apply_client_sync_page(&self, page: ApplySyncPage) -> Result<usize> {
180        let envelope = super::wire::validate_sync_request_envelope(&page.request)?;
181        let request_change_ids = page
182            .request
183            .changes
184            .iter()
185            .map(|change| change.change_id.clone())
186            .collect::<Vec<_>>();
187        super::wire::validate_sync_response_for_request(
188            envelope.after,
189            envelope.pull_limit,
190            &request_change_ids,
191            &page.response,
192        )?;
193        let mut conn = self.acquire_writer().await?;
194        apply_sync_response(&mut conn, page).await
195    }
196
197    pub async fn persist_server_sync_page(&self, page: ServerSyncPage) -> Result<ServerSyncResult> {
198        self.persist_server_sync_page_inner(page, None).await
199    }
200
201    pub async fn persist_server_sync_page_with_blobs(
202        &self,
203        page: ServerSyncPage,
204        blob_dir: &Path,
205    ) -> Result<ServerSyncResult> {
206        self.persist_server_sync_page_inner(page, Some(blob_dir))
207            .await
208    }
209
210    async fn persist_server_sync_page_inner(
211        &self,
212        page: ServerSyncPage,
213        blob_dir: Option<&Path>,
214    ) -> Result<ServerSyncResult> {
215        let envelope = super::wire::validate_sync_request_envelope(&page.request)?;
216        for change in &page.request.changes {
217            super::wire::validate_pushed_change(change)?;
218        }
219        if blob_dir.is_none()
220            && page
221                .request
222                .changes
223                .iter()
224                .any(|change| change.op_type == op_type::ATTACHMENT_ADD)
225        {
226            bail!("error attachment-blob-storage-required");
227        }
228        let blob_prepare_started = Instant::now();
229        if let Some(blob_dir) = blob_dir {
230            let mut conn = self.acquire_reader().await?;
231            prepare_server_blobs(&mut conn, blob_dir, &page.request.changes).await?;
232        }
233        let blob_prepare_ms = blob_prepare_started.elapsed().as_millis();
234        let mut conn = self.acquire_writer().await?;
235        let assign_started = Instant::now();
236        let (accepted_count, push_acks) =
237            assign_server_sequences(&mut conn, page.request.changes, blob_dir).await?;
238        let assign_ms = assign_started.elapsed().as_millis();
239        let pull_query_started = Instant::now();
240        let (changes, has_more) =
241            load_server_changes_after(&mut conn, envelope.after, envelope.pull_limit).await?;
242        let pull_query_ms = pull_query_started.elapsed().as_millis();
243        Ok(ServerSyncResult {
244            accepted_count,
245            push_acks,
246            changes,
247            has_more,
248            blob_prepare_ms,
249            assign_ms,
250            pull_query_ms,
251        })
252    }
253}
254
255async fn sync_cursor(conn: &mut SqliteConnection) -> Result<i64> {
256    Ok(get_meta(conn, "sync_cursor")
257        .await?
258        .unwrap_or_else(|| "0".to_string())
259        .parse::<i64>()?)
260}
261
262async fn validate_sync_server(conn: &mut SqliteConnection, server: &str) -> Result<()> {
263    let normalized = server.trim_end_matches('/');
264    if let Some(existing) = get_meta(conn, "sync_server_url").await? {
265        if existing != normalized {
266            bail!(
267                "error sync-server-changed existing={} requested={} hint=\"use a fresh database for a different sync server\"",
268                existing,
269                normalized
270            );
271        }
272    } else {
273        set_meta(conn, "sync_server_url", normalized).await?;
274    }
275    Ok(())
276}
277
278async fn load_unsynced_changes(
279    conn: &mut SqliteConnection,
280    limit: usize,
281) -> Result<Vec<ChangeWire>> {
282    let limit = limit as i64;
283    let rows = sqlx::query_as!(
284        ChangeRow,
285        r#"SELECT change_id AS "change_id!: String", client_id AS "client_id!: String",
286         local_seq AS "local_seq!: i64", entity_type AS "entity_type!: String",
287         entity_id AS "entity_id!: String", field, op_type AS "op_type!: String",
288         payload AS "payload!: String", base_version, created_at AS "created_at!: String",
289         server_seq
290         FROM changes WHERE server_seq IS NULL ORDER BY local_seq, created_at LIMIT ?"#,
291        limit,
292    )
293    .fetch_all(&mut *conn)
294    .await?;
295    Ok(rows.into_iter().map(ChangeRow::into_wire).collect())
296}
297
298async fn apply_sync_response(conn: &mut SqliteConnection, page: ApplySyncPage) -> Result<usize> {
299    let mut applied = 0;
300    let mut tx = begin_immediate(conn).await?;
301    let current_cursor = sync_cursor(&mut tx).await?;
302    if current_cursor != page.request.after {
303        bail!(
304            "error stale-sync-page expected_cursor={} request_cursor={}",
305            current_cursor,
306            page.request.after
307        );
308    }
309    update_change_server_seqs_if_missing(&mut tx, &page.response.push_acks).await?;
310    let existing_change_ids = load_existing_change_ids(&mut tx, &page.response.changes).await?;
311    let mut affected_series = HashSet::new();
312    for change in &page.response.changes {
313        if existing_change_ids.contains(change.change_id.as_str()) {
314            verify_existing_change(&mut tx, change).await?;
315            update_change_server_seq(&mut tx, &change.change_id, change.server_seq).await?;
316            continue;
317        }
318        let related_mutation = matches!(
319            change.op_type.as_str(),
320            op_type::RELATED_ADD | op_type::RELATED_REMOVE
321        );
322        if related_mutation {
323            insert_wire_change(&mut tx, change).await?;
324        }
325        apply_remote_change(&mut tx, change).await?;
326        if change.entity_type == "recurrence_series" {
327            let workspace_id = change
328                .payload
329                .get("workspace_id")
330                .and_then(serde_json::Value::as_str)
331                .context("recurrence change missing workspace_id")?;
332            affected_series.insert((workspace_id.to_string(), change.entity_id.clone()));
333        } else if let Some(series_id) = change
334            .payload
335            .get("series_id")
336            .and_then(serde_json::Value::as_str)
337        {
338            let workspace_id = change
339                .payload
340                .get("workspace_id")
341                .and_then(serde_json::Value::as_str)
342                .context("recurrence task change missing workspace_id")?;
343            affected_series.insert((workspace_id.to_string(), series_id.to_string()));
344        }
345        if !related_mutation {
346            insert_wire_change(&mut tx, change).await?;
347        }
348        applied += 1;
349    }
350    for (workspace_id, series_id) in affected_series {
351        let workspace_id: crate::ids::WorkspaceId = workspace_id.parse()?;
352        let series_id: crate::recurrence::RecurrenceSeriesId = series_id.parse()?;
353        let workspace = crate::workspaces::workspace_for_id(&mut tx, &workspace_id).await?;
354        let at =
355            chrono::DateTime::parse_from_rfc3339(&page.attempted_at)?.with_timezone(&chrono::Utc);
356        crate::operations::recurrence::reconcile_recurrence_series_in_transaction(
357            &mut tx, &workspace, &series_id, at,
358        )
359        .await?;
360    }
361    let pushed = page.previous_pushed + page.response.push_acks.len() as i64;
362    let pulled = page.previous_pulled + applied;
363    set_meta(&mut tx, "sync_cursor", &page.response.cursor.to_string()).await?;
364    set_meta(&mut tx, "sync_last_success_at", &page.attempted_at).await?;
365    set_meta(&mut tx, "sync_last_error", "").await?;
366    set_meta(&mut tx, "sync_last_pushed", &pushed.to_string()).await?;
367    set_meta(&mut tx, "sync_last_pulled", &pulled.to_string()).await?;
368    set_meta(
369        &mut tx,
370        "sync_last_cursor",
371        &page.response.cursor.to_string(),
372    )
373    .await?;
374    tx.commit().await?;
375    Ok(applied)
376}
377
378async fn load_existing_change_ids(
379    conn: &mut SqliteConnection,
380    changes: &[ChangeWire],
381) -> Result<HashSet<String>> {
382    if changes.is_empty() {
383        return Ok(HashSet::new());
384    }
385    let mut query_builder =
386        QueryBuilder::<Sqlite>::new("SELECT change_id FROM changes WHERE change_id IN (");
387    let mut separated = query_builder.separated(", ");
388    for change in changes {
389        separated.push_bind(&change.change_id);
390    }
391    separated.push_unseparated(")");
392    Ok(query_builder
393        .build_query_scalar::<String>()
394        .fetch_all(&mut *conn)
395        .await?
396        .into_iter()
397        .collect())
398}
399
400async fn load_assigned_change_ids(
401    conn: &mut SqliteConnection,
402    changes: &[ChangeWire],
403) -> Result<HashSet<String>> {
404    if changes.is_empty() {
405        return Ok(HashSet::new());
406    }
407    let mut query_builder = QueryBuilder::<Sqlite>::new(
408        "SELECT change_id FROM changes WHERE server_seq IS NOT NULL AND change_id IN (",
409    );
410    let mut separated = query_builder.separated(", ");
411    for change in changes {
412        separated.push_bind(&change.change_id);
413    }
414    separated.push_unseparated(")");
415    Ok(query_builder
416        .build_query_scalar::<String>()
417        .fetch_all(&mut *conn)
418        .await?
419        .into_iter()
420        .collect())
421}
422
423async fn verify_existing_change(conn: &mut SqliteConnection, incoming: &ChangeWire) -> Result<()> {
424    let row = sqlx::query(
425        "SELECT entity_type, entity_id, field, op_type, payload, base_version, created_at
426         FROM changes WHERE change_id = ?",
427    )
428    .bind(&incoming.change_id)
429    .fetch_one(&mut *conn)
430    .await?;
431    use sqlx::Row;
432    let stored_payload: String = row.try_get("payload")?;
433    let stored_payload: serde_json::Value = serde_json::from_str(&stored_payload)?;
434    let equal = row.try_get::<String, _>("entity_type")? == incoming.entity_type
435        && row.try_get::<String, _>("entity_id")? == incoming.entity_id
436        && row.try_get::<Option<String>, _>("field")? == incoming.field
437        && row.try_get::<String, _>("op_type")? == incoming.op_type
438        && stored_payload == incoming.payload
439        && row.try_get::<Option<String>, _>("base_version")? == incoming.base_version
440        && row.try_get::<String, _>("created_at")? == incoming.created_at;
441    if !equal {
442        bail!(
443            "error sync-change-id-payload-mismatch change_id={}",
444            incoming.change_id
445        );
446    }
447    Ok(())
448}
449
450async fn update_change_server_seq(
451    conn: &mut SqliteConnection,
452    change_id: &str,
453    server_seq: Option<i64>,
454) -> Result<()> {
455    if let Some(server_seq) = server_seq {
456        sqlx::query!(
457            "UPDATE changes SET server_seq = ? WHERE change_id = ? AND server_seq IS NULL",
458            server_seq,
459            change_id,
460        )
461        .execute(&mut *conn)
462        .await?;
463    }
464    Ok(())
465}
466
467async fn update_change_server_seqs_if_missing(
468    conn: &mut SqliteConnection,
469    push_acks: &[PushAck],
470) -> Result<()> {
471    if push_acks.is_empty() {
472        return Ok(());
473    }
474    let mut query_builder = QueryBuilder::<Sqlite>::new("WITH updates(change_id, server_seq) AS (");
475    query_builder.push_values(push_acks, |mut row, ack| {
476        row.push_bind(&ack.change_id).push_bind(ack.server_seq);
477    });
478    query_builder.push(
479        ") UPDATE changes
480         SET server_seq = (
481             SELECT updates.server_seq
482             FROM updates
483             WHERE updates.change_id = changes.change_id
484         )
485         WHERE server_seq IS NULL
486           AND change_id IN (SELECT change_id FROM updates)",
487    );
488    query_builder.build().execute(&mut *conn).await?;
489    Ok(())
490}
491
492async fn insert_wire_change(conn: &mut SqliteConnection, change: &ChangeWire) -> Result<()> {
493    let payload = change.payload.to_string();
494    sqlx::query!(
495        "INSERT OR IGNORE INTO changes(change_id, client_id, local_seq, entity_type, entity_id, field,
496         op_type, payload, base_version, created_at, server_seq)
497         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
498        change.change_id,
499        change.client_id,
500        change.local_seq,
501        change.entity_type,
502        change.entity_id,
503        change.field,
504        change.op_type,
505        payload,
506        change.base_version,
507        change.created_at,
508        change.server_seq,
509    )
510    .execute(&mut *conn)
511    .await?;
512    Ok(())
513}
514
515async fn assign_server_sequences(
516    conn: &mut SqliteConnection,
517    changes: Vec<ChangeWire>,
518    blob_dir: Option<&Path>,
519) -> Result<(i64, Vec<PushAck>)> {
520    if changes.is_empty() {
521        return Ok((0, Vec::new()));
522    }
523    let mut tx = begin_immediate(conn).await?;
524    let assigned_change_ids = load_assigned_change_ids(&mut tx, &changes).await?;
525    let unassigned_changes = changes
526        .iter()
527        .filter(|change| !assigned_change_ids.contains(&change.change_id))
528        .cloned()
529        .collect::<Vec<_>>();
530    if unassigned_changes
531        .iter()
532        .any(|change| change.op_type == op_type::ATTACHMENT_ADD)
533        && blob_dir.is_none()
534    {
535        bail!("error attachment-blob-storage-required");
536    }
537    if let Some(blob_dir) = blob_dir {
538        ensure_attachment_blobs_admitted(&mut tx, blob_dir, &unassigned_changes).await?;
539    }
540    let mut next_server_seq = next_available_server_seq(&mut tx).await?;
541    let mut accepted_count = 0_i64;
542    let mut push_acks = Vec::with_capacity(changes.len());
543    for change in changes {
544        let existing_server_seq = sqlx::query_scalar::<_, Option<i64>>(
545            "SELECT server_seq FROM changes WHERE change_id = ?",
546        )
547        .bind(&change.change_id)
548        .fetch_optional(&mut *tx)
549        .await?;
550        let server_seq = if let Some(existing_server_seq) = existing_server_seq {
551            verify_existing_change(&mut tx, &change).await?;
552            existing_server_seq.context("existing server change missing sequence")?
553        } else {
554            let server_seq = next_server_seq;
555            next_server_seq += 1;
556            let payload = change.payload.to_string();
557            sqlx::query(
558                "INSERT INTO changes(change_id, client_id, local_seq, entity_type, entity_id, field,
559                 op_type, payload, base_version, created_at, server_seq)
560                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
561            )
562            .bind(&change.change_id)
563            .bind(&change.client_id)
564            .bind(change.local_seq)
565            .bind(&change.entity_type)
566            .bind(&change.entity_id)
567            .bind(&change.field)
568            .bind(&change.op_type)
569            .bind(payload)
570            .bind(&change.base_version)
571            .bind(&change.created_at)
572            .bind(server_seq)
573            .execute(&mut *tx)
574            .await?;
575            apply_server_blob_reference(&mut tx, &change).await?;
576            accepted_count += 1;
577            server_seq
578        };
579        push_acks.push(PushAck {
580            change_id: change.change_id,
581            server_seq,
582        });
583    }
584    tx.commit().await?;
585    Ok((accepted_count, push_acks))
586}
587
588async fn next_available_server_seq(conn: &mut SqliteConnection) -> Result<i64> {
589    Ok(sqlx::query_scalar!(
590        r#"SELECT COALESCE(MAX(server_seq), 0) + 1 AS "seq!: i64" FROM changes"#
591    )
592    .fetch_one(&mut *conn)
593    .await?)
594}
595
596async fn apply_server_blob_reference(
597    conn: &mut SqliteConnection,
598    change: &ChangeWire,
599) -> Result<()> {
600    match change.op_type.as_str() {
601        op_type::ATTACHMENT_ADD => {
602            let payload = AttachmentAddPayload::from_change(change)?;
603            sqlx::query(
604                "INSERT INTO server_blob_references(
605                   workspace_id, attachment_id, task_id, sha256, byte_size, deleted
606                 ) VALUES (?, ?, ?, ?, ?, 0)
607                 ON CONFLICT(workspace_id, attachment_id) DO UPDATE SET
608                   task_id = excluded.task_id, sha256 = excluded.sha256,
609                   byte_size = excluded.byte_size, deleted = 0",
610            )
611            .bind(&payload.workspace_id)
612            .bind(&payload.attachment_id)
613            .bind(&change.entity_id)
614            .bind(&payload.sha256)
615            .bind(payload.byte_size)
616            .execute(&mut *conn)
617            .await?;
618            sqlx::query(
619                "DELETE FROM blob_upload_reservations WHERE workspace_id = ? AND sha256 = ?",
620            )
621            .bind(&payload.workspace_id)
622            .bind(&payload.sha256)
623            .execute(&mut *conn)
624            .await?;
625        }
626        op_type::ATTACHMENT_DELETE => {
627            sqlx::query(
628                "UPDATE server_blob_references SET deleted = 1
629                 WHERE workspace_id = ? AND attachment_id = ?",
630            )
631            .bind(
632                change.payload["workspace_id"]
633                    .as_str()
634                    .context("payload missing workspace_id")?,
635            )
636            .bind(
637                change.payload["attachment_id"]
638                    .as_str()
639                    .context("payload missing attachment_id")?,
640            )
641            .execute(&mut *conn)
642            .await?;
643        }
644        op_type::SET_FIELD if change.field.as_deref() == Some("deleted") => {
645            let deleted = change.payload["value"]
646                .as_str()
647                .is_some_and(|value| value == "1");
648            sqlx::query(
649                "INSERT INTO server_task_tombstones(workspace_id, task_id, deleted)
650                 VALUES (?, ?, ?)
651                 ON CONFLICT(workspace_id, task_id) DO UPDATE SET deleted = excluded.deleted",
652            )
653            .bind(
654                change.payload["workspace_id"]
655                    .as_str()
656                    .context("payload missing workspace_id")?,
657            )
658            .bind(&change.entity_id)
659            .bind(i64::from(deleted))
660            .execute(&mut *conn)
661            .await?;
662        }
663        _ => {}
664    }
665    Ok(())
666}
667
668async fn prepare_server_blobs(
669    conn: &mut SqliteConnection,
670    blob_dir: &Path,
671    changes: &[ChangeWire],
672) -> Result<()> {
673    let assigned_change_ids = load_assigned_change_ids(conn, changes).await?;
674    for change in changes {
675        if change.op_type != op_type::ATTACHMENT_ADD
676            || assigned_change_ids.contains(&change.change_id)
677        {
678            continue;
679        }
680        let contract = super::blob::attachment_blob_contract(change)?
681            .context("error attachment-blob-missing")?;
682        validate_server_blob_before_writer(conn, blob_dir, &contract).await?;
683    }
684    Ok(())
685}
686
687async fn validate_server_blob_before_writer(
688    conn: &mut SqliteConnection,
689    blob_dir: &Path,
690    contract: &super::wire::BlobUploadContract,
691) -> Result<()> {
692    let Some(row) = crate::attachments::storage::blob_inventory_row(conn, &contract.sha256).await?
693    else {
694        bail!("error attachment-blob-missing");
695    };
696    validate_server_blob_inventory(&row, contract)?;
697    let path = crate::attachments::object_path(blob_dir, &contract.sha256)?;
698    let bytes = match tokio::fs::read(path).await {
699        Ok(bytes) => bytes,
700        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
701            bail!("error attachment-blob-missing")
702        }
703        Err(error) => return Err(error.into()),
704    };
705    if i64::try_from(bytes.len()).ok() != Some(contract.byte_size)
706        || crate::attachments::storage::sha256_hex(&bytes) != contract.sha256
707    {
708        bail!("error attachment-blob-content-mismatch");
709    }
710    let validated =
711        crate::attachments::decode::validate_image(bytes, Some(contract.media_type.clone()))
712            .await?;
713    if (validated.facts.width, validated.facts.height) != (contract.width, contract.height) {
714        bail!("error blob-inventory-metadata-mismatch");
715    }
716    Ok(())
717}
718
719fn validate_server_blob_inventory(
720    row: &crate::types::BlobInventoryRow,
721    contract: &super::wire::BlobUploadContract,
722) -> Result<()> {
723    if !row.available {
724        bail!("error attachment-blob-missing");
725    }
726    if row.byte_size != contract.byte_size || row.media_type != contract.media_type {
727        bail!("error blob-inventory-metadata-mismatch");
728    }
729    Ok(())
730}
731
732async fn ensure_attachment_blobs_admitted(
733    conn: &mut SqliteConnection,
734    blob_dir: &Path,
735    changes: &[ChangeWire],
736) -> Result<()> {
737    for change in changes {
738        if change.op_type != op_type::ATTACHMENT_ADD {
739            continue;
740        }
741        let contract = super::blob::attachment_blob_contract(change)?
742            .context("error attachment-blob-missing")?;
743        let Some(row) =
744            crate::attachments::storage::blob_inventory_row(conn, &contract.sha256).await?
745        else {
746            bail!("error attachment-blob-missing");
747        };
748        validate_server_blob_inventory(&row, &contract)?;
749        if !crate::attachments::object_path(blob_dir, &contract.sha256)?.exists() {
750            bail!("error attachment-blob-missing");
751        }
752        let admitted: bool = sqlx::query_scalar(
753            "SELECT EXISTS(
754               SELECT 1 FROM server_blob_references sbr
755               LEFT JOIN server_task_tombstones st
756                 ON st.workspace_id = sbr.workspace_id AND st.task_id = sbr.task_id
757               WHERE sbr.workspace_id = ? AND sbr.sha256 = ? AND sbr.deleted = 0
758                 AND COALESCE(st.deleted, 0) = 0
759             ) OR EXISTS(
760               SELECT 1 FROM blob_upload_reservations
761               WHERE workspace_id = ? AND sha256 = ? AND byte_size = ? AND expires_at > ?
762             )",
763        )
764        .bind(&contract.workspace_id)
765        .bind(&contract.sha256)
766        .bind(&contract.workspace_id)
767        .bind(&contract.sha256)
768        .bind(contract.byte_size)
769        .bind(crate::ids::now())
770        .fetch_one(&mut *conn)
771        .await?;
772        if !admitted {
773            bail!("error attachment-blob-unreserved");
774        }
775    }
776    Ok(())
777}
778
779async fn load_server_changes_after(
780    conn: &mut SqliteConnection,
781    after: i64,
782    pull_limit: u32,
783) -> Result<(Vec<ChangeWire>, bool)> {
784    let fetch_limit = i64::from(pull_limit) + 1;
785    let rows = sqlx::query_as!(
786        ChangeRow,
787        r#"SELECT change_id AS "change_id!: String", client_id AS "client_id!: String",
788         local_seq AS "local_seq!: i64", entity_type AS "entity_type!: String",
789         entity_id AS "entity_id!: String", field, op_type AS "op_type!: String",
790         payload AS "payload!: String", base_version, created_at AS "created_at!: String",
791         server_seq
792         FROM changes WHERE server_seq > ? ORDER BY server_seq LIMIT ?"#,
793        after,
794        fetch_limit,
795    )
796    .fetch_all(&mut *conn)
797    .await?;
798    let has_more = rows.len() > pull_limit as usize;
799    let changes = rows
800        .into_iter()
801        .take(pull_limit as usize)
802        .map(ChangeRow::into_wire)
803        .collect();
804    Ok((changes, has_more))
805}
806
807#[cfg(test)]
808mod tests {
809    use std::io::Cursor;
810    use std::time::Duration;
811
812    use image::{DynamicImage, ImageFormat};
813    use serde_json::json;
814
815    use super::*;
816    use crate::attachments::storage::{object_path, sha256_hex, upsert_inventory_available};
817    use crate::sync::wire::{ChangeWire, SYNC_PROTOCOL_VERSION, SyncRequest};
818
819    #[tokio::test]
820    async fn related_comparison_observes_push_acknowledgement_first() {
821        let (_temp, mut conn) = crate::test_support::test_conn().await;
822        let workspace = crate::workspaces::Workspace::default();
823        let project = crate::projects::create_project(&mut conn, &workspace, "related-ack")
824            .await
825            .unwrap();
826        let task_id: crate::ids::TaskId = "AAAA000000000001".parse().unwrap();
827        let related_task_id: crate::ids::TaskId = "BBBB000000000002".parse().unwrap();
828        for id in [&task_id, &related_task_id] {
829            sqlx::query(
830                "INSERT INTO tasks(id, workspace_id, title, description, project_id, status, priority, created_at, updated_at)
831                 VALUES (?, ?, 'task', '', ?, 'todo', 'none', 't', 't')",
832            )
833            .bind(id)
834            .bind(&workspace.id)
835            .bind(&project.id)
836            .execute(&mut *conn)
837            .await
838            .unwrap();
839        }
840        let local = crate::operations::set_task_related_link_in_transaction(
841            &mut conn,
842            &workspace,
843            &task_id,
844            &related_task_id,
845            true,
846        )
847        .await
848        .unwrap();
849        let local_change_id = local.change_id.unwrap();
850        set_meta(&mut conn, "sync_cursor", "3").await.unwrap();
851
852        let remote = ChangeWire {
853            change_id: "CCCC000000000003".to_string(),
854            client_id: "remote".to_string(),
855            local_seq: 1,
856            entity_type: "task".to_string(),
857            entity_id: task_id.to_string(),
858            field: Some("related".to_string()),
859            op_type: op_type::RELATED_REMOVE.to_string(),
860            payload: json!({
861                "workspace_id": workspace.id,
862                "workspace_key": workspace.key,
863                "related_task_id": related_task_id,
864            }),
865            base_version: None,
866            created_at: "2026-08-22T00:00:00Z".to_string(),
867            server_seq: Some(4),
868        };
869        let page = ApplySyncPage {
870            request: SyncRequest {
871                protocol_version: Some(SYNC_PROTOCOL_VERSION),
872                client_id: "local".to_string(),
873                after: 3,
874                pull_limit: Some(100),
875                changes: Vec::new(),
876            },
877            response: SyncResponse {
878                protocol_version: SYNC_PROTOCOL_VERSION,
879                cursor: 4,
880                has_more: false,
881                push_acks: vec![PushAck {
882                    change_id: local_change_id.clone(),
883                    server_seq: 5,
884                }],
885                changes: vec![remote],
886            },
887            attempted_at: "2026-08-22T00:00:01Z".to_string(),
888            previous_pushed: 0,
889            previous_pulled: 0,
890        };
891
892        apply_sync_response(&mut conn, page).await.unwrap();
893
894        let state: (i64, String) = sqlx::query_as(
895            "SELECT linked, last_change_id FROM task_related_links
896             WHERE workspace_id = ? AND task_a_id = ? AND task_b_id = ?",
897        )
898        .bind(&workspace.id)
899        .bind(&task_id)
900        .bind(&related_task_id)
901        .fetch_one(&mut *conn)
902        .await
903        .unwrap();
904        assert_eq!(state, (1, local_change_id.clone()));
905        let acknowledged: Option<i64> =
906            sqlx::query_scalar("SELECT server_seq FROM changes WHERE change_id = ?")
907                .bind(local_change_id)
908                .fetch_one(&mut *conn)
909                .await
910                .unwrap();
911        assert_eq!(acknowledged, Some(5));
912    }
913
914    async fn corrupt_attachment_page() -> (
915        tempfile::TempDir,
916        Database,
917        std::path::PathBuf,
918        ServerSyncPage,
919    ) {
920        let temp = tempfile::tempdir().unwrap();
921        let database = Database::open(&temp.path().join("server.sqlite"))
922            .await
923            .unwrap();
924        let blob_dir = temp.path().join("blobs");
925        let expected = b"expected";
926        let sha256 = sha256_hex(expected);
927        {
928            let mut conn = database.acquire_writer().await.unwrap();
929            upsert_inventory_available(&mut conn, &sha256, expected.len() as i64, "image/png")
930                .await
931                .unwrap();
932        }
933        let path = object_path(&blob_dir, &sha256).unwrap();
934        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
935        std::fs::write(path, b"corrupt!").unwrap();
936        let change = ChangeWire {
937            change_id: "0123456789ABCDEF".to_string(),
938            client_id: "client-a".to_string(),
939            local_seq: 1,
940            entity_type: "task".to_string(),
941            entity_id: "0123456789ABCDE0".to_string(),
942            field: Some("attachments".to_string()),
943            op_type: op_type::ATTACHMENT_ADD.to_string(),
944            payload: json!({
945                "workspace_id": "0000000000000000",
946                "workspace_key": "default",
947                "attachment_id": "7KQ9A1X4MV2P8D6R",
948                "sha256": sha256,
949                "byte_size": expected.len(),
950                "media_type": "image/png",
951                "filename": "photo.png",
952                "alt_text": "photo",
953                "width": 1,
954                "height": 1,
955                "created_at": "2026-01-01T00:00:00Z"
956            }),
957            base_version: None,
958            created_at: "2026-01-01T00:00:00Z".to_string(),
959            server_seq: None,
960        };
961        let page = ServerSyncPage {
962            request: SyncRequest {
963                protocol_version: Some(SYNC_PROTOCOL_VERSION),
964                client_id: "client-a".to_string(),
965                after: 0,
966                pull_limit: Some(100),
967                changes: vec![change],
968            },
969        };
970        (temp, database, blob_dir, page)
971    }
972
973    fn page_for_contract(contract: &super::super::wire::BlobUploadContract) -> ServerSyncPage {
974        let change = ChangeWire {
975            change_id: "0123456789ABCDEF".to_string(),
976            client_id: "client-a".to_string(),
977            local_seq: 1,
978            entity_type: "task".to_string(),
979            entity_id: "0123456789ABCDE0".to_string(),
980            field: Some("attachments".to_string()),
981            op_type: op_type::ATTACHMENT_ADD.to_string(),
982            payload: json!({
983                "workspace_id": contract.workspace_id,
984                "workspace_key": "default",
985                "attachment_id": "7KQ9A1X4MV2P8D6R",
986                "sha256": contract.sha256,
987                "byte_size": contract.byte_size,
988                "media_type": contract.media_type,
989                "filename": "photo.png",
990                "alt_text": "photo",
991                "width": contract.width,
992                "height": contract.height,
993                "created_at": "2026-01-01T00:00:00Z"
994            }),
995            base_version: None,
996            created_at: "2026-01-01T00:00:00Z".to_string(),
997            server_seq: None,
998        };
999        ServerSyncPage {
1000            request: SyncRequest {
1001                protocol_version: Some(SYNC_PROTOCOL_VERSION),
1002                client_id: "client-a".to_string(),
1003                after: 0,
1004                pull_limit: Some(100),
1005                changes: vec![change],
1006            },
1007        }
1008    }
1009
1010    #[tokio::test]
1011    async fn corrupt_server_blob_is_rejected_before_writer_acquisition() {
1012        let (_temp, database, blob_dir, page) = corrupt_attachment_page().await;
1013        let writer = database.acquire_writer().await.unwrap();
1014        let task = tokio::spawn({
1015            let database = database.clone();
1016            async move {
1017                database
1018                    .persist_server_sync_page_with_blobs(page, &blob_dir)
1019                    .await
1020            }
1021        });
1022        tokio::time::sleep(Duration::from_millis(50)).await;
1023        assert!(
1024            task.is_finished(),
1025            "corrupt content validation should not wait for the writer gate"
1026        );
1027        drop(writer);
1028        let error = task.await.unwrap().unwrap_err();
1029        assert!(
1030            error
1031                .to_string()
1032                .contains("attachment-blob-content-mismatch")
1033        );
1034    }
1035
1036    #[tokio::test]
1037    async fn reservation_is_rechecked_after_blob_preparation() {
1038        let temp = tempfile::tempdir().unwrap();
1039        let database = Database::open(&temp.path().join("server.sqlite"))
1040            .await
1041            .unwrap();
1042        let blob_dir = temp.path().join("blobs");
1043        let mut encoded = Cursor::new(Vec::new());
1044        DynamicImage::new_rgba8(1, 1)
1045            .write_to(&mut encoded, ImageFormat::Png)
1046            .unwrap();
1047        let bytes = encoded.into_inner();
1048        let contract = super::super::wire::BlobUploadContract {
1049            workspace_id: "0000000000000000".to_string(),
1050            sha256: sha256_hex(&bytes),
1051            byte_size: bytes.len() as i64,
1052            media_type: "image/png".to_string(),
1053            width: 1,
1054            height: 1,
1055        };
1056        database
1057            .store_server_blob(
1058                &blob_dir,
1059                crate::attachments::lifecycle::LifecyclePolicy::default(),
1060                &contract,
1061                bytes,
1062            )
1063            .await
1064            .unwrap();
1065        let page = page_for_contract(&contract);
1066        {
1067            let mut reader = database.acquire_reader().await.unwrap();
1068            prepare_server_blobs(&mut reader, &blob_dir, &page.request.changes)
1069                .await
1070                .unwrap();
1071        }
1072        {
1073            let mut writer = database.acquire_writer().await.unwrap();
1074            sqlx::query("DELETE FROM blob_upload_reservations")
1075                .execute(&mut *writer)
1076                .await
1077                .unwrap();
1078        }
1079        let mut writer = database.acquire_writer().await.unwrap();
1080        let error = assign_server_sequences(&mut writer, page.request.changes, Some(&blob_dir))
1081            .await
1082            .unwrap_err();
1083        assert!(error.to_string().contains("attachment-blob-unreserved"));
1084        let accepted: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM changes")
1085            .fetch_one(&mut *writer)
1086            .await
1087            .unwrap();
1088        assert_eq!(accepted, 0);
1089    }
1090}