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        self,
73        kind: TransactionOperationKind,
74        collection: String,
75        id: String,
76        value: Value,
77    ) -> Self {
78        self.operation(TransactionOperation {
79            kind,
80            collection,
81            id,
82            value,
83            if_version: None,
84        })
85    }
86
87    /// Create a record that must not exist.
88    pub fn insert(
89        self,
90        collection: impl Into<String>,
91        id: impl Into<String>,
92        value: Value,
93    ) -> Self {
94        self.push(
95            TransactionOperationKind::Insert,
96            collection.into(),
97            id.into(),
98            value,
99        )
100    }
101
102    /// Write a record.
103    pub fn update(
104        self,
105        collection: impl Into<String>,
106        id: impl Into<String>,
107        value: Value,
108    ) -> Self {
109        self.push(
110            TransactionOperationKind::Update,
111            collection.into(),
112            id.into(),
113            value,
114        )
115    }
116
117    pub fn delete(self, collection: impl Into<String>, id: impl Into<String>) -> Self {
118        self.push(
119            TransactionOperationKind::Delete,
120            collection.into(),
121            id.into(),
122            Value::Null,
123        )
124    }
125
126    /// Merge `value` into a record only if its `__version` is still `version`. Refused with
127    /// `PRECONDITION_FAILED` otherwise; nothing in the transaction is written.
128    pub fn update_if_version(
129        self,
130        collection: impl Into<String>,
131        id: impl Into<String>,
132        value: Value,
133        version: u64,
134    ) -> Self {
135        self.operation(TransactionOperation {
136            kind: TransactionOperationKind::Update,
137            collection: collection.into(),
138            id: id.into(),
139            value,
140            if_version: Some(version),
141        })
142    }
143
144    /// Delete a record only if its `__version` is still `version`. With an
145    /// [`AppTransaction::insert`] of the same id after it, an atomic, fenced replace.
146    pub fn delete_if_version(
147        self,
148        collection: impl Into<String>,
149        id: impl Into<String>,
150        version: u64,
151    ) -> Self {
152        self.operation(TransactionOperation {
153            kind: TransactionOperationKind::Delete,
154            collection: collection.into(),
155            id: id.into(),
156            value: Value::Null,
157            if_version: Some(version),
158        })
159    }
160
161    /// Any operation of the canonical contract, as the wire document states it.
162    pub fn operation(mut self, operation: TransactionOperation) -> Self {
163        self.operations.push(operation);
164        self
165    }
166
167    /// Any precondition of the canonical contract, e.g. a record's `__version`:
168    ///
169    /// ```
170    /// # use feltdb_client::{AppTransaction, wire::TransactionPrecondition};
171    /// let tx = AppTransaction::new("tx-1").precondition(TransactionPrecondition::Record {
172    ///     collection: "Order".into(),
173    ///     id: "o-1".into(),
174    ///     require_absent: false,
175    ///     expected_version: Some(3),
176    ///     expected_epoch: None,
177    ///     expected_lease_id: None,
178    /// });
179    /// # let _ = tx;
180    /// ```
181    pub fn precondition(mut self, precondition: TransactionPrecondition) -> Self {
182        self.preconditions.push(precondition);
183        self
184    }
185
186    /// Commit only if the record's `__version` is still `version`.
187    pub fn expect_version(
188        self,
189        collection: impl Into<String>,
190        id: impl Into<String>,
191        version: u64,
192    ) -> Self {
193        self.precondition(TransactionPrecondition::Record {
194            collection: collection.into(),
195            id: id.into(),
196            require_absent: false,
197            expected_version: Some(version),
198            expected_epoch: None,
199            expected_lease_id: None,
200        })
201    }
202
203    /// Commit only if the record does not exist.
204    pub fn expect_absent(self, collection: impl Into<String>, id: impl Into<String>) -> Self {
205        self.precondition(TransactionPrecondition::Record {
206            collection: collection.into(),
207            id: id.into(),
208            require_absent: true,
209            expected_version: None,
210            expected_epoch: None,
211            expected_lease_id: None,
212        })
213    }
214
215    /// Commit only if the state `basis` covers is unchanged since [`Application::read_basis`].
216    pub fn fence(mut self, basis: ReadBasis) -> Self {
217        self.fences.push(basis);
218        self
219    }
220}
221
222/// What a canonical transaction committed.
223#[derive(Debug, Clone, Deserialize)]
224pub struct AppCommit {
225    #[serde(flatten)]
226    pub result: TransactionResult,
227    pub revision_id: String,
228    #[serde(default)]
229    pub operation_ids: Vec<String>,
230}
231
232/// The application's committed state version: [`Application::state_version`].
233#[derive(Debug, Clone, PartialEq, Deserialize)]
234pub struct StateVersion {
235    pub application_id: String,
236    pub revision_id: String,
237    pub schema_version: u64,
238    /// Advances inside the commit critical section, so it totally orders commits.
239    pub state_version: u64,
240    /// The state namespace the version counts. Versions of two namespaces are not comparable.
241    #[serde(default)]
242    pub state_namespace: Option<String>,
243    /// Per-operation versions, for a causal read.
244    #[serde(default)]
245    pub causal_cursor: Value,
246}
247
248/// One page of committed history.
249#[derive(Debug, Clone, PartialEq, Deserialize)]
250pub struct ReplayPage {
251    pub contract_version: String,
252    pub cursor_format: String,
253    pub application_id: String,
254    pub environment: String,
255    /// Committed events in order; each carries its own resumable cursor in `metadata.cursor`.
256    pub events: Vec<Value>,
257    /// Where the next page starts. Bound to this authority, tenant and application.
258    pub next_cursor: String,
259}
260
261/// One application environment on a FeltDB server. Cheap to clone.
262#[derive(Debug, Clone)]
263pub struct Application {
264    client: Client,
265    application_id: String,
266    environment: String,
267    /// The active revision, cached after a successful discovery. A failure is never cached.
268    revision: Arc<Mutex<Option<String>>>,
269}
270
271#[derive(Serialize)]
272struct Envelope<'a, T: Serialize> {
273    application_id: &'a str,
274    revision_id: &'a str,
275    environment: &'a str,
276    #[serde(flatten)]
277    payload: T,
278}
279
280impl Application {
281    pub(crate) fn new(client: Client, application_id: String) -> Self {
282        Self {
283            client,
284            application_id,
285            environment: "production".into(),
286            revision: Arc::default(),
287        }
288    }
289
290    /// The same application in another environment.
291    pub fn environment(mut self, environment: impl Into<String>) -> Self {
292        self.environment = environment.into();
293        self.revision = Arc::default();
294        self
295    }
296
297    pub fn id(&self) -> &str {
298        &self.application_id
299    }
300
301    pub fn environment_name(&self) -> &str {
302        &self.environment
303    }
304
305    fn scoped(&self, path: &str) -> String {
306        let separator = if path.contains('?') { '&' } else { '?' };
307        format!(
308            "/v1{path}{separator}application_id={}&environment={}",
309            encode(&self.application_id),
310            encode(&self.environment)
311        )
312    }
313
314    /// Discover the environment's active contract, and remember its revision for later requests.
315    pub async fn discover(&self) -> Result<ApplicationInfo> {
316        let info: ApplicationInfo = self
317            .client
318            .send::<_, ()>(Method::GET, &self.scoped("/application"), None)
319            .await?;
320        *self
321            .revision
322            .lock()
323            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(info.revision_id.clone());
324        Ok(info)
325    }
326
327    /// The revision canonical requests are evaluated against: cached after the first discovery.
328    /// Call [`Application::discover`] again after a promotion to move to the new revision.
329    pub async fn revision_id(&self) -> Result<String> {
330        let cached = self
331            .revision
332            .lock()
333            .unwrap_or_else(|poisoned| poisoned.into_inner())
334            .clone();
335        match cached {
336            Some(revision) => Ok(revision),
337            None => Ok(self.discover().await?.revision_id),
338        }
339    }
340
341    async fn canonical<T: serde::de::DeserializeOwned>(
342        &self,
343        path: &str,
344        payload: impl Serialize,
345    ) -> Result<T> {
346        let revision_id = self.revision_id().await?;
347        let body = Envelope {
348            application_id: &self.application_id,
349            revision_id: &revision_id,
350            environment: &self.environment,
351            payload,
352        };
353        self.client
354            .send(Method::POST, &self.scoped(path), Some(&body))
355            .await
356    }
357
358    /// The committed state version of the environment's state, at its active revision.
359    pub async fn state_version(&self) -> Result<StateVersion> {
360        let revision_id = self.revision_id().await?;
361        let path = self.scoped(&format!(
362            "/state/version?revision_id={}",
363            encode(&revision_id)
364        ));
365        self.client.send::<_, ()>(Method::GET, &path, None).await
366    }
367
368    /// Run a canonical query, authorized by the contract's read policy for the collection.
369    pub async fn query(&self, query: &CanonicalQuery) -> Result<QueryResult> {
370        self.canonical("/query", json!({ "query": query })).await
371    }
372
373    /// Commit a canonical transaction.
374    pub async fn commit(&self, transaction: &AppTransaction) -> Result<AppCommit> {
375        if transaction.operations.is_empty() {
376            return Err(Error::InvalidRequest(
377                "a transaction requires at least one operation".into(),
378            ));
379        }
380        let revision_id = self.revision_id().await?;
381        // Tenant, revision, schema version and authorization are the authority's to assign: it
382        // overwrites them from the authenticated principal and the active contract. They are sent
383        // because the document requires them, never as a claim.
384        let tenant_id = match &self.client.inner.scope {
385            Some(crate::AuthorityScope::Tenant { tenant_id, .. }) => tenant_id.clone(),
386            _ => String::new(),
387        };
388        let request = TransactionRequest {
389            transaction_id: Some(transaction.transaction_id.clone()),
390            tenant_id: tenant_id.clone(),
391            application_id: self.application_id.clone(),
392            revision_id: revision_id.clone(),
393            schema_version: 0,
394            state_namespace: None,
395            causal_parent: None,
396            authorization: AuthorizationContext {
397                subject: String::new(),
398                tenant_id,
399                application_id: self.application_id.clone(),
400                revision_id,
401                capabilities: BTreeSet::new(),
402                readable_collections: BTreeSet::new(),
403                writable_collections: BTreeSet::new(),
404                field_projections: Default::default(),
405            },
406            operations: transaction.operations.clone(),
407            preconditions: transaction.preconditions.clone(),
408            fences: transaction.fences.clone(),
409        };
410        self.canonical("/transactions", json!({ "transaction": request }))
411            .await
412    }
413
414    /// Capture a read basis over the application's state, under the caller's read authorization.
415    pub async fn read_basis(&self, reads: &ReadSet) -> Result<ReadBasis> {
416        self.canonical("/transactions/read-basis", json!({ "reads": reads }))
417            .await
418    }
419
420    /// Committed changes to this application as they happen, from `cursor` or from now.
421    pub async fn events(&self, cursor: Option<&str>) -> Result<EventStream> {
422        self.client
423            .open_events(&self.scoped("/events"), cursor)
424            .await
425    }
426
427    /// A bounded page of committed history from `cursor` (the start when `None`), at most `limit`
428    /// events (the server caps it at 256). Replay, not the live stream, is the authority on history.
429    pub async fn replay(&self, cursor: Option<&str>, limit: Option<usize>) -> Result<ReplayPage> {
430        let mut path = self.scoped("/public/events/replay");
431        if let Some(cursor) = cursor {
432            path.push_str(&format!("&cursor={}", encode(cursor)));
433        }
434        if let Some(limit) = limit {
435            path.push_str(&format!("&limit={limit}"));
436        }
437        self.client.send::<_, ()>(Method::GET, &path, None).await
438    }
439
440    /// The complete mutation chain of one record.
441    pub async fn provenance(&self, collection: &str, id: &str) -> Result<Value> {
442        let path = self.scoped(&format!(
443            "/control-plane/provenance/{}/{}",
444            encode(collection),
445            encode(id)
446        ));
447        self.client.send::<_, ()>(Method::GET, &path, None).await
448    }
449
450    /// Sign an end user in to this application; the session's access token becomes this client's
451    /// bearer token.
452    pub async fn sign_in(&self, email: &str, password: &str) -> Result<Value> {
453        let body =
454            json!({ "application_id": self.application_id, "email": email, "password": password });
455        self.authenticate("/v1/auth/signin", &body).await
456    }
457
458    /// Register an end user of this application and sign them in.
459    pub async fn sign_up(
460        &self,
461        email: &str,
462        password: &str,
463        display_name: Option<&str>,
464    ) -> Result<Value> {
465        let body = json!({
466            "application_id": self.application_id,
467            "email": email,
468            "password": password,
469            "display_name": display_name,
470        });
471        self.authenticate("/v1/auth/signup", &body).await
472    }
473
474    async fn authenticate(&self, path: &str, body: &Value) -> Result<Value> {
475        let session: Value = self.client.send(Method::POST, path, Some(body)).await?;
476        if let Some(token) = session.get("access_token").and_then(Value::as_str) {
477            self.client.set_token(token);
478        }
479        Ok(session)
480    }
481
482    /// The current session's principal.
483    pub async fn session(&self) -> Result<Value> {
484        self.client
485            .send::<_, ()>(Method::GET, "/v1/auth/session", None)
486            .await
487    }
488
489    /// End the current session and clear this client's token.
490    pub async fn sign_out(&self) -> Result<()> {
491        self.client
492            .exchange::<()>(Method::POST, "/v1/auth/signout", None)
493            .await?;
494        self.client.set_token("");
495        Ok(())
496    }
497}
498
499impl Client {
500    /// The server's versioned capability declaration.
501    pub async fn capabilities(&self) -> Result<Value> {
502        self.send::<_, ()>(Method::GET, "/v1/control-plane/capabilities", None)
503            .await
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn scoped_paths_carry_the_application_and_environment() {
513        let app = Client::builder("http://localhost:1")
514            .build()
515            .unwrap()
516            .application("app 1")
517            .environment("staging");
518        assert_eq!(
519            app.scoped("/application"),
520            "/v1/application?application_id=app%201&environment=staging"
521        );
522        assert_eq!(
523            app.scoped("/x?y=1"),
524            "/v1/x?y=1&application_id=app%201&environment=staging"
525        );
526    }
527}