1use anyhow::{Context, Result, bail};
3use serde_json::Value;
4use sqlx::SqliteConnection;
5
6use super::wire::{ChangeWire, SYNC_PROTOCOL_VERSION, SyncRequest, SyncResponse};
7use crate::db::{get_meta, set_meta};
8
9pub const MAINTAINED_PROTOCOL_BASELINE: u32 = 18;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct SyncCompatibilityError {
14 pub server_protocol: u32,
15 pub client_protocol: u32,
16}
17
18impl std::fmt::Display for SyncCompatibilityError {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 let action = if self.server_protocol > self.client_protocol {
21 "Update Aven on this device to continue syncing."
22 } else {
23 "Update your Aven sync server to continue syncing."
24 };
25 write!(f, "{action} Your local tasks and edits remain saved.")
26 }
27}
28impl std::error::Error for SyncCompatibilityError {}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct SharedOperationCompatibilityError {
32 pub required_protocol: u32,
33 pub established_protocol: u32,
34}
35
36impl std::fmt::Display for SharedOperationCompatibilityError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 f.write_str("This feature requires a newer Aven sync server. Existing tasks and edits will continue syncing normally.")
39 }
40}
41impl std::error::Error for SharedOperationCompatibilityError {}
42
43pub fn supports_protocol(protocol: u32) -> bool {
44 (MAINTAINED_PROTOCOL_BASELINE..=SYNC_PROTOCOL_VERSION).contains(&protocol)
45}
46
47pub(crate) fn validate_behavior_protocol(protocol: u32) -> Result<()> {
48 if supports_protocol(protocol) {
49 return Ok(());
50 }
51 #[cfg(test)]
52 if matches!(protocol, 19 | 20) {
53 return Ok(());
54 }
55 Err(SyncCompatibilityError {
56 server_protocol: protocol,
57 client_protocol: SYNC_PROTOCOL_VERSION,
58 }
59 .into())
60}
61
62pub(crate) async fn replica_protocol(conn: &mut SqliteConnection) -> Result<u32> {
63 let protocol = get_meta(conn, "sync_established_protocol")
64 .await?
65 .map(|value| {
66 value
67 .parse::<u32>()
68 .context("invalid established sync protocol")
69 })
70 .transpose()?
71 .unwrap_or(MAINTAINED_PROTOCOL_BASELINE);
72 validate_behavior_protocol(protocol)?;
73 Ok(protocol)
74}
75
76pub(crate) async fn establish_protocol(conn: &mut SqliteConnection, protocol: u32) -> Result<()> {
77 validate_behavior_protocol(protocol)?;
78 set_meta(conn, "sync_established_protocol", &protocol.to_string()).await
79}
80
81const BASELINE_OPERATIONS: &[&str] = &[
83 "create_task",
84 "set_field",
85 "resolve_field",
86 "label_add",
87 "label_remove",
88 "note_add",
89 "note_edit",
90 "note_delete",
91 "dependency_add",
92 "dependency_remove",
93 "related_add",
94 "related_remove",
95 "epic_link_add",
96 "epic_link_remove",
97 "attachment_add",
98 "attachment_delete",
99 "create_project",
100 "set_project_metadata",
101 "project_delete",
102 "create_label",
103 "set_label_name",
104 "label_delete",
105 "label_restore",
106 "create_workspace",
107 "set_workspace_field",
108 "create_recurrence_series",
109 "update_recurrence_template",
110 "project_recurrence_occurrence",
111 "resolve_recurrence_occurrence",
112 "set_recurrence_state",
113 "create_metadata_field",
114 "set_metadata_field",
115 "set_task_metadata",
116 "remove_task_metadata",
117 "set_recurrence_metadata",
118 "remove_recurrence_metadata",
119 "open_recurrence_pause",
120 "close_recurrence_pause",
121 "stop_recurrence_series",
122];
123const STATUSES: &[&str] = &["inbox", "backlog", "todo", "active", "done", "canceled"];
124const PRIORITIES: &[&str] = &["none", "low", "medium", "high", "urgent"];
125const SOURCES: &[&str] = &["cli", "tui", "api", "ios", "android", "unknown"];
126const TASK_FIELDS: &[&str] = &[
127 "title",
128 "description",
129 "project",
130 "status",
131 "priority",
132 "available_at",
133 "due_on",
134 "deleted",
135 "is_epic",
136];
137
138pub(crate) fn validate_operation(
139 protocol: u32,
140 op: &str,
141 field: Option<&str>,
142 payload: &Value,
143) -> Result<()> {
144 validate_behavior_protocol(protocol)?;
145 let required = required_protocol(op)?;
146 if required > protocol {
147 return Err(SharedOperationCompatibilityError {
148 required_protocol: required,
149 established_protocol: protocol,
150 }
151 .into());
152 }
153 if op == "set_workspace_field" {
154 registered(
155 field.context("error invalid-sync-change field missing")?,
156 &["key", "name"],
157 )?;
158 }
159 if op == "set_metadata_field" {
160 registered(
161 field.context("error invalid-sync-change field missing")?,
162 &["key"],
163 )?;
164 }
165 if op == "attachment_add"
166 && let Some(value) = payload.get("media_type").and_then(Value::as_str)
167 {
168 registered(
169 value,
170 &["image/png", "image/jpeg", "image/gif", "image/webp"],
171 )?;
172 }
173 if matches!(op, "set_field" | "resolve_field") {
174 let field = field.context("error invalid-sync-change field missing")?;
175 registered(field, TASK_FIELDS)?;
176 if let Some(value) = payload.get("value").and_then(Value::as_str) {
177 validate_value(field, value)?;
178 }
179 }
180 if matches!(
181 op,
182 "create_task"
183 | "create_recurrence_series"
184 | "project_recurrence_occurrence"
185 | "resolve_recurrence_occurrence"
186 | "set_recurrence_state"
187 | "stop_recurrence_series"
188 | "update_recurrence_template"
189 ) {
190 for key in [
191 "status",
192 "initial_status",
193 "priority",
194 "source",
195 "frequency",
196 "state",
197 "outcome",
198 "due_policy",
199 ] {
200 if let Some(value) = payload.get(key).and_then(Value::as_str) {
201 validate_value(key, value)?;
202 }
203 }
204 if let Some(fields) = payload.get("fields").and_then(Value::as_array) {
205 for pair in fields {
206 if let (Some(field), Some(value)) = (
207 pair.get(0).and_then(Value::as_str),
208 pair.get(1).and_then(Value::as_str),
209 ) {
210 registered(
211 field,
212 &[
213 "title",
214 "description",
215 "project",
216 "priority",
217 "initial_status",
218 "available_local_time",
219 "due_policy",
220 ],
221 )?;
222 validate_value(field, value)?;
223 }
224 }
225 }
226 }
227 Ok(())
228}
229
230fn required_protocol(op: &str) -> Result<u32> {
231 #[cfg(test)]
232 match op {
233 "test_protocol_19" => return Ok(19),
234 "test_protocol_20" => return Ok(20),
235 _ => {}
236 }
237 if !BASELINE_OPERATIONS.contains(&op) {
238 bail!("error invalid-sync-change op_type={op} unregistered-operation");
239 }
240 Ok(MAINTAINED_PROTOCOL_BASELINE)
241}
242
243fn registered(value: &str, values: &[&str]) -> Result<()> {
244 if !values.contains(&value) {
245 bail!("error invalid-sync-change unregistered-value");
246 }
247 Ok(())
248}
249
250fn validate_value(field: &str, value: &str) -> Result<()> {
251 match field {
252 "status" | "initial_status" => registered(value, STATUSES),
253 "priority" => registered(value, PRIORITIES),
254 "source" => {
255 crate::choices::TaskSource::parse(value)?;
256 registered(value, SOURCES)
257 }
258 "frequency" => registered(value, &["daily", "weekly", "monthly", "yearly"]),
259 "state" => registered(value, &["active", "paused", "stopped"]),
260 "outcome" => registered(value, &["completed", "skipped"]),
261 "due_policy" => registered(value, &["none", "same_day"]),
262 _ => Ok(()),
263 }
264}
265
266pub(crate) fn validate_change(protocol: u32, change: &ChangeWire) -> Result<()> {
267 validate_operation(
268 protocol,
269 &change.op_type,
270 change.field.as_deref(),
271 &change.payload,
272 )
273}
274
275pub fn discovery_request(protocol: u32, client_id: String) -> SyncRequest {
276 SyncRequest {
277 protocol_version: Some(protocol),
278 client_id,
279 after: i64::MAX,
280 pull_limit: Some(1),
281 changes: Vec::new(),
282 }
283}
284
285pub fn validate_discovery_response(protocol: u32, response: &SyncResponse) -> Result<()> {
286 super::wire::validate_response_at_protocol(protocol, i64::MAX, 1, &[], response)
287}
288
289pub fn protocol_mismatch(detail: &str) -> Option<(u32, u32)> {
290 let mut fields = detail
291 .trim()
292 .strip_prefix("error sync-protocol-unsupported ")?
293 .split_whitespace();
294 let client = fields.next()?.strip_prefix("client=")?.parse().ok()?;
295 let server = fields.next()?.strip_prefix("server=")?.parse().ok()?;
296 if fields.next().is_some() || client == server {
297 return None;
298 }
299 Some((client, server))
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn baseline_vocabulary_is_explicit_and_complete() {
308 let source = include_str!("../change_log.rs");
309 let operations = source
310 .lines()
311 .filter_map(|line| {
312 let (_, value) = line
313 .trim()
314 .strip_prefix("pub const ")?
315 .split_once(": &str = \"")?;
316 value.strip_suffix("\";")
317 })
318 .collect::<Vec<_>>();
319 assert_eq!(operations, BASELINE_OPERATIONS);
320 assert_eq!(crate::choices::STATUSES, STATUSES);
321 assert_eq!(crate::choices::PRIORITIES, PRIORITIES);
322 assert_eq!(crate::choices::TASK_SOURCES, SOURCES);
323 let mut fields = crate::task_fields::TaskField::VERSIONED
324 .map(|field| field.as_str())
325 .to_vec();
326 fields.sort();
327 let mut frozen = TASK_FIELDS.to_vec();
328 frozen.sort();
329 assert_eq!(fields, frozen);
330 }
331
332 #[tokio::test]
333 async fn unsupported_persisted_mode_fails_closed_on_reopen() {
334 let directory = tempfile::tempdir().unwrap();
335 let path = directory.path().join("replica.sqlite");
336 let database = crate::db::Database::open(&path).await.unwrap();
337 let mut conn = database.acquire_writer().await.unwrap();
338 set_meta(&mut conn, "sync_established_protocol", "999")
339 .await
340 .unwrap();
341 drop(conn);
342 drop(database);
343 assert!(crate::db::Database::open(&path).await.is_err());
344 }
345}