@feltdb/core 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.
Files changed (33) hide show
  1. package/README.md +36 -0
  2. package/dist/create/package-versions.js +1 -1
  3. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +116 -0
  4. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +19 -0
  5. package/dist/create/server-source/crates/feltdb/src/lib.rs +290 -0
  6. package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +471 -0
  7. package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +72 -3
  8. package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +931 -0
  9. package/dist/create/server-source/crates/feltdb-server/src/main.rs +125 -11
  10. package/dist/db.d.ts +2 -2
  11. package/dist/db.d.ts.map +1 -1
  12. package/dist/db.js +29 -2
  13. package/dist/embedded-transaction.d.ts +9 -0
  14. package/dist/embedded-transaction.d.ts.map +1 -1
  15. package/dist/embedded-transaction.js +101 -3
  16. package/dist/feltdb.d.ts +16 -0
  17. package/dist/feltdb.d.ts.map +1 -1
  18. package/dist/file-db.d.ts.map +1 -1
  19. package/dist/file-db.js +1 -0
  20. package/dist/http-db.d.ts +11 -0
  21. package/dist/http-db.d.ts.map +1 -1
  22. package/dist/http-db.js +16 -2
  23. package/dist/indexeddb-db.d.ts.map +1 -1
  24. package/dist/indexeddb-db.js +5 -0
  25. package/dist/memory-db.d.ts.map +1 -1
  26. package/dist/memory-db.js +1 -0
  27. package/dist/studio-app/assets/index-sQyf4Ewl.js +28 -0
  28. package/dist/studio-app/index.html +1 -1
  29. package/dist/transaction.d.ts +127 -9
  30. package/dist/transaction.d.ts.map +1 -1
  31. package/dist/transaction.js +87 -3
  32. package/package.json +1 -1
  33. package/dist/studio-app/assets/index-D4RZ44qs.js +0 -28
package/README.md CHANGED
@@ -75,6 +75,42 @@ Browser mutations resolve after their IndexedDB transaction commits. The
75
75
  durable change journal replays after reload and coordinates live collections
76
76
  across tabs through `BroadcastChannel` when available.
77
77
 
78
+ ## Atomic conditional transactions
79
+
80
+ Core 0.7.1 can fence a multi-record transaction on the exact version read by
81
+ the caller. The transaction either commits every operation or writes nothing:
82
+
83
+ ```typescript
84
+ import { ConditionalConflictError } from '@feltdb/core';
85
+
86
+ const current = await db.collection('accounts').get('primary');
87
+
88
+ try {
89
+ await db.transaction({
90
+ transactionId: 'transfer-42',
91
+ preconditions: [
92
+ { collection: 'accounts', id: 'primary', ifVersion: current.__version },
93
+ ],
94
+ operations: [
95
+ { collection: 'accounts', id: 'primary', value: { balance: 90 } },
96
+ { collection: 'ledger', id: 'transfer-42', requireAbsent: true, value: { amount: 10 } },
97
+ ],
98
+ });
99
+ } catch (error) {
100
+ if (error instanceof ConditionalConflictError) {
101
+ console.log(error.conflict);
102
+ }
103
+ }
104
+ ```
105
+
106
+ Reusing a successful `transactionId` safely replays its result without
107
+ advancing record versions twice. This API requires a FeltDB authority that
108
+ supports transaction-level `ifVersion` preconditions.
109
+
110
+ A write with `requireAbsent: true` is an atomic create. The authority assigns
111
+ it `__version: 1`—overriding any caller-supplied version—so it can immediately
112
+ be updated with `ifVersion: 1`.
113
+
78
114
  ## Durable Operation Management
79
115
 
80
116
  FeltDB provides atomic operation admission and lifecycle management for systems that need to survive process crashes with guaranteed identity stability.
@@ -1,4 +1,4 @@
1
1
  // One release train keeps generated applications installable. The repository
2
2
  // validation script checks these values against every workspace manifest.
3
- export const FELTDB_PACKAGE_VERSION = '0.6.14';
3
+ export const FELTDB_PACKAGE_VERSION = '0.7.1';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
@@ -51,6 +51,7 @@ use feltdb::distributed_transactions::{
51
51
  SubmitOutcome,
52
52
  };
53
53
  use feltdb::replication_protocol::ProtocolTransport;
54
+ use feltdb::replica_acknowledgements::{AcknowledgementStore, ReclamationEvidence, ReplicaAcknowledgement};
54
55
  use feltdb::replica_membership::MembershipStore;
55
56
  use feltdb::state_hash::StateHash;
56
57
  use feltdb::tcp_transport::TcpTransport;
@@ -252,6 +253,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
252
253
  )
253
254
  .map_err(|e| e.to_string())?;
254
255
 
256
+ // Acknowledgements are durable and shared with the receive tasks, because
257
+ // they arrive on the socket rather than through a command.
258
+ let acknowledgements = Arc::new(Mutex::new(
259
+ AcknowledgementStore::open(
260
+ data_dir
261
+ .as_ref()
262
+ .map(|dir| dir.join("acknowledgements.json"))
263
+ .unwrap_or_else(|| PathBuf::from("acknowledgements.json")),
264
+ )
265
+ .map_err(|e| e.to_string())?,
266
+ ));
267
+
255
268
  let initial_state = StateHash::from_hex("0".repeat(64));
256
269
  let mut executor =
257
270
  DistributedTransactionExecutor::with_log(node_id.clone(), initial_state.clone(), log_path.clone())?;
@@ -354,6 +367,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
354
367
  let peer = link.peer.clone();
355
368
  let held = held.clone();
356
369
  let backpressured = backpressured.clone();
370
+ let acknowledgements = acknowledgements.clone();
357
371
  tokio::spawn(async move {
358
372
  while !exit.load(Ordering::Relaxed) {
359
373
  {
@@ -396,6 +410,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
396
410
 
397
411
  match transport.receive().await {
398
412
  Ok(messages) => {
413
+ // Acknowledgements arrive interleaved with
414
+ // envelopes on the same connection. They are
415
+ // durable before they count as evidence.
416
+ for ack in transport.take_acknowledgements().await {
417
+ let mut store = acknowledgements.lock().await;
418
+ if let Err(error) = store.record(ack) {
419
+ eprintln!("acknowledgement not recorded: {error}");
420
+ }
421
+ }
399
422
  for message in messages {
400
423
  {
401
424
  let mut hold = held.lock().await;
@@ -792,6 +815,99 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
792
815
  io::stdout().flush().ok();
793
816
  }
794
817
 
818
+ // Report what this node has durably applied, to every peer.
819
+ //
820
+ // The frontier is read from the executor, where it advances only in
821
+ // record_applied -- after an fsynced envelope is applied. So the
822
+ // statement "durably applied through this frontier" is true by
823
+ // construction rather than by assertion.
824
+ "ack-broadcast" => {
825
+ let (frontier, marks) = {
826
+ let guard = core.lock().await;
827
+ let (executor, _) = &*guard;
828
+ (executor.causal_frontier().clone(), executor.origin_sequence_high_water())
829
+ };
830
+ let view: Vec<String> = membership
831
+ .get_members()
832
+ .into_iter()
833
+ .map(|replica| replica.replica_id)
834
+ .collect();
835
+ let replica_id = membership
836
+ .get_members()
837
+ .into_iter()
838
+ .find(|replica| replica.node_id == node_id)
839
+ .map(|replica| replica.replica_id)
840
+ .unwrap_or_else(|| node_id.clone());
841
+
842
+ let ack = ReplicaAcknowledgement::new(
843
+ replica_id.clone(),
844
+ node_id.clone(),
845
+ frontier,
846
+ marks,
847
+ view,
848
+ );
849
+
850
+ // A node's own acknowledgement is evidence it holds first-hand:
851
+ // it knows its own durable frontier without being told. Without
852
+ // this a node would be permanently blocked on itself, since
853
+ // nothing else can report on its behalf.
854
+ {
855
+ let mut store = acknowledgements.lock().await;
856
+ let _ = store.record(ack.clone());
857
+ }
858
+
859
+ let mut delivered = Vec::new();
860
+ for link in &send_links {
861
+ if link.io.send_acknowledgement(ack.clone()).await.is_ok() {
862
+ delivered.push(link.peer.clone());
863
+ } else if connect_with_retry(link, 3).await
864
+ && link.io.send_acknowledgement(ack.clone()).await.is_ok()
865
+ {
866
+ delivered.push(link.peer.clone());
867
+ }
868
+ }
869
+ println!(
870
+ "ACK_SENT {}",
871
+ json!({
872
+ "replica_id": replica_id,
873
+ "frontier": ack.applied_frontier.clocks,
874
+ "delivered": delivered,
875
+ })
876
+ );
877
+ io::stdout().flush().ok();
878
+ }
879
+
880
+ "acknowledgements" => {
881
+ let store = acknowledgements.lock().await;
882
+ println!("ACKS {}", json!({ "acknowledgements": store.all() }));
883
+ io::stdout().flush().ok();
884
+ }
885
+
886
+ // What this node's evidence permits. Computes; reclaims nothing.
887
+ "reclamation-evidence" => {
888
+ let local: Vec<String> = membership
889
+ .get_required_replicas()
890
+ .into_iter()
891
+ .map(|replica| replica.replica_id)
892
+ .collect();
893
+ let store = acknowledgements.lock().await;
894
+ let report = match store.evidence(&local) {
895
+ ReclamationEvidence::Blocked { waiting_on } => json!({
896
+ "state": "BLOCKED",
897
+ "waiting_on": waiting_on,
898
+ "required_union": store.required_union(&local),
899
+ }),
900
+ ReclamationEvidence::SafeThrough { frontier, covered } => json!({
901
+ "state": "SAFE_THROUGH",
902
+ "frontier": frontier.clocks,
903
+ "covered": covered,
904
+ "required_union": store.required_union(&local),
905
+ }),
906
+ };
907
+ println!("EVIDENCE {}", report);
908
+ io::stdout().flush().ok();
909
+ }
910
+
795
911
  "members" => {
796
912
  println!(
797
913
  "MEMBERS {}",
@@ -385,6 +385,25 @@ impl DistributedTransactionExecutor {
385
385
  // that decision alone.
386
386
  // ---------------------------------------------------------------------
387
387
 
388
+ /// The highest identity committed to the durable log, per origin.
389
+ ///
390
+ /// For this node it is its own identity high-water mark; for others it is
391
+ /// how far this replica's log carries their identities. A checkpoint that
392
+ /// discards log prefix must preserve these, because the log is also the
393
+ /// source `next_origin_sequence` is recovered from.
394
+ pub fn origin_sequence_high_water(&self) -> std::collections::BTreeMap<String, u64> {
395
+ let mut marks = std::collections::BTreeMap::new();
396
+ let Some(ref log) = self.operation_log else { return marks };
397
+ let Ok(envelopes) = log.load_all() else { return marks };
398
+ for envelope in envelopes {
399
+ let entry = marks
400
+ .entry(envelope.envelope_id.originating_node.clone())
401
+ .or_insert(0u64);
402
+ *entry = (*entry).max(envelope.envelope_id.sequence);
403
+ }
404
+ marks
405
+ }
406
+
388
407
  /// The next identity this node would issue.
389
408
  pub fn next_origin_sequence(&self) -> u64 {
390
409
  self.next_origin_sequence
@@ -24,6 +24,7 @@ pub mod analytics;
24
24
  pub mod distributed_indexing;
25
25
  pub mod indexing;
26
26
  pub mod sharding;
27
+ pub mod transaction_preconditions;
27
28
  pub mod transactions;
28
29
  pub mod state_hash;
29
30
  pub mod crash_injection;
@@ -52,6 +53,7 @@ pub mod consistency_contract;
52
53
  pub mod persistence_reality;
53
54
  pub mod adversarial_transport;
54
55
  pub mod replica_membership;
56
+ pub mod replica_acknowledgements;
55
57
  pub mod replication_manager;
56
58
  pub mod metrics;
57
59
  pub mod query_performance;
@@ -254,6 +256,12 @@ pub enum FlowError {
254
256
  Serde(serde_json::Error),
255
257
  CorruptLogLine(String),
256
258
  CapabilityError(String),
259
+ /// A transaction precondition did not hold. Nothing was written.
260
+ ///
261
+ /// Separate from `CapabilityError` because a conflict is an expected
262
+ /// outcome of a race and a caller is meant to branch on it, while a
263
+ /// capability error is not.
264
+ PreconditionFailed(Box<PreconditionFailure>),
257
265
  }
258
266
 
259
267
  impl Display for FlowError {
@@ -263,6 +271,7 @@ impl Display for FlowError {
263
271
  FlowError::Serde(e) => write!(f, "serde error: {e}"),
264
272
  FlowError::CorruptLogLine(line) => write!(f, "corrupt log line: {line}"),
265
273
  FlowError::CapabilityError(msg) => write!(f, "capability error: {msg}"),
274
+ FlowError::PreconditionFailed(failure) => write!(f, "PRECONDITION_FAILED: {failure}"),
266
275
  }
267
276
  }
268
277
  }
@@ -330,6 +339,109 @@ pub struct AtomicPrecondition {
330
339
  pub key: String,
331
340
  pub expected_version: Option<u64>,
332
341
  }
342
+
343
+ /// What must be true of one record for a transaction to commit.
344
+ ///
345
+ /// Distinct from `AtomicPrecondition`, and deliberately so. That one compares
346
+ /// the row's internal operation *sequence*, and `None` on it means "this record
347
+ /// must not exist". This one compares the fields a caller can actually read
348
+ /// back -- the document's `__version`, its authority epoch, its lease -- which
349
+ /// are the same fields single-record CAS compares. Plumbing a caller's
350
+ /// `expectedVersion` into the sequence check would have compared a document
351
+ /// version against a storage sequence: two different numbers that happen to
352
+ /// share a name.
353
+ ///
354
+ /// Every predicate is optional and only a supplied one is checked. That differs
355
+ /// from CAS, where an absent `expected_lease_id` asserts the record is *not*
356
+ /// leased; here an absent predicate asserts nothing at all. The difference is
357
+ /// deliberate -- a transaction precondition is a fence the caller opts into,
358
+ /// not a full description of the record -- and it is why the fields are named
359
+ /// `expected_*` rather than describing a state.
360
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
361
+ pub struct RecordPrecondition {
362
+ pub capability: String,
363
+ pub key: String,
364
+ /// The record must not exist. Mutually exclusive with the predicates below.
365
+ #[serde(default)]
366
+ pub require_absent: bool,
367
+ /// The document's `__version`, as `updateIfVersion` compares it.
368
+ #[serde(default)]
369
+ pub expected_version: Option<u64>,
370
+ /// The record's authority epoch, at `/authority/epoch`.
371
+ #[serde(default)]
372
+ pub expected_epoch: Option<u64>,
373
+ /// The id of an unexpired lease held on the record.
374
+ #[serde(default)]
375
+ pub expected_lease_id: Option<String>,
376
+ }
377
+
378
+ impl RecordPrecondition {
379
+ /// True when this precondition asks for nothing, which is a caller error
380
+ /// rather than a satisfied fence: a precondition that constrains nothing
381
+ /// would read as protection and provide none.
382
+ pub fn is_empty(&self) -> bool {
383
+ !self.require_absent
384
+ && self.expected_version.is_none()
385
+ && self.expected_epoch.is_none()
386
+ && self.expected_lease_id.is_none()
387
+ }
388
+ }
389
+
390
+ /// Which predicate failed, and what the authority actually holds.
391
+ ///
392
+ /// Structured rather than a formatted string, because the caller has to tell a
393
+ /// lost race from a broken deployment: the first is retried with fresh state,
394
+ /// the second is not retried at all.
395
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
396
+ #[serde(rename_all = "camelCase", tag = "predicate")]
397
+ pub enum PreconditionFailure {
398
+ /// Required absent, but the record exists.
399
+ Present { collection: String, key: String },
400
+ /// A predicate was given for a record that does not exist.
401
+ Missing { collection: String, key: String },
402
+ Version { collection: String, key: String, expected: u64, actual: u64 },
403
+ Epoch { collection: String, key: String, expected: u64, actual: u64 },
404
+ Lease { collection: String, key: String, expected: String, actual: Option<String> },
405
+ }
406
+
407
+ impl PreconditionFailure {
408
+ pub fn collection(&self) -> &str {
409
+ match self {
410
+ Self::Present { collection, .. }
411
+ | Self::Missing { collection, .. }
412
+ | Self::Version { collection, .. }
413
+ | Self::Epoch { collection, .. }
414
+ | Self::Lease { collection, .. } => collection,
415
+ }
416
+ }
417
+ /// The storage key, which is namespaced as `collection:id`.
418
+ pub fn key(&self) -> &str {
419
+ match self {
420
+ Self::Present { key, .. }
421
+ | Self::Missing { key, .. }
422
+ | Self::Version { key, .. }
423
+ | Self::Epoch { key, .. }
424
+ | Self::Lease { key, .. } => key,
425
+ }
426
+ }
427
+ }
428
+
429
+ impl Display for PreconditionFailure {
430
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
431
+ match self {
432
+ Self::Present { collection, key } =>
433
+ write!(f, "{collection}/{key} was required to be absent but exists"),
434
+ Self::Missing { collection, key } =>
435
+ write!(f, "{collection}/{key} does not exist"),
436
+ Self::Version { collection, key, expected, actual } =>
437
+ write!(f, "{collection}/{key} is at version {actual}, expected {expected}"),
438
+ Self::Epoch { collection, key, expected, actual } =>
439
+ write!(f, "{collection}/{key} is at epoch {actual}, expected {expected}"),
440
+ Self::Lease { collection, key, expected, actual } =>
441
+ write!(f, "{collection}/{key} holds lease {actual:?}, expected {expected}"),
442
+ }
443
+ }
444
+ }
333
445
  #[derive(Debug, Clone, Serialize, Deserialize)]
334
446
  pub struct AtomicCommit {
335
447
  pub transaction_id: String,
@@ -931,6 +1043,40 @@ impl FeltDb {
931
1043
  preconditions: &[AtomicPrecondition],
932
1044
  mutations: &[AtomicMutation],
933
1045
  audit: Option<Value>,
1046
+ ) -> Result<AtomicCommit> {
1047
+ self.apply_atomic_transaction_guarded(
1048
+ transaction_id,
1049
+ payload_hash,
1050
+ expected_parent,
1051
+ preconditions,
1052
+ &[],
1053
+ mutations,
1054
+ audit,
1055
+ )
1056
+ }
1057
+
1058
+ /// Commit a transaction only if every record precondition holds.
1059
+ ///
1060
+ /// The preconditions are evaluated inside the same lock as the writes and
1061
+ /// before any of them, so there is no state in which one record is checked
1062
+ /// while the others commit. A failure returns
1063
+ /// `FlowError::PreconditionFailed` and applies nothing -- not a prefix, not
1064
+ /// the guarded record, nothing.
1065
+ ///
1066
+ /// A failed precondition does **not** consume the transaction id. The id is
1067
+ /// recorded only after a commit, so a caller that loses a race may retry
1068
+ /// the same id once it has re-read state, and a caller that wants the
1069
+ /// retry to be a distinct decision may use a new one. Both work; the choice
1070
+ /// is the caller's and is asserted in the tests rather than left implied.
1071
+ pub fn apply_atomic_transaction_guarded(
1072
+ &self,
1073
+ transaction_id: &str,
1074
+ payload_hash: Option<&str>,
1075
+ expected_parent: Option<u64>,
1076
+ preconditions: &[AtomicPrecondition],
1077
+ record_preconditions: &[RecordPrecondition],
1078
+ mutations: &[AtomicMutation],
1079
+ audit: Option<Value>,
934
1080
  ) -> Result<AtomicCommit> {
935
1081
  let (commit, events) = {
936
1082
  let mut inner = self.inner.lock().expect("lock poisoned");
@@ -948,6 +1094,150 @@ impl FeltDb {
948
1094
  duplicate: true,
949
1095
  });
950
1096
  }
1097
+ // Record preconditions, evaluated before anything is staged and
1098
+ // under the same lock the writes take. A caller reads state,
1099
+ // decides, and commits; this is the fence that makes the decision
1100
+ // still true at the moment of the write.
1101
+ for condition in record_preconditions {
1102
+ if condition.is_empty() {
1103
+ return Err(FlowError::CapabilityError(format!(
1104
+ "EMPTY_PRECONDITION:{}:{}",
1105
+ condition.capability, condition.key
1106
+ )));
1107
+ }
1108
+ let collection = condition.capability.clone();
1109
+ let key = condition.key.clone();
1110
+ let current = inner
1111
+ .rows
1112
+ .get(&condition.capability)
1113
+ .and_then(|bucket| bucket.get(&condition.key));
1114
+
1115
+ if condition.require_absent {
1116
+ if current.is_some() {
1117
+ return Err(FlowError::PreconditionFailed(Box::new(
1118
+ PreconditionFailure::Present { collection, key },
1119
+ )));
1120
+ }
1121
+ continue;
1122
+ }
1123
+
1124
+ let Some(current) = current else {
1125
+ return Err(FlowError::PreconditionFailed(Box::new(
1126
+ PreconditionFailure::Missing { collection, key },
1127
+ )));
1128
+ };
1129
+
1130
+ // The same fields single-record CAS compares, read the same way.
1131
+ if let Some(expected) = condition.expected_version {
1132
+ let actual = current
1133
+ .value
1134
+ .get("__version")
1135
+ .and_then(Value::as_u64)
1136
+ .unwrap_or(1);
1137
+ if actual != expected {
1138
+ return Err(FlowError::PreconditionFailed(Box::new(
1139
+ PreconditionFailure::Version { collection, key, expected, actual },
1140
+ )));
1141
+ }
1142
+ }
1143
+ if let Some(expected) = condition.expected_epoch {
1144
+ let actual = current
1145
+ .value
1146
+ .pointer("/authority/epoch")
1147
+ .and_then(Value::as_u64)
1148
+ .unwrap_or(0);
1149
+ if actual != expected {
1150
+ return Err(FlowError::PreconditionFailed(Box::new(
1151
+ PreconditionFailure::Epoch { collection, key, expected, actual },
1152
+ )));
1153
+ }
1154
+ }
1155
+ if let Some(expected) = &condition.expected_lease_id {
1156
+ let lease = current.value.get("lease").filter(|lease| !lease.is_null());
1157
+ let held = lease.and_then(|lease| {
1158
+ lease.get("leaseId").and_then(Value::as_str).map(str::to_string)
1159
+ });
1160
+ // An expired lease is not held, so naming it is a conflict:
1161
+ // the caller believes it owns something it no longer does.
1162
+ let unexpired = lease
1163
+ .and_then(|lease| lease.get("expiresAt").and_then(Value::as_u64))
1164
+ .is_some_and(|expires_at| expires_at > now_ms() as u64);
1165
+ if held.as_deref() != Some(expected.as_str()) || !unexpired {
1166
+ return Err(FlowError::PreconditionFailed(Box::new(
1167
+ PreconditionFailure::Lease {
1168
+ collection,
1169
+ key,
1170
+ expected: expected.clone(),
1171
+ actual: held,
1172
+ },
1173
+ )));
1174
+ }
1175
+ }
1176
+ }
1177
+
1178
+ // Every precondition held. The authority owns both ends of the
1179
+ // public document-version lifecycle inside this lock:
1180
+ //
1181
+ // - a create protected by `requireAbsent` writes version 1;
1182
+ // - a write fenced by `expected_version = N` writes N + 1.
1183
+ //
1184
+ // An unconditional write remains verbatim, and a guard-only
1185
+ // precondition writes nothing. See
1186
+ // docs/architecture/transaction-version-contract.md.
1187
+ let advanced: Vec<AtomicMutation>;
1188
+ let creates: HashSet<(&str, &str)> = preconditions
1189
+ .iter()
1190
+ .filter(|condition| condition.expected_version.is_none())
1191
+ .map(|condition| (condition.capability.as_str(), condition.key.as_str()))
1192
+ .chain(
1193
+ record_preconditions
1194
+ .iter()
1195
+ .filter(|condition| condition.require_absent)
1196
+ .map(|condition| (condition.capability.as_str(), condition.key.as_str())),
1197
+ )
1198
+ .collect();
1199
+ let fenced: HashMap<(&str, &str), u64> = record_preconditions
1200
+ .iter()
1201
+ .filter_map(|condition| {
1202
+ condition
1203
+ .expected_version
1204
+ .map(|version| ((condition.capability.as_str(), condition.key.as_str()), version))
1205
+ })
1206
+ .collect();
1207
+ let mutations: &[AtomicMutation] = if fenced.is_empty() && creates.is_empty() {
1208
+ mutations
1209
+ } else {
1210
+ advanced = mutations
1211
+ .iter()
1212
+ .map(|mutation| {
1213
+ let record = (mutation.capability.as_str(), mutation.key.as_str());
1214
+ let version = if creates.contains(&record) {
1215
+ Some(1)
1216
+ } else {
1217
+ fenced.get(&record).map(|expected| expected + 1)
1218
+ };
1219
+ let Some(version) = version else {
1220
+ return mutation.clone();
1221
+ };
1222
+ // A delete has no record left to carry a version, so a
1223
+ // fenced delete is fence-then-remove and nothing more.
1224
+ let Some(Value::Object(fields)) = mutation.value.clone() else {
1225
+ return mutation.clone();
1226
+ };
1227
+ let mut fields = fields;
1228
+ // The caller cannot manufacture either the initial or
1229
+ // next authoritative version.
1230
+ fields.insert("__version".to_string(), Value::from(version));
1231
+ AtomicMutation {
1232
+ capability: mutation.capability.clone(),
1233
+ key: mutation.key.clone(),
1234
+ value: Some(Value::Object(fields)),
1235
+ }
1236
+ })
1237
+ .collect();
1238
+ &advanced
1239
+ };
1240
+
951
1241
  if let Some(expected) = expected_parent {
952
1242
  if expected != inner.sequence {
953
1243
  return Err(FlowError::CapabilityError(format!(