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.
@@ -41,7 +41,7 @@ use feltdb::{
41
41
  Grant, GrantSigner, GrantStore, Subject as GrantSubject,
42
42
  },
43
43
  cardinality_endpoint::{CardinalityContext, CardinalityDiagnosticResponse},
44
- AtomicMutation, AtomicPrecondition,
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
- // The store reads a precondition with no expected version as
9177
- // "this record must not exist", and evaluates it inside the same
9178
- // lock as the commit, so there is no window between the check and
9179
- // the write.
9180
- preconditions.push(AtomicPrecondition {
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
- &preconditions,
9404
+ &[],
9220
9405
  &record_preconditions,
9221
9406
  &mutations,
9222
9407
  None,
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.0",
5
+ "version": "0.7.2",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"