@feltdb/core 0.9.1 → 0.10.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/cli/commands.js +19 -21
- package/dist/cli/development-session.js +16 -0
- package/dist/cli/studio-artifact.js +33 -0
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +8 -1
- package/dist/create/server-source/crates/feltdb/src/application.rs +16 -0
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +21 -22
- package/dist/create/server-source/crates/feltdb/src/lib.rs +451 -33
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +48 -3
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +569 -4
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +1 -0
- package/dist/create/server-source/crates/feltdb/src/versioned_object_substrate.rs +708 -0
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
- package/dist/create/server-source/crates/feltdb/src/workload.rs +480 -44
- package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +123 -0
- package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +281 -0
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +15 -3
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +28 -18
- package/dist/create/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +328 -0
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +20 -3
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +14 -4
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +0 -4
- package/dist/create/server-source/crates/feltdb-server/src/canonical_principals.rs +1093 -0
- package/dist/create/server-source/crates/feltdb-server/src/causal.rs +905 -144
- package/dist/create/server-source/crates/feltdb-server/src/connections.rs +9 -6
- package/dist/create/server-source/crates/feltdb-server/src/control_plane.rs +90 -0
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +408 -74
- package/dist/create/server-source/crates/feltdb-server/src/principals.rs +9 -501
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +22 -0
- package/dist/create/server-source/crates/feltdb-server/tests/principal_tenant_migration.rs +247 -0
- package/dist/db.d.ts +6 -5
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +18 -24
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +4 -2
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +4 -0
- package/dist/error-codes.d.ts +12 -0
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +20 -2
- package/dist/feltdb.d.ts +4 -0
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +10 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +18 -0
- package/dist/http-db.d.ts +4 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +73 -12
- package/dist/index-core.d.ts +1 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +1 -0
- package/dist/public-contract.d.ts +112 -0
- package/dist/public-contract.d.ts.map +1 -0
- package/dist/public-contract.js +125 -0
- package/dist/studio/app.d.ts +23 -0
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/ContractInspector.d.ts +5 -0
- package/dist/studio/components/ContractInspector.d.ts.map +1 -1
- package/dist/studio/components/SettingsPanel.d.ts +4 -1
- package/dist/studio/components/SettingsPanel.d.ts.map +1 -1
- package/dist/studio/components/index.js +4 -4
- package/dist/studio/{components-CehkcX_k.js → components-CJFgdKG6.js} +322 -303
- package/dist/studio/control-plane/Data.d.ts.map +1 -1
- package/dist/studio/control-plane/Instance.d.ts +10 -0
- package/dist/studio/control-plane/Instance.d.ts.map +1 -1
- package/dist/studio/control-plane/Operations.d.ts +3 -0
- package/dist/studio/control-plane/Operations.d.ts.map +1 -1
- package/dist/studio/control-plane/State.d.ts.map +1 -1
- package/dist/studio/control-plane/index.d.ts +4 -2
- package/dist/studio/control-plane/index.d.ts.map +1 -1
- package/dist/studio/control-plane/index.js +2 -2
- package/dist/studio/control-plane-DP8ljTDO.js +3207 -0
- package/dist/studio/index.js +96 -75
- package/dist/studio/utils/api.d.ts +1 -1
- package/dist/studio/utils/api.d.ts.map +1 -1
- package/dist/studio/utils/control-plane-api.d.ts +12 -1
- package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
- package/dist/studio/utils/control-plane-api.js +1 -1
- package/dist/studio/utils/index.js +16 -16
- package/dist/studio/utils/service-api.d.ts +8 -0
- package/dist/studio/utils/service-api.d.ts.map +1 -1
- package/dist/studio/utils/service-api.js +15 -12
- package/dist/studio-app/assets/{feltdb_wasm-DksXDnLa.js → feltdb_wasm-CfNb3IoF.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-D9d3gY-E.wasm +0 -0
- package/dist/studio-app/assets/index-SDXdROi1.js +30 -0
- package/dist/studio-app/feltdb-studio.json +6 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +9 -1
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +6 -2
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workspace/local-development-authority.d.ts +8 -0
- package/dist/workspace/local-development-authority.d.ts.map +1 -1
- package/dist/workspace/local-development-authority.js +136 -1
- package/package.json +2 -2
- package/dist/studio/control-plane-D6TT2fCQ.js +0 -3064
- package/dist/studio-app/assets/feltdb_wasm_bg-Yy4QRHJT.wasm +0 -0
- package/dist/studio-app/assets/index-CZ55nLgp.js +0 -30
|
@@ -76,7 +76,10 @@ use feltdb_server::{
|
|
|
76
76
|
create_bundle, create_bundle_with_virtual, restore_bundle, select_bundle_at,
|
|
77
77
|
server_artifacts, verify_bundle,
|
|
78
78
|
},
|
|
79
|
-
causal::{
|
|
79
|
+
causal::{
|
|
80
|
+
decode_cursor, decode_scoped_cursor, encode_cursor, encode_scoped_cursor, CausalEvent,
|
|
81
|
+
CanonicalEventAuthority,
|
|
82
|
+
},
|
|
80
83
|
certification::default_report,
|
|
81
84
|
clock::LeaseClock,
|
|
82
85
|
cluster::{ClusterConfig, ClusterStore, MembershipProposal, ProposalPhase},
|
|
@@ -89,7 +92,7 @@ use feltdb_server::{
|
|
|
89
92
|
leases::LeaseStore,
|
|
90
93
|
metrics::Metrics,
|
|
91
94
|
portable_bundle::{BundleArtifact, BundleStore, ExportOptions, ImportMode, PortableBundle},
|
|
92
|
-
principals::{PrincipalKind, PrincipalStatus
|
|
95
|
+
principals::{PrincipalKind, PrincipalStatus},
|
|
93
96
|
providers::{ProviderManifest, ProviderStatus, ProviderStore},
|
|
94
97
|
releases::{
|
|
95
98
|
provider_for, AuthorizedDeploymentContext, DeploymentPlan, DeploymentProfile,
|
|
@@ -152,15 +155,18 @@ fn api_error_body(status: StatusCode, message: String) -> Value {
|
|
|
152
155
|
let object = body
|
|
153
156
|
.as_object_mut()
|
|
154
157
|
.expect("normalized error body is an object");
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
// A scope-bound cursor has a stable public recovery action, so preserve
|
|
159
|
+
// that code. Other forbidden details retain the non-disclosing canonical
|
|
160
|
+
// `FORBIDDEN` wire contract.
|
|
161
|
+
let explicit_code = object.get("code").and_then(Value::as_str);
|
|
162
|
+
let resolved_code = if status != StatusCode::FORBIDDEN
|
|
163
|
+
|| explicit_code == Some("CURSOR_SCOPE_VIOLATION")
|
|
164
|
+
{
|
|
165
|
+
explicit_code.unwrap_or(code)
|
|
157
166
|
} else {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.unwrap_or(code)
|
|
162
|
-
.to_string()
|
|
163
|
-
};
|
|
167
|
+
code
|
|
168
|
+
}
|
|
169
|
+
.to_string();
|
|
164
170
|
object.insert("error".into(), json!(resolved_code));
|
|
165
171
|
object.insert("code".into(), json!(resolved_code));
|
|
166
172
|
object.entry("message").or_insert_with(|| {
|
|
@@ -185,6 +191,11 @@ struct HealthResponse<'a> {
|
|
|
185
191
|
status: &'a str,
|
|
186
192
|
version: &'a str,
|
|
187
193
|
git_commit: &'a str,
|
|
194
|
+
build_timestamp: &'a str,
|
|
195
|
+
release_id: &'a str,
|
|
196
|
+
deployment_id: String,
|
|
197
|
+
public_contract: &'a str,
|
|
198
|
+
capability_fingerprint: String,
|
|
188
199
|
runtime: &'a str,
|
|
189
200
|
storage: &'a str,
|
|
190
201
|
fabric: &'a str,
|
|
@@ -542,6 +553,16 @@ struct PreviewSimulationRequest {
|
|
|
542
553
|
#[derive(Deserialize, Default)]
|
|
543
554
|
struct EventScopeRequest {
|
|
544
555
|
application_id: Option<String>,
|
|
556
|
+
cursor: Option<String>,
|
|
557
|
+
}
|
|
558
|
+
#[derive(Deserialize)]
|
|
559
|
+
struct PublicEventReplayRequest {
|
|
560
|
+
application_id: String,
|
|
561
|
+
#[serde(default = "production_environment")]
|
|
562
|
+
environment: String,
|
|
563
|
+
cursor: Option<String>,
|
|
564
|
+
#[serde(default)]
|
|
565
|
+
limit: Option<usize>,
|
|
545
566
|
}
|
|
546
567
|
#[derive(Deserialize)]
|
|
547
568
|
struct SignUpRequest {
|
|
@@ -1150,6 +1171,11 @@ async fn push_sync(
|
|
|
1150
1171
|
operations: vec![operation.operation.clone()],
|
|
1151
1172
|
preconditions: vec![],
|
|
1152
1173
|
};
|
|
1174
|
+
let _transaction_permit = state
|
|
1175
|
+
.state_transaction_admission
|
|
1176
|
+
.acquire()
|
|
1177
|
+
.await
|
|
1178
|
+
.expect("transaction admission is never closed");
|
|
1153
1179
|
match execute_transaction(&state.db, &schema, &transaction, None) {
|
|
1154
1180
|
Ok(committed) => {
|
|
1155
1181
|
let position = state
|
|
@@ -1451,6 +1477,18 @@ fn worker_error(error: feltdb::worker_mesh::WorkerMeshError) -> ApiError {
|
|
|
1451
1477
|
serde_json::to_string(&error).unwrap_or(error.message),
|
|
1452
1478
|
)
|
|
1453
1479
|
}
|
|
1480
|
+
fn release_worker_claim_projection(state: &AppState, workload_id: &str, worker_id: &str, claim_id: &str) {
|
|
1481
|
+
match state.mesh.lock() {
|
|
1482
|
+
Ok(mut mesh) => {
|
|
1483
|
+
if let Err(error) = mesh.release_claim(worker_id, claim_id) {
|
|
1484
|
+
tracing::warn!(%workload_id, %claim_id, %error,
|
|
1485
|
+
"durable work reached a terminal attempt state but the worker mesh projection did not release its claim");
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
Err(_) => tracing::warn!(%workload_id, %claim_id,
|
|
1489
|
+
"durable work reached a terminal attempt state but the worker mesh projection was unavailable"),
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1454
1492
|
fn workload_decision(
|
|
1455
1493
|
state: &AppState,
|
|
1456
1494
|
subject: GrantSubject,
|
|
@@ -1502,6 +1540,7 @@ async fn create_workload(
|
|
|
1502
1540
|
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
1503
1541
|
let subject = grant_subject(&principal);
|
|
1504
1542
|
input.requested_by = subject.clone();
|
|
1543
|
+
input.session_id = principal.session_id.clone();
|
|
1505
1544
|
let resource = format!("flow://{}/workloads", input.application_id);
|
|
1506
1545
|
workload_decision(
|
|
1507
1546
|
&state,
|
|
@@ -1754,6 +1793,12 @@ async fn complete_workload(
|
|
|
1754
1793
|
unix_seconds_i64(),
|
|
1755
1794
|
)
|
|
1756
1795
|
.map_err(workload_error)?;
|
|
1796
|
+
release_worker_claim_projection(
|
|
1797
|
+
&state,
|
|
1798
|
+
&id,
|
|
1799
|
+
&input.fence.worker_id,
|
|
1800
|
+
&input.fence.claim_id,
|
|
1801
|
+
);
|
|
1757
1802
|
Ok(Json(json!(result)))
|
|
1758
1803
|
}
|
|
1759
1804
|
async fn fail_workload(
|
|
@@ -1777,6 +1822,12 @@ async fn fail_workload(
|
|
|
1777
1822
|
unix_seconds_i64(),
|
|
1778
1823
|
)
|
|
1779
1824
|
.map_err(workload_error)?;
|
|
1825
|
+
release_worker_claim_projection(
|
|
1826
|
+
&state,
|
|
1827
|
+
&id,
|
|
1828
|
+
&input.fence.worker_id,
|
|
1829
|
+
&input.fence.claim_id,
|
|
1830
|
+
);
|
|
1780
1831
|
Ok(Json(json!({"state":status})))
|
|
1781
1832
|
}
|
|
1782
1833
|
async fn confirm_workload_cancelled(
|
|
@@ -3103,7 +3154,7 @@ async fn append_causal_event(
|
|
|
3103
3154
|
event.subject_id = principal.key_id;
|
|
3104
3155
|
Ok((
|
|
3105
3156
|
StatusCode::CREATED,
|
|
3106
|
-
Json(json!(state.
|
|
3157
|
+
Json(json!(state.events.append(event).map_err(control_error)?)),
|
|
3107
3158
|
))
|
|
3108
3159
|
}
|
|
3109
3160
|
async fn certification_report() -> Json<Value> {
|
|
@@ -3172,32 +3223,32 @@ async fn causal_events(
|
|
|
3172
3223
|
"applications:read",
|
|
3173
3224
|
)?;
|
|
3174
3225
|
Ok(Json(
|
|
3175
|
-
json!({"events":state.
|
|
3226
|
+
json!({"events":state.events.all().map_err(control_error)?.into_iter().filter(|e|e.application_id==scope.application_id).collect::<Vec<_>>() }),
|
|
3176
3227
|
))
|
|
3177
3228
|
}
|
|
3178
3229
|
async fn causal_chain(
|
|
3179
3230
|
State(state): State<AppState>,
|
|
3180
3231
|
Path(id): Path<String>,
|
|
3181
3232
|
) -> Result<Json<Value>, ApiError> {
|
|
3182
|
-
Ok(Json(json!({"events":state.
|
|
3233
|
+
Ok(Json(json!({"events":state.events.list(&id).map_err(control_error)?})))
|
|
3183
3234
|
}
|
|
3184
3235
|
async fn workload_timeline(
|
|
3185
3236
|
State(state): State<AppState>,
|
|
3186
3237
|
Path(id): Path<String>,
|
|
3187
3238
|
) -> Result<Json<Value>, ApiError> {
|
|
3188
|
-
Ok(Json(json!({"events":state.
|
|
3239
|
+
Ok(Json(json!({"events":state.events.workload(&id).map_err(control_error)?})))
|
|
3189
3240
|
}
|
|
3190
3241
|
async fn execution_timeline(
|
|
3191
3242
|
State(state): State<AppState>,
|
|
3192
3243
|
Path(id): Path<String>,
|
|
3193
3244
|
) -> Result<Json<Value>, ApiError> {
|
|
3194
|
-
Ok(Json(json!({"events":state.
|
|
3245
|
+
Ok(Json(json!({"events":state.events.execution(&id).map_err(control_error)?})))
|
|
3195
3246
|
}
|
|
3196
3247
|
async fn revision_timeline(
|
|
3197
3248
|
State(state): State<AppState>,
|
|
3198
3249
|
Path(id): Path<String>,
|
|
3199
3250
|
) -> Result<Json<Value>, ApiError> {
|
|
3200
|
-
Ok(Json(json!({"events":state.
|
|
3251
|
+
Ok(Json(json!({"events":state.events.revision(&id).map_err(control_error)?})))
|
|
3201
3252
|
}
|
|
3202
3253
|
async fn export_bundle(
|
|
3203
3254
|
State(state): State<AppState>,
|
|
@@ -5450,14 +5501,13 @@ fn proposal_event(
|
|
|
5450
5501
|
) -> Result<(), ApiError> {
|
|
5451
5502
|
let id = format!("pe_{}", uuid::Uuid::new_v4().simple());
|
|
5452
5503
|
let proposal = proposal_value(state, proposal_id).unwrap_or(Value::Null);
|
|
5453
|
-
let event_sequence = state.db.sequence()?.saturating_add(1);
|
|
5454
5504
|
insert_canonical(
|
|
5455
5505
|
state,
|
|
5456
5506
|
PROPOSAL_EVENT_COLLECTION,
|
|
5457
5507
|
&id,
|
|
5458
5508
|
json!({ "id":id, "event_id":id, "proposal_id":proposal_id, "event_type":to,
|
|
5459
5509
|
"from_status":from, "to_status":to, "actor":actor, "created_at":unix_seconds_i64(),
|
|
5460
|
-
"timestamp":unix_seconds_i64(), "
|
|
5510
|
+
"timestamp":unix_seconds_i64(), "contract_hash":proposal.get("base_contract_hash"),
|
|
5461
5511
|
"flow_hash":proposal.get("base_flow_hash"), "metadata":{} }),
|
|
5462
5512
|
)?;
|
|
5463
5513
|
Ok(())
|
|
@@ -5743,15 +5793,25 @@ async fn proposal_history(
|
|
|
5743
5793
|
.map(|row| row.value)
|
|
5744
5794
|
.filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str()))
|
|
5745
5795
|
.collect();
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5796
|
+
let canonical_order = state
|
|
5797
|
+
.db
|
|
5798
|
+
.committed_transactions_after(0, usize::MAX)?
|
|
5799
|
+
.into_iter()
|
|
5800
|
+
.flat_map(|transaction| {
|
|
5801
|
+
transaction
|
|
5802
|
+
.keys
|
|
5803
|
+
.into_iter()
|
|
5804
|
+
.map(move |key| (key, transaction.commit_revision))
|
|
5805
|
+
})
|
|
5806
|
+
.collect::<HashMap<_, _>>();
|
|
5807
|
+
events.sort_by_key(|event| {
|
|
5808
|
+
let key = event
|
|
5809
|
+
.get("id")
|
|
5810
|
+
.and_then(Value::as_str)
|
|
5811
|
+
.and_then(|event_id| record_key(PROPOSAL_EVENT_COLLECTION, event_id).ok())
|
|
5812
|
+
.map(|key| format!("{PROPOSAL_EVENT_COLLECTION}:{key}"));
|
|
5813
|
+
key.and_then(|key| canonical_order.get(&key).copied())
|
|
5814
|
+
.unwrap_or(u64::MAX)
|
|
5755
5815
|
});
|
|
5756
5816
|
for event in &mut events {
|
|
5757
5817
|
redact_inspection_value(event);
|
|
@@ -7116,6 +7176,11 @@ async fn run_runtime_action(
|
|
|
7116
7176
|
.iter()
|
|
7117
7177
|
.find(|p| p.resource == definition.collection)
|
|
7118
7178
|
.and_then(|p| p.write.as_ref().and_then(|s| PolicySubject::from_str(s)));
|
|
7179
|
+
let _transaction_permit = state
|
|
7180
|
+
.state_transaction_admission
|
|
7181
|
+
.acquire()
|
|
7182
|
+
.await
|
|
7183
|
+
.expect("transaction admission is never closed");
|
|
7119
7184
|
Ok(Json(json!(execute_transaction(
|
|
7120
7185
|
&state.db,
|
|
7121
7186
|
&contract.state.schema,
|
|
@@ -7448,7 +7513,7 @@ fn state_authorization_legacy(
|
|
|
7448
7513
|
fn state_contract_error(error: feltdb::state_contract::StateFailure) -> ApiError {
|
|
7449
7514
|
let status = match error.code.as_str() {
|
|
7450
7515
|
"AUTHORIZATION_DENIED" => StatusCode::FORBIDDEN,
|
|
7451
|
-
"PRECONDITION_FAILED" | "CONFLICT" => StatusCode::CONFLICT,
|
|
7516
|
+
"PRECONDITION_FAILED" | "CONFLICT" | "IDEMPOTENCY_CONFLICT" => StatusCode::CONFLICT,
|
|
7452
7517
|
"UNKNOWN_COLLECTION" => StatusCode::NOT_FOUND,
|
|
7453
7518
|
"STORAGE_FAILURE" | "STATE_UNAVAILABLE" => StatusCode::SERVICE_UNAVAILABLE,
|
|
7454
7519
|
_ => StatusCode::UNPROCESSABLE_ENTITY,
|
|
@@ -7778,6 +7843,11 @@ async fn execute_canonical_transaction(
|
|
|
7778
7843
|
"state:denied"
|
|
7779
7844
|
},
|
|
7780
7845
|
);
|
|
7846
|
+
let _transaction_permit = state
|
|
7847
|
+
.state_transaction_admission
|
|
7848
|
+
.acquire()
|
|
7849
|
+
.await
|
|
7850
|
+
.expect("transaction admission is never closed");
|
|
7781
7851
|
let result = execute_transaction_with_collection_policies(
|
|
7782
7852
|
&state.db,
|
|
7783
7853
|
&contract.state.schema,
|
|
@@ -7793,7 +7863,13 @@ async fn execute_canonical_transaction(
|
|
|
7793
7863
|
"allowed",
|
|
7794
7864
|
201,
|
|
7795
7865
|
);
|
|
7796
|
-
|
|
7866
|
+
let attribution = state.db.transaction_attribution(&result.transaction_id)?;
|
|
7867
|
+
let mut response = json!(result);
|
|
7868
|
+
response["revision_id"] = json!(format!("revision-{}", result.commit_revision));
|
|
7869
|
+
response["operation_ids"] = json!(attribution
|
|
7870
|
+
.map(|value| value.operation_ids)
|
|
7871
|
+
.unwrap_or_default());
|
|
7872
|
+
Ok(Json(response))
|
|
7797
7873
|
}
|
|
7798
7874
|
|
|
7799
7875
|
async fn get_cardinality_diagnostic(
|
|
@@ -8388,7 +8464,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8388
8464
|
let grant_signer = GrantSigner::new("feltdb-primary-v1", grant_key.to_vec())?;
|
|
8389
8465
|
let grant_store = GrantStore::load(config.data.with_extension("grants.json"))?;
|
|
8390
8466
|
let sync_store = SyncStore::load(config.data.with_extension("sync.json"))?;
|
|
8391
|
-
let
|
|
8467
|
+
let legacy_workloads = config.data.with_extension("workloads.json");
|
|
8468
|
+
let workload_store = if legacy_workloads.exists() {
|
|
8469
|
+
WorkloadStore::migrate_legacy_file(db.clone(), &legacy_workloads)?
|
|
8470
|
+
} else {
|
|
8471
|
+
WorkloadStore::from_db(db.clone())?
|
|
8472
|
+
};
|
|
8392
8473
|
let state_trigger_store = StateTriggerStore::load(config.data.with_extension("triggers.json"))?;
|
|
8393
8474
|
let mesh_store = WorkerMeshStore::load(config.data.with_extension("workers.json"))?;
|
|
8394
8475
|
let content_store = ContentStore::new(config.data.with_extension("content"))?;
|
|
@@ -8398,9 +8479,28 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8398
8479
|
)?;
|
|
8399
8480
|
let bundle_store = BundleStore::load(config.data.with_extension("bundles.json"))?;
|
|
8400
8481
|
let release_store = ReleaseStore::load(config.data.with_extension("releases.json"))?;
|
|
8401
|
-
let
|
|
8482
|
+
let event_authority = CanonicalEventAuthority::new(db.clone());
|
|
8483
|
+
let legacy_causal = config.data.with_extension("causal-events.json");
|
|
8484
|
+
if legacy_causal.exists() {
|
|
8485
|
+
event_authority.migrate_legacy_file(&legacy_causal)?;
|
|
8486
|
+
}
|
|
8402
8487
|
let provider_store = ProviderStore::load(config.data.with_extension("providers.json"))?;
|
|
8403
8488
|
let readiness_probe = config.data.with_extension("readiness");
|
|
8489
|
+
let tenancy_store = TenancyStore::load(config.data.with_extension("platform.json"))?;
|
|
8490
|
+
let application_store = ApplicationStore::load(config.data.with_extension("applications.json"))?;
|
|
8491
|
+
let principal_authority =
|
|
8492
|
+
feltdb_server::canonical_principals::CanonicalPrincipalAuthority::new(db.clone());
|
|
8493
|
+
let legacy_principals = config.data.with_extension("principals.json");
|
|
8494
|
+
if legacy_principals.exists() {
|
|
8495
|
+
principal_authority.migrate_legacy_file(&legacy_principals, |application_id| {
|
|
8496
|
+
feltdb_server::canonical_principals::resolve_principal_tenant(
|
|
8497
|
+
application_id,
|
|
8498
|
+
&tenancy_store,
|
|
8499
|
+
&application_store,
|
|
8500
|
+
&db,
|
|
8501
|
+
)
|
|
8502
|
+
})?;
|
|
8503
|
+
}
|
|
8404
8504
|
// Constructed before the state so the same handle can be stopped after
|
|
8405
8505
|
// serving: the pipeline outlives the router by exactly one shutdown.
|
|
8406
8506
|
let audit_handle = AuditLog::new(config.audit.clone());
|
|
@@ -8426,13 +8526,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8426
8526
|
peer_client,
|
|
8427
8527
|
peer_token,
|
|
8428
8528
|
lifecycle_lock: Arc::new(tokio::sync::Mutex::new(())),
|
|
8529
|
+
state_transaction_admission: Arc::new(tokio::sync::Semaphore::new(2)),
|
|
8429
8530
|
worker_enabled: config.worker_enabled,
|
|
8430
8531
|
leases: LeaseStore::load(config.data.with_extension("leases.json"))?,
|
|
8431
8532
|
content: content_store,
|
|
8432
8533
|
artifacts: artifact_store,
|
|
8433
8534
|
bundles: bundle_store,
|
|
8434
8535
|
releases: release_store,
|
|
8435
|
-
|
|
8536
|
+
events: event_authority,
|
|
8436
8537
|
providers: provider_store,
|
|
8437
8538
|
contracts: ApplicationContractStore::load(
|
|
8438
8539
|
config.data.with_extension("application-contracts.json"),
|
|
@@ -8441,14 +8542,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8441
8542
|
lease_clock: LeaseClock::new(config.maximum_clock_drift_ms),
|
|
8442
8543
|
backup_barrier: Arc::new(tokio::sync::RwLock::new(())),
|
|
8443
8544
|
backup_artifacts: Arc::new(backup_artifacts),
|
|
8444
|
-
tenancy:
|
|
8545
|
+
tenancy: tenancy_store,
|
|
8445
8546
|
sessions: SessionVerifier::load(
|
|
8446
8547
|
std::env::var("FELTDB_SESSION_SECRET")
|
|
8447
8548
|
.unwrap_or_else(|_| "feltdb-development-session-secret-change-me".into())
|
|
8448
8549
|
.into_bytes(),
|
|
8449
8550
|
config.data.with_extension("revoked-sessions.json"),
|
|
8450
8551
|
)?,
|
|
8451
|
-
principals:
|
|
8552
|
+
principals: principal_authority,
|
|
8452
8553
|
connections: ConnectionStore::load_with_provider(
|
|
8453
8554
|
config.data.with_extension("connections.json"),
|
|
8454
8555
|
provider_from_environment(
|
|
@@ -8457,7 +8558,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8457
8558
|
)?,
|
|
8458
8559
|
)?,
|
|
8459
8560
|
identities: IdentityStore::load(config.data.with_extension("identities.json"))?,
|
|
8460
|
-
applications:
|
|
8561
|
+
applications: application_store,
|
|
8461
8562
|
runtimes: RuntimeStore::load(config.data.with_extension("application-runtimes.json"))?,
|
|
8462
8563
|
grant_signer,
|
|
8463
8564
|
grants: Arc::new(std::sync::Mutex::new(grant_store)),
|
|
@@ -8796,6 +8897,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8796
8897
|
.route("/v1/auth/session", get(auth_session))
|
|
8797
8898
|
.route("/v1/auth/signout", axum::routing::post(auth_sign_out))
|
|
8798
8899
|
.route("/v1/events", get(events))
|
|
8900
|
+
.route("/v1/public/events/replay", get(public_event_replay))
|
|
8799
8901
|
.route(
|
|
8800
8902
|
"/v1/workflows/{name}/run",
|
|
8801
8903
|
axum::routing::post(start_workflow_run),
|
|
@@ -9831,6 +9933,13 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
|
|
|
9831
9933
|
status: overall_status(membership == "healthy", clock, &database.storage),
|
|
9832
9934
|
version: env!("CARGO_PKG_VERSION"),
|
|
9833
9935
|
git_commit: option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown"),
|
|
9936
|
+
build_timestamp: option_env!("FELTDB_BUILD_TIMESTAMP").unwrap_or("unknown"),
|
|
9937
|
+
release_id: option_env!("FELTDB_RELEASE_ID").unwrap_or("unknown"),
|
|
9938
|
+
deployment_id: std::env::var("FELTDB_DEPLOYMENT_ID")
|
|
9939
|
+
.or_else(|_| std::env::var("FLY_IMAGE_REF"))
|
|
9940
|
+
.unwrap_or_else(|_| "unknown".into()),
|
|
9941
|
+
public_contract: "feltdb.public.substrate@1.0",
|
|
9942
|
+
capability_fingerprint: control_plane::public_contract_capability_fingerprint(),
|
|
9834
9943
|
runtime: "self-hosted",
|
|
9835
9944
|
storage: database.storage.label(),
|
|
9836
9945
|
fabric: "healthy",
|
|
@@ -10072,8 +10181,7 @@ fn manage_workloads() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10072
10181
|
.and_then(|i| arguments.get(i + 1))
|
|
10073
10182
|
.cloned()
|
|
10074
10183
|
};
|
|
10075
|
-
let path =
|
|
10076
|
-
PathBuf::from(option("--store").unwrap_or_else(|| "./data/feltdb.workloads.json".into()));
|
|
10184
|
+
let path = PathBuf::from(option("--data").unwrap_or_else(|| "./data/feltdb.log".into()));
|
|
10077
10185
|
let mut store = WorkloadStore::load(path)?;
|
|
10078
10186
|
if family == "worker" || family == "mesh" {
|
|
10079
10187
|
let mesh_path = PathBuf::from(
|
|
@@ -10126,17 +10234,13 @@ fn manage_state_triggers() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10126
10234
|
),
|
|
10127
10235
|
"evaluate" => {
|
|
10128
10236
|
let db = FeltDb::open(&data)?;
|
|
10129
|
-
let
|
|
10130
|
-
option("--workload-store")
|
|
10131
|
-
.unwrap_or_else(|| data.with_extension("workloads.json").to_string_lossy().into_owned()),
|
|
10132
|
-
);
|
|
10133
|
-
let mut workloads = WorkloadStore::load(workload_path)?;
|
|
10237
|
+
let mut workloads = WorkloadStore::from_db(db.clone())?;
|
|
10134
10238
|
let produced = triggers.evaluate(&db, &mut workloads, unix_seconds_i64())?;
|
|
10135
10239
|
println!("{}", serde_json::to_string_pretty(&produced)?);
|
|
10136
10240
|
}
|
|
10137
10241
|
_ => {
|
|
10138
10242
|
return Err(
|
|
10139
|
-
"usage: feltdb-server trigger <define|list|status|evaluate> [--data path] [--store path] [--
|
|
10243
|
+
"usage: feltdb-server trigger <define|list|status|evaluate> [--data path] [--store path] [--input trigger.json]"
|
|
10140
10244
|
.into(),
|
|
10141
10245
|
)
|
|
10142
10246
|
}
|
|
@@ -13147,7 +13251,7 @@ async fn get_record(
|
|
|
13147
13251
|
}
|
|
13148
13252
|
|
|
13149
13253
|
/// One operation inside an application transaction.
|
|
13150
|
-
#[derive(Deserialize)]
|
|
13254
|
+
#[derive(Deserialize, Serialize)]
|
|
13151
13255
|
#[serde(rename_all = "camelCase")]
|
|
13152
13256
|
struct AtomicTxOperationRequest {
|
|
13153
13257
|
collection: String,
|
|
@@ -13177,7 +13281,7 @@ struct AtomicTxOperationRequest {
|
|
|
13177
13281
|
/// fencing routinely guards on a record it does not modify -- a session
|
|
13178
13282
|
/// generation, an ownership epoch -- and expressing that as a no-op write would
|
|
13179
13283
|
/// make the guard a mutation.
|
|
13180
|
-
#[derive(Deserialize)]
|
|
13284
|
+
#[derive(Deserialize, Serialize)]
|
|
13181
13285
|
#[serde(rename_all = "camelCase")]
|
|
13182
13286
|
struct AtomicTxPreconditionRequest {
|
|
13183
13287
|
collection: String,
|
|
@@ -13192,7 +13296,7 @@ struct AtomicTxPreconditionRequest {
|
|
|
13192
13296
|
expected_lease_id: Option<String>,
|
|
13193
13297
|
}
|
|
13194
13298
|
|
|
13195
|
-
#[derive(Deserialize)]
|
|
13299
|
+
#[derive(Deserialize, Serialize)]
|
|
13196
13300
|
#[serde(rename_all = "camelCase")]
|
|
13197
13301
|
struct AtomicTxRequest {
|
|
13198
13302
|
/// Caller-supplied identity. Reusing one makes the commit idempotent.
|
|
@@ -13217,6 +13321,8 @@ struct AtomicTxResponse {
|
|
|
13217
13321
|
operations: usize,
|
|
13218
13322
|
state_before: u64,
|
|
13219
13323
|
state_after: u64,
|
|
13324
|
+
revision_id: String,
|
|
13325
|
+
operation_ids: Vec<String>,
|
|
13220
13326
|
}
|
|
13221
13327
|
|
|
13222
13328
|
/// Commit several operations as one atomic, durable transaction.
|
|
@@ -13245,6 +13351,16 @@ async fn commit_transaction(
|
|
|
13245
13351
|
));
|
|
13246
13352
|
}
|
|
13247
13353
|
|
|
13354
|
+
// The authority, not the adapter, binds idempotency identity to semantic
|
|
13355
|
+
// input. This digest is written in the same transaction record as the
|
|
13356
|
+
// mutations and retained across compaction.
|
|
13357
|
+
let payload_hash = format!(
|
|
13358
|
+
"sha256:{:x}",
|
|
13359
|
+
Sha256::digest(serde_json::to_vec(&request).map_err(|error| {
|
|
13360
|
+
ApiError(StatusCode::BAD_REQUEST, error.to_string())
|
|
13361
|
+
})?)
|
|
13362
|
+
);
|
|
13363
|
+
|
|
13248
13364
|
// Validate every operation before any of them is turned into a mutation, so
|
|
13249
13365
|
// a malformed request cannot commit a prefix of the transaction.
|
|
13250
13366
|
let mut mutations = Vec::with_capacity(request.operations.len());
|
|
@@ -13330,7 +13446,7 @@ async fn commit_transaction(
|
|
|
13330
13446
|
.db
|
|
13331
13447
|
.apply_atomic_transaction_guarded(
|
|
13332
13448
|
&request.transaction_id,
|
|
13333
|
-
|
|
13449
|
+
Some(&payload_hash),
|
|
13334
13450
|
None,
|
|
13335
13451
|
&[],
|
|
13336
13452
|
&record_preconditions,
|
|
@@ -13353,9 +13469,27 @@ async fn commit_transaction(
|
|
|
13353
13469
|
"failure": *failure,
|
|
13354
13470
|
}),
|
|
13355
13471
|
),
|
|
13472
|
+
FlowError::CapabilityError(message)
|
|
13473
|
+
if message.starts_with("TRANSACTION_ID_PAYLOAD_MISMATCH:") =>
|
|
13474
|
+
{
|
|
13475
|
+
ApiError::structured(
|
|
13476
|
+
StatusCode::CONFLICT,
|
|
13477
|
+
json!({
|
|
13478
|
+
"committed": false,
|
|
13479
|
+
"code": "IDEMPOTENCY_CONFLICT",
|
|
13480
|
+
"error": "transaction identity is already bound to different semantic input",
|
|
13481
|
+
"transaction_id": request.transaction_id,
|
|
13482
|
+
}),
|
|
13483
|
+
)
|
|
13484
|
+
}
|
|
13356
13485
|
other => ApiError(StatusCode::CONFLICT, other.to_string()),
|
|
13357
13486
|
}
|
|
13358
13487
|
})?;
|
|
13488
|
+
let operation_ids = state
|
|
13489
|
+
.db
|
|
13490
|
+
.transaction_attribution(&commit.transaction_id)?
|
|
13491
|
+
.map(|value| value.operation_ids)
|
|
13492
|
+
.unwrap_or_default();
|
|
13359
13493
|
|
|
13360
13494
|
Ok((
|
|
13361
13495
|
if commit.duplicate {
|
|
@@ -13372,6 +13506,8 @@ async fn commit_transaction(
|
|
|
13372
13506
|
operations,
|
|
13373
13507
|
state_before: commit.state_before,
|
|
13374
13508
|
state_after: commit.state_after,
|
|
13509
|
+
revision_id: format!("revision-{}", commit.commit_revision),
|
|
13510
|
+
operation_ids,
|
|
13375
13511
|
}),
|
|
13376
13512
|
))
|
|
13377
13513
|
}
|
|
@@ -13584,6 +13720,7 @@ async fn delete_record(
|
|
|
13584
13720
|
async fn events(
|
|
13585
13721
|
State(state): State<AppState>,
|
|
13586
13722
|
Extension(principal): Extension<Principal>,
|
|
13723
|
+
headers: HeaderMap,
|
|
13587
13724
|
Query(scope): Query<EventScopeRequest>,
|
|
13588
13725
|
) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
|
|
13589
13726
|
if !principal.permits("events:read") {
|
|
@@ -13592,39 +13729,137 @@ async fn events(
|
|
|
13592
13729
|
"event access is not permitted".into(),
|
|
13593
13730
|
));
|
|
13594
13731
|
}
|
|
13595
|
-
if let Some(application_id) = scope.application_id {
|
|
13732
|
+
if let Some(application_id) = scope.application_id.as_ref() {
|
|
13596
13733
|
application_scope(&state, &principal.key_id, &application_id, "events:read")?;
|
|
13597
13734
|
}
|
|
13598
|
-
let
|
|
13599
|
-
|
|
13600
|
-
|
|
13735
|
+
let requested_cursor = scope.cursor.as_deref().or_else(|| {
|
|
13736
|
+
headers
|
|
13737
|
+
.get("last-event-id")
|
|
13738
|
+
.and_then(|value| value.to_str().ok())
|
|
13739
|
+
});
|
|
13740
|
+
let mut revision = requested_cursor
|
|
13741
|
+
.map(decode_cursor)
|
|
13742
|
+
.transpose()
|
|
13743
|
+
.map_err(control_error)?
|
|
13744
|
+
.unwrap_or_default();
|
|
13745
|
+
let authority = state.events.clone();
|
|
13746
|
+
let application_id = scope.application_id;
|
|
13747
|
+
let mut pending = authority.after(revision, 256).map_err(control_error)?;
|
|
13601
13748
|
let stream = async_stream::stream! {
|
|
13602
13749
|
loop {
|
|
13603
|
-
|
|
13604
|
-
|
|
13605
|
-
|
|
13606
|
-
|
|
13607
|
-
|
|
13608
|
-
|
|
13609
|
-
|
|
13610
|
-
|
|
13611
|
-
|
|
13612
|
-
|
|
13613
|
-
|
|
13614
|
-
|
|
13615
|
-
});
|
|
13616
|
-
yield Ok(Event::default().event("state.changed").json_data(data).expect("serializable event"));
|
|
13617
|
-
}
|
|
13618
|
-
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
|
13619
|
-
yield Ok(Event::default().event("stream.lagged").data(skipped.to_string()));
|
|
13750
|
+
for event in pending.drain(..) {
|
|
13751
|
+
let event_revision = event
|
|
13752
|
+
.state_version
|
|
13753
|
+
.as_deref()
|
|
13754
|
+
.and_then(|value| value.parse::<u64>().ok())
|
|
13755
|
+
.unwrap_or(revision);
|
|
13756
|
+
revision = revision.max(event_revision);
|
|
13757
|
+
if application_id
|
|
13758
|
+
.as_ref()
|
|
13759
|
+
.is_some_and(|application| event.application_id != *application)
|
|
13760
|
+
{
|
|
13761
|
+
continue;
|
|
13620
13762
|
}
|
|
13621
|
-
|
|
13763
|
+
let cursor = encode_cursor(revision);
|
|
13764
|
+
yield Ok(Event::default()
|
|
13765
|
+
.id(cursor)
|
|
13766
|
+
.event("state.committed")
|
|
13767
|
+
.json_data(event)
|
|
13768
|
+
.expect("serializable event"));
|
|
13622
13769
|
}
|
|
13770
|
+
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
13771
|
+
pending = match authority.after(revision, 256) {
|
|
13772
|
+
Ok(committed) => committed,
|
|
13773
|
+
Err(error) => {
|
|
13774
|
+
yield Ok(Event::default().event("authority.error").data(error));
|
|
13775
|
+
break;
|
|
13776
|
+
}
|
|
13777
|
+
};
|
|
13623
13778
|
}
|
|
13624
13779
|
};
|
|
13625
13780
|
Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
|
|
13626
13781
|
}
|
|
13627
13782
|
|
|
13783
|
+
/// Bounded JSON replay over canonical committed history.
|
|
13784
|
+
///
|
|
13785
|
+
/// The cursor is bound to this authority and application. It is deliberately
|
|
13786
|
+
/// not interchangeable with a cursor from another tenant/application scope.
|
|
13787
|
+
async fn public_event_replay(
|
|
13788
|
+
State(state): State<AppState>,
|
|
13789
|
+
Extension(principal): Extension<Principal>,
|
|
13790
|
+
headers: HeaderMap,
|
|
13791
|
+
Query(request): Query<PublicEventReplayRequest>,
|
|
13792
|
+
) -> Result<Json<Value>, ApiError> {
|
|
13793
|
+
if !principal.permits("events:read") {
|
|
13794
|
+
return Err(ApiError::structured(
|
|
13795
|
+
StatusCode::FORBIDDEN,
|
|
13796
|
+
json!({"code":"FORBIDDEN","message":"event access is not permitted"}),
|
|
13797
|
+
));
|
|
13798
|
+
}
|
|
13799
|
+
let tenant = application_scope(
|
|
13800
|
+
&state,
|
|
13801
|
+
&principal.key_id,
|
|
13802
|
+
&request.application_id,
|
|
13803
|
+
"events:read",
|
|
13804
|
+
)?;
|
|
13805
|
+
require_bound_tenant(&headers, &tenant)?;
|
|
13806
|
+
let instance_id = state.db.instance_id()?;
|
|
13807
|
+
let cursor_scope = format!("{instance_id}:{tenant}:{}", request.application_id);
|
|
13808
|
+
let mut revision = match request.cursor.as_deref() {
|
|
13809
|
+
Some(cursor) => decode_scoped_cursor(&cursor_scope, cursor).map_err(|code| {
|
|
13810
|
+
let status = if code == "CURSOR_SCOPE_VIOLATION" {
|
|
13811
|
+
StatusCode::FORBIDDEN
|
|
13812
|
+
} else {
|
|
13813
|
+
StatusCode::BAD_REQUEST
|
|
13814
|
+
};
|
|
13815
|
+
ApiError::structured(
|
|
13816
|
+
status,
|
|
13817
|
+
json!({"code":code,"message":"event cursor is invalid for this authority scope"}),
|
|
13818
|
+
)
|
|
13819
|
+
})?,
|
|
13820
|
+
None => 0,
|
|
13821
|
+
};
|
|
13822
|
+
let limit = request.limit.unwrap_or(100).clamp(1, 256);
|
|
13823
|
+
let mut projected = Vec::new();
|
|
13824
|
+
loop {
|
|
13825
|
+
let batch = state.events.after(revision, 256).map_err(control_error)?;
|
|
13826
|
+
let batch_len = batch.len();
|
|
13827
|
+
if batch_len == 0 {
|
|
13828
|
+
break;
|
|
13829
|
+
}
|
|
13830
|
+
for mut event in batch {
|
|
13831
|
+
let event_revision = event
|
|
13832
|
+
.state_version
|
|
13833
|
+
.as_deref()
|
|
13834
|
+
.and_then(|value| value.parse::<u64>().ok())
|
|
13835
|
+
.unwrap_or(revision);
|
|
13836
|
+
revision = revision.max(event_revision);
|
|
13837
|
+
if event.application_id != request.application_id {
|
|
13838
|
+
continue;
|
|
13839
|
+
}
|
|
13840
|
+
event.metadata.insert(
|
|
13841
|
+
"cursor".into(),
|
|
13842
|
+
Value::String(encode_scoped_cursor(&cursor_scope, event_revision)),
|
|
13843
|
+
);
|
|
13844
|
+
projected.push(event);
|
|
13845
|
+
if projected.len() == limit {
|
|
13846
|
+
break;
|
|
13847
|
+
}
|
|
13848
|
+
}
|
|
13849
|
+
if projected.len() == limit || batch_len < 256 {
|
|
13850
|
+
break;
|
|
13851
|
+
}
|
|
13852
|
+
}
|
|
13853
|
+
Ok(Json(json!({
|
|
13854
|
+
"contract_version":"1.0",
|
|
13855
|
+
"cursor_format":"fh1",
|
|
13856
|
+
"application_id":request.application_id,
|
|
13857
|
+
"environment":request.environment,
|
|
13858
|
+
"events":projected,
|
|
13859
|
+
"next_cursor":encode_scoped_cursor(&cursor_scope, revision),
|
|
13860
|
+
})))
|
|
13861
|
+
}
|
|
13862
|
+
|
|
13628
13863
|
fn record_response(row: StoredRow) -> RecordResponse {
|
|
13629
13864
|
let id = row
|
|
13630
13865
|
.key
|
|
@@ -13711,19 +13946,84 @@ struct ControlPlaneScope {
|
|
|
13711
13946
|
environment: String,
|
|
13712
13947
|
}
|
|
13713
13948
|
|
|
13949
|
+
fn authority_state_summary(
|
|
13950
|
+
current_sequence: u64,
|
|
13951
|
+
events: u64,
|
|
13952
|
+
cardinalities: &[(String, u64)],
|
|
13953
|
+
namespace_prefix: &str,
|
|
13954
|
+
) -> Value {
|
|
13955
|
+
let collections = cardinalities
|
|
13956
|
+
.iter()
|
|
13957
|
+
.filter(|(capability, count)| *capability != "state" && *count > 0)
|
|
13958
|
+
.map(|(capability, count)| {
|
|
13959
|
+
json!({
|
|
13960
|
+
"collection": capability.strip_prefix(namespace_prefix).unwrap_or(capability),
|
|
13961
|
+
"records": count,
|
|
13962
|
+
})
|
|
13963
|
+
})
|
|
13964
|
+
.collect::<Vec<_>>();
|
|
13965
|
+
let records = collections
|
|
13966
|
+
.iter()
|
|
13967
|
+
.filter_map(|entry| entry["records"].as_u64())
|
|
13968
|
+
.sum::<u64>();
|
|
13969
|
+
json!({
|
|
13970
|
+
"current_sequence": current_sequence,
|
|
13971
|
+
"records": records,
|
|
13972
|
+
"events": events,
|
|
13973
|
+
"collections": collections,
|
|
13974
|
+
})
|
|
13975
|
+
}
|
|
13976
|
+
|
|
13977
|
+
#[cfg(test)]
|
|
13978
|
+
mod studio_state_summary_tests {
|
|
13979
|
+
use super::authority_state_summary;
|
|
13980
|
+
use serde_json::json;
|
|
13981
|
+
|
|
13982
|
+
#[test]
|
|
13983
|
+
fn fresh_authority_is_a_truthful_zero_summary() {
|
|
13984
|
+
assert_eq!(
|
|
13985
|
+
authority_state_summary(0, 0, &[], "default:"),
|
|
13986
|
+
json!({"current_sequence":0,"records":0,"events":0,"collections":[]})
|
|
13987
|
+
);
|
|
13988
|
+
}
|
|
13989
|
+
|
|
13990
|
+
#[test]
|
|
13991
|
+
fn summary_counts_live_authority_rows_without_counting_revision_infrastructure() {
|
|
13992
|
+
let cardinalities = vec![
|
|
13993
|
+
("_feltdb.Intent".to_string(), 1),
|
|
13994
|
+
("_feltdb.IntentEvent".to_string(), 2),
|
|
13995
|
+
("state".to_string(), 7),
|
|
13996
|
+
("empty".to_string(), 0),
|
|
13997
|
+
];
|
|
13998
|
+
assert_eq!(
|
|
13999
|
+
authority_state_summary(4, 4, &cardinalities, "default:"),
|
|
14000
|
+
json!({
|
|
14001
|
+
"current_sequence":4,
|
|
14002
|
+
"records":3,
|
|
14003
|
+
"events":4,
|
|
14004
|
+
"collections":[
|
|
14005
|
+
{"collection":"_feltdb.Intent","records":1},
|
|
14006
|
+
{"collection":"_feltdb.IntentEvent","records":2}
|
|
14007
|
+
]
|
|
14008
|
+
})
|
|
14009
|
+
);
|
|
14010
|
+
}
|
|
14011
|
+
}
|
|
14012
|
+
|
|
13714
14013
|
/// Instance, authority, runtime, storage and activity in one document.
|
|
13715
14014
|
///
|
|
13716
14015
|
/// One request rather than eight, because an overview whose panels arrive
|
|
13717
14016
|
/// independently shows eight moments and calls them one instant.
|
|
13718
14017
|
async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
|
13719
14018
|
let db = state.db.clone();
|
|
13720
|
-
let (state_version, authority_revision, instance_id, cardinalities, log) =
|
|
14019
|
+
let (state_version, authority_revision, instance_id, cardinalities, events, log) =
|
|
13721
14020
|
tokio::task::spawn_blocking(move || {
|
|
13722
14021
|
Ok::<_, FlowError>((
|
|
13723
14022
|
db.sequence()?,
|
|
13724
14023
|
db.current_revision()?,
|
|
13725
14024
|
db.instance_id()?,
|
|
13726
14025
|
db.list_cardinalities()?,
|
|
14026
|
+
db.operations_since(&HashMap::new())?.len() as u64,
|
|
13727
14027
|
db.log_recovery(),
|
|
13728
14028
|
))
|
|
13729
14029
|
})
|
|
@@ -13751,6 +14051,12 @@ async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Va
|
|
|
13751
14051
|
.find(|(capability, _)| capability == "state")
|
|
13752
14052
|
.map(|(_, count)| *count)
|
|
13753
14053
|
.unwrap_or(0);
|
|
14054
|
+
// This is the authority-wide materialized view used by Studio's State
|
|
14055
|
+
// summary. Unlike the application-oriented `storage.collections` above,
|
|
14056
|
+
// it deliberately includes `_feltdb.*` system collections. State revision
|
|
14057
|
+
// rows are history infrastructure rather than current materialized records
|
|
14058
|
+
// and remain reported separately as `storage.state_revisions`.
|
|
14059
|
+
let authority_state = authority_state_summary(state_version, events, &cardinalities, &prefix);
|
|
13754
14060
|
Ok(Json(json!({
|
|
13755
14061
|
"instance": {
|
|
13756
14062
|
"instance_id": instance_id,
|
|
@@ -13766,6 +14072,7 @@ async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Va
|
|
|
13766
14072
|
"lease_clock_healthy": state.lease_clock.is_healthy(),
|
|
13767
14073
|
"advertise_url": state.advertise_url.as_ref().map(|value| value.to_string()),
|
|
13768
14074
|
},
|
|
14075
|
+
"authority_state": authority_state,
|
|
13769
14076
|
"storage": {
|
|
13770
14077
|
"records": records,
|
|
13771
14078
|
"state_revisions": revisions,
|
|
@@ -14253,10 +14560,37 @@ async fn control_plane_transaction(
|
|
|
14253
14560
|
let db = state.db.clone();
|
|
14254
14561
|
let id = transaction_id.clone();
|
|
14255
14562
|
let (applied, attribution) = tokio::task::spawn_blocking(move || {
|
|
14256
|
-
|
|
14257
|
-
|
|
14258
|
-
db.
|
|
14259
|
-
|
|
14563
|
+
let direct = db.transaction_attribution(&id)?;
|
|
14564
|
+
let mut public_matches = if direct.is_none() {
|
|
14565
|
+
db.committed_transactions_after(0, usize::MAX)?
|
|
14566
|
+
.into_iter()
|
|
14567
|
+
.filter(|value| {
|
|
14568
|
+
value
|
|
14569
|
+
.audit
|
|
14570
|
+
.as_ref()
|
|
14571
|
+
.and_then(|audit| audit.get("transaction_id"))
|
|
14572
|
+
.and_then(Value::as_str)
|
|
14573
|
+
== Some(id.as_str())
|
|
14574
|
+
})
|
|
14575
|
+
.collect::<Vec<_>>()
|
|
14576
|
+
} else {
|
|
14577
|
+
Vec::new()
|
|
14578
|
+
};
|
|
14579
|
+
if public_matches.len() > 1 {
|
|
14580
|
+
return Err(FlowError::CapabilityError(
|
|
14581
|
+
"PUBLIC_TRANSACTION_ID_AMBIGUOUS".into(),
|
|
14582
|
+
));
|
|
14583
|
+
}
|
|
14584
|
+
let mut attribution = direct.or_else(|| public_matches.pop());
|
|
14585
|
+
let storage_id = attribution
|
|
14586
|
+
.as_ref()
|
|
14587
|
+
.map(|value| value.transaction_id.as_str())
|
|
14588
|
+
.unwrap_or(id.as_str());
|
|
14589
|
+
let applied = db.has_applied_transaction(storage_id)?;
|
|
14590
|
+
if let Some(value) = attribution.as_mut() {
|
|
14591
|
+
value.transaction_id = id;
|
|
14592
|
+
}
|
|
14593
|
+
Ok::<_, FlowError>((applied, attribution))
|
|
14260
14594
|
})
|
|
14261
14595
|
.await
|
|
14262
14596
|
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
|