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