create-feltdb 0.7.1 → 0.7.3

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.
@@ -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>,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "create-feltdb",
3
3
  "private": false,
4
4
  "description": "Create a new FeltDB application with one command",
5
- "version": "0.7.1",
5
+ "version": "0.7.3",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"