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}