create-feltdb 0.11.8 → 0.11.9

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.
@@ -135,10 +135,18 @@ pub enum Phase {
135
135
  /// AuditWrite` is therefore routing plus decode plus encode, measured as a
136
136
  /// difference of two spans rather than asserted.
137
137
  HandlerBody,
138
+ /// Resolving a managed request's application contract: the revision, the
139
+ /// runtime inventory it is validated against, and the resolved runtime.
140
+ /// Every `/v1` request pays it, so it must not depend on unrelated state.
141
+ ContractResolution,
142
+ /// Cloning one state namespace's rows for a state-contract read or
143
+ /// transaction. Proportional to that application's stored rows, not to
144
+ /// other applications'.
145
+ NamespaceSnapshot,
138
146
  }
139
147
 
140
148
  impl Phase {
141
- pub const ALL: [Phase; 25] = [
149
+ pub const ALL: [Phase; 27] = [
142
150
  Phase::StateLockWait,
143
151
  Phase::StateLockHold,
144
152
  Phase::IndexedQuery,
@@ -164,6 +172,8 @@ impl Phase {
164
172
  Phase::AuditGroupSync,
165
173
  Phase::AuditClose,
166
174
  Phase::HandlerBody,
175
+ Phase::ContractResolution,
176
+ Phase::NamespaceSnapshot,
167
177
  ];
168
178
 
169
179
  /// The stable name this phase is reported under.
@@ -194,6 +204,8 @@ impl Phase {
194
204
  Phase::AuditGroupSync => "audit_group_sync",
195
205
  Phase::AuditClose => "audit_close",
196
206
  Phase::HandlerBody => "handler_body",
207
+ Phase::ContractResolution => "contract_resolution",
208
+ Phase::NamespaceSnapshot => "namespace_snapshot",
197
209
  }
198
210
  }
199
211
 
@@ -210,9 +222,11 @@ impl Phase {
210
222
  // The predicate runs on both executions, so its parent is the lock
211
223
  // rather than either query phase.
212
224
  Phase::PredicateEvaluation => Some(Phase::StateLockHold),
213
- Phase::Authorization | Phase::AuditWrite | Phase::HandlerBody => {
214
- Some(Phase::HttpHandler)
215
- }
225
+ Phase::Authorization
226
+ | Phase::AuditWrite
227
+ | Phase::HandlerBody
228
+ | Phase::ContractResolution
229
+ | Phase::NamespaceSnapshot => Some(Phase::HttpHandler),
216
230
  Phase::Ordering | Phase::ResultMaterialization => Some(Phase::HandlerBody),
217
231
  Phase::RecordMutation | Phase::IndexMaintenance | Phase::Persistence => {
218
232
  Some(Phase::StateLockHold)
@@ -601,6 +601,14 @@ struct CanonicalTransactionRequest {
601
601
  environment: String,
602
602
  transaction: TransactionRequest,
603
603
  }
604
+ #[derive(Deserialize)]
605
+ struct CanonicalReadBasisRequest {
606
+ application_id: String,
607
+ revision_id: String,
608
+ #[serde(default = "production_environment")]
609
+ environment: String,
610
+ reads: feltdb::ReadSet,
611
+ }
604
612
  fn production_environment() -> String {
605
613
  "production".into()
606
614
  }
@@ -1207,6 +1215,7 @@ async fn push_sync(
1207
1215
  ),
1208
1216
  operations: vec![operation.operation.clone()],
1209
1217
  preconditions: vec![],
1218
+ fences: vec![],
1210
1219
  };
1211
1220
  let _transaction_permit = state
1212
1221
  .state_transaction_admission
@@ -4620,12 +4629,15 @@ fn runtime_inventory(
4620
4629
  .filter(|value| value.status == ConnectionStatus::Active)
4621
4630
  .map(|value| value.id)
4622
4631
  .collect();
4623
- let snapshot = state.db.export_snapshot()?;
4632
+ // Every managed request resolves its contract through here, so this reads
4633
+ // only the capability registry's own rows. A whole-database snapshot would
4634
+ // clone, sort, and hash every tenant's state per request.
4624
4635
  let mut capabilities = BTreeSet::new();
4625
- for row in snapshot
4626
- .rows
4636
+ for row in state
4637
+ .db
4638
+ .list_collection("_flow_capabilities")?
4627
4639
  .iter()
4628
- .filter(|row| row.capability == "_flow_capabilities" && !row.deleted)
4640
+ .filter(|row| !row.deleted)
4629
4641
  {
4630
4642
  if let Some(name) = row.value.get("name").and_then(Value::as_str) {
4631
4643
  capabilities.insert(name.to_string());
@@ -4653,6 +4665,8 @@ fn revision_contract(
4653
4665
  revision_id: &str,
4654
4666
  environment: &str,
4655
4667
  ) -> Result<ApplicationRuntimeContract, ApiError> {
4668
+ let _span =
4669
+ feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::ContractResolution);
4656
4670
  let tenant = application_scope(state, actor, application_id, "application:revision:read")?;
4657
4671
  let revision = state
4658
4672
  .applications
@@ -7209,6 +7223,7 @@ async fn run_runtime_action(
7209
7223
  if_version,
7210
7224
  }],
7211
7225
  preconditions: vec![],
7226
+ fences: vec![],
7212
7227
  };
7213
7228
  let write_policy = contract
7214
7229
  .policies
@@ -7658,11 +7673,34 @@ async fn execute_canonical_query(
7658
7673
  &input.revision_id,
7659
7674
  &input.environment,
7660
7675
  )?;
7676
+ let authorization = if feltdb::state_contract::is_substrate_collection(&input.query.collection)
7677
+ {
7678
+ let mut authorization = state_authorization_legacy(
7679
+ &principal,
7680
+ &tenant,
7681
+ &input.application_id,
7682
+ &contract.revision_id,
7683
+ "state:denied",
7684
+ );
7685
+ if application_scope(
7686
+ &state,
7687
+ &principal.key_id,
7688
+ &input.application_id,
7689
+ "composition:read",
7690
+ )
7691
+ .is_ok()
7692
+ {
7693
+ authorization.capabilities.insert("composition:read".into());
7694
+ }
7695
+ authorization
7696
+ } else {
7697
+ state_authorization(&principal, &contract, &input.query.collection, "read")
7698
+ };
7661
7699
  let context = feltdb::state_contract::begin_query_read(
7662
7700
  &state.db,
7663
7701
  &contract.state.schema,
7664
7702
  &contract.environment.state_namespace,
7665
- state_authorization(&principal, &contract, &input.query.collection, "read"),
7703
+ authorization,
7666
7704
  )
7667
7705
  .map_err(state_contract_error)?;
7668
7706
 
@@ -7885,6 +7923,76 @@ async fn execute_revision_recovery(
7885
7923
  Ok(Json(response))
7886
7924
  }
7887
7925
 
7926
+ /// Whether `principal` may read every record of `collection` — the condition
7927
+ /// for a read basis, captured or committed, to cover it.
7928
+ ///
7929
+ /// A token digests every row it covers, and a refused fence reveals that one
7930
+ /// of them changed. Both are reads, so both require the right to read the whole
7931
+ /// collection: `state:read` under a read policy that does not depend on the
7932
+ /// record (none, or `authenticated`), or `composition:read` for a substrate
7933
+ /// collection. Owner, member, and self policies filter rows per actor; a read
7934
+ /// basis over such a collection is refused rather than digested.
7935
+ fn read_basis_readable(
7936
+ state: &AppState,
7937
+ principal: &Principal,
7938
+ contract: &ApplicationRuntimeContract,
7939
+ collection: &str,
7940
+ ) -> bool {
7941
+ if feltdb::state_contract::is_substrate_collection(collection) {
7942
+ return application_scope(
7943
+ state,
7944
+ &principal.key_id,
7945
+ &contract.application_id,
7946
+ "composition:read",
7947
+ )
7948
+ .is_ok();
7949
+ }
7950
+ if !state_authorization(principal, contract, collection, "read")
7951
+ .capabilities
7952
+ .contains("state:read")
7953
+ {
7954
+ return false;
7955
+ }
7956
+ matches!(
7957
+ matching_state_policy(contract, collection)
7958
+ .and_then(|policy| policy.read.as_deref())
7959
+ .and_then(PolicySubject::from_str),
7960
+ None | Some(PolicySubject::Authenticated)
7961
+ )
7962
+ }
7963
+
7964
+ /// Capture a read basis over the application's state namespace.
7965
+ async fn capture_canonical_read_basis(
7966
+ State(state): State<AppState>,
7967
+ Extension(principal): Extension<Principal>,
7968
+ headers: HeaderMap,
7969
+ Json(input): Json<CanonicalReadBasisRequest>,
7970
+ ) -> Result<Json<Value>, ApiError> {
7971
+ let tenant = application_scope(
7972
+ &state,
7973
+ &principal.key_id,
7974
+ &input.application_id,
7975
+ "state:read",
7976
+ )?;
7977
+ require_bound_tenant(&headers, &tenant)?;
7978
+ let contract = revision_contract(
7979
+ &state,
7980
+ &principal.key_id,
7981
+ &input.application_id,
7982
+ &input.revision_id,
7983
+ &input.environment,
7984
+ )?;
7985
+ let basis = feltdb::state_contract::capture_read_basis(
7986
+ &state.db,
7987
+ &contract.state.schema,
7988
+ &contract.environment.state_namespace,
7989
+ &input.reads,
7990
+ &|collection| read_basis_readable(&state, &principal, &contract, collection),
7991
+ )
7992
+ .map_err(state_contract_error)?;
7993
+ Ok(Json(json!(basis)))
7994
+ }
7995
+
7888
7996
  async fn execute_canonical_transaction(
7889
7997
  State(state): State<AppState>,
7890
7998
  Extension(principal): Extension<Principal>,
@@ -7921,14 +8029,49 @@ async fn execute_canonical_transaction(
7921
8029
  input.transaction.revision_id = contract.revision_id.clone();
7922
8030
  input.transaction.schema_version = contract.state.schema.schema_version;
7923
8031
  input.transaction.state_namespace = Some(contract.environment.state_namespace.clone());
7924
- let collections = input
8032
+ let (substrate, collections): (Vec<String>, Vec<String>) = input
7925
8033
  .transaction
7926
8034
  .operations
7927
8035
  .iter()
7928
8036
  .map(|operation| operation.collection.clone())
7929
- .collect::<Vec<_>>();
7930
- let (authorized, write_policies) =
7931
- authorize_transaction_collections(&principal, &contract, &tenant, &collections);
8037
+ .partition(|collection| feltdb::state_contract::is_substrate_collection(collection));
8038
+ let (authorized, write_policies) = if collections.is_empty() && !substrate.is_empty() {
8039
+ (tenant == contract.tenant_id, BTreeMap::new())
8040
+ } else {
8041
+ authorize_transaction_collections(&principal, &contract, &tenant, &collections)
8042
+ };
8043
+ // Substrate records are written only under the dedicated capability.
8044
+ let substrate_authorized = substrate.is_empty()
8045
+ || application_scope(
8046
+ &state,
8047
+ &principal.key_id,
8048
+ &input.application_id,
8049
+ "composition:write",
8050
+ )
8051
+ .is_ok();
8052
+ // A fence is a read of everything it covers: the committer must be able to
8053
+ // read all of it, exactly as capturing it required.
8054
+ for fence in &input.transaction.fences {
8055
+ for collection in fence
8056
+ .reads
8057
+ .records
8058
+ .iter()
8059
+ .map(|record| record.collection.as_str())
8060
+ .chain(
8061
+ fence
8062
+ .reads
8063
+ .predicates
8064
+ .iter()
8065
+ .map(|predicate| predicate.collection.as_str()),
8066
+ )
8067
+ {
8068
+ if !read_basis_readable(&state, &principal, &contract, collection) {
8069
+ return Err(state_contract_error(
8070
+ feltdb::state_contract::read_basis_denied(collection),
8071
+ ));
8072
+ }
8073
+ }
8074
+ }
7932
8075
  input.transaction.authorization = state_authorization_legacy(
7933
8076
  &principal,
7934
8077
  &tenant,
@@ -7940,6 +8083,13 @@ async fn execute_canonical_transaction(
7940
8083
  "state:denied"
7941
8084
  },
7942
8085
  );
8086
+ if authorized && substrate_authorized && !substrate.is_empty() {
8087
+ input
8088
+ .transaction
8089
+ .authorization
8090
+ .capabilities
8091
+ .insert("composition:write".into());
8092
+ }
7943
8093
  let _transaction_permit = state
7944
8094
  .state_transaction_admission
7945
8095
  .acquire()
@@ -9036,6 +9186,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9036
9186
  "/v1/transactions",
9037
9187
  axum::routing::post(execute_canonical_transaction),
9038
9188
  )
9189
+ .route(
9190
+ "/v1/transactions/read-basis",
9191
+ axum::routing::post(capture_canonical_read_basis),
9192
+ )
9039
9193
  .route(
9040
9194
  "/v1/revision/recover",
9041
9195
  axum::routing::post(execute_revision_recovery),
@@ -9312,6 +9466,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9312
9466
  axum::routing::delete(delete_connection_control),
9313
9467
  )
9314
9468
  .route("/transactions", axum::routing::post(commit_transaction))
9469
+ .route(
9470
+ "/transactions/read-basis",
9471
+ axum::routing::post(capture_read_basis),
9472
+ )
9315
9473
  .route("/query", axum::routing::post(execute_bounded_query))
9316
9474
  .route("/revision", get(get_revision))
9317
9475
  .route(
@@ -9631,6 +9789,8 @@ async fn authenticate(
9631
9789
  || path == "/v1/state/conflicts"
9632
9790
  || path == "/v1/state/reconciliation/plan"
9633
9791
  || path == "/v1/experimental/semantic-decisions"
9792
+ || path == "/transactions/read-basis"
9793
+ || path == "/v1/transactions/read-basis"
9634
9794
  {
9635
9795
  // Describing state is reading it. These are POSTs because they take
9636
9796
  // three revision identities and a plan, not because they change
@@ -13873,6 +14033,28 @@ struct AtomicTxRequest {
13873
14033
  /// exactly as before.
13874
14034
  #[serde(default)]
13875
14035
  preconditions: Vec<AtomicTxPreconditionRequest>,
14036
+ /// Read bases this authority issued (`POST /transactions/read-basis`),
14037
+ /// re-evaluated inside the commit lock. Absent means no fence.
14038
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
14039
+ fences: Vec<feltdb::ReadBasis>,
14040
+ }
14041
+
14042
+ /// Capture a read basis: what the authority holds, now, for a set of records
14043
+ /// and equality predicates. Reading, so it requires `state:read`.
14044
+ async fn capture_read_basis(
14045
+ State(state): State<AppState>,
14046
+ Json(reads): Json<feltdb::ReadSet>,
14047
+ ) -> Result<Json<feltdb::ReadBasis>, ApiError> {
14048
+ state
14049
+ .db
14050
+ .read_basis(&reads)
14051
+ .map(Json)
14052
+ .map_err(|error| match error {
14053
+ FlowError::CapabilityError(message) if message.starts_with("INVALID_READ_SET:") => {
14054
+ ApiError(StatusCode::BAD_REQUEST, message)
14055
+ }
14056
+ other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
14057
+ })
13876
14058
  }
13877
14059
 
13878
14060
  #[derive(Serialize)]
@@ -13934,6 +14116,8 @@ async fn commit_transaction(
13934
14116
  let mut record_preconditions: Vec<RecordPrecondition> = Vec::new();
13935
14117
  let mut seen = std::collections::HashSet::new();
13936
14118
 
14119
+ let fences = request.fences.clone();
14120
+
13937
14121
  // Transaction-level preconditions first, on records the transaction need
13938
14122
  // not write at all.
13939
14123
  for condition in &request.preconditions {
@@ -14011,12 +14195,13 @@ async fn commit_transaction(
14011
14195
  let operations = mutations.len();
14012
14196
  let commit = state
14013
14197
  .db
14014
- .apply_atomic_transaction_guarded(
14198
+ .apply_atomic_transaction_fenced(
14015
14199
  &request.transaction_id,
14016
14200
  Some(&payload_hash),
14017
14201
  None,
14018
14202
  &[],
14019
14203
  &record_preconditions,
14204
+ &fences,
14020
14205
  &mutations,
14021
14206
  None,
14022
14207
  )
@@ -14036,6 +14221,9 @@ async fn commit_transaction(
14036
14221
  "failure": *failure,
14037
14222
  }),
14038
14223
  ),
14224
+ FlowError::CapabilityError(message) if message.starts_with("INVALID_READ_SET:") => {
14225
+ ApiError(StatusCode::BAD_REQUEST, message)
14226
+ }
14039
14227
  FlowError::CapabilityError(message)
14040
14228
  if message.starts_with("TRANSACTION_ID_PAYLOAD_MISMATCH:") =>
14041
14229
  {
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.11.8",
5
+ "version": "0.11.9",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"
File without changes