create-feltdb 0.6.14 → 0.7.1

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,
@@ -57,7 +57,7 @@ use feltdb::{
57
57
  WorkerRegistration,
58
58
  },
59
59
  workload::{CreateWorkload, WorkloadStore},
60
- DatabaseSnapshot, FeltDb, JsonCasResult, Operation, PeerAdvertisement, PeerId, StoredRow,
60
+ DatabaseSnapshot, FeltDb, JsonCasResult, Operation, PeerAdvertisement, PeerId, StoredRow, FlowError, RecordPrecondition,
61
61
  };
62
62
  use feltdb_server::{
63
63
  app_state::AppState,
@@ -103,6 +103,16 @@ const PROTOCOL_VERSION: &str = "1";
103
103
  #[derive(Debug)]
104
104
  struct ApiError(StatusCode, String);
105
105
 
106
+ impl ApiError {
107
+ /// An error whose body is a structured document rather than a message.
108
+ ///
109
+ /// `api_error_body` already parses a message that happens to be JSON, so
110
+ /// this is the existing mechanism named rather than a second one.
111
+ fn structured(status: StatusCode, body: Value) -> Self {
112
+ Self(status, serde_json::to_string(&body).unwrap_or_else(|_| body.to_string()))
113
+ }
114
+ }
115
+
106
116
  impl IntoResponse for ApiError {
107
117
  fn into_response(self) -> Response {
108
118
  let body = api_error_body(self.0, self.1);
@@ -9039,6 +9049,38 @@ struct AtomicTxOperationRequest {
9039
9049
  /// When set, the record must not already exist for the transaction to commit.
9040
9050
  #[serde(default)]
9041
9051
  require_absent: bool,
9052
+ /// Fence this write on the record's current `__version`.
9053
+ ///
9054
+ /// Present because the client declares it; it was previously accepted by
9055
+ /// serde and silently discarded, so a caller could attach a stale version
9056
+ /// to a staged write and watch the transaction commit anyway.
9057
+ #[serde(default)]
9058
+ expected_version: Option<u64>,
9059
+ #[serde(default)]
9060
+ expected_epoch: Option<u64>,
9061
+ #[serde(default)]
9062
+ expected_lease_id: Option<String>,
9063
+ }
9064
+
9065
+ /// A precondition on a record the transaction does not necessarily write.
9066
+ ///
9067
+ /// Explicit rather than overloaded onto an operation, because read/decide/write
9068
+ /// fencing routinely guards on a record it does not modify -- a session
9069
+ /// generation, an ownership epoch -- and expressing that as a no-op write would
9070
+ /// make the guard a mutation.
9071
+ #[derive(Deserialize)]
9072
+ #[serde(rename_all = "camelCase")]
9073
+ struct AtomicTxPreconditionRequest {
9074
+ collection: String,
9075
+ id: String,
9076
+ #[serde(default)]
9077
+ require_absent: bool,
9078
+ #[serde(default)]
9079
+ expected_version: Option<u64>,
9080
+ #[serde(default)]
9081
+ expected_epoch: Option<u64>,
9082
+ #[serde(default)]
9083
+ expected_lease_id: Option<String>,
9042
9084
  }
9043
9085
 
9044
9086
  #[derive(Deserialize)]
@@ -9047,6 +9089,11 @@ struct AtomicTxRequest {
9047
9089
  /// Caller-supplied identity. Reusing one makes the commit idempotent.
9048
9090
  transaction_id: String,
9049
9091
  operations: Vec<AtomicTxOperationRequest>,
9092
+ /// Conditions the authority evaluates inside the same atomic boundary as
9093
+ /// the writes. Absent means an unconditional transaction, which behaves
9094
+ /// exactly as before.
9095
+ #[serde(default)]
9096
+ preconditions: Vec<AtomicTxPreconditionRequest>,
9050
9097
  }
9051
9098
 
9052
9099
  #[derive(Serialize)]
@@ -9087,8 +9134,35 @@ async fn commit_transaction(
9087
9134
  // Validate every operation before any of them is turned into a mutation, so
9088
9135
  // a malformed request cannot commit a prefix of the transaction.
9089
9136
  let mut mutations = Vec::with_capacity(request.operations.len());
9090
- let mut preconditions = Vec::new();
9137
+ let mut record_preconditions: Vec<RecordPrecondition> = Vec::new();
9091
9138
  let mut seen = std::collections::HashSet::new();
9139
+
9140
+ // Transaction-level preconditions first, on records the transaction need
9141
+ // not write at all.
9142
+ for condition in &request.preconditions {
9143
+ let key = record_key(&condition.collection, &condition.id)?;
9144
+ let precondition = RecordPrecondition {
9145
+ capability: condition.collection.clone(),
9146
+ key,
9147
+ require_absent: condition.require_absent,
9148
+ expected_version: condition.expected_version,
9149
+ expected_epoch: condition.expected_epoch,
9150
+ expected_lease_id: condition.expected_lease_id.clone(),
9151
+ };
9152
+ if precondition.is_empty() {
9153
+ // Refused rather than ignored. A precondition that constrains
9154
+ // nothing reads as protection and provides none, and silently
9155
+ // accepting one is how this whole class of defect happens.
9156
+ return Err(ApiError(
9157
+ StatusCode::BAD_REQUEST,
9158
+ format!(
9159
+ "precondition on {}/{} constrains nothing",
9160
+ condition.collection, condition.id
9161
+ ),
9162
+ ));
9163
+ }
9164
+ record_preconditions.push(precondition);
9165
+ }
9092
9166
  for operation in request.operations {
9093
9167
  let key = record_key(&operation.collection, &operation.id)?;
9094
9168
  if !seen.insert(key.clone()) {
@@ -9098,14 +9172,32 @@ async fn commit_transaction(
9098
9172
  ));
9099
9173
  }
9100
9174
  if operation.require_absent {
9101
- // The store reads a precondition with no expected version as
9102
- // "this record must not exist", and evaluates it inside the same
9103
- // lock as the commit, so there is no window between the check and
9104
- // the write.
9105
- preconditions.push(AtomicPrecondition {
9175
+ // Use the public record-precondition path so a lost creation race
9176
+ // is both evaluated under the store lock and returned as a
9177
+ // structured presence conflict. The same condition tells the
9178
+ // authority to assign document version 1 to the created value.
9179
+ record_preconditions.push(RecordPrecondition {
9106
9180
  capability: operation.collection.clone(),
9107
9181
  key: key.clone(),
9182
+ require_absent: true,
9108
9183
  expected_version: None,
9184
+ expected_epoch: None,
9185
+ expected_lease_id: None,
9186
+ });
9187
+ }
9188
+ // A guard attached to a staged write. Previously declared by the
9189
+ // client, accepted by serde, and dropped here.
9190
+ if operation.expected_version.is_some()
9191
+ || operation.expected_epoch.is_some()
9192
+ || operation.expected_lease_id.is_some()
9193
+ {
9194
+ record_preconditions.push(RecordPrecondition {
9195
+ capability: operation.collection.clone(),
9196
+ key: key.clone(),
9197
+ require_absent: false,
9198
+ expected_version: operation.expected_version,
9199
+ expected_epoch: operation.expected_epoch,
9200
+ expected_lease_id: operation.expected_lease_id.clone(),
9109
9201
  });
9110
9202
  }
9111
9203
  let value = operation.value.map(|mut fields| {
@@ -9122,11 +9214,33 @@ async fn commit_transaction(
9122
9214
  let operations = mutations.len();
9123
9215
  let commit = state
9124
9216
  .db
9125
- .apply_atomic_transaction(&request.transaction_id, None, &preconditions, &mutations, None)
9217
+ .apply_atomic_transaction_guarded(
9218
+ &request.transaction_id,
9219
+ None,
9220
+ None,
9221
+ &[],
9222
+ &record_preconditions,
9223
+ &mutations,
9224
+ None,
9225
+ )
9126
9226
  .map_err(|error| {
9127
9227
  // A refused transaction applied nothing, so this is a conflict, not
9128
- // a partially completed request.
9129
- ApiError(StatusCode::CONFLICT, error.to_string())
9228
+ // a partially completed request. A precondition failure carries
9229
+ // which predicate failed and what the authority holds, so a caller
9230
+ // can tell a lost race from a broken deployment without parsing
9231
+ // prose.
9232
+ match error {
9233
+ FlowError::PreconditionFailed(failure) => ApiError::structured(
9234
+ StatusCode::CONFLICT,
9235
+ json!({
9236
+ "committed": false,
9237
+ "code": "PRECONDITION_FAILED",
9238
+ "error": failure.to_string(),
9239
+ "failure": *failure,
9240
+ }),
9241
+ ),
9242
+ other => ApiError(StatusCode::CONFLICT, other.to_string()),
9243
+ }
9130
9244
  })?;
9131
9245
 
9132
9246
  Ok((
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.6.14",
5
+ "version": "0.7.1",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"