@feltdb/core 0.12.2 → 0.15.0
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.
- package/dist/agent-runtime.d.ts +64 -2
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +121 -11
- package/dist/agent-state.d.ts +48 -0
- package/dist/agent-state.d.ts.map +1 -1
- package/dist/agent-state.js +174 -12
- package/dist/agent.d.ts +10 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -1
- package/dist/build-identity.json +4 -4
- package/dist/cli/commands.js +34 -7
- package/dist/cli/workspace-integration.js +8 -0
- package/dist/collection.d.ts +3 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +30 -8
- package/dist/create/build-identity.json +4 -4
- package/dist/create/cli.js +47 -38
- package/dist/create/create.js +21 -8
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +6 -6
- package/dist/create/server-source/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +341 -2
- package/dist/create/server-source/crates/feltdb/src/workload.rs +2094 -126
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
- package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
- package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +113 -25
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
- package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
- package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +4 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +1740 -236
- package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
- package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
- package/dist/create/setup-summary.js +40 -0
- package/dist/db.d.ts +91 -10
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +170 -31
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +38 -2
- package/dist/development-runtime-bridge.d.ts +20 -0
- package/dist/development-runtime-bridge.d.ts.map +1 -1
- package/dist/development-runtime-bridge.js +61 -12
- package/dist/embedded-query.d.ts.map +1 -1
- package/dist/embedded-query.js +5 -4
- package/dist/embedded-transaction.d.ts +8 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +55 -19
- package/dist/error-codes.d.ts +31 -2
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +41 -2
- package/dist/feltdb.d.ts +7 -0
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +27 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +145 -7
- package/dist/http-client.d.ts +11 -3
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +27 -14
- package/dist/http-db.d.ts +40 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +285 -58
- package/dist/index.js +1 -1
- package/dist/local-state-lock.d.ts +34 -0
- package/dist/local-state-lock.d.ts.map +1 -0
- package/dist/local-state-lock.js +225 -0
- package/dist/reactive-graph.d.ts +0 -3
- package/dist/reactive-graph.d.ts.map +1 -1
- package/dist/reactive-graph.js +11 -1
- package/dist/runtime-registry.d.ts +7 -2
- package/dist/runtime-registry.d.ts.map +1 -1
- package/dist/runtime-registry.js +2 -2
- package/dist/studio/{KeyManagementPanel-D5FMvvU9.js → KeyManagementPanel-skUNnKRw.js} +36 -29
- package/dist/studio/app.d.ts +8 -1
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
- package/dist/studio/components/KeyManagementPanel.js +1 -1
- package/dist/studio/components/index.js +2 -2
- package/dist/studio/{components-B4eKIUN5.js → components-CJmzntBi.js} +8 -7
- package/dist/studio/control-plane/Data.d.ts.map +1 -1
- package/dist/studio/control-plane/Instance.d.ts +4 -1
- package/dist/studio/control-plane/Instance.d.ts.map +1 -1
- package/dist/studio/control-plane/Operations.d.ts +4 -1
- package/dist/studio/control-plane/Operations.d.ts.map +1 -1
- package/dist/studio/control-plane/index.js +2 -2
- package/dist/studio/control-plane/primitives.d.ts +16 -0
- package/dist/studio/control-plane/primitives.d.ts.map +1 -1
- package/dist/studio/{control-plane-fxMcJn74.js → control-plane-CaBA7Rgv.js} +448 -424
- package/dist/studio/index.d.ts +1 -0
- package/dist/studio/index.d.ts.map +1 -1
- package/dist/studio/index.js +384 -247
- package/dist/studio/navigation.d.ts +57 -0
- package/dist/studio/navigation.d.ts.map +1 -0
- package/dist/studio/studio.css +1 -1
- package/dist/studio/utils/control-plane-api.d.ts +2 -0
- package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
- package/dist/studio/utils/control-plane-api.js +5 -1
- package/dist/studio-app/assets/{feltdb_wasm-BatwwogY.js → feltdb_wasm-BjM1ylL3.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
- package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
- package/dist/studio-app/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
- package/dist/studio-app/index.html +2 -2
- package/dist/transaction.d.ts +27 -11
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +20 -14
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/wasm/package.json +1 -1
- package/dist/workload.d.ts +31 -3
- package/dist/workload.d.ts.map +1 -1
- package/dist/workload.js +28 -3
- package/dist/workspace/local-development-authority.d.ts +19 -0
- package/dist/workspace/local-development-authority.d.ts.map +1 -1
- package/dist/workspace/local-development-authority.js +335 -15
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
- package/dist/studio-app/assets/index-DoVvbIay.js +0 -30
package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
//! The transaction-precondition contract through the Rust client (`feltdb-client`) against this
|
|
2
|
+
//! crate's real server binary, over both transaction routes.
|
|
3
|
+
//!
|
|
4
|
+
//! The same contract is held for the TypeScript SDK by
|
|
5
|
+
//! `e2e/transaction-precondition-contract.test.mjs`. Every test stages the failure the contract
|
|
6
|
+
//! forbids and asserts the authority's structured answer as the Rust client decodes it.
|
|
7
|
+
|
|
8
|
+
use std::{
|
|
9
|
+
io::{BufRead, BufReader},
|
|
10
|
+
path::PathBuf,
|
|
11
|
+
process::{Child, Command, Stdio},
|
|
12
|
+
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
use feltdb_client::{
|
|
16
|
+
path_segment, AppTransaction, Client, Error, Method, Operation, Precondition,
|
|
17
|
+
PreconditionFailure, Transaction,
|
|
18
|
+
};
|
|
19
|
+
use serde_json::{json, Value};
|
|
20
|
+
|
|
21
|
+
struct Server {
|
|
22
|
+
child: Child,
|
|
23
|
+
url: String,
|
|
24
|
+
directory: PathBuf,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
impl Server {
|
|
28
|
+
fn start(name: &str) -> Self {
|
|
29
|
+
let directory = std::env::temp_dir().join(format!(
|
|
30
|
+
"feltdb-txp-{name}-{}-{}",
|
|
31
|
+
std::process::id(),
|
|
32
|
+
SystemTime::now()
|
|
33
|
+
.duration_since(UNIX_EPOCH)
|
|
34
|
+
.unwrap()
|
|
35
|
+
.as_nanos()
|
|
36
|
+
));
|
|
37
|
+
std::fs::create_dir_all(&directory).unwrap();
|
|
38
|
+
let data = directory.join("feltdb.log");
|
|
39
|
+
let mut child = Command::new(env!("CARGO_BIN_EXE_feltdb-server"))
|
|
40
|
+
.args([
|
|
41
|
+
"--host",
|
|
42
|
+
"127.0.0.1",
|
|
43
|
+
"--port",
|
|
44
|
+
"0",
|
|
45
|
+
"--namespace",
|
|
46
|
+
"txp",
|
|
47
|
+
"--data",
|
|
48
|
+
data.to_str().unwrap(),
|
|
49
|
+
"--keys",
|
|
50
|
+
directory.join("keys.json").to_str().unwrap(),
|
|
51
|
+
"--audit",
|
|
52
|
+
directory.join("audit.log").to_str().unwrap(),
|
|
53
|
+
])
|
|
54
|
+
.env("FELTDB_MASTER_KEY", "txp-development-key")
|
|
55
|
+
.stdout(Stdio::piped())
|
|
56
|
+
.stderr(Stdio::null())
|
|
57
|
+
.spawn()
|
|
58
|
+
.unwrap();
|
|
59
|
+
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
|
|
60
|
+
let url = loop {
|
|
61
|
+
let line = lines
|
|
62
|
+
.next()
|
|
63
|
+
.expect("server exited before readiness")
|
|
64
|
+
.unwrap();
|
|
65
|
+
if let Some(rest) = line.split("ready at ").nth(1) {
|
|
66
|
+
break rest.split_whitespace().next().unwrap().to_owned();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
std::thread::spawn(move || lines.for_each(drop));
|
|
70
|
+
Self {
|
|
71
|
+
child,
|
|
72
|
+
url,
|
|
73
|
+
directory,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn client(&self) -> Client {
|
|
78
|
+
Client::builder(&self.url).build().unwrap()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fn data(&self) -> PathBuf {
|
|
82
|
+
self.directory.join("feltdb.log")
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
impl Drop for Server {
|
|
87
|
+
fn drop(&mut self) {
|
|
88
|
+
let _ = self.child.kill();
|
|
89
|
+
let _ = self.child.wait();
|
|
90
|
+
let _ = std::fs::remove_dir_all(&self.directory);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Provision one application with collections `A` and `B` and return its id.
|
|
95
|
+
async fn provision(client: &Client) -> String {
|
|
96
|
+
let tenant = client
|
|
97
|
+
.request(
|
|
98
|
+
Method::POST,
|
|
99
|
+
"/api/tenants",
|
|
100
|
+
Some(&json!({ "name": "txp" })),
|
|
101
|
+
)
|
|
102
|
+
.await
|
|
103
|
+
.unwrap();
|
|
104
|
+
let tenant_id = tenant["tenant_id"]
|
|
105
|
+
.as_str()
|
|
106
|
+
.or(tenant["id"].as_str())
|
|
107
|
+
.unwrap();
|
|
108
|
+
let application = client
|
|
109
|
+
.request(
|
|
110
|
+
Method::POST,
|
|
111
|
+
&format!("/api/tenants/{tenant_id}/applications"),
|
|
112
|
+
Some(&json!({ "name": "txp" })),
|
|
113
|
+
)
|
|
114
|
+
.await
|
|
115
|
+
.unwrap();
|
|
116
|
+
let application_id = application["application_id"]
|
|
117
|
+
.as_str()
|
|
118
|
+
.or(application["id"].as_str())
|
|
119
|
+
.unwrap()
|
|
120
|
+
.to_owned();
|
|
121
|
+
let drafts = format!("/api/applications/{application_id}/drafts");
|
|
122
|
+
let draft = client
|
|
123
|
+
.request(Method::POST, &drafts, Some(&json!({})))
|
|
124
|
+
.await
|
|
125
|
+
.unwrap();
|
|
126
|
+
let draft_id = draft["draft_id"].as_str().unwrap();
|
|
127
|
+
let mut manifest = draft["manifest"].clone();
|
|
128
|
+
let fields = json!([{ "name": "n", "type": "integer", "required": false }]);
|
|
129
|
+
manifest["collections"] = json!([
|
|
130
|
+
{ "name": "A", "fields": fields },
|
|
131
|
+
{ "name": "B", "fields": fields },
|
|
132
|
+
]);
|
|
133
|
+
manifest["policies"] = json!(
|
|
134
|
+
["A", "B"].map(|name| json!({ "name": name, "resource": name,
|
|
135
|
+
"read": "authenticated", "write": "authenticated", "capabilities": [] }))
|
|
136
|
+
);
|
|
137
|
+
let version = draft["version"].as_u64().unwrap().to_string();
|
|
138
|
+
client
|
|
139
|
+
.request_with_headers(
|
|
140
|
+
Method::PUT,
|
|
141
|
+
&format!("{drafts}/{draft_id}"),
|
|
142
|
+
&[("if-version", &version)],
|
|
143
|
+
Some(&json!({ "manifest": manifest })),
|
|
144
|
+
)
|
|
145
|
+
.await
|
|
146
|
+
.unwrap();
|
|
147
|
+
let revision = client
|
|
148
|
+
.request(
|
|
149
|
+
Method::POST,
|
|
150
|
+
&format!("{drafts}/{draft_id}/commit"),
|
|
151
|
+
Some(&json!({})),
|
|
152
|
+
)
|
|
153
|
+
.await
|
|
154
|
+
.unwrap();
|
|
155
|
+
let encoded = path_segment(revision["revision_id"].as_str().unwrap());
|
|
156
|
+
client
|
|
157
|
+
.request(
|
|
158
|
+
Method::POST,
|
|
159
|
+
&format!("/api/applications/{application_id}/revisions/{encoded}/promote"),
|
|
160
|
+
Some(&json!({ "environment": "production", "reason": "txp" })),
|
|
161
|
+
)
|
|
162
|
+
.await
|
|
163
|
+
.unwrap();
|
|
164
|
+
application_id
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
fn failure_body(error: &Error) -> Value {
|
|
168
|
+
match error {
|
|
169
|
+
Error::Api(api) => api.body["failure"].clone(),
|
|
170
|
+
other => panic!("expected an API refusal, got {other}"),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#[tokio::test]
|
|
175
|
+
async fn direct_route_a_stale_guard_refuses_every_staged_write_with_a_structured_failure() {
|
|
176
|
+
let server = Server::start("direct-stale");
|
|
177
|
+
let client = server.client();
|
|
178
|
+
client
|
|
179
|
+
.commit(&Transaction::generated().operation(Operation::create("A", "a", json!({}))))
|
|
180
|
+
.await
|
|
181
|
+
.unwrap();
|
|
182
|
+
client
|
|
183
|
+
.commit(&Transaction::generated().operation(Operation::create("B", "b", json!({ "n": 1 }))))
|
|
184
|
+
.await
|
|
185
|
+
.unwrap();
|
|
186
|
+
// Another writer advances A from 1 to 2.
|
|
187
|
+
client
|
|
188
|
+
.commit(
|
|
189
|
+
&Transaction::generated()
|
|
190
|
+
.operation(Operation::put("A", "a", json!({ "n": 2 })).expect_version(1)),
|
|
191
|
+
)
|
|
192
|
+
.await
|
|
193
|
+
.unwrap();
|
|
194
|
+
let refused = client
|
|
195
|
+
.commit(
|
|
196
|
+
&Transaction::generated()
|
|
197
|
+
.precondition(Precondition::version("A", "a", 1))
|
|
198
|
+
.operation(Operation::put("A", "a", json!({ "n": 9 })))
|
|
199
|
+
.operation(Operation::put("B", "b", json!({ "n": 9 }))),
|
|
200
|
+
)
|
|
201
|
+
.await
|
|
202
|
+
.unwrap_err();
|
|
203
|
+
assert_eq!(refused.code(), Some("PRECONDITION_FAILED"));
|
|
204
|
+
assert_eq!(refused.status(), Some(409));
|
|
205
|
+
assert_eq!(
|
|
206
|
+
refused.precondition_failure(),
|
|
207
|
+
Some(&PreconditionFailure::Version {
|
|
208
|
+
collection: "A".into(),
|
|
209
|
+
key: "A:a".into(),
|
|
210
|
+
expected: 1,
|
|
211
|
+
actual: 2
|
|
212
|
+
})
|
|
213
|
+
);
|
|
214
|
+
assert_eq!(
|
|
215
|
+
failure_body(&refused)["id"],
|
|
216
|
+
"a",
|
|
217
|
+
"the failure names the record id"
|
|
218
|
+
);
|
|
219
|
+
assert_eq!(
|
|
220
|
+
client.get("B", "b").await.unwrap().unwrap().value["n"],
|
|
221
|
+
1,
|
|
222
|
+
"B is unchanged"
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#[tokio::test]
|
|
227
|
+
async fn application_route_refusals_name_the_public_collection_and_record() {
|
|
228
|
+
let server = Server::start("app-shape");
|
|
229
|
+
let client = server.client();
|
|
230
|
+
let app = client.application(&provision(&client).await);
|
|
231
|
+
app.commit(
|
|
232
|
+
&AppTransaction::generated()
|
|
233
|
+
.insert("A", "a", json!({ "n": 1 }))
|
|
234
|
+
.insert("B", "b", json!({ "n": 1 })),
|
|
235
|
+
)
|
|
236
|
+
.await
|
|
237
|
+
.unwrap();
|
|
238
|
+
// `__version` is the document version the authority owns: 1 on create, 2 after a fenced write.
|
|
239
|
+
app.commit(
|
|
240
|
+
&AppTransaction::generated()
|
|
241
|
+
.expect_version("A", "a", 1)
|
|
242
|
+
.update("A", "a", json!({ "n": 2 })),
|
|
243
|
+
)
|
|
244
|
+
.await
|
|
245
|
+
.unwrap();
|
|
246
|
+
|
|
247
|
+
let stale = app
|
|
248
|
+
.commit(
|
|
249
|
+
&AppTransaction::generated()
|
|
250
|
+
.expect_version("A", "a", 1)
|
|
251
|
+
.update("A", "a", json!({ "n": 9 }))
|
|
252
|
+
.update("B", "b", json!({ "n": 9 })),
|
|
253
|
+
)
|
|
254
|
+
.await
|
|
255
|
+
.unwrap_err();
|
|
256
|
+
assert_eq!(stale.code(), Some("PRECONDITION_FAILED"), "{stale}");
|
|
257
|
+
assert_eq!(
|
|
258
|
+
stale.precondition_failure(),
|
|
259
|
+
Some(&PreconditionFailure::Version {
|
|
260
|
+
collection: "A".into(),
|
|
261
|
+
key: "A:a".into(),
|
|
262
|
+
expected: 1,
|
|
263
|
+
actual: 2
|
|
264
|
+
}),
|
|
265
|
+
"the refusal names the application's collection, not the authority's storage namespace: {}",
|
|
266
|
+
failure_body(&stale)
|
|
267
|
+
);
|
|
268
|
+
assert_eq!(failure_body(&stale)["id"], "a");
|
|
269
|
+
|
|
270
|
+
// A create of a record that exists is the same structured refusal on every route.
|
|
271
|
+
let present = app
|
|
272
|
+
.commit(&AppTransaction::generated().insert("A", "a", json!({ "n": 1 })))
|
|
273
|
+
.await
|
|
274
|
+
.unwrap_err();
|
|
275
|
+
assert_eq!(present.code(), Some("PRECONDITION_FAILED"), "{present}");
|
|
276
|
+
assert!(
|
|
277
|
+
matches!(
|
|
278
|
+
present.precondition_failure(),
|
|
279
|
+
Some(PreconditionFailure::Present { collection, key }) if collection == "A" && key == "A:a"
|
|
280
|
+
),
|
|
281
|
+
"{present}: {}",
|
|
282
|
+
failure_body(&present)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#[tokio::test]
|
|
287
|
+
async fn application_route_concurrent_writers_from_one_version_exactly_one_commits() {
|
|
288
|
+
let server = Server::start("app-race");
|
|
289
|
+
let client = server.client();
|
|
290
|
+
let application_id = provision(&client).await;
|
|
291
|
+
let app = client.application(&application_id);
|
|
292
|
+
app.commit(&AppTransaction::generated().insert("A", "a", json!({ "n": 0 })))
|
|
293
|
+
.await
|
|
294
|
+
.unwrap();
|
|
295
|
+
for round in 0..20u64 {
|
|
296
|
+
let from = round + 1;
|
|
297
|
+
let contenders = (0..6u64).map(|index| {
|
|
298
|
+
let app = server.client().application(&application_id);
|
|
299
|
+
tokio::spawn(async move {
|
|
300
|
+
app.commit(
|
|
301
|
+
&AppTransaction::generated()
|
|
302
|
+
.expect_version("A", "a", from)
|
|
303
|
+
.update("A", "a", json!({ "n": index }))
|
|
304
|
+
.insert("B", format!("b-{round}-{index}"), json!({ "n": index })),
|
|
305
|
+
)
|
|
306
|
+
.await
|
|
307
|
+
})
|
|
308
|
+
});
|
|
309
|
+
let outcomes: Vec<_> = futures_join(contenders).await;
|
|
310
|
+
let winners = outcomes.iter().filter(|outcome| outcome.is_ok()).count();
|
|
311
|
+
assert_eq!(winners, 1, "round {round}: {winners} winners");
|
|
312
|
+
for loser in outcomes.iter().filter_map(|outcome| outcome.as_ref().err()) {
|
|
313
|
+
assert!(
|
|
314
|
+
matches!(
|
|
315
|
+
loser.precondition_failure(),
|
|
316
|
+
Some(PreconditionFailure::Version { expected, actual, .. }) if *expected == from && *actual == from + 1
|
|
317
|
+
),
|
|
318
|
+
"round {round}: {loser}"
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async fn futures_join<T: Send + 'static>(
|
|
325
|
+
handles: impl Iterator<Item = tokio::task::JoinHandle<T>>,
|
|
326
|
+
) -> Vec<T> {
|
|
327
|
+
let mut outcomes = Vec::new();
|
|
328
|
+
for handle in handles.collect::<Vec<_>>() {
|
|
329
|
+
outcomes.push(handle.await.unwrap());
|
|
330
|
+
}
|
|
331
|
+
outcomes
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#[tokio::test]
|
|
335
|
+
async fn application_route_a_duplicate_reports_the_original_commit() {
|
|
336
|
+
let server = Server::start("app-duplicate");
|
|
337
|
+
let client = server.client();
|
|
338
|
+
let app = client.application(&provision(&client).await);
|
|
339
|
+
let original = AppTransaction::new("txp-dup").insert("A", "a", json!({ "n": 1 }));
|
|
340
|
+
let first = app.commit(&original).await.unwrap();
|
|
341
|
+
app.commit(&AppTransaction::generated().insert("B", "b", json!({ "n": 1 })))
|
|
342
|
+
.await
|
|
343
|
+
.unwrap();
|
|
344
|
+
let replay = app.commit(&original).await.unwrap();
|
|
345
|
+
assert!(replay.result.duplicate);
|
|
346
|
+
assert_eq!(replay.result.commit_revision, first.result.commit_revision);
|
|
347
|
+
assert_eq!(replay.result.base_revision, first.result.base_revision);
|
|
348
|
+
assert_eq!(replay.result.state_after, first.result.state_after);
|
|
349
|
+
let different = app
|
|
350
|
+
.commit(&AppTransaction::new("txp-dup").insert("A", "other", json!({ "n": 1 })))
|
|
351
|
+
.await
|
|
352
|
+
.unwrap_err();
|
|
353
|
+
assert_eq!(different.code(), Some("IDEMPOTENCY_CONFLICT"));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#[tokio::test]
|
|
357
|
+
async fn a_storage_failure_at_commit_is_a_durability_failure_not_a_conflict() {
|
|
358
|
+
let server = Server::start("direct-storage");
|
|
359
|
+
let client = server.client();
|
|
360
|
+
client
|
|
361
|
+
.commit(&Transaction::generated().operation(Operation::create("A", "a", json!({}))))
|
|
362
|
+
.await
|
|
363
|
+
.unwrap();
|
|
364
|
+
// The next append opens a directory and fails before writing anything.
|
|
365
|
+
let aside = server.data().with_extension("aside");
|
|
366
|
+
std::fs::rename(server.data(), &aside).unwrap();
|
|
367
|
+
std::fs::create_dir(server.data()).unwrap();
|
|
368
|
+
let refused = client
|
|
369
|
+
.commit(&Transaction::generated().operation(Operation::create("A", "b", json!({}))))
|
|
370
|
+
.await
|
|
371
|
+
.unwrap_err();
|
|
372
|
+
std::fs::remove_dir(server.data()).unwrap();
|
|
373
|
+
std::fs::rename(&aside, server.data()).unwrap();
|
|
374
|
+
assert!(
|
|
375
|
+
!refused.is_conflict(),
|
|
376
|
+
"a storage failure was reported as a lost race: {refused}"
|
|
377
|
+
);
|
|
378
|
+
assert_eq!(refused.code(), Some("DURABILITY_FAILURE"), "{refused}");
|
|
379
|
+
assert_eq!(refused.status(), Some(503));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
#[tokio::test]
|
|
383
|
+
async fn a_write_whose_answer_never_arrives_is_outcome_unknown() {
|
|
384
|
+
// A listener that accepts and never answers: the request is sent, no answer arrives.
|
|
385
|
+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
386
|
+
let address = listener.local_addr().unwrap();
|
|
387
|
+
tokio::spawn(async move {
|
|
388
|
+
let mut held = Vec::new();
|
|
389
|
+
while let Ok((socket, _)) = listener.accept().await {
|
|
390
|
+
held.push(socket);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
let client = Client::builder(format!("http://{address}"))
|
|
394
|
+
.timeout(Duration::from_millis(300))
|
|
395
|
+
.build()
|
|
396
|
+
.unwrap();
|
|
397
|
+
let error = client
|
|
398
|
+
.commit(&Transaction::new("txp-unknown").operation(Operation::create("A", "a", json!({}))))
|
|
399
|
+
.await
|
|
400
|
+
.unwrap_err();
|
|
401
|
+
assert!(error.is_outcome_unknown(), "{error}");
|
|
402
|
+
assert!(!error.is_conflict());
|
|
403
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `create-feltdb` will generate, in the words the developer chose it in.
|
|
3
|
+
*
|
|
4
|
+
* The summary is printed before anything is written, so it must describe the
|
|
5
|
+
* project that is about to exist — not the menu that produced it. Each line
|
|
6
|
+
* states what is included and what it needs; nothing here claims a capability
|
|
7
|
+
* the resulting instance would not provide.
|
|
8
|
+
*/
|
|
9
|
+
export const RUNTIME_CHOICES = [
|
|
10
|
+
{ value: 'browser', label: 'Browser', summary: 'data stays in the browser (IndexedDB); nothing else to run' },
|
|
11
|
+
{ value: 'node', label: 'Node.js authority', summary: 'data lives in a FeltDB authority the application connects to; `feltdb dev` runs one locally' },
|
|
12
|
+
{ value: 'self-hosted', label: 'Self-hosted server', summary: 'data lives in your own feltdb-server; Docker Compose included' },
|
|
13
|
+
{ value: 'managed', label: 'Managed', summary: 'data lives in FeltDB-hosted infrastructure; needs a FeltDB account' },
|
|
14
|
+
];
|
|
15
|
+
/** The React starter's tour includes its assistants; only the vanilla template makes them optional. */
|
|
16
|
+
export function agentsIncluded(options) {
|
|
17
|
+
return options.framework === 'react' || options.agents;
|
|
18
|
+
}
|
|
19
|
+
export function describeSetup(options) {
|
|
20
|
+
const runtime = RUNTIME_CHOICES.find(choice => choice.value === options.runtime);
|
|
21
|
+
const lines = [
|
|
22
|
+
`Data: ${runtime ? `${runtime.label} — ${runtime.summary}` : options.runtime}`,
|
|
23
|
+
`Framework: ${options.framework === 'react' ? 'React' : 'Vanilla TypeScript'}`,
|
|
24
|
+
];
|
|
25
|
+
if (options.framework === 'react') {
|
|
26
|
+
lines.push('Starter: sign-up, projects, invitations, activity, and two assistants that run bounded capabilities (no AI model needed)');
|
|
27
|
+
lines.push('Capabilities: declared in feltdb.flow (ProjectSearch, ProjectSummary, ActivitySummary)');
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
lines.push(`Agents: ${options.agents ? 'example included' : 'not included'}`);
|
|
31
|
+
lines.push(`Capabilities: ${options.capabilities}`);
|
|
32
|
+
}
|
|
33
|
+
lines.push(options.webllm
|
|
34
|
+
? 'AI builder: included — @feltdb/webllm; downloads a small language model (a few hundred MB) into the browser on first use and needs WebGPU'
|
|
35
|
+
: 'AI builder: not included (add later: npm install @feltdb/webllm)');
|
|
36
|
+
if (options.distributed) {
|
|
37
|
+
lines.push('Distributed: recorded in feltdb.config.json; it takes effect when you deploy several feltdb-server nodes. Development runs one local authority.');
|
|
38
|
+
}
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
package/dist/db.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { type TransactionReadBasis, type TransactionReadSet, type AtomicTransact
|
|
|
11
11
|
import { type Cell } from './cell.js';
|
|
12
12
|
import type { AgentRef, AgentDefinition, AgentExecution } from './agent.js';
|
|
13
13
|
import { AgentRegistry } from './agent-registry.js';
|
|
14
|
-
import { AgentRuntime } from './agent-runtime.js';
|
|
14
|
+
import { AgentRuntime, type CreateAgentExecutionOptions, type FindAgentExecutionQuery } from './agent-runtime.js';
|
|
15
15
|
import type { AuthorityQueryPage, AuthorityQueryRequest } from './http-db.js';
|
|
16
16
|
import type { EmbeddedQueryExplanation } from './embedded-query.js';
|
|
17
17
|
import { type FeltDBArchitectureInvariantService } from './architecture-invariant.js';
|
|
@@ -38,9 +38,25 @@ export interface FeltDBOptions extends FeltDBDeploymentConfig {
|
|
|
38
38
|
applicationId?: string;
|
|
39
39
|
environment?: string;
|
|
40
40
|
requestTimeoutMs?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Operator consoles only (FeltDB Studio): a human pastes their own service
|
|
43
|
+
* key into their own browser tab at runtime. Applications must never set
|
|
44
|
+
* this — a service key in an application's browser code is published to
|
|
45
|
+
* every visitor. Without it, a `fdb_live_` key in a browser runtime throws
|
|
46
|
+
* `FeltDBBrowserCredentialError`.
|
|
47
|
+
*/
|
|
48
|
+
operatorConsole?: true;
|
|
41
49
|
};
|
|
42
50
|
memory?: true;
|
|
43
51
|
browser?: true;
|
|
52
|
+
/**
|
|
53
|
+
* Local file runtime only. `exclusive` (default): this process owns the
|
|
54
|
+
* state directory; another process opening it is refused with
|
|
55
|
+
* `LocalStateLockedError` (`STATE_LOCKED`) until `db.close()` or exit.
|
|
56
|
+
* `shared`: several processes may open the directory at once (each commit
|
|
57
|
+
* is still atomic and serialized); use it for multi-process workers.
|
|
58
|
+
*/
|
|
59
|
+
access?: 'exclusive' | 'shared';
|
|
44
60
|
/** Explicit durable authority. Omitted only for legacy unscoped clients. */
|
|
45
61
|
authorityScope?: AuthorityScope;
|
|
46
62
|
}
|
|
@@ -77,6 +93,8 @@ export interface FeltDBRuntimeCapabilities {
|
|
|
77
93
|
replication: boolean;
|
|
78
94
|
remoteAuthority: boolean;
|
|
79
95
|
offline: boolean;
|
|
96
|
+
/** `storeContent` / `acquireContent` are available. */
|
|
97
|
+
content: boolean;
|
|
80
98
|
}
|
|
81
99
|
export interface FeltDBCapabilitySurface {
|
|
82
100
|
(): FeltDBRuntimeCapabilities;
|
|
@@ -249,10 +267,6 @@ export interface RuntimeDiagnostics {
|
|
|
249
267
|
message: string;
|
|
250
268
|
}>;
|
|
251
269
|
}
|
|
252
|
-
/**
|
|
253
|
-
* State-first database interface.
|
|
254
|
-
* Applications use this to treat FeltDB collections as live application state.
|
|
255
|
-
*/
|
|
256
270
|
export declare class StateFirstDB {
|
|
257
271
|
private jsDb;
|
|
258
272
|
private collections;
|
|
@@ -287,10 +301,24 @@ export declare class StateFirstDB {
|
|
|
287
301
|
idempotencyKey?: string;
|
|
288
302
|
}) => Promise<unknown>;
|
|
289
303
|
};
|
|
304
|
+
/**
|
|
305
|
+
* Start an agent run. It completes only when `completion` — criteria the
|
|
306
|
+
* server checks against the result it observed — holds; without it the run
|
|
307
|
+
* executes but ends `failed` with `COMPLETION_UNVERIFIABLE`.
|
|
308
|
+
*/
|
|
290
309
|
readonly agents: {
|
|
291
310
|
run: (name: string, input?: unknown, options?: {
|
|
292
311
|
idempotencyKey?: string;
|
|
293
312
|
goal?: string;
|
|
313
|
+
completion?: {
|
|
314
|
+
kind: "result_count";
|
|
315
|
+
min: number;
|
|
316
|
+
max?: number;
|
|
317
|
+
} | {
|
|
318
|
+
kind: "result_contains";
|
|
319
|
+
field: string;
|
|
320
|
+
equals: unknown;
|
|
321
|
+
};
|
|
294
322
|
}) => Promise<unknown>;
|
|
295
323
|
};
|
|
296
324
|
readonly capabilities: FeltDBCapabilitySurface;
|
|
@@ -431,6 +459,17 @@ export declare class StateFirstDB {
|
|
|
431
459
|
* transactions. Runtimes that cannot refuse rather than silently degrading
|
|
432
460
|
* the transaction into a sequence of independent writes.
|
|
433
461
|
*/
|
|
462
|
+
/**
|
|
463
|
+
* Tell this process's live collections that a transaction committed.
|
|
464
|
+
*
|
|
465
|
+
* Collection writes publish through the reactive dependency graph; a
|
|
466
|
+
* transaction bypasses the collection, so without this a subscriber on the
|
|
467
|
+
* local file or memory runtime never learns about it. The announcement is
|
|
468
|
+
* a notification only (subscribers re-read committed state), and it runs
|
|
469
|
+
* after the commit is durable: a failing subscriber cannot turn a committed
|
|
470
|
+
* transaction into a reported failure.
|
|
471
|
+
*/
|
|
472
|
+
private announceCommittedTransaction;
|
|
434
473
|
transaction(build: ((tx: AtomicTransactionScope) => void | Promise<void>) | AtomicTransactionDocument, options?: AtomicTransactionOptions): Promise<AtomicTransactionResult>;
|
|
435
474
|
/**
|
|
436
475
|
* Capture a read basis: what the authority holds, now, for a set of records
|
|
@@ -498,14 +537,28 @@ export declare class StateFirstDB {
|
|
|
498
537
|
}>;
|
|
499
538
|
/** Inspect the causal operation that currently materializes a remote record. */
|
|
500
539
|
recordProvenance(collection: string, id: string): Promise<any>;
|
|
501
|
-
/**
|
|
540
|
+
/**
|
|
541
|
+
* Persist immutable content by its SHA-256; repeated bytes deduplicate.
|
|
542
|
+
* Returns `{ hash, bytes, ref: 'sha256:<hash>' }`.
|
|
543
|
+
*
|
|
544
|
+
* A server runtime stores it in the server's content store; the local file
|
|
545
|
+
* runtime stores it under its state directory (`content/<hash>`), durably
|
|
546
|
+
* before this returns. Memory and browser runtimes refuse with
|
|
547
|
+
* `CONTRACT_UNSUPPORTED`.
|
|
548
|
+
*/
|
|
502
549
|
storeContent(content: Uint8Array): Promise<{
|
|
503
550
|
hash: string;
|
|
504
551
|
bytes: number;
|
|
505
552
|
ref: string;
|
|
506
553
|
}>;
|
|
507
|
-
/**
|
|
554
|
+
/**
|
|
555
|
+
* Resolve content by hash: verified against its SHA-256 before it is
|
|
556
|
+
* returned (`CONTENT_INTEGRITY_FAILURE` otherwise), `null` when absent. A
|
|
557
|
+
* server also asks its configured peers; the local runtime reads its own
|
|
558
|
+
* directory only.
|
|
559
|
+
*/
|
|
508
560
|
acquireContent(hash: string): Promise<Uint8Array | null>;
|
|
561
|
+
private contentUnsupported;
|
|
509
562
|
/** Define a durable workflow whose lifecycle is ordinary replicated state. */
|
|
510
563
|
defineWorkflow(name: string, steps: string[]): Promise<any>;
|
|
511
564
|
startWorkflow(name: string, input?: unknown): Promise<any>;
|
|
@@ -513,7 +566,19 @@ export declare class StateFirstDB {
|
|
|
513
566
|
completeWorkflowStep(runId: string, step: string, claimId: string, result?: unknown): Promise<any>;
|
|
514
567
|
/** Define and start state-first agents; workers observe and advance these records externally. */
|
|
515
568
|
defineStateAgent(name: string, capabilities?: string[], constraints?: unknown): Promise<any>;
|
|
516
|
-
|
|
569
|
+
/**
|
|
570
|
+
* `completion` is what the server must observe for the run to complete; a
|
|
571
|
+
* run without it never completes. See the agent-run section of the public API contract.
|
|
572
|
+
*/
|
|
573
|
+
startStateAgent(name: string, goal: string, input?: unknown, completion?: {
|
|
574
|
+
kind: 'result_count';
|
|
575
|
+
min: number;
|
|
576
|
+
max?: number;
|
|
577
|
+
} | {
|
|
578
|
+
kind: 'result_contains';
|
|
579
|
+
field: string;
|
|
580
|
+
equals: unknown;
|
|
581
|
+
}): Promise<any>;
|
|
517
582
|
/**
|
|
518
583
|
* Define an agent with declarative configuration.
|
|
519
584
|
*
|
|
@@ -546,16 +611,32 @@ export declare class StateFirstDB {
|
|
|
546
611
|
*/
|
|
547
612
|
getAgentRuntime(): AgentRuntime;
|
|
548
613
|
/**
|
|
549
|
-
* Create an agent execution
|
|
614
|
+
* Create an agent execution. It is committed to `_flow_agent_executions`
|
|
615
|
+
* before this returns; pass `taskId` to find it later with
|
|
616
|
+
* `findAgentExecution({ taskId })`.
|
|
550
617
|
*/
|
|
551
|
-
createAgentExecution(agentRef: AgentRef, goal: string, inputs: string[]): Promise<AgentExecution>;
|
|
618
|
+
createAgentExecution(agentRef: AgentRef, goal: string, inputs: string[], options?: CreateAgentExecutionOptions): Promise<AgentExecution>;
|
|
552
619
|
private agentExecutionRecord;
|
|
620
|
+
private agentExecutionIndexesDeclared;
|
|
621
|
+
/** Declare the lookup indexes once per handle, on an embedded runtime only. */
|
|
622
|
+
private declareAgentExecutionIndexes;
|
|
623
|
+
private queryAgentExecution;
|
|
624
|
+
/**
|
|
625
|
+
* The most recently created execution for an agent-state task or an agent,
|
|
626
|
+
* read from durable state through a declared index (one bounded query; it
|
|
627
|
+
* never scans `_flow_agent_executions`). Null when there is none.
|
|
628
|
+
*/
|
|
629
|
+
findAgentExecution(query: FindAgentExecutionQuery): Promise<AgentExecution | null>;
|
|
553
630
|
/** Claim and durably materialize an embedded agent execution. */
|
|
554
631
|
startAgentExecution(execution: AgentExecution, peerId?: string): Promise<void>;
|
|
555
632
|
/** Advance an agent lifecycle and expose the transition as ordinary state. */
|
|
556
633
|
transitionAgentExecution(execution: AgentExecution, status: import('./agent.js').AgentExecutionStatus): Promise<void>;
|
|
634
|
+
/** Resume an execution for `peerId` (status `Observing`), committed before this returns. */
|
|
635
|
+
resumeAgentExecution(execution: AgentExecution, peerId?: string): Promise<void>;
|
|
557
636
|
completeAgentExecution(execution: AgentExecution, resultRef: string): Promise<void>;
|
|
558
637
|
failAgentExecution(execution: AgentExecution, error: string): Promise<void>;
|
|
638
|
+
/** Cancel a non-terminal execution (terminal status `Cancelled`), committed before this returns. */
|
|
639
|
+
cancelAgentExecution(execution: AgentExecution, reason?: string): Promise<void>;
|
|
559
640
|
/**
|
|
560
641
|
* Close the database and clean up resources.
|
|
561
642
|
*/
|