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        if self.project_key.is_empty() {
59            return Err(ValidationError::MissingProjectKey);
60        }
61        crate::validate_file_path(&self.file_path)
62    }
63}
64
65/// Body returned by `POST /sync`.
66#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
67pub struct PushResponse {
68    /// Always `true`; a failure is a non-2xx carrying an
69    /// [`ErrorResponse`](crate::ErrorResponse) instead.
70    pub ok: bool,
71    /// Echoed back from the request.
72    pub project_key: String,
73    /// Echoed back from the request.
74    pub file_path: String,
75    /// Whether the stored row is now a tombstone.
76    pub deleted: bool,
77    /// Whether the stored content is the result of a semantic merge rather
78    /// than a plain write.
79    pub merged: bool,
80    /// When the row was written, in the crate's frozen timestamp format.
81    pub updated_at: String,
82}
83
84/// One memory file as returned by `GET /sync`.
85///
86/// `content` is an [`Option`] so a tombstoned row reports JSON `null` rather
87/// than `""`: the server withholds deleted content so a pull cannot
88/// resurrect it, and an empty string would be indistinguishable from a
89/// genuinely empty file.
90#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
91pub struct File {
92    /// The file's path relative to the project's memory directory.
93    pub file_path: String,
94    /// The file's exact bytes, or [`None`] for a tombstone.
95    pub content: Option<String>,
96    /// The machine that last wrote this file.
97    #[serde(default)]
98    pub source_env: String,
99    /// When it was last written, in the crate's frozen timestamp format.
100    #[serde(default)]
101    pub updated_at: String,
102    /// Whether this row is a tombstone, meaning a puller should delete its
103    /// local copy.
104    #[serde(default)]
105    pub deleted: bool,
106}
107
108/// Body returned by `GET /sync`: every file held for one project,
109/// tombstones included.
110#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
111pub struct SyncResponse {
112    /// Echoed back from the query string.
113    pub project_key: String,
114    /// Every file the server holds for the project.
115    pub files: Vec<File>,
116}
117
118#[allow(clippy::trivially_copy_pass_by_ref)]
119fn is_false(b: &bool) -> bool {
120    !*b
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn validates_push_requests() {
129        let valid = PushRequest {
130            project_key: "acme/app".into(),
131            file_path: "MEMORY.md".into(),
132            content: Some("hi".into()),
133            ..Default::default()
134        };
135        assert!(valid.validate().is_ok());
136
137        let no_key = PushRequest {
138            file_path: "MEMORY.md".into(),
139            ..Default::default()
140        };
141        assert_eq!(no_key.validate(), Err(ValidationError::MissingProjectKey));
142
143        // A delete carries no content; that must not read as invalid.
144        let delete = PushRequest {
145            project_key: "acme/app".into(),
146            file_path: "MEMORY.md".into(),
147            deleted: true,
148            ..Default::default()
149        };
150        assert!(delete.validate().is_ok());
151    }
152
153    /// Byte-for-byte compatibility with the deployed Node server. These
154    /// exact strings were captured from it.
155    #[test]
156    fn push_body_matches_the_node_server() {
157        let push = PushRequest {
158            project_key: "acme/app".into(),
159            file_path: "MEMORY.md".into(),
160            content: Some("hello".into()),
161            source_env: "laptop".into(),
162            deleted: false,
163            base_sha256: None,
164        };
165        assert_eq!(
166            serde_json::to_string(&push).unwrap(),
167            r#"{"project_key":"acme/app","file_path":"MEMORY.md","content":"hello","source_env":"laptop"}"#
168        );
169
170        let delete = PushRequest {
171            project_key: "acme/app".into(),
172            file_path: "gone.md".into(),
173            source_env: "laptop".into(),
174            deleted: true,
175            ..Default::default()
176        };
177        assert_eq!(
178            serde_json::to_string(&delete).unwrap(),
179            r#"{"project_key":"acme/app","file_path":"gone.md","source_env":"laptop","deleted":true}"#
180        );
181    }
182
183    /// The bug the compatibility script caught: an empty memory file must
184    /// still send `"content":""`. Omitting it makes the Node server answer
185    /// 400, so a project containing one empty note could never sync.
186    #[test]
187    fn an_empty_file_still_sends_its_content_field() {
188        let push = PushRequest {
189            project_key: "acme/app".into(),
190            file_path: "empty.md".into(),
191            content: Some(String::new()),
192            source_env: "laptop".into(),
193            deleted: false,
194            base_sha256: None,
195        };
196        let json = serde_json::to_string(&push).unwrap();
197        assert!(
198            json.contains(r#""content":"""#),
199            "empty file must serialize its content field, got {json}"
200        );
201
202        // A delete still omits it, which is what keeps the two apart.
203        let delete = PushRequest {
204            project_key: "acme/app".into(),
205            file_path: "gone.md".into(),
206            deleted: true,
207            ..Default::default()
208        };
209        assert!(!serde_json::to_string(&delete).unwrap().contains("content"));
210    }
211
212    #[test]
213    fn tombstoned_file_serializes_content_as_null() {
214        let resp = SyncResponse {
215            project_key: "acme/app".into(),
216            files: vec![File {
217                file_path: "gone.md".into(),
218                content: None,
219                source_env: "laptop".into(),
220                updated_at: "2026-01-01T00:00:00.000Z".into(),
221                deleted: true,
222            }],
223        };
224        assert_eq!(
225            serde_json::to_string(&resp).unwrap(),
226            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}]}"#
227        );
228    }
229
230    /// An empty file is a legitimate memory file. If it serialized like a
231    /// tombstone, a pull would skip writing it.
232    #[test]
233    fn empty_file_is_distinguishable_from_a_tombstone() {
234        let empty = File {
235            file_path: "empty.md".into(),
236            content: Some(String::new()),
237            ..Default::default()
238        };
239        let json = serde_json::to_string(&empty).unwrap();
240        assert!(json.contains(r#""content":"""#), "got {json}");
241
242        let tombstone = File {
243            file_path: "empty.md".into(),
244            content: None,
245            ..Default::default()
246        };
247        assert!(serde_json::to_string(&tombstone)
248            .unwrap()
249            .contains(r#""content":null"#));
250    }
251
252    /// Responses from the deployed Node server must deserialize as-is.
253    #[test]
254    fn parses_a_real_node_server_response() {
255        let body = r#"{"project_key":"acme/app","files":[
256            {"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},
257            {"file_path":"gone.md","content":null,"source_env":"node-era","updated_at":"2026-09-03T21:49:55.212Z","deleted":true}
258        ]}"#;
259        let parsed: SyncResponse = serde_json::from_str(body).unwrap();
260        assert_eq!(parsed.files.len(), 2);
261        assert_eq!(
262            parsed.files[0].content.as_deref(),
263            Some("written by the NODE server\n")
264        );
265        assert!(parsed.files[1].deleted);
266        assert_eq!(parsed.files[1].content, None);
267    }
268}