Skip to main content

macrame/
plan.rs

1//! What a read asks for, stated once (§16, F-34).
2//!
3//! Three qualifiers appear on every read surface in this crate — the lineage,
4//! the valid-time instant, the transaction-time instant — and until 0.15.9
5//! each surface spelled them itself. [`TraversalBuilder`] carries three
6//! fields, `query_as_of_edges_on` takes two positional arguments and cannot
7//! express the third at all, and the Python binding repeats the set as
8//! keywords on five entry points. Nothing was wrong with any one of them; what
9//! was wrong is that "read `exp` as it stood on Tuesday, under what we believed
10//! in March" was a sentence the crate could not hold as a value, so it could
11//! not be passed, stored, compared, or given a default.
12//!
13//! [`ReadPlan`] is that value. It is caller-facing and deliberately dumb: it
14//! holds four `Option`s and knows no SQL. The lowering that turns a read into
15//! CTEs lives in `graph::plan` and stays crate-private, which is why there are
16//! two modules called `plan` and only one of them is a public path. The
17//! division is the useful one — this module is *what was asked*, that one is
18//! *how it is answered*, and a caller who never reads SQL should never meet
19//! the second.
20//!
21//! # The fourth qualifier, and the promise 0.15.9 made about it
22//!
23//! `limit` was in [the 0.16.0 plan]'s sketch of this struct and was left out of
24//! 0.15.9 on the grounds that a public knob that silently does nothing is the
25//! one failure mode a plan value has that three loose arguments do not — a
26//! caller can *see* an argument go unused at a call site and cannot see a field
27//! go unread. `#[non_exhaustive]` made it additive on the day it meant
28//! something, and 0.15.10 ([D-252]) is that day: it bounds the walk from
29//! inside the recursive CTE, and every surface that takes a plan reads it.
30//!
31//! It is not a fourth *temporal* qualifier and it does not compose like one.
32//! The other three narrow which rows are true; this one says how much of the
33//! answer to pay for, so two reads under the same plan can differ. That is
34//! stated on [`ReadPlan::limit()`] rather than smoothed over, and it is why
35//! the walk reports whether the ceiling bit
36//! ([`WalkOutcome`](crate::graph::WalkOutcome)) where it never had to report
37//! on an instant.
38//!
39//! [the 0.16.0 plan]: ../../docs/Macrame%20Update%20Plan%20v0.16.0.md
40//! [D-252]: ../../docs/architecture/s13-decision-register.md#d-252
41//! [`TraversalBuilder`]: crate::graph::TraversalBuilder
42
43use crate::branch::BranchId;
44use crate::error::Result;
45use crate::graph::lineage::{ancestry_params, resolve_for};
46use crate::graph::plan::{lower, Resolution};
47use crate::temporal::EdgeBelief;
48
49/// The lineage, the two instants, and the ceiling a read is taken under.
50///
51/// Every field is `None` by default and `None` means the same thing on all of
52/// them: **the ordinary read**. No branch is the trunk, no valid instant is
53/// now, no recorded instant is current belief, no limit is the whole answer. A
54/// default [`ReadPlan`] and no plan at all are the same read, which is what
55/// lets [`TraversalBuilder::plan`](crate::graph::TraversalBuilder::plan) be
56/// additive over the setters it composes rather than another way to configure a
57/// traversal.
58///
59/// # Why the branch is a [`BranchId`] and the instants are `String`
60///
61/// Not an oversight, and not symmetry withheld for its own sake. A branch name
62/// is validated at construction — length, control characters, surrounding
63/// whitespace — and [`BranchId`] is the type that has already asked those
64/// questions, so taking a `String` here would move a refusal out of the
65/// caller's `BranchId::new` and into somewhere inside a read. A timestamp has
66/// no such type in this crate: the canonical form is enforced at the boundary
67/// (`util::timestamps`) and carried as a string everywhere below it, and
68/// inventing an instant newtype for one struct would give the crate two
69/// answers to what a stamp is. So [`Self::on`] cannot fail and neither can
70/// [`Self::valid_at`]; a malformed stamp is refused where every other stamp in
71/// the crate is refused.
72///
73/// # Errors, when this is executed
74///
75/// A plan is inert and returns nothing. The refusals belong to the read that
76/// takes one: [`DbError::UnknownBranch`](crate::DbError::UnknownBranch) for a
77/// lineage that was never registered, and
78/// [`DbError::RecordedInstantUnreachable`](crate::DbError::RecordedInstantUnreachable)
79/// for a transaction-time instant the hot log no longer answers for
80/// ([D-247](../../docs/architecture/s13-decision-register.md#d-247)).
81///
82/// ```no_run
83/// use macrame::prelude::*;
84///
85/// # async fn f(db: &Database) -> macrame::Result<()> {
86/// let plan = ReadPlan::new()
87///     .on(BranchId::new("exp")?)
88///     .valid_at("2026-01-06T00:00:00.000000Z")
89///     .recorded_at("2026-03-01T00:00:00.000000Z");
90///
91/// // The same qualifiers, on a whole-ledger read and on a walk.
92/// let edges = db.edges(plan.clone()).await?;
93/// let reached = TraversalBuilder::new("a")
94///     .plan(plan)
95///     .execute_ids(db.read_conn(), "2026-06-01T00:00:00.000000Z")
96///     .await?;
97/// # let _ = (edges, reached);
98/// # Ok(())
99/// # }
100/// ```
101#[derive(Debug, Clone, Default, PartialEq, Eq)]
102#[non_exhaustive]
103pub struct ReadPlan {
104    /// The lineage to read, or `None` for the trunk (§15.3, D-220).
105    pub branch: Option<BranchId>,
106    /// The **valid-time** instant: *what was true then*. `None` is now.
107    pub valid: Option<String>,
108    /// The **transaction-time** instant: *what did we believe then*.
109    ///
110    /// `None` is current belief, which is a projection read rather than a fold
111    /// bounded at the present — the same answer, and only one of them is
112    /// cheap. See
113    /// [`TraversalBuilder::as_of_recorded`](crate::graph::TraversalBuilder::as_of_recorded).
114    pub recorded: Option<String>,
115    /// How much of the answer to pay for, or `None` for all of it (0.15.10,
116    /// W13.5, C-8).
117    ///
118    /// **The one field that does not narrow which rows are true.** The other
119    /// three name a read; this one bounds it, so a plan carrying a limit
120    /// describes an answer that is a prefix of the read rather than the read.
121    /// What "prefix" means differs by surface and each says so:
122    /// [`TraversalBuilder::limit()`](crate::graph::TraversalBuilder::limit)
123    /// bounds the walk's rows and returns the near end of the neighbourhood,
124    /// [`Database::edges`](crate::Database::edges) bounds its own statement and
125    /// returns an arbitrary `n` of the ledger.
126    pub limit: Option<usize>,
127}
128
129impl ReadPlan {
130    /// The ordinary read: the trunk, now, under current belief.
131    pub fn new() -> Self {
132        Self::default()
133    }
134
135    /// Read `branch`'s belief rather than the trunk's.
136    pub fn on(mut self, branch: BranchId) -> Self {
137        self.branch = Some(branch);
138        self
139    }
140
141    /// Read at a valid-time instant rather than the present.
142    pub fn valid_at(mut self, ts: impl Into<String>) -> Self {
143        self.valid = Some(ts.into());
144        self
145    }
146
147    /// Read under the belief held at a transaction-time instant.
148    pub fn recorded_at(mut self, ts: impl Into<String>) -> Self {
149        self.recorded = Some(ts.into());
150        self
151    }
152
153    /// Pay for at most `n` rows (0.15.10, W13.5, C-8).
154    ///
155    /// A ceiling on the read's cost, not on which rows are true, and the two
156    /// surfaces that take a plan spend it differently because their statements
157    /// are different shapes. On a traversal it becomes
158    /// [`TraversalBuilder::limit()`](crate::graph::TraversalBuilder::limit),
159    /// where it stops the recursion and yields the nodes nearest the start. On
160    /// [`Database::edges`](crate::Database::edges) it becomes a `LIMIT` on one
161    /// flat projection, where the rows it keeps are **whichever `n` the engine
162    /// reaches first** — that read states no order and adding one to make the
163    /// truncation look principled would put a sort on the largest statement in
164    /// the crate.
165    ///
166    /// So a limited plan is honest about being a sample, and both surfaces let
167    /// a caller tell that it was one: the traversal by
168    /// [`WalkOutcome`](crate::graph::WalkOutcome), and `edges` by returning
169    /// exactly `n`, which is the ordinary convention because that statement
170    /// drops nothing after the limit applies.
171    ///
172    /// The method and the field share a name, as they do nowhere else on this
173    /// struct. `on`/`branch`, `valid_at`/`valid` and `recorded_at`/`recorded`
174    /// read as sentences and `limit_to`/`limit` does not; `p.limit(5)` and
175    /// `p.limit` are unambiguous to the compiler and to a reader, and the
176    /// spelling matches
177    /// [`TraversalBuilder::limit()`](crate::graph::TraversalBuilder::limit),
178    /// which is where a caller meets the idea first.
179    pub fn limit(mut self, n: usize) -> Self {
180        self.limit = Some(n);
181        self
182    }
183
184    /// The branch name the read binds, or `None` for the trunk.
185    pub(crate) fn branch_name(&self) -> Option<&str> {
186        self.branch.as_ref().map(BranchId::as_str)
187    }
188}
189
190/// Where the branch binds in [`edges_at`]'s statement, when the shape has one.
191///
192/// `?1` is the valid instant, which every shape binds. This is the
193/// [`TraversalBuilder`](crate::graph::TraversalBuilder)'s layout with four
194/// slots removed — no start node, no depth, no weight floor — and it is a named
195/// constant for the same reason it is one there: the SQL and the parameter
196/// vector must agree exactly, and D-030's failure mode is two places agreeing
197/// by comment.
198const BRANCH_SLOT: usize = 2;
199
200/// Every edge one plan names, as the ledger held them.
201///
202/// The whole projection filtered to an instant — this is not a neighbourhood
203/// read and there is no budget on it. `load_subgraph_with` is the bounded one.
204///
205/// **The order is unspecified**, as it is for
206/// [`query_as_of_edges`](crate::temporal::query_as_of_edges), whose statement
207/// this is. Adding an `ORDER BY` would put a sort on the largest read in the
208/// crate to make its result look tidy; a caller who needs an order knows which
209/// one, and sorting a `Vec` they already own is cheaper than sorting a relation
210/// SQLite has to spill.
211///
212/// `limit` is a plain `LIMIT` on that projection, and it interacts with the
213/// unspecified order exactly as badly as it sounds: the rows kept are whichever
214/// the engine reaches first. It is offered anyway because the alternative —
215/// leaving [`ReadPlan::limit`] unread on this surface — is the silently
216/// ignored field 0.15.9 refused to ship, and because bounding a whole-ledger
217/// read is worth having even when the sample is arbitrary. Unlike the walk it
218/// needs nothing to report truncation: nothing drops rows after the limit
219/// applies, so `out.len() == n` is exact.
220pub(crate) async fn edges_at(
221    conn: &libsql::Connection,
222    valid: &str,
223    recorded: Option<&str>,
224    branch: Option<&str>,
225    limit: Option<usize>,
226) -> Result<Vec<EdgeBelief>> {
227    // Refuses an unregistered lineage, and picks between the three shapes for
228    // D-219's measured reason: the resolved form is 3x on a database with
229    // nothing to resolve.
230    let (shape, ancestry) = resolve_for(conn, branch).await?;
231
232    // The traversal's reach guard, asked here for the same reason and at the
233    // same cost (D-247): a fold below the hot log's newest surviving stamp is
234    // a wrong answer rather than a slow one, and refusing it costs one index
235    // seek at every instant a caller is actually likely to ask about.
236    if let Some(ts) = recorded {
237        if !crate::temporal::replay::hot_log_answers_for(conn, ts).await? {
238            return Err(crate::error::DbError::RecordedInstantUnreachable { ts: ts.to_string() });
239        }
240    }
241
242    let recorded_slot = BRANCH_SLOT + usize::from(shape.binds_branch());
243    // After every fixed slot, for `TraversalBuilder::ancestry_slot`'s reason:
244    // the block's length is a function of the database's fork depth, so
245    // anything placed after it would move when an unrelated branch was created.
246    let ancestry_slot = recorded_slot + usize::from(recorded.is_some());
247    let lowered = lower(&Resolution {
248        shape,
249        branch_slot: BRANCH_SLOT,
250        recorded_slot: recorded.map(|_| recorded_slot),
251        tag: "",
252        // A whole-projection read discovers its edges; there is no key to push
253        // down. See `Resolution::key`.
254        key: None,
255        ancestry: &ancestry,
256        ancestry_slot,
257    });
258
259    // Spliced rather than bound, which is the one place this file departs
260    // from its own rule. A `LIMIT` placeholder would have to sit after the
261    // lineage and recorded slots, whose presence varies by shape, so the
262    // number would be computed in a third place that has to agree with the
263    // other two — D-030's failure mode, bought for nothing. The value is a
264    // `usize` this function was handed, so there is no string to carry SQL.
265    let limit_clause = match limit {
266        Some(n) => format!(" LIMIT {n}"),
267        None => String::new(),
268    };
269
270    let sql = format!(
271        "{}SELECT l.source_id, l.target_id, l.edge_type, l.valid_from, l.valid_to, l.branch_id \
272         FROM {} l WHERE l.valid_from <= ?1 AND ?1 < l.valid_to{}{}",
273        lowered.with_clause(),
274        lowered.source,
275        lowered.filter,
276        limit_clause,
277    );
278
279    // Pushed in placeholder order, and only when the emitted SQL names the
280    // slot: a `Trunk` read emits no `lineage` CTE and binds no branch, so
281    // everything after it moves back by one.
282    let mut params: Vec<libsql::Value> = vec![valid.into()];
283    if shape.binds_branch() {
284        params.push(branch.unwrap_or(crate::schema::ddl::MAIN_BRANCH).into());
285    }
286    if let Some(ts) = recorded {
287        params.push(ts.into());
288    }
289    // Last, matching `ancestry_slot`, and only under the shape that emits the
290    // relation — `ancestry` is empty under the other two anyway.
291    params.extend(ancestry_params(&ancestry));
292
293    let mut rows = conn.query(&sql, params).await?;
294    let mut out = Vec::new();
295    while let Some(row) = rows.next().await? {
296        out.push(EdgeBelief {
297            source_id: row.get(0)?,
298            target_id: row.get(1)?,
299            edge_type: row.get(2)?,
300            valid_from: row.get(3)?,
301            valid_to: row.get(4)?,
302            branch_id: row.get(5)?,
303        });
304    }
305    Ok(out)
306}