create-feltdb 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package-versions.js +1 -1
- package/dist/server-source/crates/feltdb/src/bin/feltdb_node.rs +84 -0
- package/dist/server-source/crates/feltdb/src/lib.rs +38 -20
- package/dist/server-source/crates/feltdb/src/state_facade.rs +292 -0
- package/dist/server-source/crates/feltdb/src/state_model.rs +1856 -0
- package/dist/server-source/crates/feltdb/src/transaction_preconditions.rs +43 -11
- package/dist/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +634 -0
- package/dist/server-source/crates/feltdb/tests/state_model_integration.rs +366 -0
- package/dist/server-source/crates/feltdb/tests/state_persistence_integration.rs +270 -0
- package/dist/server-source/crates/feltdb-server/src/app_state.rs +13 -0
- package/dist/server-source/crates/feltdb-server/src/main.rs +194 -9
- package/package.json +1 -1
|
@@ -41,7 +41,7 @@ use feltdb::{
|
|
|
41
41
|
Grant, GrantSigner, GrantStore, Subject as GrantSubject,
|
|
42
42
|
},
|
|
43
43
|
cardinality_endpoint::{CardinalityContext, CardinalityDiagnosticResponse},
|
|
44
|
-
AtomicMutation,
|
|
44
|
+
AtomicMutation,
|
|
45
45
|
policy_evaluation::{Actor, PolicyContext, PolicyEvaluator, PolicySubject},
|
|
46
46
|
state_contract::{
|
|
47
47
|
begin_read, compare_schemas, execute_query as execute_state_query, execute_transaction,
|
|
@@ -60,7 +60,7 @@ use feltdb::{
|
|
|
60
60
|
DatabaseSnapshot, FeltDb, JsonCasResult, Operation, PeerAdvertisement, PeerId, StoredRow, FlowError, RecordPrecondition,
|
|
61
61
|
};
|
|
62
62
|
use feltdb_server::{
|
|
63
|
-
app_state::AppState,
|
|
63
|
+
app_state::{AppState, BoundedQueryCursor},
|
|
64
64
|
application_contract::{ApplicationContractStore, ContractPatch},
|
|
65
65
|
artifacts::{ArtifactKind, ArtifactLifecycle, ArtifactMetadata, ArtifactStore, ProducerRef},
|
|
66
66
|
audit::{AuditEvent, AuditLog},
|
|
@@ -5803,6 +5803,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
5803
5803
|
workloads: Arc::new(std::sync::Mutex::new(workload_store)),
|
|
5804
5804
|
mesh: Arc::new(std::sync::Mutex::new(mesh_store)),
|
|
5805
5805
|
readiness_probe: Arc::new(readiness_probe),
|
|
5806
|
+
bounded_query_cursors: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
|
5806
5807
|
};
|
|
5807
5808
|
start_peer_sessions(&state, &config)?;
|
|
5808
5809
|
start_membership_recovery(&state);
|
|
@@ -6310,6 +6311,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
6310
6311
|
axum::routing::delete(delete_connection_control),
|
|
6311
6312
|
)
|
|
6312
6313
|
.route("/transactions", axum::routing::post(commit_transaction))
|
|
6314
|
+
.route("/query", axum::routing::post(execute_bounded_query))
|
|
6313
6315
|
.route("/revision", get(get_revision))
|
|
6314
6316
|
.route(
|
|
6315
6317
|
"/collections/{collection}",
|
|
@@ -9011,6 +9013,187 @@ async fn get_revision(State(state): State<AppState>) -> Result<Json<RevisionResp
|
|
|
9011
9013
|
}))
|
|
9012
9014
|
}
|
|
9013
9015
|
|
|
9016
|
+
const MAX_BOUNDED_QUERY_LIMIT: usize = 500;
|
|
9017
|
+
const BOUNDED_CURSOR_TTL_SECONDS: u64 = 300;
|
|
9018
|
+
|
|
9019
|
+
#[derive(Clone, Deserialize, Serialize)]
|
|
9020
|
+
#[serde(rename_all = "camelCase")]
|
|
9021
|
+
struct BoundedQueryRequest {
|
|
9022
|
+
collection: String,
|
|
9023
|
+
#[serde(default, rename = "where")]
|
|
9024
|
+
conditions: Vec<BoundedQueryCondition>,
|
|
9025
|
+
#[serde(default)]
|
|
9026
|
+
order_by: Vec<BoundedQueryOrder>,
|
|
9027
|
+
limit: usize,
|
|
9028
|
+
#[serde(default)]
|
|
9029
|
+
cursor: Option<String>,
|
|
9030
|
+
}
|
|
9031
|
+
|
|
9032
|
+
#[derive(Clone, Deserialize, Serialize)]
|
|
9033
|
+
struct BoundedQueryCondition {
|
|
9034
|
+
field: String,
|
|
9035
|
+
#[serde(flatten)]
|
|
9036
|
+
operators: Map<String, Value>,
|
|
9037
|
+
}
|
|
9038
|
+
|
|
9039
|
+
#[derive(Clone, Deserialize, Serialize)]
|
|
9040
|
+
struct BoundedQueryOrder {
|
|
9041
|
+
field: String,
|
|
9042
|
+
direction: String,
|
|
9043
|
+
}
|
|
9044
|
+
|
|
9045
|
+
#[derive(Serialize)]
|
|
9046
|
+
#[serde(rename_all = "camelCase")]
|
|
9047
|
+
struct BoundedQueryPage {
|
|
9048
|
+
records: Vec<Value>,
|
|
9049
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
9050
|
+
next_cursor: Option<String>,
|
|
9051
|
+
exhausted: bool,
|
|
9052
|
+
}
|
|
9053
|
+
|
|
9054
|
+
fn bounded_query_error(code: &str, message: impl Into<String>) -> ApiError {
|
|
9055
|
+
ApiError::structured(StatusCode::UNPROCESSABLE_ENTITY, json!({
|
|
9056
|
+
"code": code,
|
|
9057
|
+
"message": message.into(),
|
|
9058
|
+
}))
|
|
9059
|
+
}
|
|
9060
|
+
|
|
9061
|
+
fn bounded_query_hash(request: &BoundedQueryRequest) -> Result<String, ApiError> {
|
|
9062
|
+
let context = json!({
|
|
9063
|
+
"collection": request.collection,
|
|
9064
|
+
"where": request.conditions,
|
|
9065
|
+
"orderBy": request.order_by,
|
|
9066
|
+
"limit": request.limit,
|
|
9067
|
+
});
|
|
9068
|
+
let bytes = serde_json::to_vec(&context)
|
|
9069
|
+
.map_err(|error| bounded_query_error("INVALID_QUERY", error.to_string()))?;
|
|
9070
|
+
Ok(format!("sha256:{:x}", Sha256::digest(bytes)))
|
|
9071
|
+
}
|
|
9072
|
+
|
|
9073
|
+
fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Ordering {
|
|
9074
|
+
match (left, right) {
|
|
9075
|
+
(None, None) => std::cmp::Ordering::Equal,
|
|
9076
|
+
(None, Some(_)) => std::cmp::Ordering::Less,
|
|
9077
|
+
(Some(_), None) => std::cmp::Ordering::Greater,
|
|
9078
|
+
(Some(Value::Null), Some(Value::Null)) => std::cmp::Ordering::Equal,
|
|
9079
|
+
(Some(Value::Null), Some(_)) => std::cmp::Ordering::Less,
|
|
9080
|
+
(Some(_), Some(Value::Null)) => std::cmp::Ordering::Greater,
|
|
9081
|
+
(Some(Value::Number(a)), Some(Value::Number(b))) => a.as_f64().partial_cmp(&b.as_f64()).unwrap_or(std::cmp::Ordering::Equal),
|
|
9082
|
+
(Some(Value::String(a)), Some(Value::String(b))) => a.cmp(b),
|
|
9083
|
+
(Some(Value::Bool(a)), Some(Value::Bool(b))) => a.cmp(b),
|
|
9084
|
+
(Some(a), Some(b)) => a.to_string().cmp(&b.to_string()),
|
|
9085
|
+
}
|
|
9086
|
+
}
|
|
9087
|
+
|
|
9088
|
+
fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Result<bool, ApiError> {
|
|
9089
|
+
if condition.operators.len() != 1 || condition.field.trim().is_empty() {
|
|
9090
|
+
return Err(bounded_query_error("INVALID_QUERY", "each where condition requires a field and exactly one operator"));
|
|
9091
|
+
}
|
|
9092
|
+
let (operator, expected) = condition.operators.iter().next().expect("validated operator");
|
|
9093
|
+
let actual = record.get(&condition.field);
|
|
9094
|
+
if operator == "eq" { return Ok(actual == Some(expected)); }
|
|
9095
|
+
if operator == "neq" { return Ok(actual != Some(expected)); }
|
|
9096
|
+
let Some(actual) = actual else { return Ok(false) };
|
|
9097
|
+
let comparison = query_scalar_cmp(Some(actual), Some(expected));
|
|
9098
|
+
Ok(match operator.as_str() {
|
|
9099
|
+
"lt" => comparison.is_lt(),
|
|
9100
|
+
"lte" => !comparison.is_gt(),
|
|
9101
|
+
"gt" => comparison.is_gt(),
|
|
9102
|
+
"gte" => !comparison.is_lt(),
|
|
9103
|
+
_ => return Err(bounded_query_error("INVALID_QUERY", format!("unsupported where operator: {operator}"))),
|
|
9104
|
+
})
|
|
9105
|
+
}
|
|
9106
|
+
|
|
9107
|
+
fn issue_bounded_cursor(state: &AppState, mut cursor: BoundedQueryCursor) -> Result<String, ApiError> {
|
|
9108
|
+
let token = uuid::Uuid::new_v4().simple().to_string();
|
|
9109
|
+
let now = unix_seconds_i64().max(0) as u64;
|
|
9110
|
+
cursor.created_at = now;
|
|
9111
|
+
let mut cursors = state.bounded_query_cursors.lock().map_err(|_| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "query cursor store unavailable".into()))?;
|
|
9112
|
+
cursors.retain(|_, value| now.saturating_sub(value.created_at) <= BOUNDED_CURSOR_TTL_SECONDS);
|
|
9113
|
+
if cursors.len() >= 1024 {
|
|
9114
|
+
return Err(ApiError(StatusCode::SERVICE_UNAVAILABLE, "query cursor capacity exhausted".into()));
|
|
9115
|
+
}
|
|
9116
|
+
cursors.insert(token.clone(), cursor);
|
|
9117
|
+
Ok(token)
|
|
9118
|
+
}
|
|
9119
|
+
|
|
9120
|
+
async fn execute_bounded_query(
|
|
9121
|
+
State(state): State<AppState>,
|
|
9122
|
+
Extension(principal): Extension<Principal>,
|
|
9123
|
+
Json(request): Json<BoundedQueryRequest>,
|
|
9124
|
+
) -> Result<Json<BoundedQueryPage>, ApiError> {
|
|
9125
|
+
validate_segment(&request.collection)?;
|
|
9126
|
+
if request.limit == 0 || request.limit > MAX_BOUNDED_QUERY_LIMIT {
|
|
9127
|
+
return Err(bounded_query_error("INVALID_QUERY", format!("limit must be between 1 and {MAX_BOUNDED_QUERY_LIMIT}")));
|
|
9128
|
+
}
|
|
9129
|
+
if request.order_by.is_empty() {
|
|
9130
|
+
return Err(bounded_query_error("INVALID_QUERY", "orderBy must contain at least one field"));
|
|
9131
|
+
}
|
|
9132
|
+
for order in &request.order_by {
|
|
9133
|
+
if order.field.trim().is_empty() || !matches!(order.direction.as_str(), "asc" | "desc") {
|
|
9134
|
+
return Err(bounded_query_error("INVALID_QUERY", "orderBy directions must be asc or desc"));
|
|
9135
|
+
}
|
|
9136
|
+
}
|
|
9137
|
+
for condition in &request.conditions {
|
|
9138
|
+
if condition.operators.len() != 1 || condition.field.trim().is_empty()
|
|
9139
|
+
|| !condition.operators.keys().all(|operator| matches!(operator.as_str(), "eq" | "neq" | "lt" | "lte" | "gt" | "gte")) {
|
|
9140
|
+
return Err(bounded_query_error("INVALID_QUERY", "each where condition requires a field and exactly one operator"));
|
|
9141
|
+
}
|
|
9142
|
+
}
|
|
9143
|
+
let hash = bounded_query_hash(&request)?;
|
|
9144
|
+
let (records, position) = if let Some(token) = &request.cursor {
|
|
9145
|
+
let cursor = state.bounded_query_cursors.lock()
|
|
9146
|
+
.map_err(|_| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "query cursor store unavailable".into()))?
|
|
9147
|
+
.get(token).cloned()
|
|
9148
|
+
.ok_or_else(|| bounded_query_error("INVALID_CURSOR", "cursor is invalid or expired"))?;
|
|
9149
|
+
if cursor.query_hash != hash || cursor.principal_key_id != principal.key_id || cursor.namespace != state.namespace.as_ref() {
|
|
9150
|
+
return Err(bounded_query_error("INVALID_CURSOR", "cursor does not match this query, principal, or namespace"));
|
|
9151
|
+
}
|
|
9152
|
+
if (unix_seconds_i64().max(0) as u64).saturating_sub(cursor.created_at) > BOUNDED_CURSOR_TTL_SECONDS {
|
|
9153
|
+
return Err(bounded_query_error("INVALID_CURSOR", "cursor is expired"));
|
|
9154
|
+
}
|
|
9155
|
+
(cursor.records, cursor.position)
|
|
9156
|
+
} else {
|
|
9157
|
+
let mut records = Vec::new();
|
|
9158
|
+
for row in state.db.list_collection(&request.collection)? {
|
|
9159
|
+
let mut value = row.value;
|
|
9160
|
+
if let Some(object) = value.as_object_mut() {
|
|
9161
|
+
let id = row.key.split_once(':').map(|(_, id)| id).unwrap_or(&row.key);
|
|
9162
|
+
// `recordId` is authority metadata for this query surface, not
|
|
9163
|
+
// caller-controlled document data. It is the final total-order
|
|
9164
|
+
// tie-breaker even when a document contains a field by that name.
|
|
9165
|
+
object.insert("recordId".into(), Value::String(id.to_string()));
|
|
9166
|
+
}
|
|
9167
|
+
if request.conditions.iter().all(|condition| condition_matches(&value, condition).unwrap_or(false)) {
|
|
9168
|
+
records.push(value);
|
|
9169
|
+
}
|
|
9170
|
+
}
|
|
9171
|
+
// The authority always adds its immutable record identity as the final
|
|
9172
|
+
// tie-breaker, so equal user sort values still form a total order.
|
|
9173
|
+
records.sort_by(|left, right| {
|
|
9174
|
+
for order in &request.order_by {
|
|
9175
|
+
let comparison = query_scalar_cmp(left.get(&order.field), right.get(&order.field));
|
|
9176
|
+
if !comparison.is_eq() { return if order.direction == "desc" { comparison.reverse() } else { comparison }; }
|
|
9177
|
+
}
|
|
9178
|
+
query_scalar_cmp(left.get("recordId"), right.get("recordId"))
|
|
9179
|
+
});
|
|
9180
|
+
(Arc::new(records), 0)
|
|
9181
|
+
};
|
|
9182
|
+
let end = position.saturating_add(request.limit).min(records.len());
|
|
9183
|
+
let page = records[position.min(records.len())..end].to_vec();
|
|
9184
|
+
let next_cursor = if end < records.len() {
|
|
9185
|
+
Some(issue_bounded_cursor(&state, BoundedQueryCursor {
|
|
9186
|
+
query_hash: hash,
|
|
9187
|
+
principal_key_id: principal.key_id,
|
|
9188
|
+
namespace: state.namespace.to_string(),
|
|
9189
|
+
records: records.clone(),
|
|
9190
|
+
position: end,
|
|
9191
|
+
created_at: 0,
|
|
9192
|
+
})?)
|
|
9193
|
+
} else { None };
|
|
9194
|
+
Ok(Json(BoundedQueryPage { records: page, exhausted: next_cursor.is_none(), next_cursor }))
|
|
9195
|
+
}
|
|
9196
|
+
|
|
9014
9197
|
async fn list_records(
|
|
9015
9198
|
State(state): State<AppState>,
|
|
9016
9199
|
Path(collection): Path<String>,
|
|
@@ -9134,7 +9317,6 @@ async fn commit_transaction(
|
|
|
9134
9317
|
// Validate every operation before any of them is turned into a mutation, so
|
|
9135
9318
|
// a malformed request cannot commit a prefix of the transaction.
|
|
9136
9319
|
let mut mutations = Vec::with_capacity(request.operations.len());
|
|
9137
|
-
let mut preconditions = Vec::new();
|
|
9138
9320
|
let mut record_preconditions: Vec<RecordPrecondition> = Vec::new();
|
|
9139
9321
|
let mut seen = std::collections::HashSet::new();
|
|
9140
9322
|
|
|
@@ -9173,14 +9355,17 @@ async fn commit_transaction(
|
|
|
9173
9355
|
));
|
|
9174
9356
|
}
|
|
9175
9357
|
if operation.require_absent {
|
|
9176
|
-
//
|
|
9177
|
-
//
|
|
9178
|
-
//
|
|
9179
|
-
// the
|
|
9180
|
-
|
|
9358
|
+
// Use the public record-precondition path so a lost creation race
|
|
9359
|
+
// is both evaluated under the store lock and returned as a
|
|
9360
|
+
// structured presence conflict. The same condition tells the
|
|
9361
|
+
// authority to assign document version 1 to the created value.
|
|
9362
|
+
record_preconditions.push(RecordPrecondition {
|
|
9181
9363
|
capability: operation.collection.clone(),
|
|
9182
9364
|
key: key.clone(),
|
|
9365
|
+
require_absent: true,
|
|
9183
9366
|
expected_version: None,
|
|
9367
|
+
expected_epoch: None,
|
|
9368
|
+
expected_lease_id: None,
|
|
9184
9369
|
});
|
|
9185
9370
|
}
|
|
9186
9371
|
// A guard attached to a staged write. Previously declared by the
|
|
@@ -9216,7 +9401,7 @@ async fn commit_transaction(
|
|
|
9216
9401
|
&request.transaction_id,
|
|
9217
9402
|
None,
|
|
9218
9403
|
None,
|
|
9219
|
-
&
|
|
9404
|
+
&[],
|
|
9220
9405
|
&record_preconditions,
|
|
9221
9406
|
&mutations,
|
|
9222
9407
|
None,
|