Skip to main content

aven_core/recurrence/
identity.rs

1use chrono::NaiveDate;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4
5use crate::ids::{TaskId, WorkspaceId, encode_crockford};
6
7use super::schedule::{RecurrenceScheduleError, slot_values};
8use super::{RecurrenceSchedule, RecurrenceSeriesId};
9
10const TASK_ID_DOMAIN: &[u8] = b"aven recurrence task v1";
11const TASK_CHANGE_DOMAIN: &[u8] = b"aven recurrence task change v1";
12const OCCURRENCE_CHANGE_DOMAIN: &[u8] = b"aven recurrence occurrence change v1";
13const TASK_FIELD_VERSION_DOMAIN: &[u8] = b"aven recurrence task field version v1";
14const OCCURRENCE_FIELD_VERSION_DOMAIN: &[u8] = b"aven recurrence occurrence field version v1";
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct RecurrenceOccurrenceIdentity {
18    pub task_id: TaskId,
19    pub task_change_id: String,
20    pub occurrence_change_id: String,
21    pub created_at: String,
22    pub updated_at: String,
23    pub occurrence_link: RecurrenceOccurrenceLink,
24    pub field_version_seeds: RecurrenceFieldVersionSeeds,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct RecurrenceOccurrenceLink {
29    pub workspace_id: WorkspaceId,
30    pub series_id: RecurrenceSeriesId,
31    pub slot_on: NaiveDate,
32    pub task_id: TaskId,
33    pub projected_at: String,
34    pub change_id: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct RecurrenceFieldVersionSeeds {
39    pub task: String,
40    pub occurrence: String,
41}
42
43pub fn derive_occurrence_identity(
44    workspace_id: &WorkspaceId,
45    series_id: &RecurrenceSeriesId,
46    schedule: &RecurrenceSchedule,
47    slot_on: NaiveDate,
48) -> Result<RecurrenceOccurrenceIdentity, RecurrenceScheduleError> {
49    let slot = slot_values(schedule, slot_on)?;
50    let slot_text = slot_on.format("%Y-%m-%d").to_string();
51    let components = [
52        workspace_id.as_str(),
53        series_id.as_str(),
54        slot_text.as_str(),
55    ];
56    let task_id = derive_id(TASK_ID_DOMAIN, &components)
57        .parse::<TaskId>()
58        .expect("derived recurrence task ID is valid");
59    let task_change_id = derive_id(TASK_CHANGE_DOMAIN, &components);
60    let occurrence_change_id = derive_id(OCCURRENCE_CHANGE_DOMAIN, &components);
61    let field_version_seeds = RecurrenceFieldVersionSeeds {
62        task: derive_id(TASK_FIELD_VERSION_DOMAIN, &components),
63        occurrence: derive_id(OCCURRENCE_FIELD_VERSION_DOMAIN, &components),
64    };
65    let occurrence_link = RecurrenceOccurrenceLink {
66        workspace_id: workspace_id.clone(),
67        series_id: series_id.clone(),
68        slot_on,
69        task_id: task_id.clone(),
70        projected_at: slot.boundary_at.clone(),
71        change_id: occurrence_change_id.clone(),
72    };
73    Ok(RecurrenceOccurrenceIdentity {
74        task_id,
75        task_change_id,
76        occurrence_change_id,
77        created_at: slot.boundary_at.clone(),
78        updated_at: slot.boundary_at,
79        occurrence_link,
80        field_version_seeds,
81    })
82}
83
84fn derive_id(domain: &[u8], components: &[&str]) -> String {
85    let mut hasher = Sha256::new();
86    hasher.update(domain);
87    for component in components {
88        hasher.update(component.as_bytes());
89    }
90    let digest = hasher.finalize();
91    let mut bytes = [0u8; 10];
92    bytes.copy_from_slice(&digest[..10]);
93    encode_crockford(&bytes)
94}
95
96const PROPOSAL_DOMAIN: &[u8] = b"aven recurrence proposal v1\0";
97const PROPOSAL_TASK_CHANGE_DOMAIN: &[u8] = b"aven recurrence proposal task change v1";
98const PROPOSAL_OCCURRENCE_CHANGE_DOMAIN: &[u8] = b"aven recurrence proposal occurrence change v1";
99const PROPOSAL_TASK_FIELD_VERSION_DOMAIN: &[u8] = b"aven recurrence proposal task field version v1";
100
101/// Operation identities of one generation proposal. Replicas that generate an
102/// occurrence from equal template contents and schedule context derive equal
103/// identities; any difference yields distinct operations for the same task.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct RecurrenceProposalIds {
106    pub task_change_id: String,
107    pub occurrence_change_id: String,
108    pub task_field_version_seed: String,
109}
110
111/// Derives proposal identities from a generated `create_task` payload. Labels must
112/// be strictly ascending and metadata strictly ascending by key with unique field
113/// IDs, so equal proposals always have equal payload bytes. The digest input is a
114/// JSON array of strings and arrays only, which frames every value unambiguously.
115pub fn derive_proposal_ids(payload: &serde_json::Value) -> anyhow::Result<RecurrenceProposalIds> {
116    use anyhow::{Context, ensure};
117    let text = |key: &str| {
118        payload[key]
119            .as_str()
120            .with_context(|| format!("error invalid-sync-change recurrence-proposal field={key}"))
121    };
122    let labels = payload["labels"]
123        .as_array()
124        .context("error invalid-sync-change recurrence-proposal field=labels")?
125        .iter()
126        .map(|label| {
127            label
128                .as_str()
129                .context("error invalid-sync-change recurrence-proposal field=labels")
130        })
131        .collect::<anyhow::Result<Vec<_>>>()?;
132    ensure!(
133        labels.windows(2).all(|pair| pair[0] < pair[1]),
134        "error invalid-sync-change recurrence-proposal field=labels"
135    );
136    let metadata = payload["metadata"]
137        .as_array()
138        .context("error invalid-sync-change recurrence-proposal field=metadata")?
139        .iter()
140        .map(|entry| {
141            let value = |key: &str| {
142                entry[key]
143                    .as_str()
144                    .context("error invalid-sync-change recurrence-proposal field=metadata")
145            };
146            Ok([value("field_id")?, value("key")?, value("value")?])
147        })
148        .collect::<anyhow::Result<Vec<_>>>()?;
149    let mut field_ids = metadata.iter().map(|entry| entry[0]).collect::<Vec<_>>();
150    field_ids.sort_unstable();
151    field_ids.dedup();
152    ensure!(
153        metadata.windows(2).all(|pair| pair[0][1] < pair[1][1])
154            && field_ids.len() == metadata.len(),
155        "error invalid-sync-change recurrence-proposal field=metadata"
156    );
157    let input = serde_json::json!([
158        text("workspace_id")?,
159        text("series_id")?,
160        text("slot_on")?,
161        text("title")?,
162        text("description")?,
163        text("project_id")?,
164        text("status")?,
165        text("priority")?,
166        labels,
167        metadata,
168        text("available_local_time")?,
169        text("due_policy")?,
170    ]);
171    let mut hasher = Sha256::new();
172    hasher.update(PROPOSAL_DOMAIN);
173    hasher.update(serde_json::to_vec(&input)?);
174    let digest = hex::encode(hasher.finalize());
175    Ok(proposal_ids_for_task_change(derive_id(
176        PROPOSAL_TASK_CHANGE_DOMAIN,
177        &[digest.as_str()],
178    )))
179}
180
181/// Derives the projection and field-version identities bound to a proposal's
182/// task change, which is all a projection record can check on its own.
183pub fn proposal_ids_for_task_change(task_change_id: String) -> RecurrenceProposalIds {
184    RecurrenceProposalIds {
185        occurrence_change_id: derive_id(PROPOSAL_OCCURRENCE_CHANGE_DOMAIN, &[&task_change_id]),
186        task_field_version_seed: derive_id(PROPOSAL_TASK_FIELD_VERSION_DOMAIN, &[&task_change_id]),
187        task_change_id,
188    }
189}
190
191impl RecurrenceOccurrenceIdentity {
192    /// Whether a generated record carries proposal-form rather than occurrence-form
193    /// identities.
194    pub fn is_proposal(&self, task_change_id: &str) -> bool {
195        task_change_id != self.task_change_id
196    }
197
198    /// Expected identities of a generated `create_task` payload in its own form.
199    pub fn generated_task_ids(
200        &self,
201        payload: &serde_json::Value,
202    ) -> anyhow::Result<RecurrenceProposalIds> {
203        match payload["task_change_id"].as_str() {
204            Some(id) if !self.is_proposal(id) => Ok(self.projection_ids(id)),
205            _ => derive_proposal_ids(payload),
206        }
207    }
208
209    /// Expected identities of a projection referencing `task_change_id`.
210    pub fn projection_ids(&self, task_change_id: &str) -> RecurrenceProposalIds {
211        if self.is_proposal(task_change_id) {
212            return proposal_ids_for_task_change(task_change_id.to_owned());
213        }
214        RecurrenceProposalIds {
215            task_change_id: self.task_change_id.clone(),
216            occurrence_change_id: self.occurrence_change_id.clone(),
217            task_field_version_seed: self.field_version_seeds.task.clone(),
218        }
219    }
220
221    /// Identities of a stored generated `create_task` or projection record, when it
222    /// is a valid generation of this occurrence in its own derivation form.
223    pub fn stored_generation_ids(
224        &self,
225        payload: &serde_json::Value,
226        slot_on: NaiveDate,
227        projection: bool,
228    ) -> Option<RecurrenceProposalIds> {
229        let ids = if projection {
230            self.projection_ids(payload["task_change_id"].as_str()?)
231        } else {
232            self.generated_task_ids(payload).ok()?
233        };
234        let slot = slot_on.format("%Y-%m-%d").to_string();
235        let matches = [
236            ("task_id", self.task_id.as_str()),
237            ("series_id", self.occurrence_link.series_id.as_str()),
238            ("slot_on", slot.as_str()),
239            ("task_change_id", ids.task_change_id.as_str()),
240            ("occurrence_change_id", ids.occurrence_change_id.as_str()),
241            (
242                "task_field_version_seed",
243                ids.task_field_version_seed.as_str(),
244            ),
245            (
246                "occurrence_field_version_seed",
247                self.field_version_seeds.occurrence.as_str(),
248            ),
249        ]
250        .into_iter()
251        .all(|(key, expected)| payload[key].as_str() == Some(expected))
252            && (!projection
253                || payload["projected_at"].as_str()
254                    == Some(self.occurrence_link.projected_at.as_str()));
255        matches.then_some(ids)
256    }
257}