@feltdb/core 0.12.1 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/build-identity.json +13 -4
  2. package/dist/cli/commands.js +88 -43
  3. package/dist/collection.d.ts +41 -4
  4. package/dist/collection.d.ts.map +1 -1
  5. package/dist/collection.js +116 -47
  6. package/dist/composition.d.ts.map +1 -1
  7. package/dist/composition.js +11 -1
  8. package/dist/create/build-identity.json +13 -4
  9. package/dist/create/cli.js +3 -3
  10. package/dist/create/create.js +17 -19
  11. package/dist/create/package-versions.js +1 -1
  12. package/dist/create/server-source/Cargo.lock +18 -3
  13. package/dist/create/server-source/Cargo.toml +4 -2
  14. package/dist/create/server-source/crates/feltdb/Cargo.toml +20 -0
  15. package/dist/create/server-source/crates/feltdb/LICENSE +21 -0
  16. package/dist/create/server-source/crates/feltdb/README.md +75 -0
  17. package/dist/create/server-source/crates/feltdb/src/agent_state.rs +320 -0
  18. package/dist/create/server-source/crates/feltdb/src/agent_transition_contract.rs +1545 -0
  19. package/dist/create/server-source/crates/feltdb/src/authority.rs +145 -15
  20. package/dist/create/server-source/crates/feltdb/src/lib.rs +227 -23
  21. package/dist/create/server-source/crates/feltdb/src/state_model.rs +1 -5
  22. package/dist/create/server-source/crates/feltdb/tests/agent_state_native.rs +1188 -0
  23. package/dist/create/server-source/crates/feltdb/tests/agent_transition_contract_conformance.rs +2884 -0
  24. package/dist/create/server-source/crates/feltdb/tests/authority_transaction_identity.rs +534 -0
  25. package/dist/create/server-source/crates/feltdb/tests/fixtures/agent_transition_corpus.json +23340 -0
  26. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +29 -0
  27. package/dist/create/server-source/crates/feltdb-client/LICENSE +21 -0
  28. package/dist/create/server-source/crates/feltdb-client/README.md +70 -0
  29. package/dist/create/server-source/crates/feltdb-client/src/application.rs +527 -0
  30. package/dist/create/server-source/crates/feltdb-client/src/authority.rs +679 -0
  31. package/dist/create/server-source/crates/feltdb-client/src/control.rs +292 -0
  32. package/dist/create/server-source/crates/feltdb-client/src/error.rs +227 -0
  33. package/dist/create/server-source/crates/feltdb-client/src/events.rs +146 -0
  34. package/dist/create/server-source/crates/feltdb-client/src/lib.rs +466 -0
  35. package/dist/create/server-source/crates/feltdb-client/tests/fixtures/README.md +4 -0
  36. package/dist/create/server-source/crates/feltdb-client/tests/fixtures/private-ca.pem +20 -0
  37. package/dist/create/server-source/crates/feltdb-client/tests/fixtures/server-key.pem +28 -0
  38. package/dist/create/server-source/crates/feltdb-client/tests/fixtures/server.pem +20 -0
  39. package/dist/create/server-source/crates/feltdb-client/tests/private_ca.rs +95 -0
  40. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +20 -1
  41. package/dist/create/server-source/crates/feltdb-server/LICENSE +21 -0
  42. package/dist/create/server-source/crates/feltdb-server/README.md +12 -1
  43. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +6 -2
  44. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +20 -0
  45. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1 -3
  46. package/dist/create/server-source/crates/feltdb-server/tests/rust_client.rs +683 -0
  47. package/dist/create/server-source/crates/feltdb-wasm/Cargo.toml +3 -1
  48. package/dist/create/template/default-project/README.md +12 -7
  49. package/dist/create/template/default-project/src/pages/Dashboard.tsx +1 -1
  50. package/dist/create/template/dot-feltdb-README.md +3 -3
  51. package/dist/db.d.ts.map +1 -1
  52. package/dist/db.js +43 -17
  53. package/dist/development-runtime-bridge.d.ts.map +1 -1
  54. package/dist/development-runtime-bridge.js +22 -1
  55. package/dist/embedded-transaction.d.ts +75 -8
  56. package/dist/embedded-transaction.d.ts.map +1 -1
  57. package/dist/embedded-transaction.js +124 -18
  58. package/dist/error-codes.d.ts +2 -0
  59. package/dist/error-codes.d.ts.map +1 -1
  60. package/dist/error-codes.js +2 -0
  61. package/dist/feltdb.d.ts +1 -11
  62. package/dist/feltdb.d.ts.map +1 -1
  63. package/dist/file-db.d.ts +6 -0
  64. package/dist/file-db.d.ts.map +1 -1
  65. package/dist/file-db.js +43 -11
  66. package/dist/indexeddb-db.d.ts.map +1 -1
  67. package/dist/indexeddb-db.js +65 -41
  68. package/dist/memory-db.d.ts.map +1 -1
  69. package/dist/memory-db.js +29 -8
  70. package/dist/studio/{KeyManagementPanel-DZuSeWBK.js → KeyManagementPanel-D5FMvvU9.js} +1 -0
  71. package/dist/studio/app.d.ts.map +1 -1
  72. package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
  73. package/dist/studio/components/KeyManagementPanel.js +1 -1
  74. package/dist/studio/components/RouteErrorBoundary.d.ts +5 -0
  75. package/dist/studio/components/RouteErrorBoundary.d.ts.map +1 -0
  76. package/dist/studio/components/index.js +2 -2
  77. package/dist/studio/{components-Bivm2lX6.js → components-B4eKIUN5.js} +1 -1
  78. package/dist/studio/control-plane/Agents.d.ts.map +1 -1
  79. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  80. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  81. package/dist/studio/control-plane/Overview.d.ts.map +1 -1
  82. package/dist/studio/control-plane/Schema.d.ts.map +1 -1
  83. package/dist/studio/control-plane/context.d.ts +4 -1
  84. package/dist/studio/control-plane/context.d.ts.map +1 -1
  85. package/dist/studio/control-plane/index.js +2 -2
  86. package/dist/studio/control-plane/primitives.d.ts +8 -0
  87. package/dist/studio/control-plane/primitives.d.ts.map +1 -1
  88. package/dist/studio/{control-plane-CLm69XwX.js → control-plane-fxMcJn74.js} +1371 -1319
  89. package/dist/studio/index.js +146 -105
  90. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  91. package/dist/studio/utils/control-plane-api.js +4 -3
  92. package/dist/studio/utils/service-api.d.ts.map +1 -1
  93. package/dist/studio/utils/service-api.js +5 -4
  94. package/dist/studio-app/assets/{feltdb_wasm-DACcD-zs.js → feltdb_wasm-BatwwogY.js} +1 -1
  95. package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
  96. package/dist/studio-app/assets/index-DoVvbIay.js +30 -0
  97. package/dist/studio-app/index.html +1 -1
  98. package/dist/telemetry.d.ts +1 -0
  99. package/dist/telemetry.d.ts.map +1 -1
  100. package/dist/telemetry.js +4 -3
  101. package/dist/transaction-identity.d.ts +62 -0
  102. package/dist/transaction-identity.d.ts.map +1 -0
  103. package/dist/transaction-identity.js +138 -0
  104. package/dist/transaction.d.ts +50 -5
  105. package/dist/transaction.d.ts.map +1 -1
  106. package/dist/transaction.js +28 -0
  107. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  108. package/dist/wasm/package.json +1 -1
  109. package/package.json +1 -1
  110. package/dist/create/server-source/crates/feltdb-wasm/test.db +0 -0
  111. package/dist/studio-app/assets/feltdb_wasm_bg-BejThWID.wasm +0 -0
  112. package/dist/studio-app/assets/index-CoZaxnlm.js +0 -30
@@ -0,0 +1,292 @@
1
+ //! The control plane: server health, tenants, applications, the draft → revision → promotion
2
+ //! lifecycle of an application's contract, and online backup.
3
+
4
+ use feltdb::application::{
5
+ ApplicationDraft, ApplicationManifest, ApplicationRevision, ValidationReport,
6
+ };
7
+ use serde::Deserialize;
8
+ use serde_json::{json, Value};
9
+
10
+ use crate::{encode, Client, Method, Result};
11
+
12
+ /// The server's own health report (`GET /health`): liveness, build identity and subsystem status.
13
+ #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
14
+ pub struct Health {
15
+ /// `healthy`, or the reason it is not.
16
+ pub status: String,
17
+ /// The server's package version.
18
+ pub version: String,
19
+ #[serde(default)]
20
+ pub git_commit: Option<String>,
21
+ #[serde(default)]
22
+ pub release_id: Option<String>,
23
+ #[serde(default)]
24
+ pub public_contract: Option<String>,
25
+ #[serde(default)]
26
+ pub capability_fingerprint: Option<String>,
27
+ #[serde(default)]
28
+ pub storage: Option<String>,
29
+ #[serde(default)]
30
+ pub membership: Option<String>,
31
+ }
32
+
33
+ /// A tenant on the server.
34
+ #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
35
+ pub struct Tenant {
36
+ pub id: String,
37
+ pub name: String,
38
+ #[serde(default)]
39
+ pub slug: Option<String>,
40
+ }
41
+
42
+ /// An application as the control plane records it. Which revision an environment runs is
43
+ /// [`crate::Application::discover`]'s to answer.
44
+ #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
45
+ pub struct ApplicationRecord {
46
+ pub id: String,
47
+ pub tenant_id: String,
48
+ pub name: String,
49
+ #[serde(default)]
50
+ pub slug: Option<String>,
51
+ #[serde(default)]
52
+ pub status: Option<String>,
53
+ }
54
+
55
+ /// Moves an environment to a revision: [`Control::promote`].
56
+ #[derive(Debug, Clone)]
57
+ pub struct Promotion {
58
+ environment: String,
59
+ reason: String,
60
+ expected_current_revision: Option<String>,
61
+ approve_destructive_changes: bool,
62
+ }
63
+
64
+ impl Promotion {
65
+ pub fn new(environment: impl Into<String>) -> Self {
66
+ Self {
67
+ environment: environment.into(),
68
+ reason: String::new(),
69
+ expected_current_revision: None,
70
+ approve_destructive_changes: false,
71
+ }
72
+ }
73
+
74
+ /// Recorded in the environment's history.
75
+ pub fn reason(mut self, reason: impl Into<String>) -> Self {
76
+ self.reason = reason.into();
77
+ self
78
+ }
79
+
80
+ /// Promote only if the environment still runs `revision_id` (`None`: still runs nothing), so
81
+ /// two promotions racing from the same base cannot both win.
82
+ pub fn expect_current(mut self, revision_id: Option<String>) -> Self {
83
+ self.expected_current_revision = revision_id;
84
+ self
85
+ }
86
+
87
+ /// Allow a promotion whose schema change would drop data.
88
+ pub fn approve_destructive_changes(mut self) -> Self {
89
+ self.approve_destructive_changes = true;
90
+ self
91
+ }
92
+ }
93
+
94
+ /// The control-plane routes, from [`Client::control`]. Cheap to clone.
95
+ #[derive(Debug, Clone)]
96
+ pub struct Control {
97
+ client: Client,
98
+ }
99
+
100
+ impl Control {
101
+ fn application_path(application_id: &str, rest: &str) -> String {
102
+ format!("/api/applications/{}{rest}", encode(application_id))
103
+ }
104
+
105
+ fn draft_path(application_id: &str, draft_id: &str, rest: &str) -> String {
106
+ Self::application_path(
107
+ application_id,
108
+ &format!("/drafts/{}{rest}", encode(draft_id)),
109
+ )
110
+ }
111
+
112
+ /// The tenants this key can see.
113
+ pub async fn tenants(&self) -> Result<Vec<Tenant>> {
114
+ self.client
115
+ .send::<_, ()>(Method::GET, "/api/tenants", None)
116
+ .await
117
+ }
118
+
119
+ pub async fn create_tenant(&self, name: &str) -> Result<Tenant> {
120
+ self.client
121
+ .send(Method::POST, "/api/tenants", Some(&json!({ "name": name })))
122
+ .await
123
+ }
124
+
125
+ /// A tenant's applications that this key can see.
126
+ pub async fn applications(&self, tenant_id: &str) -> Result<Vec<ApplicationRecord>> {
127
+ let path = format!("/api/tenants/{}/applications", encode(tenant_id));
128
+ self.client.send::<_, ()>(Method::GET, &path, None).await
129
+ }
130
+
131
+ pub async fn create_application(
132
+ &self,
133
+ tenant_id: &str,
134
+ name: &str,
135
+ ) -> Result<ApplicationRecord> {
136
+ let path = format!("/api/tenants/{}/applications", encode(tenant_id));
137
+ self.client
138
+ .send(Method::POST, &path, Some(&json!({ "name": name })))
139
+ .await
140
+ }
141
+
142
+ pub async fn application(&self, application_id: &str) -> Result<ApplicationRecord> {
143
+ self.client
144
+ .send::<_, ()>(
145
+ Method::GET,
146
+ &Self::application_path(application_id, ""),
147
+ None,
148
+ )
149
+ .await
150
+ }
151
+
152
+ /// Start a draft of the application's contract from `base_revision_id`, or from an empty
153
+ /// manifest.
154
+ pub async fn create_draft(
155
+ &self,
156
+ application_id: &str,
157
+ base_revision_id: Option<&str>,
158
+ ) -> Result<ApplicationDraft> {
159
+ self.client
160
+ .send(
161
+ Method::POST,
162
+ &Self::application_path(application_id, "/drafts"),
163
+ Some(&json!({ "base_revision_id": base_revision_id })),
164
+ )
165
+ .await
166
+ }
167
+
168
+ pub async fn draft(&self, application_id: &str, draft_id: &str) -> Result<ApplicationDraft> {
169
+ self.client
170
+ .send::<_, ()>(
171
+ Method::GET,
172
+ &Self::draft_path(application_id, draft_id, ""),
173
+ None,
174
+ )
175
+ .await
176
+ }
177
+
178
+ /// Replace the draft's manifest, only if the draft is still at `version` (the
179
+ /// [`ApplicationDraft::version`] last read); a concurrent edit is refused, not overwritten.
180
+ pub async fn update_draft(
181
+ &self,
182
+ application_id: &str,
183
+ draft_id: &str,
184
+ version: u64,
185
+ manifest: &ApplicationManifest,
186
+ ) -> Result<ApplicationDraft> {
187
+ self.client
188
+ .send_with_headers(
189
+ Method::PUT,
190
+ &Self::draft_path(application_id, draft_id, ""),
191
+ &[("if-version", &version.to_string())],
192
+ Some(&json!({ "manifest": manifest })),
193
+ )
194
+ .await
195
+ }
196
+
197
+ /// Validate the draft without committing it. An invalid manifest is a report with
198
+ /// `valid: false` and its issues, not an error.
199
+ pub async fn validate_draft(
200
+ &self,
201
+ application_id: &str,
202
+ draft_id: &str,
203
+ ) -> Result<ValidationReport> {
204
+ self.client
205
+ .send(
206
+ Method::POST,
207
+ &Self::draft_path(application_id, draft_id, "/validate"),
208
+ Some(&json!({})),
209
+ )
210
+ .await
211
+ }
212
+
213
+ /// Commit the draft as an immutable revision. No environment runs it until it is promoted.
214
+ pub async fn commit_draft(
215
+ &self,
216
+ application_id: &str,
217
+ draft_id: &str,
218
+ ) -> Result<ApplicationRevision> {
219
+ self.client
220
+ .send(
221
+ Method::POST,
222
+ &Self::draft_path(application_id, draft_id, "/commit"),
223
+ Some(&json!({})),
224
+ )
225
+ .await
226
+ }
227
+
228
+ pub async fn revision(
229
+ &self,
230
+ application_id: &str,
231
+ revision_id: &str,
232
+ ) -> Result<ApplicationRevision> {
233
+ let path = Self::application_path(
234
+ application_id,
235
+ &format!("/revisions/{}", encode(revision_id)),
236
+ );
237
+ self.client.send::<_, ()>(Method::GET, &path, None).await
238
+ }
239
+
240
+ /// Make `revision_id` the revision an environment runs. Returns the server's promotion
241
+ /// record and readiness.
242
+ pub async fn promote(
243
+ &self,
244
+ application_id: &str,
245
+ revision_id: &str,
246
+ promotion: &Promotion,
247
+ ) -> Result<Value> {
248
+ let path = Self::application_path(
249
+ application_id,
250
+ &format!("/revisions/{}/promote", encode(revision_id)),
251
+ );
252
+ self.client
253
+ .send(
254
+ Method::POST,
255
+ &path,
256
+ Some(&json!({
257
+ "environment": promotion.environment,
258
+ "reason": promotion.reason,
259
+ "expected_current_revision": promotion.expected_current_revision,
260
+ "approve_destructive_changes": promotion.approve_destructive_changes,
261
+ })),
262
+ )
263
+ .await
264
+ }
265
+
266
+ /// An online backup: a consistent snapshot of the whole database written to `output`, a path
267
+ /// on the server's host. Returns the bundle's manifest. Requires `cluster:write`; verify the
268
+ /// bundle with `feltdb-server backup verify`.
269
+ pub async fn backup(&self, output: &str) -> Result<Value> {
270
+ self.client
271
+ .send(
272
+ Method::POST,
273
+ "/admin/backups",
274
+ Some(&json!({ "output": output })),
275
+ )
276
+ .await
277
+ }
278
+ }
279
+
280
+ impl Client {
281
+ /// The server's health report. Unauthenticated on the server.
282
+ pub async fn health(&self) -> Result<Health> {
283
+ self.send::<_, ()>(Method::GET, "/health", None).await
284
+ }
285
+
286
+ /// The control plane: tenants, applications, contract drafts, revisions, promotion, backup.
287
+ pub fn control(&self) -> Control {
288
+ Control {
289
+ client: self.clone(),
290
+ }
291
+ }
292
+ }
@@ -0,0 +1,227 @@
1
+ use std::fmt;
2
+
3
+ use feltdb::PreconditionFailure;
4
+ use serde_json::Value;
5
+
6
+ /// Everything a request can end in other than success.
7
+ ///
8
+ /// Match on the variant and on [`ApiError::code`], never on message text. The enum is
9
+ /// `#[non_exhaustive]`, so keep a catch-all arm.
10
+ #[derive(Debug)]
11
+ #[non_exhaustive]
12
+ pub enum Error {
13
+ /// The server answered and refused the request.
14
+ Api(Box<ApiError>),
15
+ /// The request did not complete: connection refused, reset, timed out, or the response could
16
+ /// not be read.
17
+ ///
18
+ /// For a write, [`Error::is_outcome_unknown`] says whether the server may still have applied it.
19
+ /// A transaction with an identity is safe to resubmit with the same id: the authority
20
+ /// recognizes the retry as a duplicate instead of applying it twice.
21
+ Transport {
22
+ outcome_unknown: bool,
23
+ source: reqwest::Error,
24
+ },
25
+ /// The server answered successfully with a body this client could not decode.
26
+ Decode {
27
+ status: u16,
28
+ source: serde_json::Error,
29
+ },
30
+ /// Refused before anything was sent: the request is not one the server could accept.
31
+ InvalidRequest(String),
32
+ /// An event stream ended with a reported authority error.
33
+ Stream(String),
34
+ }
35
+
36
+ /// A refusal from the server, as its JSON error body describes it.
37
+ #[derive(Debug, Clone)]
38
+ pub struct ApiError {
39
+ /// The HTTP status.
40
+ pub status: u16,
41
+ /// The stable machine-readable code, e.g. `PRECONDITION_FAILED`, `VERSION_CONFLICT`,
42
+ /// `IDEMPOTENCY_CONFLICT`, `FORBIDDEN`, `NOT_FOUND`.
43
+ pub code: String,
44
+ /// Human-readable detail. Not a contract: do not branch on it.
45
+ pub message: String,
46
+ /// The server's request id, when it reported one. Quote it when reporting a problem.
47
+ pub request_id: Option<String>,
48
+ /// Which predicate failed and what the authority holds, when a transaction was refused for a
49
+ /// failed precondition or fence. Nothing was written.
50
+ pub failure: Option<PreconditionFailure>,
51
+ /// The complete error body, for fields this type does not name.
52
+ pub body: Value,
53
+ }
54
+
55
+ impl Error {
56
+ /// The stable error code, when the server reported one.
57
+ pub fn code(&self) -> Option<&str> {
58
+ match self {
59
+ Self::Api(error) => Some(&error.code),
60
+ _ => None,
61
+ }
62
+ }
63
+
64
+ /// The HTTP status, when the server answered.
65
+ pub fn status(&self) -> Option<u16> {
66
+ match self {
67
+ Self::Api(error) => Some(error.status),
68
+ Self::Decode { status, .. } => Some(*status),
69
+ _ => None,
70
+ }
71
+ }
72
+
73
+ /// True when a write may or may not have been applied: it was sent, and no answer arrived.
74
+ /// Reconcile by resubmitting a transaction with the same id, or by reading the state back.
75
+ pub fn is_outcome_unknown(&self) -> bool {
76
+ matches!(
77
+ self,
78
+ Self::Transport {
79
+ outcome_unknown: true,
80
+ ..
81
+ }
82
+ )
83
+ }
84
+
85
+ /// The structured precondition failure of a refused transaction.
86
+ pub fn precondition_failure(&self) -> Option<&PreconditionFailure> {
87
+ match self {
88
+ Self::Api(error) => error.failure.as_ref(),
89
+ _ => None,
90
+ }
91
+ }
92
+
93
+ /// True for a refusal caused by state: a failed precondition or fence, a version, epoch or
94
+ /// lease conflict, a record already present. Retry against fresh state, not unchanged.
95
+ pub fn is_conflict(&self) -> bool {
96
+ match self {
97
+ Self::Api(error) => error.status == 409 || error.failure.is_some(),
98
+ _ => false,
99
+ }
100
+ }
101
+
102
+ pub(crate) fn transport(source: reqwest::Error, mutating: bool) -> Self {
103
+ // A connection that was never established carried no request, so nothing can have been
104
+ // applied. Every other failure of a write leaves its outcome open.
105
+ let outcome_unknown = mutating && !source.is_connect() && !source.is_builder();
106
+ Self::Transport {
107
+ outcome_unknown,
108
+ source,
109
+ }
110
+ }
111
+ }
112
+
113
+ impl ApiError {
114
+ pub(crate) fn from_body(status: u16, text: &str) -> Self {
115
+ let body = serde_json::from_str::<Value>(text)
116
+ .ok()
117
+ .filter(Value::is_object)
118
+ .unwrap_or_else(|| serde_json::json!({ "message": text }));
119
+ let field = |name: &str| body.get(name).and_then(Value::as_str).map(str::to_owned);
120
+ let code = field("code")
121
+ .or_else(|| field("error"))
122
+ .unwrap_or_else(|| fallback_code(status).to_owned());
123
+ let message = field("message")
124
+ .or_else(|| field("error"))
125
+ .unwrap_or_else(|| format!("FeltDB request failed with HTTP {status}"));
126
+ let failure = body
127
+ .get("failure")
128
+ .cloned()
129
+ .and_then(|value| serde_json::from_value(value).ok());
130
+ Self {
131
+ status,
132
+ code,
133
+ message,
134
+ request_id: field("request_id"),
135
+ failure,
136
+ body,
137
+ }
138
+ }
139
+ }
140
+
141
+ fn fallback_code(status: u16) -> &'static str {
142
+ match status {
143
+ 401 => "UNAUTHORIZED",
144
+ 403 => "FORBIDDEN",
145
+ 404 => "NOT_FOUND",
146
+ 409 => "CONFLICT",
147
+ _ if status >= 500 => "EXECUTION_FAILED",
148
+ _ => "INVALID_REQUEST",
149
+ }
150
+ }
151
+
152
+ impl fmt::Display for Error {
153
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154
+ match self {
155
+ Self::Api(error) => write!(f, "{error}"),
156
+ Self::Transport {
157
+ outcome_unknown: true,
158
+ source,
159
+ } => write!(f, "request outcome unknown: {source}"),
160
+ Self::Transport { source, .. } => write!(f, "request failed: {source}"),
161
+ Self::Decode { status, source } => {
162
+ write!(f, "could not decode an HTTP {status} response: {source}")
163
+ }
164
+ Self::InvalidRequest(message) => write!(f, "invalid request: {message}"),
165
+ Self::Stream(message) => write!(f, "event stream ended: {message}"),
166
+ }
167
+ }
168
+ }
169
+
170
+ impl fmt::Display for ApiError {
171
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172
+ write!(f, "{} (HTTP {}): {}", self.code, self.status, self.message)?;
173
+ if let Some(request_id) = &self.request_id {
174
+ write!(f, " [{request_id}]")?;
175
+ }
176
+ Ok(())
177
+ }
178
+ }
179
+
180
+ impl std::error::Error for Error {
181
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
182
+ match self {
183
+ Self::Transport { source, .. } => Some(source),
184
+ Self::Decode { source, .. } => Some(source),
185
+ _ => None,
186
+ }
187
+ }
188
+ }
189
+
190
+ impl std::error::Error for ApiError {}
191
+
192
+ pub type Result<T, E = Error> = std::result::Result<T, E>;
193
+
194
+ #[cfg(test)]
195
+ mod tests {
196
+ use super::*;
197
+
198
+ #[test]
199
+ fn a_refused_transaction_carries_its_structured_failure() {
200
+ let error = ApiError::from_body(
201
+ 409,
202
+ r#"{"committed":false,"code":"PRECONDITION_FAILED","error":"PRECONDITION_FAILED","message":"Conflict",
203
+ "failure":{"predicate":"version","collection":"orders","key":"orders:o-1","expected":1,"actual":2},
204
+ "request_id":"req_1"}"#,
205
+ );
206
+ assert_eq!(error.code, "PRECONDITION_FAILED");
207
+ assert_eq!(error.request_id.as_deref(), Some("req_1"));
208
+ assert_eq!(
209
+ error.failure,
210
+ Some(PreconditionFailure::Version {
211
+ collection: "orders".into(),
212
+ key: "orders:o-1".into(),
213
+ expected: 1,
214
+ actual: 2
215
+ })
216
+ );
217
+ assert!(Error::Api(Box::new(error)).is_conflict());
218
+ }
219
+
220
+ #[test]
221
+ fn a_body_that_is_not_json_still_yields_a_code() {
222
+ let error = ApiError::from_body(502, "bad gateway");
223
+ assert_eq!(error.code, "EXECUTION_FAILED");
224
+ assert_eq!(error.message, "bad gateway");
225
+ assert!(error.failure.is_none());
226
+ }
227
+ }
@@ -0,0 +1,146 @@
1
+ use serde::de::DeserializeOwned;
2
+
3
+ use crate::error::{Error, Result};
4
+
5
+ /// One server-sent event.
6
+ #[derive(Debug, Clone, PartialEq, Eq)]
7
+ pub struct ServerEvent {
8
+ /// The opaque cursor after this event. Pass it back to resume from here.
9
+ pub id: Option<String>,
10
+ /// The event name; `state.committed` for a committed change.
11
+ pub event: String,
12
+ /// The event payload, a JSON document for `state.committed`.
13
+ pub data: String,
14
+ }
15
+
16
+ impl ServerEvent {
17
+ /// Decode the payload.
18
+ pub fn json<T: DeserializeOwned>(&self) -> Result<T> {
19
+ serde_json::from_str(&self.data).map_err(|source| Error::Decode {
20
+ status: 200,
21
+ source,
22
+ })
23
+ }
24
+ }
25
+
26
+ /// A live event stream: a delivery adapter over committed history, not the history itself.
27
+ ///
28
+ /// It does not reconnect on its own. When it ends or fails, open a new one from
29
+ /// [`EventStream::last_event_id`] and nothing committed in between is skipped. For a complete,
30
+ /// bounded read of history use the application's event replay instead.
31
+ pub struct EventStream {
32
+ response: reqwest::Response,
33
+ buffer: Vec<u8>,
34
+ last_event_id: Option<String>,
35
+ finished: bool,
36
+ }
37
+
38
+ impl EventStream {
39
+ pub(crate) fn new(response: reqwest::Response, resumed_from: Option<String>) -> Self {
40
+ Self {
41
+ response,
42
+ buffer: Vec::new(),
43
+ last_event_id: resumed_from,
44
+ finished: false,
45
+ }
46
+ }
47
+
48
+ /// The cursor of the last event delivered (or the one this stream resumed from).
49
+ pub fn last_event_id(&self) -> Option<&str> {
50
+ self.last_event_id.as_deref()
51
+ }
52
+
53
+ /// The next event; `None` when the server closed the stream.
54
+ ///
55
+ /// An `authority.error` event from the server ends the stream with [`Error::Stream`].
56
+ pub async fn next(&mut self) -> Option<Result<ServerEvent>> {
57
+ loop {
58
+ if self.finished {
59
+ return None;
60
+ }
61
+ while let Some(frame) = take_frame(&mut self.buffer) {
62
+ let Some(event) = parse_frame(&frame) else {
63
+ continue;
64
+ };
65
+ if event.event == "authority.error" {
66
+ self.finished = true;
67
+ return Some(Err(Error::Stream(event.data)));
68
+ }
69
+ if event.id.is_some() {
70
+ self.last_event_id.clone_from(&event.id);
71
+ }
72
+ return Some(Ok(event));
73
+ }
74
+ match self.response.chunk().await {
75
+ // Bytes, not text: a chunk can end inside a multi-byte character, and only a
76
+ // complete frame is decoded. Carriage returns are dropped so CRLF framing parses
77
+ // as LF; JSON payloads escape theirs.
78
+ Ok(Some(bytes)) => self
79
+ .buffer
80
+ .extend(bytes.iter().copied().filter(|byte| *byte != b'\r')),
81
+ Ok(None) => {
82
+ self.finished = true;
83
+ return None;
84
+ }
85
+ Err(source) => {
86
+ self.finished = true;
87
+ return Some(Err(Error::transport(source, false)));
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ fn take_frame(buffer: &mut Vec<u8>) -> Option<String> {
95
+ let end = buffer.windows(2).position(|pair| pair == b"\n\n")?;
96
+ let frame = String::from_utf8_lossy(&buffer[..end]).into_owned();
97
+ buffer.drain(..end + 2);
98
+ Some(frame)
99
+ }
100
+
101
+ /// Parse one SSE frame. Comments (keep-alives) and frames without data are skipped.
102
+ fn parse_frame(frame: &str) -> Option<ServerEvent> {
103
+ let mut id = None;
104
+ let mut event = None;
105
+ let mut data: Vec<&str> = Vec::new();
106
+ for line in frame.split('\n') {
107
+ if line.starts_with(':') {
108
+ continue;
109
+ }
110
+ let (field, value) = line.split_once(':').unwrap_or((line, ""));
111
+ let value = value.strip_prefix(' ').unwrap_or(value);
112
+ match field {
113
+ "id" => id = Some(value.to_owned()),
114
+ "event" => event = Some(value.to_owned()),
115
+ "data" => data.push(value),
116
+ _ => {}
117
+ }
118
+ }
119
+ if data.is_empty() {
120
+ return None;
121
+ }
122
+ Some(ServerEvent {
123
+ id,
124
+ event: event.unwrap_or_else(|| "message".to_owned()),
125
+ data: data.join("\n"),
126
+ })
127
+ }
128
+
129
+ #[cfg(test)]
130
+ mod tests {
131
+ use super::*;
132
+
133
+ #[test]
134
+ fn frames_are_split_and_parsed() {
135
+ let mut buffer =
136
+ b": keep-alive\n\nid: c-2\nevent: state.committed\ndata: {\"a\":\ndata: 1}\n\nid: partial"
137
+ .to_vec();
138
+ assert_eq!(parse_frame(&take_frame(&mut buffer).unwrap()), None);
139
+ let event = parse_frame(&take_frame(&mut buffer).unwrap()).unwrap();
140
+ assert_eq!(event.id.as_deref(), Some("c-2"));
141
+ assert_eq!(event.event, "state.committed");
142
+ assert_eq!(event.data, "{\"a\":\n1}");
143
+ assert_eq!(take_frame(&mut buffer), None);
144
+ assert_eq!(buffer, b"id: partial");
145
+ }
146
+ }