@feltdb/core 0.9.0 → 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/authority-scope.d.ts +2 -0
- package/dist/authority-scope.d.ts.map +1 -1
- package/dist/authority-scope.js +7 -2
- 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 +443 -77
- 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 +14 -7
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +50 -26
- package/dist/deployment.d.ts +2 -0
- 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 +7 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +83 -15
- 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/operation-admission.d.ts +2 -0
- package/dist/operation-admission.d.ts.map +1 -1
- 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-CjELc-ul.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,
|
|
@@ -7513,17 +7578,29 @@ async fn get_state_version(
|
|
|
7513
7578
|
json!({"application_id":target.application_id,"revision_id":contract.revision_id,"schema_version":contract.state.schema.schema_version,"state_version":state.db.sequence()?,"causal_cursor":state.db.operation_versions()?,"state_namespace":contract.environment.state_namespace}),
|
|
7514
7579
|
))
|
|
7515
7580
|
}
|
|
7581
|
+
fn require_bound_tenant(headers: &HeaderMap, tenant: &str) -> Result<(), ApiError> {
|
|
7582
|
+
let Some(bound) = headers.get("feltdb-tenant-id") else { return Ok(()); };
|
|
7583
|
+
if bound.to_str().ok() == Some(tenant) { return Ok(()); }
|
|
7584
|
+
Err(ApiError(StatusCode::FORBIDDEN, json!({
|
|
7585
|
+
"code":"AUTHORITY_SCOPE_MISMATCH",
|
|
7586
|
+
"message":"request tenant does not match the authenticated application authority",
|
|
7587
|
+
"http_status":403
|
|
7588
|
+
}).to_string()))
|
|
7589
|
+
}
|
|
7590
|
+
|
|
7516
7591
|
async fn execute_canonical_query(
|
|
7517
7592
|
State(state): State<AppState>,
|
|
7518
7593
|
Extension(principal): Extension<Principal>,
|
|
7594
|
+
headers: HeaderMap,
|
|
7519
7595
|
Json(input): Json<CanonicalQueryRequest>,
|
|
7520
7596
|
) -> Result<Json<Value>, ApiError> {
|
|
7521
|
-
let
|
|
7597
|
+
let tenant = application_scope(
|
|
7522
7598
|
&state,
|
|
7523
7599
|
&principal.key_id,
|
|
7524
7600
|
&input.application_id,
|
|
7525
7601
|
"state:read",
|
|
7526
7602
|
)?;
|
|
7603
|
+
require_bound_tenant(&headers, &tenant)?;
|
|
7527
7604
|
let contract = revision_contract(
|
|
7528
7605
|
&state,
|
|
7529
7606
|
&principal.key_id,
|
|
@@ -7718,6 +7795,7 @@ async fn execute_revision_recovery(
|
|
|
7718
7795
|
async fn execute_canonical_transaction(
|
|
7719
7796
|
State(state): State<AppState>,
|
|
7720
7797
|
Extension(principal): Extension<Principal>,
|
|
7798
|
+
headers: HeaderMap,
|
|
7721
7799
|
Json(mut input): Json<CanonicalTransactionRequest>,
|
|
7722
7800
|
) -> Result<Json<Value>, ApiError> {
|
|
7723
7801
|
let tenant = application_scope(
|
|
@@ -7726,6 +7804,7 @@ async fn execute_canonical_transaction(
|
|
|
7726
7804
|
&input.application_id,
|
|
7727
7805
|
"state:write",
|
|
7728
7806
|
)?;
|
|
7807
|
+
require_bound_tenant(&headers, &tenant)?;
|
|
7729
7808
|
let contract = revision_contract(
|
|
7730
7809
|
&state,
|
|
7731
7810
|
&principal.key_id,
|
|
@@ -7733,6 +7812,13 @@ async fn execute_canonical_transaction(
|
|
|
7733
7812
|
&input.revision_id,
|
|
7734
7813
|
&input.environment,
|
|
7735
7814
|
)?;
|
|
7815
|
+
if !input.transaction.tenant_id.is_empty() && input.transaction.tenant_id != tenant {
|
|
7816
|
+
return Err(ApiError(StatusCode::FORBIDDEN, json!({
|
|
7817
|
+
"code":"AUTHORITY_SCOPE_MISMATCH",
|
|
7818
|
+
"message":"transaction tenant does not match the bound authority",
|
|
7819
|
+
"http_status":403
|
|
7820
|
+
}).to_string()));
|
|
7821
|
+
}
|
|
7736
7822
|
input.transaction.tenant_id = tenant.clone();
|
|
7737
7823
|
input.transaction.application_id = input.application_id.clone();
|
|
7738
7824
|
input.transaction.revision_id = contract.revision_id.clone();
|
|
@@ -7757,6 +7843,11 @@ async fn execute_canonical_transaction(
|
|
|
7757
7843
|
"state:denied"
|
|
7758
7844
|
},
|
|
7759
7845
|
);
|
|
7846
|
+
let _transaction_permit = state
|
|
7847
|
+
.state_transaction_admission
|
|
7848
|
+
.acquire()
|
|
7849
|
+
.await
|
|
7850
|
+
.expect("transaction admission is never closed");
|
|
7760
7851
|
let result = execute_transaction_with_collection_policies(
|
|
7761
7852
|
&state.db,
|
|
7762
7853
|
&contract.state.schema,
|
|
@@ -7772,7 +7863,13 @@ async fn execute_canonical_transaction(
|
|
|
7772
7863
|
"allowed",
|
|
7773
7864
|
201,
|
|
7774
7865
|
);
|
|
7775
|
-
|
|
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))
|
|
7776
7873
|
}
|
|
7777
7874
|
|
|
7778
7875
|
async fn get_cardinality_diagnostic(
|
|
@@ -8367,7 +8464,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8367
8464
|
let grant_signer = GrantSigner::new("feltdb-primary-v1", grant_key.to_vec())?;
|
|
8368
8465
|
let grant_store = GrantStore::load(config.data.with_extension("grants.json"))?;
|
|
8369
8466
|
let sync_store = SyncStore::load(config.data.with_extension("sync.json"))?;
|
|
8370
|
-
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
|
+
};
|
|
8371
8473
|
let state_trigger_store = StateTriggerStore::load(config.data.with_extension("triggers.json"))?;
|
|
8372
8474
|
let mesh_store = WorkerMeshStore::load(config.data.with_extension("workers.json"))?;
|
|
8373
8475
|
let content_store = ContentStore::new(config.data.with_extension("content"))?;
|
|
@@ -8377,9 +8479,28 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8377
8479
|
)?;
|
|
8378
8480
|
let bundle_store = BundleStore::load(config.data.with_extension("bundles.json"))?;
|
|
8379
8481
|
let release_store = ReleaseStore::load(config.data.with_extension("releases.json"))?;
|
|
8380
|
-
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
|
+
}
|
|
8381
8487
|
let provider_store = ProviderStore::load(config.data.with_extension("providers.json"))?;
|
|
8382
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
|
+
}
|
|
8383
8504
|
// Constructed before the state so the same handle can be stopped after
|
|
8384
8505
|
// serving: the pipeline outlives the router by exactly one shutdown.
|
|
8385
8506
|
let audit_handle = AuditLog::new(config.audit.clone());
|
|
@@ -8405,13 +8526,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8405
8526
|
peer_client,
|
|
8406
8527
|
peer_token,
|
|
8407
8528
|
lifecycle_lock: Arc::new(tokio::sync::Mutex::new(())),
|
|
8529
|
+
state_transaction_admission: Arc::new(tokio::sync::Semaphore::new(2)),
|
|
8408
8530
|
worker_enabled: config.worker_enabled,
|
|
8409
8531
|
leases: LeaseStore::load(config.data.with_extension("leases.json"))?,
|
|
8410
8532
|
content: content_store,
|
|
8411
8533
|
artifacts: artifact_store,
|
|
8412
8534
|
bundles: bundle_store,
|
|
8413
8535
|
releases: release_store,
|
|
8414
|
-
|
|
8536
|
+
events: event_authority,
|
|
8415
8537
|
providers: provider_store,
|
|
8416
8538
|
contracts: ApplicationContractStore::load(
|
|
8417
8539
|
config.data.with_extension("application-contracts.json"),
|
|
@@ -8420,14 +8542,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8420
8542
|
lease_clock: LeaseClock::new(config.maximum_clock_drift_ms),
|
|
8421
8543
|
backup_barrier: Arc::new(tokio::sync::RwLock::new(())),
|
|
8422
8544
|
backup_artifacts: Arc::new(backup_artifacts),
|
|
8423
|
-
tenancy:
|
|
8545
|
+
tenancy: tenancy_store,
|
|
8424
8546
|
sessions: SessionVerifier::load(
|
|
8425
8547
|
std::env::var("FELTDB_SESSION_SECRET")
|
|
8426
8548
|
.unwrap_or_else(|_| "feltdb-development-session-secret-change-me".into())
|
|
8427
8549
|
.into_bytes(),
|
|
8428
8550
|
config.data.with_extension("revoked-sessions.json"),
|
|
8429
8551
|
)?,
|
|
8430
|
-
principals:
|
|
8552
|
+
principals: principal_authority,
|
|
8431
8553
|
connections: ConnectionStore::load_with_provider(
|
|
8432
8554
|
config.data.with_extension("connections.json"),
|
|
8433
8555
|
provider_from_environment(
|
|
@@ -8436,7 +8558,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8436
8558
|
)?,
|
|
8437
8559
|
)?,
|
|
8438
8560
|
identities: IdentityStore::load(config.data.with_extension("identities.json"))?,
|
|
8439
|
-
applications:
|
|
8561
|
+
applications: application_store,
|
|
8440
8562
|
runtimes: RuntimeStore::load(config.data.with_extension("application-runtimes.json"))?,
|
|
8441
8563
|
grant_signer,
|
|
8442
8564
|
grants: Arc::new(std::sync::Mutex::new(grant_store)),
|
|
@@ -8775,6 +8897,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8775
8897
|
.route("/v1/auth/session", get(auth_session))
|
|
8776
8898
|
.route("/v1/auth/signout", axum::routing::post(auth_sign_out))
|
|
8777
8899
|
.route("/v1/events", get(events))
|
|
8900
|
+
.route("/v1/public/events/replay", get(public_event_replay))
|
|
8778
8901
|
.route(
|
|
8779
8902
|
"/v1/workflows/{name}/run",
|
|
8780
8903
|
axum::routing::post(start_workflow_run),
|
|
@@ -9810,6 +9933,13 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
|
|
|
9810
9933
|
status: overall_status(membership == "healthy", clock, &database.storage),
|
|
9811
9934
|
version: env!("CARGO_PKG_VERSION"),
|
|
9812
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(),
|
|
9813
9943
|
runtime: "self-hosted",
|
|
9814
9944
|
storage: database.storage.label(),
|
|
9815
9945
|
fabric: "healthy",
|
|
@@ -10051,8 +10181,7 @@ fn manage_workloads() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10051
10181
|
.and_then(|i| arguments.get(i + 1))
|
|
10052
10182
|
.cloned()
|
|
10053
10183
|
};
|
|
10054
|
-
let path =
|
|
10055
|
-
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()));
|
|
10056
10185
|
let mut store = WorkloadStore::load(path)?;
|
|
10057
10186
|
if family == "worker" || family == "mesh" {
|
|
10058
10187
|
let mesh_path = PathBuf::from(
|
|
@@ -10105,17 +10234,13 @@ fn manage_state_triggers() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10105
10234
|
),
|
|
10106
10235
|
"evaluate" => {
|
|
10107
10236
|
let db = FeltDb::open(&data)?;
|
|
10108
|
-
let
|
|
10109
|
-
option("--workload-store")
|
|
10110
|
-
.unwrap_or_else(|| data.with_extension("workloads.json").to_string_lossy().into_owned()),
|
|
10111
|
-
);
|
|
10112
|
-
let mut workloads = WorkloadStore::load(workload_path)?;
|
|
10237
|
+
let mut workloads = WorkloadStore::from_db(db.clone())?;
|
|
10113
10238
|
let produced = triggers.evaluate(&db, &mut workloads, unix_seconds_i64())?;
|
|
10114
10239
|
println!("{}", serde_json::to_string_pretty(&produced)?);
|
|
10115
10240
|
}
|
|
10116
10241
|
_ => {
|
|
10117
10242
|
return Err(
|
|
10118
|
-
"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]"
|
|
10119
10244
|
.into(),
|
|
10120
10245
|
)
|
|
10121
10246
|
}
|
|
@@ -13126,7 +13251,7 @@ async fn get_record(
|
|
|
13126
13251
|
}
|
|
13127
13252
|
|
|
13128
13253
|
/// One operation inside an application transaction.
|
|
13129
|
-
#[derive(Deserialize)]
|
|
13254
|
+
#[derive(Deserialize, Serialize)]
|
|
13130
13255
|
#[serde(rename_all = "camelCase")]
|
|
13131
13256
|
struct AtomicTxOperationRequest {
|
|
13132
13257
|
collection: String,
|
|
@@ -13156,7 +13281,7 @@ struct AtomicTxOperationRequest {
|
|
|
13156
13281
|
/// fencing routinely guards on a record it does not modify -- a session
|
|
13157
13282
|
/// generation, an ownership epoch -- and expressing that as a no-op write would
|
|
13158
13283
|
/// make the guard a mutation.
|
|
13159
|
-
#[derive(Deserialize)]
|
|
13284
|
+
#[derive(Deserialize, Serialize)]
|
|
13160
13285
|
#[serde(rename_all = "camelCase")]
|
|
13161
13286
|
struct AtomicTxPreconditionRequest {
|
|
13162
13287
|
collection: String,
|
|
@@ -13171,7 +13296,7 @@ struct AtomicTxPreconditionRequest {
|
|
|
13171
13296
|
expected_lease_id: Option<String>,
|
|
13172
13297
|
}
|
|
13173
13298
|
|
|
13174
|
-
#[derive(Deserialize)]
|
|
13299
|
+
#[derive(Deserialize, Serialize)]
|
|
13175
13300
|
#[serde(rename_all = "camelCase")]
|
|
13176
13301
|
struct AtomicTxRequest {
|
|
13177
13302
|
/// Caller-supplied identity. Reusing one makes the commit idempotent.
|
|
@@ -13196,6 +13321,8 @@ struct AtomicTxResponse {
|
|
|
13196
13321
|
operations: usize,
|
|
13197
13322
|
state_before: u64,
|
|
13198
13323
|
state_after: u64,
|
|
13324
|
+
revision_id: String,
|
|
13325
|
+
operation_ids: Vec<String>,
|
|
13199
13326
|
}
|
|
13200
13327
|
|
|
13201
13328
|
/// Commit several operations as one atomic, durable transaction.
|
|
@@ -13224,6 +13351,16 @@ async fn commit_transaction(
|
|
|
13224
13351
|
));
|
|
13225
13352
|
}
|
|
13226
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
|
+
|
|
13227
13364
|
// Validate every operation before any of them is turned into a mutation, so
|
|
13228
13365
|
// a malformed request cannot commit a prefix of the transaction.
|
|
13229
13366
|
let mut mutations = Vec::with_capacity(request.operations.len());
|
|
@@ -13309,7 +13446,7 @@ async fn commit_transaction(
|
|
|
13309
13446
|
.db
|
|
13310
13447
|
.apply_atomic_transaction_guarded(
|
|
13311
13448
|
&request.transaction_id,
|
|
13312
|
-
|
|
13449
|
+
Some(&payload_hash),
|
|
13313
13450
|
None,
|
|
13314
13451
|
&[],
|
|
13315
13452
|
&record_preconditions,
|
|
@@ -13332,9 +13469,27 @@ async fn commit_transaction(
|
|
|
13332
13469
|
"failure": *failure,
|
|
13333
13470
|
}),
|
|
13334
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
|
+
}
|
|
13335
13485
|
other => ApiError(StatusCode::CONFLICT, other.to_string()),
|
|
13336
13486
|
}
|
|
13337
13487
|
})?;
|
|
13488
|
+
let operation_ids = state
|
|
13489
|
+
.db
|
|
13490
|
+
.transaction_attribution(&commit.transaction_id)?
|
|
13491
|
+
.map(|value| value.operation_ids)
|
|
13492
|
+
.unwrap_or_default();
|
|
13338
13493
|
|
|
13339
13494
|
Ok((
|
|
13340
13495
|
if commit.duplicate {
|
|
@@ -13351,6 +13506,8 @@ async fn commit_transaction(
|
|
|
13351
13506
|
operations,
|
|
13352
13507
|
state_before: commit.state_before,
|
|
13353
13508
|
state_after: commit.state_after,
|
|
13509
|
+
revision_id: format!("revision-{}", commit.commit_revision),
|
|
13510
|
+
operation_ids,
|
|
13354
13511
|
}),
|
|
13355
13512
|
))
|
|
13356
13513
|
}
|
|
@@ -13563,6 +13720,7 @@ async fn delete_record(
|
|
|
13563
13720
|
async fn events(
|
|
13564
13721
|
State(state): State<AppState>,
|
|
13565
13722
|
Extension(principal): Extension<Principal>,
|
|
13723
|
+
headers: HeaderMap,
|
|
13566
13724
|
Query(scope): Query<EventScopeRequest>,
|
|
13567
13725
|
) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
|
|
13568
13726
|
if !principal.permits("events:read") {
|
|
@@ -13571,39 +13729,137 @@ async fn events(
|
|
|
13571
13729
|
"event access is not permitted".into(),
|
|
13572
13730
|
));
|
|
13573
13731
|
}
|
|
13574
|
-
if let Some(application_id) = scope.application_id {
|
|
13732
|
+
if let Some(application_id) = scope.application_id.as_ref() {
|
|
13575
13733
|
application_scope(&state, &principal.key_id, &application_id, "events:read")?;
|
|
13576
13734
|
}
|
|
13577
|
-
let
|
|
13578
|
-
|
|
13579
|
-
|
|
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)?;
|
|
13580
13748
|
let stream = async_stream::stream! {
|
|
13581
13749
|
loop {
|
|
13582
|
-
|
|
13583
|
-
|
|
13584
|
-
|
|
13585
|
-
|
|
13586
|
-
|
|
13587
|
-
|
|
13588
|
-
|
|
13589
|
-
|
|
13590
|
-
|
|
13591
|
-
|
|
13592
|
-
|
|
13593
|
-
|
|
13594
|
-
});
|
|
13595
|
-
yield Ok(Event::default().event("state.changed").json_data(data).expect("serializable event"));
|
|
13596
|
-
}
|
|
13597
|
-
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
|
13598
|
-
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;
|
|
13599
13762
|
}
|
|
13600
|
-
|
|
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"));
|
|
13601
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
|
+
};
|
|
13602
13778
|
}
|
|
13603
13779
|
};
|
|
13604
13780
|
Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
|
|
13605
13781
|
}
|
|
13606
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
|
+
|
|
13607
13863
|
fn record_response(row: StoredRow) -> RecordResponse {
|
|
13608
13864
|
let id = row
|
|
13609
13865
|
.key
|
|
@@ -13690,19 +13946,84 @@ struct ControlPlaneScope {
|
|
|
13690
13946
|
environment: String,
|
|
13691
13947
|
}
|
|
13692
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
|
+
|
|
13693
14013
|
/// Instance, authority, runtime, storage and activity in one document.
|
|
13694
14014
|
///
|
|
13695
14015
|
/// One request rather than eight, because an overview whose panels arrive
|
|
13696
14016
|
/// independently shows eight moments and calls them one instant.
|
|
13697
14017
|
async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
|
13698
14018
|
let db = state.db.clone();
|
|
13699
|
-
let (state_version, authority_revision, instance_id, cardinalities, log) =
|
|
14019
|
+
let (state_version, authority_revision, instance_id, cardinalities, events, log) =
|
|
13700
14020
|
tokio::task::spawn_blocking(move || {
|
|
13701
14021
|
Ok::<_, FlowError>((
|
|
13702
14022
|
db.sequence()?,
|
|
13703
14023
|
db.current_revision()?,
|
|
13704
14024
|
db.instance_id()?,
|
|
13705
14025
|
db.list_cardinalities()?,
|
|
14026
|
+
db.operations_since(&HashMap::new())?.len() as u64,
|
|
13706
14027
|
db.log_recovery(),
|
|
13707
14028
|
))
|
|
13708
14029
|
})
|
|
@@ -13730,6 +14051,12 @@ async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Va
|
|
|
13730
14051
|
.find(|(capability, _)| capability == "state")
|
|
13731
14052
|
.map(|(_, count)| *count)
|
|
13732
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);
|
|
13733
14060
|
Ok(Json(json!({
|
|
13734
14061
|
"instance": {
|
|
13735
14062
|
"instance_id": instance_id,
|
|
@@ -13745,6 +14072,7 @@ async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Va
|
|
|
13745
14072
|
"lease_clock_healthy": state.lease_clock.is_healthy(),
|
|
13746
14073
|
"advertise_url": state.advertise_url.as_ref().map(|value| value.to_string()),
|
|
13747
14074
|
},
|
|
14075
|
+
"authority_state": authority_state,
|
|
13748
14076
|
"storage": {
|
|
13749
14077
|
"records": records,
|
|
13750
14078
|
"state_revisions": revisions,
|
|
@@ -14232,10 +14560,37 @@ async fn control_plane_transaction(
|
|
|
14232
14560
|
let db = state.db.clone();
|
|
14233
14561
|
let id = transaction_id.clone();
|
|
14234
14562
|
let (applied, attribution) = tokio::task::spawn_blocking(move || {
|
|
14235
|
-
|
|
14236
|
-
|
|
14237
|
-
db.
|
|
14238
|
-
|
|
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))
|
|
14239
14594
|
})
|
|
14240
14595
|
.await
|
|
14241
14596
|
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
|
|
@@ -15002,8 +15357,8 @@ mod worker_identity_tests {
|
|
|
15002
15357
|
|
|
15003
15358
|
#[cfg(test)]
|
|
15004
15359
|
mod authority_gate_tests {
|
|
15005
|
-
use super::{api_error_body, authorize_transaction_collections, state_authorization};
|
|
15006
|
-
use axum::http::StatusCode;
|
|
15360
|
+
use super::{api_error_body, authorize_transaction_collections, require_bound_tenant, state_authorization};
|
|
15361
|
+
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
|
15007
15362
|
use feltdb::{
|
|
15008
15363
|
application::{
|
|
15009
15364
|
manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition,
|
|
@@ -15111,6 +15466,17 @@ mod authority_gate_tests {
|
|
|
15111
15466
|
assert!(!allowed);
|
|
15112
15467
|
}
|
|
15113
15468
|
|
|
15469
|
+
#[test]
|
|
15470
|
+
fn bound_tenant_header_fails_closed_on_mismatch() {
|
|
15471
|
+
let mut headers = HeaderMap::new();
|
|
15472
|
+
headers.insert("feltdb-tenant-id", HeaderValue::from_static("tenant-a"));
|
|
15473
|
+
assert!(require_bound_tenant(&headers, "tenant-a").is_ok());
|
|
15474
|
+
let error = require_bound_tenant(&headers, "tenant-b").unwrap_err();
|
|
15475
|
+
assert_eq!(error.0, StatusCode::FORBIDDEN);
|
|
15476
|
+
let body: serde_json::Value = serde_json::from_str(&error.1).unwrap();
|
|
15477
|
+
assert_eq!(body.get("code").and_then(serde_json::Value::as_str), Some("AUTHORITY_SCOPE_MISMATCH"));
|
|
15478
|
+
}
|
|
15479
|
+
|
|
15114
15480
|
#[test]
|
|
15115
15481
|
fn every_forbidden_error_uses_the_canonical_wire_contract() {
|
|
15116
15482
|
for internal_message in [
|