onetaskgraph_plugin_api/cache.rs
1//! What a source tells the store's local cache of remote sources.
2//!
3//! The cache itself is the engine's: it keys an item on the [`Task::version`] its source
4//! gives, serves it only at that version or a later one, and holds nothing a source did not
5//! hand it. A source says three things here, and nothing else about the cache reaches it:
6//!
7//! - which parts of a task its change token vouches for — [`VersionCoverage`] — so a part it
8//! does not cover is never served from the cache;
9//! - what a write that expects one version of a task may take instead of reading it — an
10//! opaque [`TaskSource::write_context`](crate::TaskSource::write_context) and
11//! [`TaskSource::board_context`](crate::TaskSource::board_context), handed back through
12//! [`TaskSource::prime_write`](crate::TaskSource::prime_write);
13//! - what each request of such a write did — a [`WriteJournal`] — so the engine can report
14//! every part's outcome from the mutation's own response, and advance its entry only to
15//! content that response vouches for.
16//!
17//! [`Task::version`]: crate::Task::version
18
19use std::collections::BTreeMap;
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24
25use crate::{Asset, Comment, DependencyEdge, Task};
26
27/// Whether a source's change token vouches for one part of a task.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
29#[serde(tag = "coverage", rename_all = "kebab-case")]
30pub enum Coverage {
31 /// Two equal tokens say this part did not change.
32 Covered,
33 /// The token says nothing about this part, so a cached copy of it is never served.
34 Uncovered {
35 /// Why, as a reader of a read that went to the source is told.
36 reason: String,
37 },
38}
39
40impl Coverage {
41 /// The reason this part is not covered, or `None` when it is.
42 #[must_use]
43 pub fn reason(&self) -> Option<&str> {
44 match self {
45 Self::Covered => None,
46 Self::Uncovered { reason } => Some(reason),
47 }
48 }
49}
50
51/// Which parts of a task a source's change token vouches for.
52///
53/// Two parts, because they are read apart: a task's own fields — title, content, status,
54/// priority, labels, metadata, edges and the image assets its content names — and its
55/// comments.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
57pub struct VersionCoverage {
58 /// The task's own fields.
59 pub fields: Coverage,
60 /// Its comments.
61 pub comments: Coverage,
62}
63
64impl VersionCoverage {
65 /// A token covering every part of a task.
66 #[must_use]
67 pub fn whole() -> Self {
68 Self {
69 fields: Coverage::Covered,
70 comments: Coverage::Covered,
71 }
72 }
73
74 /// A token covering no part of a task, for `reason`.
75 #[must_use]
76 pub fn none(reason: impl Into<String>) -> Self {
77 let reason = reason.into();
78 Self {
79 fields: Coverage::Uncovered {
80 reason: reason.clone(),
81 },
82 comments: Coverage::Uncovered { reason },
83 }
84 }
85}
86
87/// What one request a write sent came to.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
89#[serde(tag = "outcome", rename_all = "kebab-case")]
90pub enum PartOutcome {
91 /// The backend answered that it was applied.
92 Landed,
93 /// The backend answered that it was refused.
94 Rejected {
95 /// What the backend said.
96 error: String,
97 /// Whether it was refused because an id the write took from a primed context — an
98 /// item's node id, a field's or an option's — no longer resolves. Such a part is
99 /// sent again once, after one read.
100 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
101 #[schemars(!skip_serializing_if)]
102 stale: bool,
103 },
104 /// The request left and no answer came back, so whether it was applied cannot be known.
105 /// Never sent again: doing so could apply it twice.
106 Unknown {
107 /// What went wrong.
108 error: String,
109 },
110}
111
112/// What one request of a write was.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
114#[serde(rename_all = "kebab-case")]
115pub enum RequestKind {
116 /// A read the write needed: the write fell back to reading its target.
117 Read,
118 /// A mutation: a part of the write itself.
119 Mutation,
120}
121
122/// One task as a write's own response showed it whole: everything an entry of the cache
123/// holds, at the version that response determines.
124///
125/// Built only by [`WholeTask::new`], and read back through the same check, so a whole task
126/// always carries a version: one without is not a task a cache can key.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
128#[serde(try_from = "WholeTaskShape")]
129pub struct WholeTask {
130 task: Task,
131 comments: Vec<Comment>,
132 assets: Vec<Asset>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 edges: Option<Vec<DependencyEdge>>,
135}
136
137/// [`WholeTask`] as it is spelled, before its version is checked.
138#[derive(Deserialize, JsonSchema)]
139struct WholeTaskShape {
140 task: Task,
141 comments: Vec<Comment>,
142 assets: Vec<Asset>,
143 #[serde(default)]
144 edges: Option<Vec<DependencyEdge>>,
145}
146
147impl TryFrom<WholeTaskShape> for WholeTask {
148 type Error = String;
149
150 fn try_from(shape: WholeTaskShape) -> Result<Self, Self::Error> {
151 Self::new(shape.task, shape.comments, shape.assets, shape.edges)
152 .ok_or_else(|| "a whole task carries the version it is at".to_owned())
153 }
154}
155
156impl WholeTask {
157 /// The task whole — its comments, its assets and, when known, its forward edges — or
158 /// `None` for a task without a [`Task::version`].
159 #[must_use]
160 pub fn new(
161 task: Task,
162 comments: Vec<Comment>,
163 assets: Vec<Asset>,
164 edges: Option<Vec<DependencyEdge>>,
165 ) -> Option<Self> {
166 task.version.as_ref()?;
167 Some(Self {
168 task,
169 comments,
170 assets,
171 edges,
172 })
173 }
174
175 /// The task, its [`Task::version`] the version the response determines.
176 #[must_use]
177 pub fn task(&self) -> &Task {
178 &self.task
179 }
180
181 /// The version it is at.
182 #[must_use]
183 pub fn version(&self) -> &str {
184 self.task.version.as_deref().unwrap_or_default()
185 }
186
187 /// Every comment it holds, oldest first.
188 #[must_use]
189 pub fn comments(&self) -> &[Comment] {
190 &self.comments
191 }
192
193 /// The image assets it holds.
194 #[must_use]
195 pub fn assets(&self) -> &[Asset] {
196 &self.assets
197 }
198
199 /// Its forward dependency edges, when the response carried them whole.
200 #[must_use]
201 pub fn edges(&self) -> Option<&[DependencyEdge]> {
202 self.edges.as_deref()
203 }
204}
205
206/// One request a source sent while a journal was open.
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
208pub struct JournalEntry {
209 /// Which part of the write it was, in the source's own words — `fields`, `issue`,
210 /// `comment` — or, for a read, what was read.
211 pub part: String,
212 /// Whether it was a read or a mutation.
213 pub kind: RequestKind,
214 /// What it came to.
215 #[serde(flatten)]
216 pub outcome: PartOutcome,
217 /// Each field the part set, with the value the response shows it holding.
218 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
219 pub fields: BTreeMap<String, Value>,
220 /// What the response showed of the task afterwards.
221 #[serde(default)]
222 pub shown: Shown,
223}
224
225/// What one response showed of the task a write left behind.
226///
227/// One value rather than a version beside an optional whole task, so a whole task always
228/// carries the version it is at: [`Shown::Whole`]'s own `task.version`.
229#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
230#[serde(tag = "shown", rename_all = "kebab-case")]
231pub enum Shown {
232 /// Nothing that determines the task's version.
233 #[default]
234 Nothing,
235 /// The task's version, and not every part an entry holds at it.
236 Version {
237 /// The version.
238 version: String,
239 },
240 /// The whole task, every part an entry holds, at its own `task.version` — never built for
241 /// a task without one.
242 Whole {
243 /// The task.
244 task: Box<WholeTask>,
245 },
246}
247
248impl Shown {
249 /// The version the response determines, if it does.
250 #[must_use]
251 pub fn version(&self) -> Option<&str> {
252 match self {
253 Self::Nothing => None,
254 Self::Version { version } => Some(version),
255 Self::Whole { task } => Some(task.version()),
256 }
257 }
258
259 /// The whole task, when the response carried it.
260 #[must_use]
261 pub fn whole(&self) -> Option<&WholeTask> {
262 match self {
263 Self::Whole { task } => Some(task),
264 Self::Nothing | Self::Version { .. } => None,
265 }
266 }
267}
268
269/// Every request a source sent between [`TaskSource::begin_journal`] and
270/// [`TaskSource::end_journal`], in the order sent.
271///
272/// [`TaskSource::begin_journal`]: crate::TaskSource::begin_journal
273/// [`TaskSource::end_journal`]: crate::TaskSource::end_journal
274#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
275pub struct WriteJournal {
276 /// The requests, oldest first.
277 pub requests: Vec<JournalEntry>,
278}
279
280impl WriteJournal {
281 /// Whether any request was a read.
282 #[must_use]
283 pub fn read(&self) -> bool {
284 self.requests
285 .iter()
286 .any(|entry| entry.kind == RequestKind::Read)
287 }
288
289 /// The mutations, oldest first.
290 pub fn mutations(&self) -> impl Iterator<Item = &JournalEntry> {
291 self.requests
292 .iter()
293 .filter(|entry| entry.kind == RequestKind::Mutation)
294 }
295
296 /// Whether a mutation was refused for a stale primed id.
297 #[must_use]
298 pub fn stale(&self) -> bool {
299 self.mutations()
300 .any(|entry| matches!(entry.outcome, PartOutcome::Rejected { stale: true, .. }))
301 }
302
303 /// Whether a mutation's outcome cannot be known.
304 #[must_use]
305 pub fn unknown(&self) -> bool {
306 self.mutations()
307 .any(|entry| matches!(entry.outcome, PartOutcome::Unknown { .. }))
308 }
309}