@feltdb/core 0.8.2 → 0.8.4

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 (61) hide show
  1. package/dist/cli/commands.js +4 -1
  2. package/dist/cli/provisioning-neutrality.js +79 -0
  3. package/dist/collection.d.ts +43 -1
  4. package/dist/collection.d.ts.map +1 -1
  5. package/dist/collection.js +192 -22
  6. package/dist/create/create.js +25 -21
  7. package/dist/create/managed-account.js +11 -0
  8. package/dist/create/package-versions.js +1 -1
  9. package/dist/create/server-source/crates/feltdb/src/equality_index.rs +595 -0
  10. package/dist/create/server-source/crates/feltdb/src/lib.rs +547 -115
  11. package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +11 -2
  12. package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +126 -0
  13. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +292 -2
  14. package/dist/create/server-source/crates/feltdb/src/sync.rs +12 -0
  15. package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +443 -0
  16. package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +233 -0
  17. package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +892 -0
  18. package/dist/create/server-source/crates/feltdb-server/src/audit.rs +1137 -29
  19. package/dist/create/server-source/crates/feltdb-server/src/main.rs +474 -28
  20. package/dist/db.d.ts +33 -34
  21. package/dist/db.d.ts.map +1 -1
  22. package/dist/db.js +74 -20
  23. package/dist/deployment.d.ts +30 -0
  24. package/dist/deployment.d.ts.map +1 -0
  25. package/dist/deployment.js +130 -0
  26. package/dist/embedded-transaction.d.ts +22 -4
  27. package/dist/embedded-transaction.d.ts.map +1 -1
  28. package/dist/embedded-transaction.js +51 -5
  29. package/dist/feltdb.d.ts +14 -2
  30. package/dist/feltdb.d.ts.map +1 -1
  31. package/dist/file-db.d.ts +8 -15
  32. package/dist/file-db.d.ts.map +1 -1
  33. package/dist/file-db.js +234 -130
  34. package/dist/http-client.d.ts +14 -0
  35. package/dist/http-client.d.ts.map +1 -1
  36. package/dist/http-client.js +23 -5
  37. package/dist/http-db.d.ts +119 -1
  38. package/dist/http-db.d.ts.map +1 -1
  39. package/dist/http-db.js +346 -31
  40. package/dist/index-core.d.ts +2 -0
  41. package/dist/index-core.d.ts.map +1 -1
  42. package/dist/index-core.js +2 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +9 -0
  45. package/dist/indexeddb-db.d.ts.map +1 -1
  46. package/dist/indexeddb-db.js +35 -21
  47. package/dist/managed-recovery.d.ts +192 -0
  48. package/dist/managed-recovery.d.ts.map +1 -0
  49. package/dist/managed-recovery.js +242 -0
  50. package/dist/memory-db.js +1 -1
  51. package/dist/studio-app/assets/{feltdb_wasm-DB8cX151.js → feltdb_wasm-CVQWgXO-.js} +1 -1
  52. package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
  53. package/dist/studio-app/assets/index-DwgNAIIX.js +29 -0
  54. package/dist/studio-app/index.html +1 -1
  55. package/dist/transaction.d.ts +30 -0
  56. package/dist/transaction.d.ts.map +1 -1
  57. package/dist/transaction.js +41 -0
  58. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  59. package/package.json +1 -1
  60. package/dist/studio-app/assets/feltdb_wasm_bg-ClhDHp0S.wasm +0 -0
  61. package/dist/studio-app/assets/index-B0k4UAlI.js +0 -29
@@ -468,8 +468,17 @@ mod tests {
468
468
  StateHash::from_hex("hash0".to_string()),
469
469
  );
470
470
 
471
- recovered_executor
472
- .register_replica("nodeB".to_string(), StateHash::from_hex("hash0".to_string()));
471
+ // The peer is registered at the state the recovered node actually
472
+ // holds, read back rather than restated. `load_from_disk` recomputes
473
+ // the local replica's hash from the replayed log, so a fabricated
474
+ // literal here would describe a peer that has diverged and the
475
+ // convergence check below would be asserting the opposite of what this
476
+ // test is about.
477
+ let recovered_hash = recovered_executor
478
+ .get_replica_state("nodeA")
479
+ .expect("the recovered node has a local replica")
480
+ .state_hash;
481
+ recovered_executor.register_replica("nodeB".to_string(), recovered_hash);
473
482
 
474
483
  let mut recovery = RecoveryOrchestrator::new(recovered_executor, "nodeB".to_string());
475
484
  recovery.reconcile_with_peer(&peer)?;
@@ -0,0 +1,126 @@
1
+ //! Test instrumentation for the collection read paths a bounded query can take.
2
+ //!
3
+ //! PR33 attributed sustained-workload degradation to bounded queries cloning an
4
+ //! entire collection under the state mutex before predicate, ordering and limit
5
+ //! were applied. PR34 removes that clone, and a benchmark alone cannot prove a
6
+ //! clone is gone: a faster number is evidence of speed, not of mechanism.
7
+ //!
8
+ //! These counters therefore record *which traversal a read used*, so a test can
9
+ //! assert that a bounded query never entered `FeltDb::list_collection`. They
10
+ //! are process-global, monotonic, carry no record data, and are not part of the
11
+ //! query contract: nothing in the query path reads them, and no query result
12
+ //! depends on them. The HTTP surface only exposes them when a server is started
13
+ //! with `FELTDB_QUERY_DIAGNOSTICS=1`.
14
+ //!
15
+ //! PR35 extends them with execution attribution. An index is an optimization, so
16
+ //! "the query got faster" is not evidence that it was used; these counters say
17
+ //! which execution a query actually took, how many candidates the index
18
+ //! produced, and how many records the predicate had to evaluate. That last
19
+ //! figure is the one the PR35 claim rests on: for a selective indexed query it
20
+ //! must track the candidate count rather than the collection size.
21
+
22
+ use std::sync::atomic::{AtomicU64, Ordering};
23
+
24
+ static FULL_COLLECTION_MATERIALIZATIONS: AtomicU64 = AtomicU64::new(0);
25
+ static FULL_COLLECTION_RECORDS_CLONED: AtomicU64 = AtomicU64::new(0);
26
+ static COLLECTION_SCANS: AtomicU64 = AtomicU64::new(0);
27
+ static SCAN_RECORDS_VISITED: AtomicU64 = AtomicU64::new(0);
28
+ static SCAN_RECORDS_MATERIALIZED: AtomicU64 = AtomicU64::new(0);
29
+ static QUERIES_TOTAL: AtomicU64 = AtomicU64::new(0);
30
+ static QUERIES_SCAN: AtomicU64 = AtomicU64::new(0);
31
+ static QUERIES_INDEXED: AtomicU64 = AtomicU64::new(0);
32
+ static INDEX_HITS: AtomicU64 = AtomicU64::new(0);
33
+ static INDEX_MISSES: AtomicU64 = AtomicU64::new(0);
34
+ static INDEX_CANDIDATES_EXAMINED: AtomicU64 = AtomicU64::new(0);
35
+ static RECORDS_PREDICATE_EVALUATED: AtomicU64 = AtomicU64::new(0);
36
+
37
+ /// A point-in-time reading of the collection read counters.
38
+ #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
39
+ pub struct QueryExecutionCounters {
40
+ /// Calls to `FeltDb::list_collection`, which clones a whole collection.
41
+ pub full_collection_materializations: u64,
42
+ /// Records cloned by those calls.
43
+ pub full_collection_records_cloned: u64,
44
+ /// Calls to `FeltDb::query_collection`, which evaluates borrowed records.
45
+ pub collection_scans: u64,
46
+ /// Records a scan predicate observed, borrowed rather than cloned.
47
+ pub scan_records_visited: u64,
48
+ /// Records a scan actually cloned, which is at most the matching set.
49
+ pub scan_records_materialized: u64,
50
+ /// Collection reads that executed a query: `queries_scan + queries_indexed`.
51
+ pub queries_total: u64,
52
+ /// Queries answered by evaluating the predicate over the whole collection.
53
+ pub queries_scan: u64,
54
+ /// Queries answered from equality-index candidates.
55
+ pub queries_indexed: u64,
56
+ /// Queries for which at least one condition had an applicable index.
57
+ pub index_hits: u64,
58
+ /// Queries for which no condition had an applicable index, which fall back.
59
+ pub index_misses: u64,
60
+ /// Candidate record keys the index produced, after intersection.
61
+ pub index_candidates_examined: u64,
62
+ /// Records the predicate was actually evaluated against, on either path.
63
+ /// For a selective indexed query this tracks the candidate count; for a scan
64
+ /// it is the collection size.
65
+ pub records_predicate_evaluated: u64,
66
+ }
67
+
68
+ pub(crate) fn record_full_materialization(records: usize) {
69
+ FULL_COLLECTION_MATERIALIZATIONS.fetch_add(1, Ordering::Relaxed);
70
+ FULL_COLLECTION_RECORDS_CLONED.fetch_add(records as u64, Ordering::Relaxed);
71
+ }
72
+
73
+ pub(crate) fn record_scan(visited: usize, materialized: usize) {
74
+ COLLECTION_SCANS.fetch_add(1, Ordering::Relaxed);
75
+ SCAN_RECORDS_VISITED.fetch_add(visited as u64, Ordering::Relaxed);
76
+ SCAN_RECORDS_MATERIALIZED.fetch_add(materialized as u64, Ordering::Relaxed);
77
+ QUERIES_TOTAL.fetch_add(1, Ordering::Relaxed);
78
+ QUERIES_SCAN.fetch_add(1, Ordering::Relaxed);
79
+ // A scan evaluates the predicate against every record it visits. This is
80
+ // the O(N) figure PR35 exists to reduce, recorded on the same counter the
81
+ // indexed path reports to, so the two are directly comparable.
82
+ RECORDS_PREDICATE_EVALUATED.fetch_add(visited as u64, Ordering::Relaxed);
83
+ }
84
+
85
+ /// A query for which no condition had an applicable index, so it falls back.
86
+ ///
87
+ /// This deliberately does not advance `queries_total`: the scan the caller is
88
+ /// about to run records the query itself, and counting it here would report
89
+ /// every fallback twice.
90
+ pub(crate) fn record_index_miss() {
91
+ INDEX_MISSES.fetch_add(1, Ordering::Relaxed);
92
+ }
93
+
94
+ /// A query answered from index candidates.
95
+ ///
96
+ /// `examined` is the candidate set after intersection, `evaluated` the records
97
+ /// the predicate actually ran against, and `materialized` the matching set that
98
+ /// was cloned. `evaluated` can be below `examined` only if an index entry has no
99
+ /// record behind it, which would be a maintenance defect; the gap is visible
100
+ /// here rather than hidden.
101
+ pub(crate) fn record_indexed_query(examined: usize, evaluated: usize, materialized: usize) {
102
+ QUERIES_TOTAL.fetch_add(1, Ordering::Relaxed);
103
+ QUERIES_INDEXED.fetch_add(1, Ordering::Relaxed);
104
+ INDEX_HITS.fetch_add(1, Ordering::Relaxed);
105
+ INDEX_CANDIDATES_EXAMINED.fetch_add(examined as u64, Ordering::Relaxed);
106
+ RECORDS_PREDICATE_EVALUATED.fetch_add(evaluated as u64, Ordering::Relaxed);
107
+ SCAN_RECORDS_MATERIALIZED.fetch_add(materialized as u64, Ordering::Relaxed);
108
+ }
109
+
110
+ /// Read the counters. Monotonic since process start, so tests compare deltas.
111
+ pub fn counters() -> QueryExecutionCounters {
112
+ QueryExecutionCounters {
113
+ full_collection_materializations: FULL_COLLECTION_MATERIALIZATIONS.load(Ordering::Relaxed),
114
+ full_collection_records_cloned: FULL_COLLECTION_RECORDS_CLONED.load(Ordering::Relaxed),
115
+ collection_scans: COLLECTION_SCANS.load(Ordering::Relaxed),
116
+ scan_records_visited: SCAN_RECORDS_VISITED.load(Ordering::Relaxed),
117
+ scan_records_materialized: SCAN_RECORDS_MATERIALIZED.load(Ordering::Relaxed),
118
+ queries_total: QUERIES_TOTAL.load(Ordering::Relaxed),
119
+ queries_scan: QUERIES_SCAN.load(Ordering::Relaxed),
120
+ queries_indexed: QUERIES_INDEXED.load(Ordering::Relaxed),
121
+ index_hits: INDEX_HITS.load(Ordering::Relaxed),
122
+ index_misses: INDEX_MISSES.load(Ordering::Relaxed),
123
+ index_candidates_examined: INDEX_CANDIDATES_EXAMINED.load(Ordering::Relaxed),
124
+ records_predicate_evaluated: RECORDS_PREDICATE_EVALUATED.load(Ordering::Relaxed),
125
+ }
126
+ }
@@ -25,7 +25,8 @@ use crate::{
25
25
  policy_evaluation::{
26
26
  Actor, AuthorizationState, PolicyEvaluator, PolicySubject, RecordAuthorizationContext,
27
27
  },
28
- AtomicMutation, AtomicPrecondition, FeltDb, StoredRow,
28
+ AtomicMutation, AtomicPrecondition, FeltDb, FlowError, PreconditionFailure, RecordPrecondition,
29
+ StoredRow,
29
30
  };
30
31
  use serde::{Deserialize, Serialize};
31
32
  use serde_json::{Map, Value};
@@ -853,6 +854,12 @@ pub struct StateFailure {
853
854
  pub transaction: Option<String>,
854
855
  #[serde(default)]
855
856
  pub causal_position: Option<u64>,
857
+ /// The structured predicate failure, when the refusal was a record
858
+ /// precondition. Named `failure` because that is what the direct
859
+ /// authority's refused transaction already calls it: one refusal, one
860
+ /// shape, whichever endpoint answered.
861
+ #[serde(default, skip_serializing_if = "Option::is_none")]
862
+ pub failure: Option<Value>,
856
863
  }
857
864
  impl StateFailure {
858
865
  fn new(code: &str, message: &str) -> Self {
@@ -864,6 +871,7 @@ impl StateFailure {
864
871
  resource: None,
865
872
  transaction: None,
866
873
  causal_position: None,
874
+ failure: None,
867
875
  }
868
876
  }
869
877
  fn storage(error: impl std::fmt::Display) -> Self {
@@ -1615,6 +1623,34 @@ pub enum TransactionPrecondition {
1615
1623
  StateVersion { version: u64 },
1616
1624
  ApplicationRevision { revision_id: String },
1617
1625
  CausalCursor { cursor: BTreeMap<String, u64> },
1626
+ /// What must be true of one record for the transaction to commit.
1627
+ ///
1628
+ /// Distinct from every variant above, and evaluated in a different place
1629
+ /// for a reason: those compare counters that can be read before the commit
1630
+ /// lock is taken, while this one compares the fields a caller reads back —
1631
+ /// the document's `__version`, its authority epoch, its lease — and must
1632
+ /// therefore be evaluated *inside* the same lock as the writes. Checking it
1633
+ /// against the pre-commit snapshot would be a client-side check wearing the
1634
+ /// authority's name: between the read and the write, another writer
1635
+ /// commits.
1636
+ ///
1637
+ /// It is also what makes `putIfAbsent` and `updateIfVersion` mean the same
1638
+ /// thing against a managed authority as against the file runtime. Mapping
1639
+ /// a caller's `expectedVersion` onto `if_version` would not: that fences
1640
+ /// the row's internal storage sequence, which is a different number that
1641
+ /// happens to share a name.
1642
+ Record {
1643
+ collection: String,
1644
+ id: String,
1645
+ #[serde(default)]
1646
+ require_absent: bool,
1647
+ #[serde(default)]
1648
+ expected_version: Option<u64>,
1649
+ #[serde(default)]
1650
+ expected_epoch: Option<u64>,
1651
+ #[serde(default)]
1652
+ expected_lease_id: Option<String>,
1653
+ },
1618
1654
  }
1619
1655
  #[derive(Debug, Clone, Serialize, Deserialize)]
1620
1656
  pub struct TransactionRequest {
@@ -2056,8 +2092,47 @@ fn execute_transaction_with_policies(
2056
2092
  });
2057
2093
  }
2058
2094
  let snapshot = db.state_rows().map_err(StateFailure::storage)?;
2095
+ let namespace = request
2096
+ .state_namespace
2097
+ .as_deref()
2098
+ .unwrap_or(&request.application_id)
2099
+ .to_string();
2100
+ // Record preconditions are collected here and evaluated by the store,
2101
+ // inside the same lock as the writes. Evaluating them against `snapshot`
2102
+ // alongside the counters below would reintroduce exactly the gap they
2103
+ // exist to close.
2104
+ let mut record_preconditions: Vec<RecordPrecondition> = Vec::new();
2059
2105
  for condition in &request.preconditions {
2060
2106
  let (resource, expected, actual, passed) = match condition {
2107
+ TransactionPrecondition::Record {
2108
+ collection,
2109
+ id,
2110
+ require_absent,
2111
+ expected_version,
2112
+ expected_epoch,
2113
+ expected_lease_id,
2114
+ } => {
2115
+ let precondition = RecordPrecondition {
2116
+ capability: format!("{namespace}:{collection}"),
2117
+ key: id.clone(),
2118
+ require_absent: *require_absent,
2119
+ expected_version: *expected_version,
2120
+ expected_epoch: *expected_epoch,
2121
+ expected_lease_id: expected_lease_id.clone(),
2122
+ };
2123
+ if precondition.is_empty() {
2124
+ // Refused rather than ignored: a precondition that
2125
+ // constrains nothing reads as protection and provides none.
2126
+ let mut failure = StateFailure::new(
2127
+ "VALIDATION_FAILED",
2128
+ &format!("precondition on {collection}:{id} constrains nothing"),
2129
+ );
2130
+ failure.resource = Some(format!("{collection}:{id}"));
2131
+ return Err(failure);
2132
+ }
2133
+ record_preconditions.push(precondition);
2134
+ continue;
2135
+ }
2061
2136
  TransactionPrecondition::CollectionVersion {
2062
2137
  collection,
2063
2138
  version,
@@ -2350,15 +2425,37 @@ fn execute_transaction_with_policies(
2350
2425
  }
2351
2426
  }
2352
2427
  let commit = db
2353
- .apply_atomic_transaction_content_addressed(
2428
+ .apply_atomic_transaction_guarded(
2354
2429
  &storage_transaction_id,
2355
2430
  Some(&payload_hash),
2356
2431
  request.causal_parent,
2357
2432
  &preconditions,
2433
+ &record_preconditions,
2358
2434
  &mutations,
2359
2435
  Some(serde_json::json!({"transaction_id":transaction_id.clone(),"subject":request.authorization.subject.clone(),"tenant":request.tenant_id.clone(),"application":request.application_id.clone(),"revision":request.revision_id.clone(),"schema_version":request.schema_version,"operations":request.operations.len(),"authorization_decisions":["state:write allowed"],"causal_parent":request.causal_parent,"result":"committed"})),
2360
2436
  )
2361
2437
  .map_err(|error| {
2438
+ // A record precondition that did not hold is a lost race, and it
2439
+ // says which predicate failed and what the authority holds. That
2440
+ // detail is the difference between "re-read and decide again" and
2441
+ // "this deployment is broken", so it is carried through as
2442
+ // structure rather than flattened into the message.
2443
+ if let FlowError::PreconditionFailed(refusal) = &error {
2444
+ let mut failure =
2445
+ StateFailure::new("PRECONDITION_FAILED", &error.to_string());
2446
+ failure.transaction = Some(transaction_id.clone());
2447
+ failure.causal_position = db.sequence().ok();
2448
+ failure.resource = Some(refusal.key().to_string());
2449
+ failure.failure = serde_json::to_value(refusal.as_ref()).ok();
2450
+ if let PreconditionFailure::Version {
2451
+ expected, actual, ..
2452
+ } = refusal.as_ref()
2453
+ {
2454
+ failure.expected = Some(Value::from(*expected));
2455
+ failure.actual = Some(Value::from(*actual));
2456
+ }
2457
+ return failure;
2458
+ }
2362
2459
  let text = error.to_string();
2363
2460
  if text.contains("PRECONDITION_FAILED") {
2364
2461
  let mut failure = StateFailure::new("PRECONDITION_FAILED", &text);
@@ -2665,6 +2762,199 @@ mod tests {
2665
2762
  "CONFLICT"
2666
2763
  );
2667
2764
  }
2765
+ /// A conditional write through the canonical contract has to mean the same
2766
+ /// thing it means everywhere else in FeltDB, or `putIfAbsent` and
2767
+ /// `updateIfVersion` become file-runtime privileges.
2768
+ ///
2769
+ /// The predicate under test is the document's `__version` -- the number a
2770
+ /// caller reads back -- not the row's internal storage sequence. Those are
2771
+ /// two different numbers that happen to share a name, and a fence on the
2772
+ /// wrong one is worse than no fence, because it reads as protection.
2773
+ #[test]
2774
+ fn a_record_precondition_fences_the_document_version() {
2775
+ let db = db("record-precondition");
2776
+ let schema = schema();
2777
+ let write = |id: &str,
2778
+ transaction: &str,
2779
+ title: &str,
2780
+ preconditions: Vec<TransactionPrecondition>,
2781
+ kind: TransactionOperationKind| {
2782
+ execute_transaction(
2783
+ &db,
2784
+ &schema,
2785
+ &TransactionRequest {
2786
+ transaction_id: Some(transaction.into()),
2787
+ tenant_id: "tenant".into(),
2788
+ application_id: "app".into(),
2789
+ revision_id: "rev".into(),
2790
+ schema_version: 1,
2791
+ state_namespace: None,
2792
+ causal_parent: None,
2793
+ authorization: auth(),
2794
+ preconditions,
2795
+ operations: vec![TransactionOperation {
2796
+ kind,
2797
+ collection: "incidents".into(),
2798
+ id: id.into(),
2799
+ value: serde_json::json!({ "title": title }),
2800
+ if_version: None,
2801
+ }],
2802
+ },
2803
+ None,
2804
+ )
2805
+ };
2806
+ let fence = |version: Option<u64>, absent: bool| {
2807
+ vec![TransactionPrecondition::Record {
2808
+ collection: "incidents".into(),
2809
+ id: "i1".into(),
2810
+ require_absent: absent,
2811
+ expected_version: version,
2812
+ expected_epoch: None,
2813
+ expected_lease_id: None,
2814
+ }]
2815
+ };
2816
+ let version = || {
2817
+ db.state_rows()
2818
+ .unwrap()
2819
+ .into_iter()
2820
+ .find(|row| row.capability == "app:incidents" && row.key == "i1" && !row.deleted)
2821
+ .and_then(|row| row.value.get("__version").and_then(Value::as_u64))
2822
+ };
2823
+
2824
+ // A conditional create is a create at version 1.
2825
+ write(
2826
+ "i1",
2827
+ "create",
2828
+ "First",
2829
+ fence(None, true),
2830
+ TransactionOperationKind::Insert,
2831
+ )
2832
+ .expect("the record is absent, so the create commits");
2833
+ assert_eq!(version(), Some(1));
2834
+
2835
+ // The same create again is a lost race. Whichever check catches it,
2836
+ // the answer is a conflict and nothing was written.
2837
+ let refused = write(
2838
+ "i1",
2839
+ "create-again",
2840
+ "Second",
2841
+ fence(None, true),
2842
+ TransactionOperationKind::Insert,
2843
+ )
2844
+ .expect_err("the record now exists");
2845
+ assert!(
2846
+ refused.code == "CONFLICT" || refused.code == "PRECONDITION_FAILED",
2847
+ "a lost creation race is a conflict, got {}",
2848
+ refused.code,
2849
+ );
2850
+ assert_eq!(version(), Some(1), "a refused transaction writes nothing");
2851
+
2852
+ // A guard on a record the transaction does not write is evaluated
2853
+ // inside the commit lock, and says which predicate failed.
2854
+ let guarded = write(
2855
+ "i2",
2856
+ "guarded",
2857
+ "Other",
2858
+ fence(None, true),
2859
+ TransactionOperationKind::Insert,
2860
+ )
2861
+ .expect_err("i1 exists, so the guard fails and i2 is never written");
2862
+ assert_eq!(guarded.code, "PRECONDITION_FAILED");
2863
+ assert_eq!(
2864
+ guarded
2865
+ .failure
2866
+ .as_ref()
2867
+ .and_then(|value| value.get("predicate").and_then(Value::as_str)),
2868
+ Some("present"),
2869
+ );
2870
+ assert!(
2871
+ db.state_rows()
2872
+ .unwrap()
2873
+ .iter()
2874
+ .all(|row| row.key != "i2" || row.deleted),
2875
+ "a refused guard leaves the staged write unwritten",
2876
+ );
2877
+
2878
+ // A stale fence conflicts and names what the authority actually holds.
2879
+ let stale = write(
2880
+ "i1",
2881
+ "stale",
2882
+ "Third",
2883
+ fence(Some(7), false),
2884
+ TransactionOperationKind::Update,
2885
+ )
2886
+ .expect_err("version 7 is not the version on record");
2887
+ assert_eq!(stale.code, "PRECONDITION_FAILED");
2888
+ assert_eq!(stale.expected, Some(Value::from(7)));
2889
+ assert_eq!(stale.actual, Some(Value::from(1)));
2890
+ assert_eq!(version(), Some(1));
2891
+
2892
+ // The fenced write commits and the authority owns the next version.
2893
+ write(
2894
+ "i1",
2895
+ "fenced",
2896
+ "Fourth",
2897
+ fence(Some(1), false),
2898
+ TransactionOperationKind::Update,
2899
+ )
2900
+ .expect("version 1 is the version on record");
2901
+ assert_eq!(version(), Some(2));
2902
+
2903
+ // Replaying a committed transaction id applies nothing, so it cannot
2904
+ // advance the version a second time.
2905
+ write(
2906
+ "i1",
2907
+ "fenced",
2908
+ "Fourth",
2909
+ fence(Some(1), false),
2910
+ TransactionOperationKind::Update,
2911
+ )
2912
+ .expect("a duplicate transaction id is not a failure");
2913
+ assert_eq!(version(), Some(2));
2914
+ }
2915
+
2916
+ /// A precondition that constrains nothing is refused rather than accepted,
2917
+ /// because one that is silently ignored reads as protection and provides
2918
+ /// none.
2919
+ #[test]
2920
+ fn an_empty_record_precondition_is_refused() {
2921
+ let db = db("empty-precondition");
2922
+ let schema = schema();
2923
+ let failure = execute_transaction(
2924
+ &db,
2925
+ &schema,
2926
+ &TransactionRequest {
2927
+ transaction_id: Some("empty".into()),
2928
+ tenant_id: "tenant".into(),
2929
+ application_id: "app".into(),
2930
+ revision_id: "rev".into(),
2931
+ schema_version: 1,
2932
+ state_namespace: None,
2933
+ causal_parent: None,
2934
+ authorization: auth(),
2935
+ preconditions: vec![TransactionPrecondition::Record {
2936
+ collection: "incidents".into(),
2937
+ id: "i1".into(),
2938
+ require_absent: false,
2939
+ expected_version: None,
2940
+ expected_epoch: None,
2941
+ expected_lease_id: None,
2942
+ }],
2943
+ operations: vec![TransactionOperation {
2944
+ kind: TransactionOperationKind::Insert,
2945
+ collection: "incidents".into(),
2946
+ id: "i1".into(),
2947
+ value: serde_json::json!({ "title": "First" }),
2948
+ if_version: None,
2949
+ }],
2950
+ },
2951
+ None,
2952
+ )
2953
+ .expect_err("a precondition that constrains nothing is a caller error");
2954
+ assert_eq!(failure.code, "VALIDATION_FAILED");
2955
+ assert!(db.state_rows().unwrap().iter().all(|row| row.deleted));
2956
+ }
2957
+
2668
2958
  #[test]
2669
2959
  fn validation_failure_rolls_back_every_operation() {
2670
2960
  let db = db("rollback");
@@ -19,6 +19,18 @@ pub struct ChangeLog {
19
19
  }
20
20
 
21
21
  impl ChangeLog {
22
+ /// Earliest retained sequence for each operation origin.
23
+ pub fn retained_from(&self) -> HashMap<String, u64> {
24
+ let mut floors: HashMap<String, u64> = HashMap::new();
25
+ for operation in &self.pending {
26
+ floors
27
+ .entry(operation.instance_id.clone())
28
+ .and_modify(|sequence| *sequence = (*sequence).min(operation.sequence))
29
+ .or_insert(operation.sequence);
30
+ }
31
+ floors
32
+ }
33
+
22
34
  /// Create a new empty change log
23
35
  pub fn new() -> Self {
24
36
  Self::default()