Skip to main content

io_pimdir/
codec.rs

1//! Pure, I/O-free encodings between the [`io_replica`] model and the
2//! pimdir columns (spec §13). No SQLite, no filesystem: the part an
3//! Android or any other implementation reuses.
4//!
5//! Beside the column encodings it holds the action-queue payload codec
6//! (spec §15.3): the six v1 action kinds as [`PimdirAction`], encoded to
7//! and from the versioned JSON `queue.payload` column.
8
9use alloc::{
10    string::{String, ToString},
11    vec::Vec,
12};
13use core::fmt;
14
15use io_replica::{
16    collection::ReplicaCollectionId,
17    object::ReplicaHash,
18    placement::{ReplicaFlags, ReplicaHandle, ReplicaLevel, ReplicaLinkId, ReplicaMeta},
19};
20use serde_json::{Map, Value, json};
21
22/// A flag set to its canonical JSON array, the model's set being already
23/// sorted, or `None` for the column's `NULL`.
24///
25/// The two absences spec §13 keeps apart: a known-empty set encodes as
26/// `"[]"`, a set nobody has read as `NULL`. Collapsing them would have a
27/// probed item claim to carry no markers.
28pub fn flags_to_json(flags: &ReplicaFlags) -> Option<String> {
29    let items: Vec<&String> = flags.known()?.iter().collect();
30    Some(serde_json::to_string(&items).unwrap_or_else(|_| String::from("[]")))
31}
32
33/// The inverse of [`flags_to_json`]: a `NULL` column decodes to the
34/// unknown set, and so does a column this cannot read.
35///
36/// Malformed JSON is not evidence about the item's markers. Reading it as
37/// a known-empty set turns it into an authoritative "this item carries no
38/// markers", which the merge takes as one side's opinion: it clears every
39/// marker the other side reports and persists that, so a read failure
40/// becomes permanent loss. Unknown holds no opinion instead.
41pub fn flags_from_json(json: Option<&str>) -> ReplicaFlags {
42    let Some(json) = json else {
43        return ReplicaFlags::Unknown;
44    };
45    match serde_json::from_str::<Vec<String>>(json) {
46        Ok(items) => ReplicaFlags::Known(items.into_iter().collect()),
47        Err(_) => ReplicaFlags::Unknown,
48    }
49}
50
51/// The detail ladder as its column integer (spec §13).
52pub fn level_to_int(level: ReplicaLevel) -> i64 {
53    match level {
54        ReplicaLevel::Probed => 0,
55        ReplicaLevel::Meta => 1,
56        ReplicaLevel::Full => 2,
57    }
58}
59
60/// The inverse of [`level_to_int`]; unknown integers clamp to `Probed`.
61pub fn level_from_int(value: i64) -> ReplicaLevel {
62    match value {
63        1 => ReplicaLevel::Meta,
64        2 => ReplicaLevel::Full,
65        _ => ReplicaLevel::Probed,
66    }
67}
68
69/// A queued mutation request (spec §15.3): what a producer appends to the
70/// `queue` table and the owner applies to the store.
71///
72/// The kinds mirror io-replica's mutation vocabulary on purpose: the
73/// queue is the cross-process projection of the engine's mutate verb.
74/// Existing items are addressed by their public `seq` (spec §9.1), the
75/// identifier a reading client already holds, which the owner resolves
76/// back to the internal link id.
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub enum PimdirAction {
79    /// Create an item in the collection, staged as a local creation for
80    /// the sync layer to push. A duplicate live `link_id` parks the
81    /// action, the item already existing.
82    Add {
83        /// The item's cross-source link id; `None` derives it from `object`.
84        link_id: Option<ReplicaLinkId>,
85        /// The initial flag set.
86        flags: ReplicaFlags,
87        /// The body's content hash, matching the row's `object_hash`; the
88        /// producer wrote the blob durably before enqueueing.
89        object: Option<ReplicaHash>,
90        /// The item's summary (spec Annex A), or `None` when not
91        /// projected.
92        meta: Option<ReplicaMeta>,
93        /// The provisional handle the create is staged under; `None` lets
94        /// the owner derive one.
95        handle: Option<ReplicaHandle>,
96    },
97    /// Replace the item's flag set: absolute, never a delta, so
98    /// reapplication is idempotent.
99    SetFlags {
100        /// The item's public id.
101        seq: i64,
102        /// The new flag set.
103        flags: ReplicaFlags,
104    },
105    /// Remove the item from the collection; already-absent is success,
106    /// not an error.
107    Remove {
108        /// The item's public id.
109        seq: i64,
110    },
111    /// Refile the item into another collection: a target create plus a
112    /// source removal.
113    Move {
114        /// The item's public id.
115        seq: i64,
116        /// The collection to move it into.
117        to: ReplicaCollectionId,
118    },
119    /// Copy the item into another collection: a move without the removal.
120    Copy {
121        /// The item's public id.
122        seq: i64,
123        /// The collection to copy it into.
124        to: ReplicaCollectionId,
125    },
126    /// Repoint a mutable-content item's body (a contact or event edit).
127    Update {
128        /// The item's public id.
129        seq: i64,
130        /// The new body's content hash; the producer wrote the blob
131        /// durably before enqueueing.
132        object: ReplicaHash,
133        /// The refreshed summary, or `None` to keep the cached one.
134        meta: Option<ReplicaMeta>,
135    },
136    /// An action this crate defines no semantics for: an owner-defined
137    /// intent, a mail submission, carried by the same queue as the store
138    /// mutations above.
139    ///
140    /// The store cannot apply it, so its drain skips the row rather than
141    /// parking it: the action is not unappliable, only unappliable here.
142    /// An owner that recognises the kind performs it out of band and
143    /// acknowledges it with [`drop_action`]. Only a malformed payload
144    /// parks.
145    ///
146    /// [`drop_action`]: ../client/struct.PimdirStore.html#method.drop_action
147    Unknown {
148        /// The raw `queue.action` kind.
149        kind: String,
150        /// The raw versioned JSON payload, verbatim: only its owner knows
151        /// the shape, so nothing here re-encodes it.
152        payload: String,
153        /// The body hash the payload's `object` field names, by the same
154        /// convention as the known kinds, so an intent carrying a body
155        /// pins it against garbage collection like any other.
156        object_hash: Option<ReplicaHash>,
157    },
158}
159
160impl PimdirAction {
161    /// The action kind as its `queue.action` column value (spec §13).
162    pub fn kind(&self) -> &str {
163        match self {
164            Self::Add { .. } => "add",
165            Self::SetFlags { .. } => "set-flags",
166            Self::Remove { .. } => "remove",
167            Self::Move { .. } => "move",
168            Self::Copy { .. } => "copy",
169            Self::Update { .. } => "update",
170            Self::Unknown { kind, .. } => kind,
171        }
172    }
173
174    /// The body hash the payload references, if any: the value the
175    /// enqueue carries in `queue.object_hash` so the pending body is
176    /// pinned against garbage collection (spec §15.1).
177    pub fn object_hash(&self) -> Option<&ReplicaHash> {
178        match self {
179            Self::Add { object, .. } => object.as_ref(),
180            Self::Update { object, .. } => Some(object),
181            Self::Unknown { object_hash, .. } => object_hash.as_ref(),
182            Self::SetFlags { .. } | Self::Remove { .. } | Self::Move { .. } | Self::Copy { .. } => {
183                None
184            }
185        }
186    }
187}
188
189/// A malformed action payload; the owner parks the row instead of applying it.
190///
191/// An unrecognised kind is not one of these: it decodes as
192/// [`PimdirAction::Unknown`] and is skipped, since another owner may be
193/// able to perform it. Only a payload no owner could act on lands here.
194#[derive(Clone, Debug, Eq, PartialEq)]
195pub enum PimdirActionError {
196    /// The payload is not a JSON object.
197    Json,
198    /// The payload's leading `v` is missing or not a supported version.
199    UnknownVersion(Option<i64>),
200    /// A required payload field is missing or has the wrong shape.
201    MissingField(&'static str),
202}
203
204impl fmt::Display for PimdirActionError {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        match self {
207            Self::Json => write!(f, "pimdir action payload is not a JSON object"),
208            Self::UnknownVersion(Some(v)) => write!(f, "unknown pimdir action version: {v}"),
209            Self::UnknownVersion(None) => write!(f, "pimdir action payload misses its version"),
210            Self::MissingField(field) => write!(f, "pimdir action payload misses field: {field}"),
211        }
212    }
213}
214
215impl core::error::Error for PimdirActionError {}
216
217/// Encodes an action to its versioned JSON `queue.payload` column (spec
218/// §15.3, `v: 1`). Absent optional fields are omitted; `meta` embeds as
219/// parsed JSON when it is valid JSON, else as a JSON string.
220pub fn action_to_payload(action: &PimdirAction) -> String {
221    // NOTE: an owner-defined intent round-trips byte for byte; this crate
222    // knows no more of its shape than the `object` field it pins.
223    if let PimdirAction::Unknown { payload, .. } = action {
224        return payload.clone();
225    }
226
227    let mut map = Map::new();
228    map.insert("v".into(), json!(1));
229
230    match action {
231        PimdirAction::Add {
232            link_id,
233            flags,
234            object,
235            meta,
236            handle,
237        } => {
238            if let Some(link) = link_id {
239                map.insert("link_id".into(), json!(link.0));
240            }
241            map.insert("flags".into(), flags_to_value(flags));
242            if let Some(object) = object {
243                map.insert("object".into(), json!(object.0));
244            }
245            if let Some(meta) = meta {
246                map.insert("meta".into(), meta_to_value(meta));
247            }
248            if let Some(handle) = handle {
249                map.insert("handle".into(), json!(handle.0));
250            }
251        }
252        PimdirAction::SetFlags { seq, flags } => {
253            map.insert("seq".into(), json!(seq));
254            map.insert("flags".into(), flags_to_value(flags));
255        }
256        PimdirAction::Remove { seq } => {
257            map.insert("seq".into(), json!(seq));
258        }
259        PimdirAction::Move { seq, to } | PimdirAction::Copy { seq, to } => {
260            map.insert("seq".into(), json!(seq));
261            map.insert("to".into(), json!(to.0));
262        }
263        PimdirAction::Update { seq, object, meta } => {
264            map.insert("seq".into(), json!(seq));
265            map.insert("object".into(), json!(object.0));
266            if let Some(meta) = meta {
267                map.insert("meta".into(), meta_to_value(meta));
268            }
269        }
270        PimdirAction::Unknown { .. } => {}
271    }
272
273    Value::Object(map).to_string()
274}
275
276/// Decodes a `queue.action` kind plus its `queue.payload` JSON back to a
277/// [`PimdirAction`], the inverse of [`action_to_payload`]. Strict, unlike
278/// the lenient column decoders: a malformed payload is an error the owner
279/// parks the row with, never a silently-empty action.
280pub fn action_from_payload(kind: &str, payload: &str) -> Result<PimdirAction, PimdirActionError> {
281    let value: Value = serde_json::from_str(payload).map_err(|_| PimdirActionError::Json)?;
282    let map = value.as_object().ok_or(PimdirActionError::Json)?;
283
284    let version = map.get("v").and_then(Value::as_i64);
285    if version != Some(1) {
286        return Err(PimdirActionError::UnknownVersion(version));
287    }
288
289    match kind {
290        "add" => Ok(PimdirAction::Add {
291            link_id: get_string(map, "link_id")?.map(ReplicaLinkId),
292            flags: flags_from_value(map.get("flags")),
293            object: get_string(map, "object")?.map(ReplicaHash),
294            meta: map.get("meta").map(meta_from_value),
295            handle: get_string(map, "handle")?.map(ReplicaHandle),
296        }),
297        "set-flags" => Ok(PimdirAction::SetFlags {
298            seq: require_seq(map)?,
299            flags: flags_from_value(map.get("flags")),
300        }),
301        "remove" => Ok(PimdirAction::Remove {
302            seq: require_seq(map)?,
303        }),
304        "move" => Ok(PimdirAction::Move {
305            seq: require_seq(map)?,
306            to: ReplicaCollectionId(require_string(map, "to")?),
307        }),
308        "copy" => Ok(PimdirAction::Copy {
309            seq: require_seq(map)?,
310            to: ReplicaCollectionId(require_string(map, "to")?),
311        }),
312        "update" => Ok(PimdirAction::Update {
313            seq: require_seq(map)?,
314            object: ReplicaHash(require_string(map, "object")?),
315            meta: map.get("meta").map(meta_from_value),
316        }),
317        // NOTE: an owner-defined intent, not a malformed row: the payload
318        // is well-formed and versioned, this crate simply defines no
319        // semantics for the kind. Kept whole for the owner that does.
320        other => Ok(PimdirAction::Unknown {
321            kind: other.to_string(),
322            payload: payload.to_string(),
323            object_hash: get_string(map, "object")?.map(ReplicaHash),
324        }),
325    }
326}
327
328/// A flag set as a JSON array value, or `null` for an unknown one.
329///
330/// An action states an intent, so its set is known in every payload the
331/// spec defines (§15.3). Encoding an unknown one as `null` rather than
332/// `[]` keeps a nonsensical action legible instead of turning it into a
333/// deliberate clearing of every flag.
334fn flags_to_value(flags: &ReplicaFlags) -> Value {
335    match flags.known() {
336        None => Value::Null,
337        Some(flags) => Value::Array(flags.iter().map(|f| json!(f)).collect()),
338    }
339}
340
341/// The inverse of [`flags_to_value`]: an absent or `null` value decodes
342/// to the unknown set and a malformed array to a known-empty one,
343/// matching [`flags_from_json`].
344fn flags_from_value(value: Option<&Value>) -> ReplicaFlags {
345    match value {
346        None | Some(Value::Null) => ReplicaFlags::Unknown,
347        Some(value) => ReplicaFlags::Known(
348            value
349                .as_array()
350                .map(|items| {
351                    items
352                        .iter()
353                        .filter_map(Value::as_str)
354                        .map(String::from)
355                        .collect()
356                })
357                .unwrap_or_default(),
358        ),
359    }
360}
361
362/// A stored summary embedded in a payload: parsed JSON when the opaque
363/// meta is valid JSON, a JSON string otherwise.
364fn meta_to_value(meta: &ReplicaMeta) -> Value {
365    serde_json::from_str(&meta.0).unwrap_or_else(|_| json!(meta.0))
366}
367
368/// The inverse of [`meta_to_value`]: a JSON string is taken verbatim,
369/// any other value re-serialised to the stored opaque form.
370fn meta_from_value(value: &Value) -> ReplicaMeta {
371    match value.as_str() {
372        Some(text) => ReplicaMeta(text.to_string()),
373        None => ReplicaMeta(value.to_string()),
374    }
375}
376
377/// An optional string payload field; present with a non-string shape errors.
378fn get_string(
379    map: &Map<String, Value>,
380    field: &'static str,
381) -> Result<Option<String>, PimdirActionError> {
382    match map.get(field) {
383        None => Ok(None),
384        Some(value) => match value.as_str() {
385            Some(text) => Ok(Some(text.to_string())),
386            None => Err(PimdirActionError::MissingField(field)),
387        },
388    }
389}
390
391/// A required string payload field.
392fn require_string(
393    map: &Map<String, Value>,
394    field: &'static str,
395) -> Result<String, PimdirActionError> {
396    get_string(map, field)?.ok_or(PimdirActionError::MissingField(field))
397}
398
399/// The required `seq` payload field (an integer public id).
400fn require_seq(map: &Map<String, Value>) -> Result<i64, PimdirActionError> {
401    map.get("seq")
402        .and_then(Value::as_i64)
403        .ok_or(PimdirActionError::MissingField("seq"))
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn flags_round_trip_and_escape() {
412        let flags = ReplicaFlags::from_iter(["\\Seen", "$flagged", "a\"b"]);
413        let json = flags_to_json(&flags).expect("a known set encodes");
414        assert_eq!(flags_from_json(Some(&json)), flags);
415        // canonical, sorted and JSON-escaped
416        assert!(json.starts_with('['));
417        assert!(json.contains("\\\\Seen"));
418    }
419
420    #[test]
421    fn an_unknown_set_is_null_and_a_known_empty_one_is_a_list() {
422        // the two absences the store keeps apart (spec §13): NULL means
423        // the markers were never read, '[]' that the item carries none
424        assert_eq!(flags_to_json(&ReplicaFlags::Unknown), None);
425        assert_eq!(flags_from_json(None), ReplicaFlags::Unknown);
426
427        assert_eq!(
428            flags_to_json(&ReplicaFlags::default()).as_deref(),
429            Some("[]")
430        );
431        assert_eq!(flags_from_json(Some("[]")), ReplicaFlags::default());
432    }
433
434    #[test]
435    fn a_malformed_flag_set_reads_as_unread_not_as_empty() {
436        // a decode failure must not become an authoritative "this item
437        // carries no markers", which the merge would take as one side's
438        // opinion and persist over the other's
439        assert_eq!(flags_from_json(Some("not json")), ReplicaFlags::Unknown);
440        assert_eq!(flags_from_json(Some("{}")), ReplicaFlags::Unknown);
441    }
442
443    #[test]
444    fn level_map_round_trips() {
445        for l in [ReplicaLevel::Probed, ReplicaLevel::Meta, ReplicaLevel::Full] {
446            assert_eq!(level_from_int(level_to_int(l)), l);
447        }
448    }
449
450    #[test]
451    fn every_action_kind_round_trips_through_its_payload() {
452        let actions = [
453            PimdirAction::Add {
454                link_id: Some(ReplicaLinkId("mid:new".into())),
455                flags: ReplicaFlags::from_iter(["\\Draft"]),
456                object: Some(ReplicaHash("cafebabe".into())),
457                meta: Some(ReplicaMeta("{\"subject\":\"hi\",\"v\":1}".into())),
458                handle: Some(ReplicaHandle("draft-1".into())),
459            },
460            PimdirAction::Add {
461                link_id: None,
462                flags: ReplicaFlags::default(),
463                object: None,
464                meta: None,
465                handle: None,
466            },
467            PimdirAction::SetFlags {
468                seq: 4,
469                flags: ReplicaFlags::from_iter(["\\Seen", "$flagged"]),
470            },
471            PimdirAction::Remove { seq: 5 },
472            PimdirAction::Move {
473                seq: 6,
474                to: ReplicaCollectionId("Archive".into()),
475            },
476            PimdirAction::Copy {
477                seq: 7,
478                to: ReplicaCollectionId("Backup".into()),
479            },
480            PimdirAction::Update {
481                seq: 8,
482                object: ReplicaHash("beef0000".into()),
483                meta: None,
484            },
485        ];
486        for action in actions {
487            let payload = action_to_payload(&action);
488            // versioned with a leading `v` (spec §15.3)
489            assert!(payload.contains("\"v\":1"), "versioned: {payload}");
490            let decoded = action_from_payload(action.kind(), &payload).unwrap();
491            assert_eq!(decoded, action, "round-trip of {payload}");
492        }
493    }
494
495    #[test]
496    fn a_non_json_meta_survives_the_payload_embedding() {
497        let action = PimdirAction::Update {
498            seq: 1,
499            object: ReplicaHash("cafebabe".into()),
500            meta: Some(ReplicaMeta("not json".into())),
501        };
502        let payload = action_to_payload(&action);
503        assert_eq!(action_from_payload("update", &payload).unwrap(), action);
504    }
505
506    #[test]
507    fn malformed_action_payloads_error_instead_of_decaying() {
508        // strict, unlike the column decoders: the owner parks these
509        assert_eq!(
510            action_from_payload("remove", "not json"),
511            Err(PimdirActionError::Json)
512        );
513        assert_eq!(
514            action_from_payload("remove", "{\"seq\":1}"),
515            Err(PimdirActionError::UnknownVersion(None))
516        );
517        assert_eq!(
518            action_from_payload("remove", "{\"v\":2,\"seq\":1}"),
519            Err(PimdirActionError::UnknownVersion(Some(2)))
520        );
521        assert_eq!(
522            action_from_payload("remove", "{\"v\":1}"),
523            Err(PimdirActionError::MissingField("seq"))
524        );
525    }
526
527    #[test]
528    fn an_owner_defined_kind_survives_whole_instead_of_erroring() {
529        // an intent only its owner can perform is kept verbatim rather
530        // than parked, and still pins the body the payload references
531        let payload = "{\"v\":1,\"object\":\"cafebabe\",\"to\":[\"a@b.c\"]}";
532        let decoded = action_from_payload("submit", payload).unwrap();
533        assert_eq!(
534            decoded,
535            PimdirAction::Unknown {
536                kind: "submit".into(),
537                payload: payload.into(),
538                object_hash: Some(ReplicaHash("cafebabe".into())),
539            }
540        );
541        assert_eq!(decoded.kind(), "submit");
542        assert_eq!(decoded.object_hash(), Some(&ReplicaHash("cafebabe".into())));
543        // byte for byte: nothing here understands the shape well enough
544        // to re-encode it
545        assert_eq!(action_to_payload(&decoded), payload);
546
547        // a malformed payload still parks, whatever its kind
548        assert_eq!(
549            action_from_payload("submit", "{\"to\":[]}"),
550            Err(PimdirActionError::UnknownVersion(None))
551        );
552        assert_eq!(
553            action_from_payload("submit", "nope"),
554            Err(PimdirActionError::Json)
555        );
556    }
557
558    #[test]
559    fn the_pinned_hash_follows_the_payload_body() {
560        let add = PimdirAction::Add {
561            link_id: None,
562            flags: ReplicaFlags::default(),
563            object: Some(ReplicaHash("cafebabe".into())),
564            meta: None,
565            handle: None,
566        };
567        assert_eq!(add.object_hash(), Some(&ReplicaHash("cafebabe".into())));
568        assert_eq!(PimdirAction::Remove { seq: 1 }.object_hash(), None);
569    }
570}