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