@feltdb/core 0.8.1 → 0.8.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.
Files changed (28) hide show
  1. package/dist/create/package-versions.js +1 -1
  2. package/dist/create/server-source/crates/feltdb/src/application.rs +19 -0
  3. package/dist/create/server-source/crates/feltdb/src/authority.rs +349 -0
  4. package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority-client.rs +32 -0
  5. package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority.rs +19 -0
  6. package/dist/create/server-source/crates/feltdb/src/lib.rs +444 -189
  7. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +244 -51
  8. package/dist/create/server-source/crates/feltdb/tests/authority_process.rs +297 -0
  9. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +41 -11
  10. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +8 -3
  11. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1164 -225
  12. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +134 -0
  13. package/dist/file-db.d.ts +8 -15
  14. package/dist/file-db.d.ts.map +1 -1
  15. package/dist/file-db.js +234 -130
  16. package/dist/http-db.d.ts +3 -0
  17. package/dist/http-db.d.ts.map +1 -1
  18. package/dist/http-db.js +2 -1
  19. package/dist/state-contract.d.ts +4 -1
  20. package/dist/state-contract.d.ts.map +1 -1
  21. package/dist/state-contract.js +1 -1
  22. package/dist/studio-app/assets/{feltdb_wasm-bIqcRzAr.js → feltdb_wasm-DB8cX151.js} +1 -1
  23. package/dist/studio-app/assets/feltdb_wasm_bg-ClhDHp0S.wasm +0 -0
  24. package/dist/studio-app/assets/{index-XGYdlElN.js → index-B0k4UAlI.js} +1 -1
  25. package/dist/studio-app/index.html +1 -1
  26. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  27. package/package.json +1 -1
  28. package/dist/studio-app/assets/feltdb_wasm_bg-BE79okwX.wasm +0 -0
@@ -22,7 +22,9 @@
22
22
 
23
23
  use crate::{
24
24
  application::{ApplicationManifest, ApplicationRevision, FieldDefinition},
25
- policy_evaluation::{PolicyEvaluator, PolicySubject, RecordAuthorizationContext, Actor, AuthorizationState},
25
+ policy_evaluation::{
26
+ Actor, AuthorizationState, PolicyEvaluator, PolicySubject, RecordAuthorizationContext,
27
+ },
26
28
  AtomicMutation, AtomicPrecondition, FeltDb, StoredRow,
27
29
  };
28
30
  use serde::{Deserialize, Serialize};
@@ -616,7 +618,9 @@ pub fn begin_read(
616
618
  "authorization context does not match schema",
617
619
  ));
618
620
  }
619
- let snapshot = db.state_rows().map_err(StateFailure::storage)?;
621
+ let snapshot = db
622
+ .state_rows_for_namespace(state_namespace)
623
+ .map_err(StateFailure::storage)?;
620
624
  Ok(ReadContext {
621
625
  application_id: schema.application_id.clone(),
622
626
  revision_id: schema.revision_id.clone(),
@@ -1344,11 +1348,17 @@ pub fn execute_query(
1344
1348
  .collect::<Vec<_>>();
1345
1349
 
1346
1350
  if let Some(policy) = &read_policy {
1347
- let actor = if context.authorization.subject.is_empty() || context.authorization.subject == ":" {
1348
- None
1349
- } else {
1350
- context.authorization.subject.split(':').nth(1).map(|id| Actor::new(id))
1351
- };
1351
+ let actor =
1352
+ if context.authorization.subject.is_empty() || context.authorization.subject == ":" {
1353
+ None
1354
+ } else {
1355
+ context
1356
+ .authorization
1357
+ .subject
1358
+ .split(':')
1359
+ .nth(1)
1360
+ .map(|id| Actor::new(id))
1361
+ };
1352
1362
 
1353
1363
  // Create authorization state for policies that need to verify relationships
1354
1364
  let auth_state = AuthorizationState::new(
@@ -1367,8 +1377,7 @@ pub fn execute_query(
1367
1377
  authorization_state: Some(auth_state.clone()),
1368
1378
  };
1369
1379
 
1370
- PolicyEvaluator::evaluate_record(policy.clone(), &record_context)
1371
- .is_ok()
1380
+ PolicyEvaluator::evaluate_record(policy.clone(), &record_context).is_ok()
1372
1381
  } else {
1373
1382
  true
1374
1383
  }
@@ -1618,6 +1627,8 @@ pub struct TransactionRequest {
1618
1627
  #[serde(default)]
1619
1628
  pub state_namespace: Option<String>,
1620
1629
  #[serde(default)]
1630
+ /// Optimistic authority revision. A mismatched revision is rejected.
1631
+ #[serde(rename = "base_revision")]
1621
1632
  pub causal_parent: Option<u64>,
1622
1633
  pub authorization: AuthorizationContext,
1623
1634
  pub operations: Vec<TransactionOperation>,
@@ -1642,6 +1653,9 @@ pub struct TransactionAudit {
1642
1653
  #[derive(Debug, Clone, Serialize, Deserialize)]
1643
1654
  pub struct TransactionResult {
1644
1655
  pub transaction_id: String,
1656
+ pub base_revision: u64,
1657
+ pub commit_revision: u64,
1658
+ pub status: crate::TransactionStatus,
1645
1659
  pub state_before: u64,
1646
1660
  pub state_after: u64,
1647
1661
  pub causal_cursor: BTreeMap<String, u64>,
@@ -1899,7 +1913,7 @@ fn validate_record(collection: &CollectionSchema, value: &mut Value) -> Result<(
1899
1913
  }
1900
1914
  if object
1901
1915
  .keys()
1902
- .any(|name| !collection.fields.iter().any(|f| &f.name == name))
1916
+ .any(|name| name != "__version" && !collection.fields.iter().any(|f| &f.name == name))
1903
1917
  {
1904
1918
  return Err(StateFailure::new(
1905
1919
  "VALIDATION_FAILED",
@@ -1972,13 +1986,42 @@ fn execute_transaction_with_policies(
1972
1986
  .transaction_id
1973
1987
  .clone()
1974
1988
  .unwrap_or_else(|| format!("tx_{:x}", Sha256::digest(&canonical)));
1989
+ // Client transaction identities are application-local. The managed store
1990
+ // serves many applications from one durable log, so using the bare client
1991
+ // id as the substrate id lets one application collide with another.
1992
+ // Hash the authorization boundary into the private storage identity while
1993
+ // preserving the caller's id in every public result and audit record.
1994
+ let storage_transaction_id = format!(
1995
+ "txscope_{:x}_{}",
1996
+ Sha256::digest(
1997
+ format!(
1998
+ "{}\0{}\0{}",
1999
+ request.tenant_id,
2000
+ request.application_id,
2001
+ request
2002
+ .state_namespace
2003
+ .as_deref()
2004
+ .unwrap_or(&request.application_id)
2005
+ )
2006
+ .as_bytes()
2007
+ ),
2008
+ transaction_id
2009
+ );
1975
2010
  let payload_hash = format!("sha256:{:x}", Sha256::digest(&canonical));
1976
2011
  if db
1977
- .has_applied_transaction(&transaction_id)
2012
+ .has_applied_transaction(&storage_transaction_id)
1978
2013
  .map_err(StateFailure::storage)?
1979
2014
  {
1980
- if db.applied_transaction_payload_hash(&transaction_id).map_err(StateFailure::storage)?.as_deref() != Some(payload_hash.as_str()) {
1981
- return Err(StateFailure::new("CONFLICT", "transaction ID was already committed with a different canonical payload"));
2015
+ if db
2016
+ .applied_transaction_payload_hash(&storage_transaction_id)
2017
+ .map_err(StateFailure::storage)?
2018
+ .as_deref()
2019
+ != Some(payload_hash.as_str())
2020
+ {
2021
+ return Err(StateFailure::new(
2022
+ "CONFLICT",
2023
+ "transaction ID was already committed with a different canonical payload",
2024
+ ));
1982
2025
  }
1983
2026
  let state = db.sequence().map_err(StateFailure::storage)?;
1984
2027
  let cursor = db
@@ -2002,6 +2045,9 @@ fn execute_transaction_with_policies(
2002
2045
  };
2003
2046
  return Ok(TransactionResult {
2004
2047
  transaction_id,
2048
+ base_revision: db.current_revision().map_err(StateFailure::storage)?,
2049
+ commit_revision: db.current_revision().map_err(StateFailure::storage)?,
2050
+ status: crate::TransactionStatus::Committed,
2005
2051
  state_before: state,
2006
2052
  state_after: state,
2007
2053
  causal_cursor: cursor,
@@ -2111,7 +2157,9 @@ fn execute_transaction_with_policies(
2111
2157
  .unwrap_or_else(|| persisted.map(|row| Some(row.value.clone())).unwrap_or(None));
2112
2158
 
2113
2159
  // Per-operation record authorization
2114
- let write_policy = collection_write_policies.and_then(|policies| policies.get(&operation.collection)).or(uniform_write_policy);
2160
+ let write_policy = collection_write_policies
2161
+ .and_then(|policies| policies.get(&operation.collection))
2162
+ .or(uniform_write_policy);
2115
2163
  if let Some(policy) = write_policy {
2116
2164
  let actor = if request.authorization.subject.is_empty()
2117
2165
  || request.authorization.subject == ":"
@@ -2135,9 +2183,9 @@ fn execute_transaction_with_policies(
2135
2183
  TransactionOperationKind::Update | TransactionOperationKind::Delete => {
2136
2184
  // For update/delete: authorize against EXISTING record (before changes)
2137
2185
  // This prevents authorization bypass via ownership/org changes
2138
- existing_value.clone().ok_or_else(|| {
2139
- StateFailure::new("CONFLICT", "record does not exist")
2140
- })?
2186
+ existing_value
2187
+ .clone()
2188
+ .ok_or_else(|| StateFailure::new("CONFLICT", "record does not exist"))?
2141
2189
  }
2142
2190
  };
2143
2191
 
@@ -2160,16 +2208,15 @@ fn execute_transaction_with_policies(
2160
2208
  authorization_state: Some(auth_state),
2161
2209
  };
2162
2210
 
2163
- PolicyEvaluator::evaluate_record(policy.clone(), &record_context)
2164
- .map_err(|_| {
2165
- StateFailure::new(
2166
- "AUTHORIZATION_DENIED",
2167
- &format!(
2168
- "record authorization failed for operation on {}:{}",
2169
- operation.collection, key
2170
- ),
2171
- )
2172
- })?;
2211
+ PolicyEvaluator::evaluate_record(policy.clone(), &record_context).map_err(|_| {
2212
+ StateFailure::new(
2213
+ "AUTHORIZATION_DENIED",
2214
+ &format!(
2215
+ "record authorization failed for operation on {}:{}",
2216
+ operation.collection, key
2217
+ ),
2218
+ )
2219
+ })?;
2173
2220
  }
2174
2221
 
2175
2222
  let value = match operation.kind {
@@ -2304,7 +2351,7 @@ fn execute_transaction_with_policies(
2304
2351
  }
2305
2352
  let commit = db
2306
2353
  .apply_atomic_transaction_content_addressed(
2307
- &transaction_id,
2354
+ &storage_transaction_id,
2308
2355
  Some(&payload_hash),
2309
2356
  request.causal_parent,
2310
2357
  &preconditions,
@@ -2367,6 +2414,9 @@ fn execute_transaction_with_policies(
2367
2414
  };
2368
2415
  Ok(TransactionResult {
2369
2416
  transaction_id,
2417
+ base_revision: commit.base_revision,
2418
+ commit_revision: commit.commit_revision,
2419
+ status: commit.status,
2370
2420
  state_before: commit.state_before,
2371
2421
  state_after: commit.state_after,
2372
2422
  causal_cursor: cursor,
@@ -2609,7 +2659,9 @@ mod tests {
2609
2659
  stale.transaction_id = Some("stale".into());
2610
2660
  stale.causal_parent = Some(0);
2611
2661
  assert_eq!(
2612
- execute_transaction(&db, &schema, &stale, None).unwrap_err().code,
2662
+ execute_transaction(&db, &schema, &stale, None)
2663
+ .unwrap_err()
2664
+ .code,
2613
2665
  "CONFLICT"
2614
2666
  );
2615
2667
  }
@@ -2786,16 +2838,149 @@ mod tests {
2786
2838
  }
2787
2839
  #[test]
2788
2840
  fn explicit_transaction_id_is_bound_to_canonical_payload_across_restart() {
2789
- let path=std::env::temp_dir().join(format!("feltdb-content-id-{}-{}.log",std::process::id(),crate::now_ms()));
2790
- let schema=schema();
2791
- let request=TransactionRequest{transaction_id:Some("stable-client-id".into()),tenant_id:"tenant".into(),application_id:"app".into(),revision_id:"rev".into(),schema_version:1,state_namespace:None,causal_parent:None,authorization:auth(),preconditions:vec![],operations:vec![TransactionOperation{kind:TransactionOperationKind::Insert,collection:"incidents".into(),id:"content-bound".into(),value:serde_json::json!({"title":"Original"}),if_version:None}]};
2792
- {let db=crate::open(&path).unwrap();assert!(!execute_transaction(&db,&schema,&request,None).unwrap().duplicate);assert!(execute_transaction(&db,&schema,&request,None).unwrap().duplicate);}
2793
- let reopened=crate::open(&path).unwrap();assert!(execute_transaction(&reopened,&schema,&request,None).unwrap().duplicate);
2794
- let mut changed=request.clone();changed.operations[0].value=serde_json::json!({"title":"Changed"});
2795
- let failure=execute_transaction(&reopened,&schema,&changed,None).unwrap_err();assert_eq!(failure.code,"CONFLICT");assert!(failure.message.contains("different canonical payload"));
2841
+ let path = std::env::temp_dir().join(format!(
2842
+ "feltdb-content-id-{}-{}.log",
2843
+ std::process::id(),
2844
+ crate::now_ms()
2845
+ ));
2846
+ let schema = schema();
2847
+ let request = TransactionRequest {
2848
+ transaction_id: Some("stable-client-id".into()),
2849
+ tenant_id: "tenant".into(),
2850
+ application_id: "app".into(),
2851
+ revision_id: "rev".into(),
2852
+ schema_version: 1,
2853
+ state_namespace: None,
2854
+ causal_parent: None,
2855
+ authorization: auth(),
2856
+ preconditions: vec![],
2857
+ operations: vec![TransactionOperation {
2858
+ kind: TransactionOperationKind::Insert,
2859
+ collection: "incidents".into(),
2860
+ id: "content-bound".into(),
2861
+ value: serde_json::json!({"title":"Original"}),
2862
+ if_version: None,
2863
+ }],
2864
+ };
2865
+ {
2866
+ let db = crate::open(&path).unwrap();
2867
+ assert!(
2868
+ !execute_transaction(&db, &schema, &request, None)
2869
+ .unwrap()
2870
+ .duplicate
2871
+ );
2872
+ assert!(
2873
+ execute_transaction(&db, &schema, &request, None)
2874
+ .unwrap()
2875
+ .duplicate
2876
+ );
2877
+ }
2878
+ let reopened = crate::open(&path).unwrap();
2879
+ assert!(
2880
+ execute_transaction(&reopened, &schema, &request, None)
2881
+ .unwrap()
2882
+ .duplicate
2883
+ );
2884
+ let mut changed = request.clone();
2885
+ changed.operations[0].value = serde_json::json!({"title":"Changed"});
2886
+ let failure = execute_transaction(&reopened, &schema, &changed, None).unwrap_err();
2887
+ assert_eq!(failure.code, "CONFLICT");
2888
+ assert!(failure.message.contains("different canonical payload"));
2796
2889
  std::fs::remove_file(path).ok();
2797
2890
  }
2798
2891
  #[test]
2892
+ fn explicit_transaction_id_is_isolated_between_applications() {
2893
+ fn application(
2894
+ app: &str,
2895
+ namespace: &str,
2896
+ transaction: &str,
2897
+ record: &str,
2898
+ ) -> (StateSchema, TransactionRequest) {
2899
+ let mut schema = schema();
2900
+ schema.application_id = app.into();
2901
+ let mut authorization = auth();
2902
+ authorization.application_id = app.into();
2903
+ let request = TransactionRequest {
2904
+ transaction_id: Some(transaction.into()),
2905
+ tenant_id: "tenant".into(),
2906
+ application_id: app.into(),
2907
+ revision_id: "rev".into(),
2908
+ schema_version: 1,
2909
+ state_namespace: Some(namespace.into()),
2910
+ causal_parent: None,
2911
+ authorization,
2912
+ preconditions: vec![],
2913
+ operations: vec![TransactionOperation {
2914
+ kind: TransactionOperationKind::Insert,
2915
+ collection: "incidents".into(),
2916
+ id: record.into(),
2917
+ value: serde_json::json!({"title":format!("{app}-{record}")}),
2918
+ if_version: None,
2919
+ }],
2920
+ };
2921
+ (schema, request)
2922
+ }
2923
+ for (name, order) in [
2924
+ (
2925
+ "a-then-b",
2926
+ vec![("app-a", "shared-1", "one"), ("app-b", "shared-1", "one")],
2927
+ ),
2928
+ (
2929
+ "b-then-a",
2930
+ vec![("app-b", "shared-1", "one"), ("app-a", "shared-1", "one")],
2931
+ ),
2932
+ (
2933
+ "interleaved",
2934
+ vec![
2935
+ ("app-a", "shared-1", "one"),
2936
+ ("app-b", "shared-1", "one"),
2937
+ ("app-a", "shared-2", "two"),
2938
+ ("app-b", "shared-2", "two"),
2939
+ ],
2940
+ ),
2941
+ ] {
2942
+ let path = std::env::temp_dir().join(format!(
2943
+ "feltdb-app-isolation-{name}-{}-{}.log",
2944
+ std::process::id(),
2945
+ crate::now_ms()
2946
+ ));
2947
+ {
2948
+ let db = crate::open(&path).unwrap();
2949
+ for (app, transaction, record) in &order {
2950
+ let (schema, request) =
2951
+ application(app, &format!("{app}:production"), transaction, record);
2952
+ assert!(
2953
+ !execute_transaction(&db, &schema, &request, None)
2954
+ .unwrap()
2955
+ .duplicate
2956
+ );
2957
+ }
2958
+ }
2959
+ let reopened = crate::open(&path).unwrap();
2960
+ for (app, transaction, record) in &order {
2961
+ let (schema, request) =
2962
+ application(app, &format!("{app}:production"), transaction, record);
2963
+ assert!(
2964
+ execute_transaction(&reopened, &schema, &request, None)
2965
+ .unwrap()
2966
+ .duplicate
2967
+ );
2968
+ let rows = reopened.state_rows().unwrap();
2969
+ assert_eq!(
2970
+ rows.iter()
2971
+ .filter(
2972
+ |row| row.capability == format!("{app}:production:incidents")
2973
+ && row.key == *record
2974
+ && !row.deleted
2975
+ )
2976
+ .count(),
2977
+ 1
2978
+ );
2979
+ }
2980
+ std::fs::remove_file(path).ok();
2981
+ }
2982
+ }
2983
+ #[test]
2799
2984
  fn read_context_remains_a_stable_snapshot() {
2800
2985
  let db = db("snapshot-read");
2801
2986
  let schema = schema();
@@ -2880,7 +3065,7 @@ mod tests {
2880
3065
  }
2881
3066
  let reopened = FeltDb::open(path).unwrap();
2882
3067
  assert_eq!(reopened.export_snapshot().unwrap().rows.len(), 2);
2883
- assert!(reopened.has_applied_transaction("replay-tx").unwrap());
3068
+ assert_eq!(reopened.current_revision().unwrap(), 1);
2884
3069
  }
2885
3070
  #[test]
2886
3071
  fn indexed_equality_query_uses_index_not_scan() {
@@ -3324,7 +3509,10 @@ mod tests {
3324
3509
  };
3325
3510
 
3326
3511
  let result = execute_query(&db, &schema, &context, &query, None);
3327
- assert!(result.is_ok(), "Query should succeed with state:read capability");
3512
+ assert!(
3513
+ result.is_ok(),
3514
+ "Query should succeed with state:read capability"
3515
+ );
3328
3516
  }
3329
3517
 
3330
3518
  #[test]
@@ -3571,7 +3759,10 @@ mod tests {
3571
3759
  let result = execute_query(&db, &schema, &context, &query, None);
3572
3760
  assert!(result.is_ok(), "Authenticated read must be allowed");
3573
3761
  let query_result = result.unwrap();
3574
- assert!(query_result.records.len() >= 1, "Query should return the inserted record");
3762
+ assert!(
3763
+ query_result.records.len() >= 1,
3764
+ "Query should return the inserted record"
3765
+ );
3575
3766
  }
3576
3767
 
3577
3768
  #[test]
@@ -3612,7 +3803,10 @@ mod tests {
3612
3803
 
3613
3804
  let result = execute_transaction(&db, &schema, &request, None);
3614
3805
  assert!(result.is_ok(), "Authenticated write must be allowed");
3615
- assert!(result.unwrap().state_after > 0, "Transaction should have succeeded");
3806
+ assert!(
3807
+ result.unwrap().state_after > 0,
3808
+ "Transaction should have succeeded"
3809
+ );
3616
3810
  }
3617
3811
 
3618
3812
  #[test]
@@ -3693,9 +3887,11 @@ mod tests {
3693
3887
  references: vec![],
3694
3888
  };
3695
3889
 
3696
- let verify_result = execute_query(&db, &schema, &verify_context, &verify_query, None).unwrap();
3890
+ let verify_result =
3891
+ execute_query(&db, &schema, &verify_context, &verify_query, None).unwrap();
3697
3892
  assert_eq!(
3698
- verify_result.records.len(), 1,
3893
+ verify_result.records.len(),
3894
+ 1,
3699
3895
  "Only initial record should exist; denied transaction must not partially commit"
3700
3896
  );
3701
3897
  }
@@ -3810,7 +4006,10 @@ mod tests {
3810
4006
  };
3811
4007
 
3812
4008
  let result = execute_query(&db, &schema, &read_context, &query, None);
3813
- assert!(result.is_ok(), "Member policy read should be allowed with state:read");
4009
+ assert!(
4010
+ result.is_ok(),
4011
+ "Member policy read should be allowed with state:read"
4012
+ );
3814
4013
  }
3815
4014
 
3816
4015
  #[test]
@@ -4129,14 +4328,8 @@ mod tests {
4129
4328
  }
4130
4329
 
4131
4330
  let bob_read = begin_read(&db, &schema, "app", bob_context).unwrap();
4132
- let bob_result = execute_query(
4133
- &db,
4134
- &schema,
4135
- &bob_read,
4136
- &query,
4137
- Some(PolicySubject::Owner),
4138
- )
4139
- .expect("query should succeed");
4331
+ let bob_result = execute_query(&db, &schema, &bob_read, &query, Some(PolicySubject::Owner))
4332
+ .expect("query should succeed");
4140
4333
 
4141
4334
  assert_eq!(
4142
4335
  bob_result.records.len(),