1use 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#[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 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 #[serde(default)]
38 pub snapshot: Value,
39}
40
41#[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 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 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 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 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 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 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 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 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 pub fn operation(mut self, operation: TransactionOperation) -> Self {
203 self.operations.push(operation);
204 self
205 }
206
207 pub fn precondition(mut self, precondition: TransactionPrecondition) -> Self {
223 self.preconditions.push(precondition);
224 self
225 }
226
227 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 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 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 pub fn fence(mut self, basis: ReadBasis) -> Self {
278 self.fences.push(basis);
279 self
280 }
281}
282
283#[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#[derive(Debug, Clone, PartialEq, Deserialize)]
295pub struct StateVersion {
296 pub application_id: String,
297 pub revision_id: String,
298 pub schema_version: u64,
299 pub state_version: u64,
301 #[serde(default)]
303 pub state_namespace: Option<String>,
304 #[serde(default)]
306 pub causal_cursor: Value,
307}
308
309#[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 pub events: Vec<Value>,
318 pub next_cursor: String,
320}
321
322#[derive(Debug, Clone)]
324pub struct Application {
325 client: Client,
326 application_id: String,
327 environment: String,
328 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 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 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 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 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 pub async fn query(&self, query: &CanonicalQuery) -> Result<QueryResult> {
431 self.canonical("/query", json!({ "query": query })).await
432 }
433
434 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 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 pub async fn read_basis(&self, reads: &ReadSet) -> Result<ReadBasis> {
477 self.canonical("/transactions/read-basis", json!({ "reads": reads }))
478 .await
479 }
480
481 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 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 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 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 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 pub async fn session(&self) -> Result<Value> {
545 self.client
546 .send::<_, ()>(Method::GET, "/v1/auth/session", None)
547 .await
548 }
549
550 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 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}