Skip to main content

recall_wire/
sync.rs

1//! `POST /sync` and `GET /sync` — the endpoints that carry memory files.
2
3use serde::{Deserialize, Serialize};
4
5use crate::ValidationError;
6
7/// Body of `POST /sync`: one memory file, or one delete.
8///
9/// `content` is an [`Option`], not a [`String`], and that is load-bearing:
10/// it has to distinguish "this file is empty" ([`Some`]`("")`, serialized as
11/// `"content":""`) from "this is a delete, there is no content" ([`None`],
12/// omitted entirely).
13///
14/// Skipping on emptiness instead — the obvious-looking
15/// `skip_serializing_if = "String::is_empty"` — omits the field for an
16/// empty *file* too, and the deployed Node server rejects that push with a
17/// 400, since `typeof undefined !== "string"`. Both the Go and the first
18/// Rust implementation had exactly that bug, and neither test suite caught
19/// it: every test that exercised an empty file used a stand-in server that
20/// accepted anything. It surfaced only when the compatibility script ran a
21/// real empty-file push against the real Node server.
22#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
23pub struct PushRequest {
24    /// Which project this file belongs to. See `recall_hooks::project`.
25    pub project_key: String,
26    /// The file's path relative to the project's memory directory.
27    pub file_path: String,
28    /// The file's exact bytes, or [`None`] to mean "this is a delete".
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub content: Option<String>,
31    /// A label for the machine that sent this, for display only.
32    #[serde(default, skip_serializing_if = "String::is_empty")]
33    pub source_env: String,
34    /// Whether this push is a delete rather than a write.
35    #[serde(default, skip_serializing_if = "is_false")]
36    pub deleted: bool,
37    /// [`content_sha256`](crate::content_sha256) of the version this edit
38    /// started from: the content this client last pulled or pushed for the
39    /// file. [`None`] when the client does not know — an older client, or a
40    /// file it has never synced.
41    ///
42    /// It is what lets the server tell the next edit from a concurrent one.
43    /// Without it every push that differs from what is stored went through
44    /// the semantic merge, which keeps "every distinct fact from both
45    /// versions" — so a line deleted on purpose was a fact from the stored
46    /// side and came back, and so did a resolved `CONFLICT` marker. When the
47    /// stored version is the one named here, nothing happened in between
48    /// and the push simply replaces it.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub base_sha256: Option<String>,
51}
52
53impl PushRequest {
54    /// Checks a push is well-formed, applying the same rules on both sides
55    /// of the wire: the client refuses to send what the server would refuse
56    /// to accept, so the user sees the real reason rather than a 400.
57    pub fn validate(&self) -> Result<(), ValidationError> {
58        crate::validate_project_key(&self.project_key)?;
59        crate::validate_file_path(&self.file_path)?;
60        match &self.base_sha256 {
61            Some(base) => crate::validate_base_sha256(base),
62            None => Ok(()),
63        }
64    }
65}
66
67/// Body returned by `POST /sync`.
68#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
69pub struct PushResponse {
70    /// Always `true`; a failure is a non-2xx carrying an
71    /// [`ErrorResponse`](crate::ErrorResponse) instead.
72    pub ok: bool,
73    /// Echoed back from the request.
74    pub project_key: String,
75    /// Echoed back from the request.
76    pub file_path: String,
77    /// Whether the stored row is now a tombstone.
78    pub deleted: bool,
79    /// Whether the stored content is the result of a semantic merge rather
80    /// than a plain write.
81    pub merged: bool,
82    /// When the row was written, in the crate's frozen timestamp format.
83    pub updated_at: String,
84    /// The merge job this push queued, on a server with a worker: the push
85    /// was stored as sent (`merged: false`), and the merged file arrives
86    /// with a later pull.
87    ///
88    /// Omitted rather than `null` when no job was queued, so a server
89    /// without a worker answers byte for byte as it did before the queue
90    /// existed.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub merge_job: Option<String>,
93}
94
95/// One memory file as returned by `GET /sync`.
96///
97/// `content` is an [`Option`] so a tombstoned row reports JSON `null` rather
98/// than `""`: the server withholds deleted content so a pull cannot
99/// resurrect it, and an empty string would be indistinguishable from a
100/// genuinely empty file.
101#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
102pub struct File {
103    /// The file's path relative to the project's memory directory.
104    pub file_path: String,
105    /// The file's exact bytes, or [`None`] for a tombstone.
106    pub content: Option<String>,
107    /// The machine that last wrote this file.
108    #[serde(default)]
109    pub source_env: String,
110    /// When it was last written, in the crate's frozen timestamp format.
111    #[serde(default)]
112    pub updated_at: String,
113    /// Whether this row is a tombstone, meaning a puller should delete its
114    /// local copy.
115    #[serde(default)]
116    pub deleted: bool,
117}
118
119/// Body returned by `GET /sync`: every file held for one project,
120/// tombstones included.
121#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
122pub struct SyncResponse {
123    /// Echoed back from the query string.
124    pub project_key: String,
125    /// Every file the server holds for the project.
126    pub files: Vec<File>,
127}
128
129#[allow(clippy::trivially_copy_pass_by_ref)]
130fn is_false(b: &bool) -> bool {
131    !*b
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn validates_push_requests() {
140        let valid = PushRequest {
141            project_key: "acme/app".into(),
142            file_path: "MEMORY.md".into(),
143            content: Some("hi".into()),
144            ..Default::default()
145        };
146        assert!(valid.validate().is_ok());
147
148        let no_key = PushRequest {
149            file_path: "MEMORY.md".into(),
150            ..Default::default()
151        };
152        assert_eq!(no_key.validate(), Err(ValidationError::MissingProjectKey));
153
154        // A delete carries no content; that must not read as invalid.
155        let delete = PushRequest {
156            project_key: "acme/app".into(),
157            file_path: "MEMORY.md".into(),
158            deleted: true,
159            ..Default::default()
160        };
161        assert!(delete.validate().is_ok());
162    }
163
164    /// Byte-for-byte compatibility with the deployed Node server. These
165    /// exact strings were captured from it.
166    #[test]
167    fn push_body_matches_the_node_server() {
168        let push = PushRequest {
169            project_key: "acme/app".into(),
170            file_path: "MEMORY.md".into(),
171            content: Some("hello".into()),
172            source_env: "laptop".into(),
173            deleted: false,
174            base_sha256: None,
175        };
176        assert_eq!(
177            serde_json::to_string(&push).unwrap(),
178            r#"{"project_key":"acme/app","file_path":"MEMORY.md","content":"hello","source_env":"laptop"}"#
179        );
180
181        let delete = PushRequest {
182            project_key: "acme/app".into(),
183            file_path: "gone.md".into(),
184            source_env: "laptop".into(),
185            deleted: true,
186            ..Default::default()
187        };
188        assert_eq!(
189            serde_json::to_string(&delete).unwrap(),
190            r#"{"project_key":"acme/app","file_path":"gone.md","source_env":"laptop","deleted":true}"#
191        );
192    }
193
194    /// The bug the compatibility script caught: an empty memory file must
195    /// still send `"content":""`. Omitting it makes the Node server answer
196    /// 400, so a project containing one empty note could never sync.
197    #[test]
198    fn an_empty_file_still_sends_its_content_field() {
199        let push = PushRequest {
200            project_key: "acme/app".into(),
201            file_path: "empty.md".into(),
202            content: Some(String::new()),
203            source_env: "laptop".into(),
204            deleted: false,
205            base_sha256: None,
206        };
207        let json = serde_json::to_string(&push).unwrap();
208        assert!(
209            json.contains(r#""content":"""#),
210            "empty file must serialize its content field, got {json}"
211        );
212
213        // A delete still omits it, which is what keeps the two apart.
214        let delete = PushRequest {
215            project_key: "acme/app".into(),
216            file_path: "gone.md".into(),
217            deleted: true,
218            ..Default::default()
219        };
220        assert!(!serde_json::to_string(&delete).unwrap().contains("content"));
221    }
222
223    #[test]
224    fn tombstoned_file_serializes_content_as_null() {
225        let resp = SyncResponse {
226            project_key: "acme/app".into(),
227            files: vec![File {
228                file_path: "gone.md".into(),
229                content: None,
230                source_env: "laptop".into(),
231                updated_at: "2026-01-01T00:00:00.000Z".into(),
232                deleted: true,
233            }],
234        };
235        assert_eq!(
236            serde_json::to_string(&resp).unwrap(),
237            r#"{"project_key":"acme/app","files":[{"file_path":"gone.md","content":null,"source_env":"laptop","updated_at":"2026-01-01T00:00:00.000Z","deleted":true}]}"#
238        );
239    }
240
241    /// An empty file is a legitimate memory file. If it serialized like a
242    /// tombstone, a pull would skip writing it.
243    #[test]
244    fn empty_file_is_distinguishable_from_a_tombstone() {
245        let empty = File {
246            file_path: "empty.md".into(),
247            content: Some(String::new()),
248            ..Default::default()
249        };
250        let json = serde_json::to_string(&empty).unwrap();
251        assert!(json.contains(r#""content":"""#), "got {json}");
252
253        let tombstone = File {
254            file_path: "empty.md".into(),
255            content: None,
256            ..Default::default()
257        };
258        assert!(serde_json::to_string(&tombstone)
259            .unwrap()
260            .contains(r#""content":null"#));
261    }
262
263    /// Responses from the deployed Node server must deserialize as-is.
264    #[test]
265    fn parses_a_real_node_server_response() {
266        let body = r#"{"project_key":"acme/app","files":[
267            {"file_path":"MEMORY.md","content":"written by the NODE server\n","source_env":"node-era","updated_at":"2026-09-03T21:49:55.191Z","deleted":false},
268            {"file_path":"gone.md","content":null,"source_env":"node-era","updated_at":"2026-09-03T21:49:55.212Z","deleted":true}
269        ]}"#;
270        let parsed: SyncResponse = serde_json::from_str(body).unwrap();
271        assert_eq!(parsed.files.len(), 2);
272        assert_eq!(
273            parsed.files[0].content.as_deref(),
274            Some("written by the NODE server\n")
275        );
276        assert!(parsed.files[1].deleted);
277        assert_eq!(parsed.files[1].content, None);
278    }
279}