Skip to main content

feltdb_client/
application.rs

1//! The canonical application Service API (`/v1`), scoped to one application and environment.
2
3use std::{
4    collections::BTreeSet,
5    sync::{Arc, Mutex},
6};
7
8use feltdb::{
9    state_contract::{
10        AuthorizationContext, CanonicalQuery, QueryResult, TransactionOperation,
11        TransactionOperationKind, TransactionPrecondition, TransactionRequest, TransactionResult,
12    },
13    ReadBasis, ReadSet,
14};
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17
18use crate::{encode, Client, Error, EventStream, Method, Result};
19
20/// The active contract of an application environment, as discovery reports it.
21#[derive(Debug, Clone, PartialEq, Deserialize)]
22pub struct ApplicationInfo {
23    pub application_id: String,
24    #[serde(default)]
25    pub application_name: Option<String>,
26    pub environment: String,
27    /// The contract revision every canonical request is evaluated against.
28    pub revision_id: String,
29    #[serde(default)]
30    pub version: Option<u64>,
31    pub contract_hash: String,
32    #[serde(default)]
33    pub flow_hash: Option<String>,
34    #[serde(default)]
35    pub dsl_version: Option<u32>,
36    /// The contract snapshot: schema, indexes, policies, workflows, agents and capabilities.
37    #[serde(default)]
38    pub snapshot: Value,
39}
40
41/// A canonical transaction: operations and preconditions the application contract validates and
42/// authorizes, committed atomically.
43#[derive(Debug, Clone)]
44pub struct AppTransaction {
45    transaction_id: String,
46    operations: Vec<TransactionOperation>,
47    preconditions: Vec<TransactionPrecondition>,
48    fences: Vec<ReadBasis>,
49}
50
51impl AppTransaction {
52    /// A transaction with this identity; resubmitting it after an uncertain outcome is safe.
53    pub fn new(transaction_id: impl Into<String>) -> Self {
54        Self {
55            transaction_id: transaction_id.into(),
56            operations: Vec::new(),
57            preconditions: Vec::new(),
58            fences: Vec::new(),
59        }
60    }
61
62    /// A transaction with a fresh random identity.
63    pub fn generated() -> Self {
64        Self::new(format!("tx-{}", uuid::Uuid::new_v4()))
65    }
66
67    pub fn id(&self) -> &str {
68        &self.transaction_id
69    }
70
71    fn push(
72        mut self,
73        kind: TransactionOperationKind,
74        collection: String,
75        id: String,
76        value: Value,
77    ) -> Self {
78        self.operations.push(TransactionOperation {
79            kind,
80            collection,
81            id,
82            value,
83            if_version: None,
84        });
85        self
86    }
87
88    /// Create a record that must not exist.
89    pub fn insert(
90        self,
91        collection: impl Into<String>,
92        id: impl Into<String>,
93        value: Value,
94    ) -> Self {
95        self.push(
96            TransactionOperationKind::Insert,
97            collection.into(),
98            id.into(),
99            value,
100        )
101    }
102
103    /// Write a record.
104    pub fn update(
105        self,
106        collection: impl Into<String>,
107        id: impl Into<String>,
108        value: Value,
109    ) -> Self {
110        self.push(
111            TransactionOperationKind::Update,
112            collection.into(),
113            id.into(),
114            value,
115        )
116    }
117
118    pub fn delete(self, collection: impl Into<String>, id: impl Into<String>) -> Self {
119        self.push(
120            TransactionOperationKind::Delete,
121            collection.into(),
122            id.into(),
123            Value::Null,
124        )
125    }
126
127    /// Any precondition of the canonical contract, e.g. a record's `__version`:
128    ///
129    /// ```
130    /// # use feltdb_client::{AppTransaction, wire::TransactionPrecondition};
131    /// let tx = AppTransaction::new("tx-1").precondition(TransactionPrecondition::Record {
132    ///     collection: "Order".into(),
133    ///     id: "o-1".into(),
134    ///     require_absent: false,
135    ///     expected_version: Some(3),
136    ///     expected_epoch: None,
137    ///     expected_lease_id: None,
138    /// });
139    /// # let _ = tx;
140    /// ```
141    pub fn precondition(mut self, precondition: TransactionPrecondition) -> Self {
142        self.preconditions.push(precondition);
143        self
144    }
145
146    /// Commit only if the record's `__version` is still `version`.
147    pub fn expect_version(
148        self,
149        collection: impl Into<String>,
150        id: impl Into<String>,
151        version: u64,
152    ) -> Self {
153        self.precondition(TransactionPrecondition::Record {
154            collection: collection.into(),
155            id: id.into(),
156            require_absent: false,
157            expected_version: Some(version),
158            expected_epoch: None,
159            expected_lease_id: None,
160        })
161    }
162
163    /// Commit only if the record does not exist.
164    pub fn expect_absent(self, collection: impl Into<String>, id: impl Into<String>) -> Self {
165        self.precondition(TransactionPrecondition::Record {
166            collection: collection.into(),
167            id: id.into(),
168            require_absent: true,
169            expected_version: None,
170            expected_epoch: None,
171            expected_lease_id: None,
172        })
173    }
174
175    /// Commit only if the state `basis` covers is unchanged since [`Application::read_basis`].
176    pub fn fence(mut self, basis: ReadBasis) -> Self {
177        self.fences.push(basis);
178        self
179    }
180}
181
182/// What a canonical transaction committed.
183#[derive(Debug, Clone, Deserialize)]
184pub struct AppCommit {
185    #[serde(flatten)]
186    pub result: TransactionResult,
187    pub revision_id: String,
188    #[serde(default)]
189    pub operation_ids: Vec<String>,
190}
191
192/// One page of committed history.
193#[derive(Debug, Clone, PartialEq, Deserialize)]
194pub struct ReplayPage {
195    pub contract_version: String,
196    pub cursor_format: String,
197    pub application_id: String,
198    pub environment: String,
199    /// Committed events in order; each carries its own resumable cursor in `metadata.cursor`.
200    pub events: Vec<Value>,
201    /// Where the next page starts. Bound to this authority, tenant and application.
202    pub next_cursor: String,
203}
204
205/// One application environment on a FeltDB server. Cheap to clone.
206#[derive(Debug, Clone)]
207pub struct Application {
208    client: Client,
209    application_id: String,
210    environment: String,
211    /// The active revision, cached after a successful discovery. A failure is never cached.
212    revision: Arc<Mutex<Option<String>>>,
213}
214
215#[derive(Serialize)]
216struct Envelope<'a, T: Serialize> {
217    application_id: &'a str,
218    revision_id: &'a str,
219    environment: &'a str,
220    #[serde(flatten)]
221    payload: T,
222}
223
224impl Application {
225    pub(crate) fn new(client: Client, application_id: String) -> Self {
226        Self {
227            client,
228            application_id,
229            environment: "production".into(),
230            revision: Arc::default(),
231        }
232    }
233
234    /// The same application in another environment.
235    pub fn environment(mut self, environment: impl Into<String>) -> Self {
236        self.environment = environment.into();
237        self.revision = Arc::default();
238        self
239    }
240
241    pub fn id(&self) -> &str {
242        &self.application_id
243    }
244
245    pub fn environment_name(&self) -> &str {
246        &self.environment
247    }
248
249    fn scoped(&self, path: &str) -> String {
250        let separator = if path.contains('?') { '&' } else { '?' };
251        format!(
252            "/v1{path}{separator}application_id={}&environment={}",
253            encode(&self.application_id),
254            encode(&self.environment)
255        )
256    }
257
258    /// Discover the environment's active contract, and remember its revision for later requests.
259    pub async fn discover(&self) -> Result<ApplicationInfo> {
260        let info: ApplicationInfo = self
261            .client
262            .send::<_, ()>(Method::GET, &self.scoped("/application"), None)
263            .await?;
264        *self
265            .revision
266            .lock()
267            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(info.revision_id.clone());
268        Ok(info)
269    }
270
271    /// The revision canonical requests are evaluated against: cached after the first discovery.
272    /// Call [`Application::discover`] again after a promotion to move to the new revision.
273    pub async fn revision_id(&self) -> Result<String> {
274        let cached = self
275            .revision
276            .lock()
277            .unwrap_or_else(|poisoned| poisoned.into_inner())
278            .clone();
279        match cached {
280            Some(revision) => Ok(revision),
281            None => Ok(self.discover().await?.revision_id),
282        }
283    }
284
285    async fn canonical<T: serde::de::DeserializeOwned>(
286        &self,
287        path: &str,
288        payload: impl Serialize,
289    ) -> Result<T> {
290        let revision_id = self.revision_id().await?;
291        let body = Envelope {
292            application_id: &self.application_id,
293            revision_id: &revision_id,
294            environment: &self.environment,
295            payload,
296        };
297        self.client
298            .send(Method::POST, &self.scoped(path), Some(&body))
299            .await
300    }
301
302    /// Run a canonical query, authorized by the contract's read policy for the collection.
303    pub async fn query(&self, query: &CanonicalQuery) -> Result<QueryResult> {
304        self.canonical("/query", json!({ "query": query })).await
305    }
306
307    /// Commit a canonical transaction.
308    pub async fn commit(&self, transaction: &AppTransaction) -> Result<AppCommit> {
309        if transaction.operations.is_empty() {
310            return Err(Error::InvalidRequest(
311                "a transaction requires at least one operation".into(),
312            ));
313        }
314        let revision_id = self.revision_id().await?;
315        // Tenant, revision, schema version and authorization are the authority's to assign: it
316        // overwrites them from the authenticated principal and the active contract. They are sent
317        // because the document requires them, never as a claim.
318        let tenant_id = match &self.client.inner.scope {
319            Some(crate::AuthorityScope::Tenant { tenant_id, .. }) => tenant_id.clone(),
320            _ => String::new(),
321        };
322        let request = TransactionRequest {
323            transaction_id: Some(transaction.transaction_id.clone()),
324            tenant_id: tenant_id.clone(),
325            application_id: self.application_id.clone(),
326            revision_id: revision_id.clone(),
327            schema_version: 0,
328            state_namespace: None,
329            causal_parent: None,
330            authorization: AuthorizationContext {
331                subject: String::new(),
332                tenant_id,
333                application_id: self.application_id.clone(),
334                revision_id,
335                capabilities: BTreeSet::new(),
336                readable_collections: BTreeSet::new(),
337                writable_collections: BTreeSet::new(),
338                field_projections: Default::default(),
339            },
340            operations: transaction.operations.clone(),
341            preconditions: transaction.preconditions.clone(),
342            fences: transaction.fences.clone(),
343        };
344        self.canonical("/transactions", json!({ "transaction": request }))
345            .await
346    }
347
348    /// Capture a read basis over the application's state, under the caller's read authorization.
349    pub async fn read_basis(&self, reads: &ReadSet) -> Result<ReadBasis> {
350        self.canonical("/transactions/read-basis", json!({ "reads": reads }))
351            .await
352    }
353
354    /// Committed changes to this application as they happen, from `cursor` or from now.
355    pub async fn events(&self, cursor: Option<&str>) -> Result<EventStream> {
356        self.client
357            .open_events(&self.scoped("/events"), cursor)
358            .await
359    }
360
361    /// A bounded page of committed history from `cursor` (the start when `None`), at most `limit`
362    /// events (the server caps it at 256). Replay, not the live stream, is the authority on history.
363    pub async fn replay(&self, cursor: Option<&str>, limit: Option<usize>) -> Result<ReplayPage> {
364        let mut path = self.scoped("/public/events/replay");
365        if let Some(cursor) = cursor {
366            path.push_str(&format!("&cursor={}", encode(cursor)));
367        }
368        if let Some(limit) = limit {
369            path.push_str(&format!("&limit={limit}"));
370        }
371        self.client.send::<_, ()>(Method::GET, &path, None).await
372    }
373
374    /// The complete mutation chain of one record.
375    pub async fn provenance(&self, collection: &str, id: &str) -> Result<Value> {
376        let path = self.scoped(&format!(
377            "/control-plane/provenance/{}/{}",
378            encode(collection),
379            encode(id)
380        ));
381        self.client.send::<_, ()>(Method::GET, &path, None).await
382    }
383
384    /// Sign an end user in to this application; the session's access token becomes this client's
385    /// bearer token.
386    pub async fn sign_in(&self, email: &str, password: &str) -> Result<Value> {
387        let body =
388            json!({ "application_id": self.application_id, "email": email, "password": password });
389        self.authenticate("/v1/auth/signin", &body).await
390    }
391
392    /// Register an end user of this application and sign them in.
393    pub async fn sign_up(
394        &self,
395        email: &str,
396        password: &str,
397        display_name: Option<&str>,
398    ) -> Result<Value> {
399        let body = json!({
400            "application_id": self.application_id,
401            "email": email,
402            "password": password,
403            "display_name": display_name,
404        });
405        self.authenticate("/v1/auth/signup", &body).await
406    }
407
408    async fn authenticate(&self, path: &str, body: &Value) -> Result<Value> {
409        let session: Value = self.client.send(Method::POST, path, Some(body)).await?;
410        if let Some(token) = session.get("access_token").and_then(Value::as_str) {
411            self.client.set_token(token);
412        }
413        Ok(session)
414    }
415
416    /// The current session's principal.
417    pub async fn session(&self) -> Result<Value> {
418        self.client
419            .send::<_, ()>(Method::GET, "/v1/auth/session", None)
420            .await
421    }
422
423    /// End the current session and clear this client's token.
424    pub async fn sign_out(&self) -> Result<()> {
425        self.client
426            .exchange::<()>(Method::POST, "/v1/auth/signout", None)
427            .await?;
428        self.client.set_token("");
429        Ok(())
430    }
431}
432
433impl Client {
434    /// The server's versioned capability declaration.
435    pub async fn capabilities(&self) -> Result<Value> {
436        self.send::<_, ()>(Method::GET, "/v1/control-plane/capabilities", None)
437            .await
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn scoped_paths_carry_the_application_and_environment() {
447        let app = Client::builder("http://localhost:1")
448            .build()
449            .unwrap()
450            .application("app 1")
451            .environment("staging");
452        assert_eq!(
453            app.scoped("/application"),
454            "/v1/application?application_id=app%201&environment=staging"
455        );
456        assert_eq!(
457            app.scoped("/x?y=1"),
458            "/v1/x?y=1&application_id=app%201&environment=staging"
459        );
460    }
461}