Skip to main content

nodedb_lite/query/document_ops/sets/
materialize.rs

1// SPDX-License-Identifier: Apache-2.0
2use super::super::is_strict;
3use super::super::reads::loro_value_to_ndb_value;
4use crate::error::LiteError;
5use crate::query::engine::LiteQueryEngine;
6use crate::query::msgpack_helpers::{write_array_header, write_bin, write_str, write_u32};
7use crate::query::value_utils::value_to_string;
8use crate::storage::engine::StorageEngine;
9use nodedb_types::result::QueryResult;
10use nodedb_types::value::Value;
11use std::collections::HashMap;
12/// MaterializeScan: cursor-paginated full collection scan for the clone materializer.
13///
14/// Lite is single-node — there is no distributed cursor executor. Instead,
15/// every document in the collection is enumerated in insertion order. When
16/// `cursor` is non-empty its bytes are interpreted as the UTF-8 ID of the
17/// last-seen document; scanning resumes from the ID that follows it
18/// lexicographically. Returns at most `count` entries per call. When fewer
19/// than `count` entries are returned the next-cursor is empty, signalling
20/// scan completion.
21///
22/// The response payload is msgpack-encoded as a 2-element array:
23/// `[next_cursor: bin, entries: [[doc_id: str, surrogate: u32, value_bytes: bin], ...]]`
24/// packed into `QueryResult { columns: ["payload"], rows: [[Value::Bytes(payload)]] }`.
25pub async fn materialize_scan<S: StorageEngine>(
26    engine: &LiteQueryEngine<S>,
27    collection: &str,
28    cursor: &[u8],
29    count: usize,
30) -> Result<QueryResult, LiteError> {
31    let cursor_str = if cursor.is_empty() {
32        None
33    } else {
34        Some(String::from_utf8_lossy(cursor).into_owned())
35    };
36
37    // Collect (doc_id, value_bytes) pairs, resuming from cursor if present.
38    let pairs: Vec<(String, Vec<u8>)> = if is_strict(engine, collection) {
39        let schema = engine
40            .strict
41            .schema(collection)
42            .ok_or_else(|| LiteError::collection_not_found("strict", collection))?;
43        let pk_idx = schema
44            .columns
45            .iter()
46            .position(|c| c.primary_key)
47            .ok_or_else(|| LiteError::BadRequest {
48                detail: format!("strict collection '{collection}' has no primary key"),
49            })?;
50        let columns = schema.columns;
51        let all_rows = engine.strict.list_rows(collection).await?;
52        let mut out = Vec::new();
53        let mut past_cursor = cursor_str.is_none();
54        for row in all_rows {
55            let pk = row.get(pk_idx).ok_or_else(|| LiteError::Storage {
56                detail: format!(
57                    "strict collection '{collection}' row omits primary key at column {pk_idx}"
58                ),
59            })?;
60            let pk = value_to_string(pk);
61            if !past_cursor {
62                if let Some(ref c) = cursor_str
63                    && &pk == c
64                {
65                    past_cursor = true;
66                }
67                continue;
68            }
69            if out.len() >= count {
70                break;
71            }
72            let map: HashMap<String, Value> = columns
73                .iter()
74                .enumerate()
75                .filter_map(|(i, col)| row.get(i).map(|value| (col.name.clone(), value.clone())))
76                .collect();
77            let bytes = zerompk::to_msgpack_vec(&Value::Object(map)).map_err(|e| {
78                LiteError::Serialization {
79                    detail: format!("materialize_scan serialize row: {e}"),
80                }
81            })?;
82            out.push((pk, bytes));
83        }
84        out
85    } else {
86        let crdt = engine.crdt.lock().map_err(|_| LiteError::LockPoisoned)?;
87        let ids = crdt.list_ids(collection);
88        let mut out = Vec::new();
89        let mut past_cursor = cursor_str.is_none();
90        for id in ids {
91            if !past_cursor {
92                if let Some(ref c) = cursor_str
93                    && &id == c
94                {
95                    past_cursor = true;
96                }
97                continue;
98            }
99            if out.len() >= count {
100                break;
101            }
102            if let Some(val) = crdt.read(collection, &id) {
103                let ndb_val = loro_value_to_ndb_value(&val);
104                let bytes =
105                    zerompk::to_msgpack_vec(&ndb_val).map_err(|e| LiteError::Serialization {
106                        detail: format!("materialize_scan serialize crdt row: {e}"),
107                    })?;
108                out.push((id, bytes));
109            }
110        }
111        drop(crdt);
112        out
113    };
114
115    // Build the same msgpack response shape as Origin:
116    // [next_cursor: bin, entries: [[doc_id: str, 0u32, value_bytes: bin], ...]]
117    let next_cursor: Vec<u8> = if pairs.len() < count {
118        Vec::new()
119    } else {
120        pairs
121            .last()
122            .map(|(id, _)| id.as_bytes().to_vec())
123            .unwrap_or_default()
124    };
125
126    let payload = encode_materialize_payload(&next_cursor, &pairs);
127
128    Ok(QueryResult {
129        columns: vec!["payload".into()],
130        rows: vec![vec![Value::Bytes(payload)]],
131        rows_affected: 0,
132        command: None,
133    })
134}
135
136/// Encode the MaterializeScan response payload in the same msgpack shape as Origin.
137fn encode_materialize_payload(next_cursor: &[u8], pairs: &[(String, Vec<u8>)]) -> Vec<u8> {
138    let mut out = Vec::new();
139    write_array_header(&mut out, 2);
140    write_bin(&mut out, next_cursor);
141    write_array_header(&mut out, pairs.len());
142    for (doc_id, value_bytes) in pairs {
143        write_array_header(&mut out, 3);
144        write_str(&mut out, doc_id.as_bytes());
145        // Lite has no catalog-assigned surrogates; emit 0 as a sentinel.
146        write_u32(&mut out, 0u32);
147        write_bin(&mut out, value_bytes);
148    }
149    out
150}
151
152#[cfg(test)]
153mod tests {
154    use crate::NodeDbLite;
155    use crate::PagedbStorageMem;
156
157    async fn make_db() -> std::sync::Arc<NodeDbLite<PagedbStorageMem>> {
158        let storage = PagedbStorageMem::open_in_memory().await.unwrap();
159        NodeDbLite::open(storage).await.unwrap()
160    }
161
162    /// encode_materialize_payload emits a 2-element fixarray (0x92) as outer header.
163    #[test]
164    fn materialize_scan_payload_envelope_shape() {
165        let payload = super::encode_materialize_payload(&[], &[]);
166        // 0x92 = msgpack fixarray len=2
167        assert_eq!(payload[0], 0x92, "outer envelope must be fixarray(2)");
168    }
169
170    /// encode_materialize_payload with one entry encodes doc_id as msgpack str,
171    /// surrogate as u32 (0xce prefix), and value_bytes as bin.
172    #[test]
173    fn materialize_scan_payload_one_entry() {
174        let doc_id = "abc".to_string();
175        let val_bytes = b"data".to_vec();
176        let payload =
177            super::encode_materialize_payload(&[], &[(doc_id.clone(), val_bytes.clone())]);
178        // The outer array is len=2; first element is empty bin (cursor); second is
179        // fixarray(1) wrapping fixarray(3) = [str, u32, bin].
180        assert!(payload.len() > 10, "payload must have content");
181        // Scan for the doc_id bytes within the payload.
182        let needle = doc_id.as_bytes();
183        let found = payload.windows(needle.len()).any(|w| w == needle);
184        assert!(found, "doc_id must appear in payload");
185    }
186
187    /// materialize_scan on an empty schemaless collection returns a payload row.
188    #[tokio::test]
189    async fn materialize_scan_empty_collection() {
190        let db = make_db().await;
191        let result = super::materialize_scan(&db.query_engine, "nonexistent_coll", &[], 10)
192            .await
193            .unwrap();
194        assert_eq!(result.columns, vec!["payload"]);
195        assert_eq!(result.rows.len(), 1);
196        // Payload must be non-empty — it contains the msgpack envelope at minimum.
197        if let nodedb_types::value::Value::Bytes(payload) = &result.rows[0][0] {
198            assert!(!payload.is_empty());
199        } else {
200            panic!("expected Value::Bytes for MaterializeScan result");
201        }
202    }
203}