Skip to main content

icydb_core/db/response/
page.rs

1//! Module: response::page
2//! Responsibility: public bounded scalar-page response payloads.
3//! Does not own: cursor validation, planning, or source revision proofs.
4//! Boundary: executor progress -> Candid-safe live page DTO.
5
6use crate::{db::ReadSetRevisionProof, value::OutputValue};
7use candid::CandidType;
8use serde::Deserialize;
9
10/// Bounded work observed while producing one scalar page.
11#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
12pub struct ScalarPageWork {
13    /// Exact identity of the operational work envelope used for this page.
14    pub envelope_identity: u64,
15    /// Physical keys or index entries visited by this page execution.
16    pub entries_visited: u64,
17    /// Logical rows returned to the caller.
18    pub result_rows: u32,
19}
20
21/// One revision-tolerant scalar keyset page.
22///
23/// A non-null continuation means traversal has not been proven exhausted. The
24/// token is authenticated but not encrypted and must be treated as opaque.
25#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
26pub struct LiveQueryPageOutput {
27    /// Accepted entity name used for the read.
28    pub entity: String,
29    /// Selected output-column names in row order.
30    pub columns: Vec<String>,
31    /// Row-oriented output values.
32    pub rows: Vec<Vec<OutputValue>>,
33    /// Number of returned rows.
34    pub row_count: u32,
35    /// Authenticated continuation, or `None` after proven exhaustion.
36    pub continuation: Option<String>,
37    /// Bounded work observed while producing this page.
38    pub work: ScalarPageWork,
39}
40
41/// One revision-strict exhaustive scalar page.
42///
43/// The returned proof must be persisted beside the continuation and supplied
44/// unchanged on resume. Any participating source change invalidates traversal.
45#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
46pub struct ExhaustiveQueryPageOutput {
47    /// Accepted entity name used for the read.
48    pub entity: String,
49    /// Selected output-column names in row order.
50    pub columns: Vec<String>,
51    /// Row-oriented output values.
52    pub rows: Vec<Vec<OutputValue>>,
53    /// Number of returned rows.
54    pub row_count: u32,
55    /// Authenticated continuation, or `None` after proof-bound exhaustion.
56    pub continuation: Option<String>,
57    /// Bounded work observed while producing this page.
58    pub work: ScalarPageWork,
59    /// Complete source authority that must accompany a resume.
60    pub proof: ReadSetRevisionProof,
61}
62
63impl LiveQueryPageOutput {
64    /// Return the number of rows carried by this page.
65    #[must_use]
66    pub const fn len(&self) -> usize {
67        self.rows.len()
68    }
69
70    /// Return whether this page carries no rows.
71    #[must_use]
72    pub const fn is_empty(&self) -> bool {
73        self.rows.is_empty()
74    }
75}
76
77impl ExhaustiveQueryPageOutput {
78    /// Return the number of rows carried by this page.
79    #[must_use]
80    pub const fn len(&self) -> usize {
81        self.rows.len()
82    }
83
84    /// Return whether this page carries no rows.
85    #[must_use]
86    pub const fn is_empty(&self) -> bool {
87        self.rows.is_empty()
88    }
89
90    pub(in crate::db) fn from_live_page(
91        page: LiveQueryPageOutput,
92        proof: ReadSetRevisionProof,
93    ) -> Self {
94        Self {
95            entity: page.entity,
96            columns: page.columns,
97            rows: page.rows,
98            row_count: page.row_count,
99            continuation: page.continuation,
100            work: page.work,
101            proof,
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[derive(CandidType)]
111    struct FrozenScalarPageWorkWire {
112        envelope_identity: u64,
113        entries_visited: u64,
114        result_rows: u32,
115    }
116
117    #[derive(CandidType)]
118    struct FrozenLiveQueryPageOutputWire {
119        entity: String,
120        columns: Vec<String>,
121        rows: Vec<Vec<OutputValue>>,
122        row_count: u32,
123        continuation: Option<String>,
124        work: FrozenScalarPageWorkWire,
125    }
126
127    #[derive(CandidType)]
128    struct FrozenReadSetStoreIdentityWire([u8; 32]);
129
130    #[derive(CandidType)]
131    struct FrozenReadSetStoreRevisionWire {
132        store: FrozenReadSetStoreIdentityWire,
133        data_revision: u64,
134        access_state_revision: u64,
135    }
136
137    #[derive(CandidType)]
138    struct FrozenReadSetRevisionProofWire {
139        database_incarnation: [u8; 16],
140        accepted_root_revision: u64,
141        accepted_root_fingerprint_method: u8,
142        accepted_root_fingerprint: [u8; 32],
143        stores: Vec<FrozenReadSetStoreRevisionWire>,
144    }
145
146    #[derive(CandidType)]
147    struct FrozenExhaustiveQueryPageOutputWire {
148        entity: String,
149        columns: Vec<String>,
150        rows: Vec<Vec<OutputValue>>,
151        row_count: u32,
152        continuation: Option<String>,
153        work: FrozenScalarPageWorkWire,
154        proof: FrozenReadSetRevisionProofWire,
155    }
156
157    #[test]
158    fn live_query_page_output_preserves_its_initial_candid_record_shape() {
159        let current = LiveQueryPageOutput {
160            entity: "example".to_string(),
161            columns: vec!["id".to_string()],
162            rows: vec![vec![OutputValue::nat64(7)]],
163            row_count: 1,
164            continuation: Some("opaque".to_string()),
165            work: ScalarPageWork {
166                envelope_identity: 11,
167                entries_visited: 2,
168                result_rows: 1,
169            },
170        };
171        let frozen = FrozenLiveQueryPageOutputWire {
172            entity: current.entity.clone(),
173            columns: current.columns.clone(),
174            rows: current.rows.clone(),
175            row_count: current.row_count,
176            continuation: current.continuation.clone(),
177            work: FrozenScalarPageWorkWire {
178                envelope_identity: current.work.envelope_identity,
179                entries_visited: current.work.entries_visited,
180                result_rows: current.work.result_rows,
181            },
182        };
183
184        assert_eq!(current.len(), 1);
185        assert!(!current.is_empty());
186
187        assert_eq!(
188            candid::encode_one(&current).expect("current live page should encode"),
189            candid::encode_one(&frozen).expect("frozen live page should encode"),
190        );
191    }
192
193    #[test]
194    fn exhaustive_query_page_output_freezes_its_initial_candid_record_shape() {
195        let proof = ReadSetRevisionProof::from_parts(
196            [1; 16],
197            7,
198            1,
199            [2; 32],
200            vec![crate::db::ReadSetStoreRevision::new(
201                crate::db::ReadSetStoreIdentity::from_bytes([3; 32]),
202                11,
203                13,
204            )],
205        )
206        .expect("bounded canonical proof should admit");
207        let current = ExhaustiveQueryPageOutput {
208            entity: "example".to_string(),
209            columns: vec!["id".to_string()],
210            rows: vec![vec![OutputValue::nat64(7)]],
211            row_count: 1,
212            continuation: Some("opaque".to_string()),
213            work: ScalarPageWork {
214                envelope_identity: 11,
215                entries_visited: 2,
216                result_rows: 1,
217            },
218            proof,
219        };
220        let frozen = FrozenExhaustiveQueryPageOutputWire {
221            entity: current.entity.clone(),
222            columns: current.columns.clone(),
223            rows: current.rows.clone(),
224            row_count: current.row_count,
225            continuation: current.continuation.clone(),
226            work: FrozenScalarPageWorkWire {
227                envelope_identity: current.work.envelope_identity,
228                entries_visited: current.work.entries_visited,
229                result_rows: current.work.result_rows,
230            },
231            proof: FrozenReadSetRevisionProofWire {
232                database_incarnation: current.proof.database_incarnation(),
233                accepted_root_revision: current.proof.accepted_root_revision(),
234                accepted_root_fingerprint_method: current.proof.accepted_root_fingerprint_method(),
235                accepted_root_fingerprint: current.proof.accepted_root_fingerprint(),
236                stores: current
237                    .proof
238                    .stores()
239                    .iter()
240                    .map(|store| FrozenReadSetStoreRevisionWire {
241                        store: FrozenReadSetStoreIdentityWire(store.store().to_bytes()),
242                        data_revision: store.data_revision(),
243                        access_state_revision: store.access_state_revision(),
244                    })
245                    .collect(),
246            },
247        };
248
249        assert_eq!(current.len(), 1);
250        assert!(!current.is_empty());
251
252        assert_eq!(
253            candid::encode_one(&current).expect("current exhaustive page should encode"),
254            candid::encode_one(&frozen).expect("frozen exhaustive page should encode"),
255        );
256    }
257}