Skip to main content

haystack_server/ops/
changes.rs

1//! The `changes` op — return graph changelog entries since a given version.
2//!
3//! Used by federation connectors for incremental delta sync instead of full
4//! `read("*")` on every interval.
5//!
6//! # Request Grid Columns
7//!
8//! | Column    | Kind   | Description                                     |
9//! |-----------|--------|-------------------------------------------------|
10//! | `version` | Number | Graph version to query changes since (0 for all) |
11//!
12//! # Response
13//!
14//! Grid meta contains `curVer` (Number) — the current graph version.
15//!
16//! | Column    | Kind   | Description                                    |
17//! |-----------|--------|------------------------------------------------|
18//! | `version` | Number | Version after this mutation                    |
19//! | `op`      | Str    | `"add"`, `"update"`, or `"remove"`             |
20//! | `ref`     | Str    | Entity ref value                               |
21//! | `entity`  | Dict   | Entity data (present for add/update only)      |
22//!
23//! # Errors
24//!
25//! - **400 Bad Request** — request grid decode failure.
26//! - **500 Internal Server Error** — encoding error.
27
28use actix_web::{HttpRequest, HttpResponse, web};
29
30use haystack_core::data::{HCol, HDict, HGrid};
31use haystack_core::graph::changelog::DiffOp;
32use haystack_core::kinds::Kind;
33
34use crate::content;
35use crate::error::HaystackError;
36use crate::state::AppState;
37
38/// POST /api/changes
39///
40/// Request grid should have a single row with a `version` column (Number).
41/// Returns a grid of changelog entries since that version, each with:
42/// - `version`: Number — the version after the mutation
43/// - `op`: Str — "add", "update", or "remove"
44/// - `ref`: Str — the entity ref value
45/// - `entity`: the entity dict (for add/update; absent for remove)
46///
47/// Also includes `curVer` in the response meta with the current graph version,
48/// so the caller can store it for the next delta sync.
49pub async fn handle(
50    req: HttpRequest,
51    body: String,
52    state: web::Data<AppState>,
53) -> Result<HttpResponse, HaystackError> {
54    let content_type = req
55        .headers()
56        .get("Content-Type")
57        .and_then(|v| v.to_str().ok())
58        .unwrap_or("");
59    let accept = req
60        .headers()
61        .get("Accept")
62        .and_then(|v| v.to_str().ok())
63        .unwrap_or("");
64
65    let request_grid = content::decode_request_grid(&body, content_type)
66        .map_err(|e| HaystackError::bad_request(format!("failed to decode request: {e}")))?;
67
68    let since_version = request_grid
69        .row(0)
70        .and_then(|row| row.get("version"))
71        .and_then(|k| {
72            if let Kind::Number(n) = k {
73                Some(n.val as u64)
74            } else {
75                None
76            }
77        })
78        .unwrap_or(0);
79
80    let current_version = state.graph.version();
81    let diffs = state.graph.changes_since(since_version);
82
83    let mut meta = HDict::new();
84    meta.set(
85        "curVer",
86        Kind::Number(haystack_core::kinds::Number::unitless(
87            current_version as f64,
88        )),
89    );
90
91    if diffs.is_empty() {
92        let grid = HGrid::from_parts(meta, Vec::new(), Vec::new());
93        let (encoded, ct) = content::encode_response_grid(&grid, accept)
94            .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
95        return Ok(HttpResponse::Ok().content_type(ct).body(encoded));
96    }
97
98    let cols = vec![
99        HCol::new("version"),
100        HCol::new("op"),
101        HCol::new("ref"),
102        HCol::new("entity"),
103    ];
104
105    let rows: Vec<HDict> = diffs
106        .iter()
107        .map(|diff| {
108            let mut row = HDict::new();
109            row.set(
110                "version",
111                Kind::Number(haystack_core::kinds::Number::unitless(diff.version as f64)),
112            );
113            row.set(
114                "op",
115                Kind::Str(match diff.op {
116                    DiffOp::Add => "add".to_string(),
117                    DiffOp::Update => "update".to_string(),
118                    DiffOp::Remove => "remove".to_string(),
119                }),
120            );
121            row.set("ref", Kind::Str(diff.ref_val.clone()));
122            // Include entity data for add/update (the "new" state).
123            if let Some(entity) = &diff.new {
124                row.set("entity", Kind::Dict(Box::new(entity.clone())));
125            }
126            row
127        })
128        .collect();
129
130    let grid = HGrid::from_parts(meta, cols, rows);
131    let (encoded, ct) = content::encode_response_grid(&grid, accept)
132        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
133
134    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
135}