@feltdb/core 0.5.1 → 0.5.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 (47) hide show
  1. package/dist/cell.d.ts +235 -0
  2. package/dist/cell.d.ts.map +1 -0
  3. package/dist/cell.js +1090 -0
  4. package/dist/cli/index.js +1 -1
  5. package/dist/collection.d.ts +4 -2
  6. package/dist/collection.d.ts.map +1 -1
  7. package/dist/collection.js +28 -25
  8. package/dist/create/package-versions.js +1 -1
  9. package/dist/create/server-source/crates/feltdb/src/lib.rs +173 -0
  10. package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +231 -0
  11. package/dist/create/server-source/crates/feltdb-server/src/main.rs +108 -1
  12. package/dist/create/server-source/crates/feltdb-server/src/request_telemetry.rs +1 -1
  13. package/dist/create/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +219 -0
  14. package/dist/db.d.ts +16 -0
  15. package/dist/db.d.ts.map +1 -1
  16. package/dist/db.js +21 -0
  17. package/dist/feltdb.d.ts +7 -1
  18. package/dist/feltdb.d.ts.map +1 -1
  19. package/dist/file-db.d.ts +5 -0
  20. package/dist/file-db.d.ts.map +1 -1
  21. package/dist/file-db.js +72 -9
  22. package/dist/http-db.d.ts +13 -0
  23. package/dist/http-db.d.ts.map +1 -1
  24. package/dist/http-db.js +41 -0
  25. package/dist/http-server.d.ts +42 -0
  26. package/dist/http-server.d.ts.map +1 -0
  27. package/dist/http-server.js +182 -0
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -0
  31. package/dist/indexeddb-conformance.spec.d.ts +21 -0
  32. package/dist/indexeddb-conformance.spec.d.ts.map +1 -0
  33. package/dist/indexeddb-conformance.spec.js +103 -0
  34. package/dist/indexeddb-db.d.ts +8 -0
  35. package/dist/indexeddb-db.d.ts.map +1 -1
  36. package/dist/indexeddb-db.js +69 -0
  37. package/dist/memory-db.d.ts +2 -0
  38. package/dist/memory-db.d.ts.map +1 -1
  39. package/dist/memory-db.js +9 -3
  40. package/dist/studio-app/assets/{feltdb_wasm-DYPuS6Ky.js → feltdb_wasm-h9mxesnH.js} +1 -1
  41. package/dist/studio-app/assets/feltdb_wasm_bg-B8U4A1n1.wasm +0 -0
  42. package/dist/studio-app/assets/index-Z92yFC4z.js +28 -0
  43. package/dist/studio-app/index.html +1 -1
  44. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  45. package/package.json +1 -1
  46. package/dist/studio-app/assets/feltdb_wasm_bg-DEwA82pF.wasm +0 -0
  47. package/dist/studio-app/assets/index-_qqVLfw1.js +0 -28
package/dist/cli/index.js CHANGED
@@ -23,7 +23,7 @@ import * as path from 'path';
23
23
  import * as readline from 'readline';
24
24
  import { getClient } from './api-client.js';
25
25
  import { loadFeltDBConfig, createDefaultConfig, validateModel, } from './config.js';
26
- const VERSION = '0.5.1';
26
+ const VERSION = '0.5.3';
27
27
  function prompt(question) {
28
28
  const rl = readline.createInterface({
29
29
  input: process.stdin,
@@ -24,6 +24,8 @@ export interface UpdateIfVersionResult<T> {
24
24
  };
25
25
  /** Current version if update failed due to version mismatch */
26
26
  currentVersion?: number;
27
+ /** Structured backend conflict code, when available. */
28
+ conflictCode?: string;
27
29
  }
28
30
  /**
29
31
  * A live collection that automatically updates when underlying data changes.
@@ -47,7 +49,7 @@ export declare class Collection<T> {
47
49
  private indexBackend;
48
50
  private indexStore;
49
51
  private indexesLoaded;
50
- constructor(db: JsDb, name: string, predicate?: Predicate<T>, parent?: Collection<T>);
52
+ constructor(db: JsDb, name: string, predicate?: Predicate<T>, parent?: Collection<T>, loadIndexes?: boolean);
51
53
  /**
52
54
  * Load indexes that were persisted from a previous session.
53
55
  */
@@ -126,7 +128,7 @@ export declare class Collection<T> {
126
128
  * console.log('Conflict - another writer won at version', result.currentVersion);
127
129
  * }
128
130
  */
129
- updateIfVersion(id: string | number, expectedVersion: number, updates: Partial<T>): Promise<UpdateIfVersionResult<T>>;
131
+ updateIfVersion(id: string | number, expectedVersion: number, updates: Partial<T>, expectedEpoch?: number, expectedLeaseId?: string, rejectIfDiverged?: boolean, knownCurrent?: T): Promise<UpdateIfVersionResult<T>>;
130
132
  /**
131
133
  * Delete a record from this collection.
132
134
  */
@@ -1 +1 @@
1
- {"version":3,"file":"collection.d.ts","sourceRoot":"","sources":["../src/collection.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"collection.d.ts","sourceRoot":"","sources":["../src/collection.ts"],"names":[],"mappings":"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"}
@@ -17,7 +17,7 @@ import { IndexStore } from './index-store.js';
17
17
  * Uses reactive dependency graph instead of polling for efficient updates.
18
18
  */
19
19
  export class Collection {
20
- constructor(db, name, predicate, parent) {
20
+ constructor(db, name, predicate, parent, loadIndexes = true) {
21
21
  this.predicate = null;
22
22
  this.cache = [];
23
23
  this.subscribers = new Set();
@@ -42,7 +42,10 @@ export class Collection {
42
42
  graph.registerDependency(parent.collectionId, this.collectionId);
43
43
  }
44
44
  // Load persisted indexes asynchronously
45
- this.loadPersistedIndexes();
45
+ if (loadIndexes)
46
+ this.loadPersistedIndexes();
47
+ else
48
+ this.indexesLoaded = true;
46
49
  }
47
50
  /**
48
51
  * Load indexes that were persisted from a previous session.
@@ -131,13 +134,11 @@ export class Collection {
131
134
  async get(id) {
132
135
  const key = `${this.name}:${id}`;
133
136
  const result = await this.db.get(key);
134
- if (result.success && result.data) {
135
- try {
136
- return JSON.parse(result.data);
137
- }
138
- catch {
139
- return null;
140
- }
137
+ if (!result.success) {
138
+ throw new Error(result.error || `Failed to read record ${id}`);
139
+ }
140
+ if (result.data) {
141
+ return JSON.parse(result.data);
141
142
  }
142
143
  return null;
143
144
  }
@@ -235,33 +236,30 @@ export class Collection {
235
236
  * console.log('Conflict - another writer won at version', result.currentVersion);
236
237
  * }
237
238
  */
238
- async updateIfVersion(id, expectedVersion, updates) {
239
- const current = await this.get(id);
239
+ async updateIfVersion(id, expectedVersion, updates, expectedEpoch, expectedLeaseId, rejectIfDiverged, knownCurrent) {
240
+ const current = knownCurrent ?? await this.get(id);
240
241
  if (!current) {
241
242
  throw new Error(`Record ${id} not found`);
242
243
  }
243
- const currentVersion = current.__version ?? 1;
244
- if (currentVersion !== expectedVersion) {
245
- return {
246
- updated: false,
247
- currentVersion,
248
- };
249
- }
250
- // Prepare updated record with incremented version
244
+ // Prepare updated record (no version increment - backend will handle it)
251
245
  // User updates should not include __version
252
246
  const cleanUpdates = { ...updates };
253
247
  delete cleanUpdates.__version;
254
- const newVersion = currentVersion + 1;
255
- const updated = { ...current, ...cleanUpdates, __version: newVersion };
248
+ const updated = { ...current, ...cleanUpdates };
256
249
  const key = `${this.name}:${id}`;
257
250
  // Use CAS for atomic version-checked update
258
251
  if (!this.db.cas) {
259
252
  throw new Error(`updateIfVersion is not supported by this FeltDB runtime. ` +
260
253
  `Only FileJsDb (Node.js) and HTTP/Server backends support atomic version-checked updates.`);
261
254
  }
255
+ // Delegate authoritative version and epoch check to the backend
256
+ // The backend will atomically verify both predicates
262
257
  const casResult = await this.db.cas({
263
258
  key,
264
- expectedVersion: currentVersion,
259
+ expectedVersion,
260
+ expectedEpoch,
261
+ expectedLeaseId,
262
+ rejectIfDiverged,
265
263
  value: JSON.stringify(updated),
266
264
  });
267
265
  if (!casResult.updated) {
@@ -269,22 +267,27 @@ export class Collection {
269
267
  return {
270
268
  updated: false,
271
269
  currentVersion: casResult.currentVersion,
270
+ conflictCode: casResult.conflictCode,
272
271
  };
273
272
  }
273
+ // Use the item returned by cas() which has the correct incremented version
274
+ const finalItem = (casResult.item || updated);
275
+ // CAS operation on backend increments version atomically - update finalItem to match
276
+ finalItem.__version = casResult.currentVersion;
274
277
  // Update indexes
275
- this.indexBackend.updateRecord(String(id), current, updated);
278
+ this.indexBackend.updateRecord(String(id), current, finalItem);
276
279
  // Emit change through reactive dependency graph
277
280
  const change = {
278
281
  type: 'update',
279
282
  key,
280
- value: updated,
283
+ value: finalItem,
281
284
  timestamp: Date.now(),
282
285
  };
283
286
  const graph = getReactiveDependencyGraph();
284
287
  await graph.emitChange(this.name, change);
285
288
  return {
286
289
  updated: true,
287
- item: updated,
290
+ item: finalItem,
288
291
  };
289
292
  }
290
293
  /**
@@ -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.5.1';
3
+ export const FELTDB_PACKAGE_VERSION = '0.5.3';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
@@ -2,6 +2,8 @@
2
2
  mod acceptance_tests;
3
3
  #[cfg(test)]
4
4
  mod admission_contract_tests;
5
+ #[cfg(test)]
6
+ mod managed_cas_tests;
5
7
  mod acquisition;
6
8
  pub mod admission;
7
9
  pub mod application;
@@ -329,6 +331,32 @@ pub struct AtomicCommit {
329
331
  pub rows: Vec<StoredRow>,
330
332
  pub duplicate: bool,
331
333
  }
334
+
335
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
336
+ pub enum JsonCasResult {
337
+ Updated {
338
+ current_version: u64,
339
+ current_epoch: u64,
340
+ value: Value,
341
+ },
342
+ VersionConflict {
343
+ current_version: u64,
344
+ current_epoch: u64,
345
+ },
346
+ AuthorityConflict {
347
+ current_version: u64,
348
+ current_epoch: u64,
349
+ },
350
+ LeaseConflict {
351
+ current_version: u64,
352
+ current_epoch: u64,
353
+ },
354
+ ReplicationConflict {
355
+ current_version: u64,
356
+ current_epoch: u64,
357
+ },
358
+ NotFound,
359
+ }
332
360
  #[derive(Debug, Clone, Serialize, Deserialize)]
333
361
  struct TransactionLogRecord {
334
362
  record_type: String,
@@ -478,6 +506,151 @@ impl FeltDb {
478
506
  Ok(())
479
507
  }
480
508
 
509
+ /// Atomically compare the logical record version and authority epoch before
510
+ /// durably replacing a JSON object. Failed predicates append no operation
511
+ /// and emit no change event.
512
+ pub fn compare_and_set_json(
513
+ &self,
514
+ key: &str,
515
+ expected_version: u64,
516
+ expected_epoch: Option<u64>,
517
+ expected_lease_id: Option<&str>,
518
+ reject_if_diverged: bool,
519
+ mut value: Value,
520
+ ) -> Result<JsonCasResult> {
521
+ let capability = key
522
+ .split_once(':')
523
+ .map(|(cap, _)| cap)
524
+ .unwrap_or("default")
525
+ .to_string();
526
+ let rust_type = type_name::<Value>().to_string();
527
+ let (result, event) = {
528
+ let mut inner = self.inner.lock().expect("lock poisoned");
529
+ let Some(current) = inner.rows.get(&capability).and_then(|rows| rows.get(key)) else {
530
+ return Ok(JsonCasResult::NotFound);
531
+ };
532
+ let current_version = current
533
+ .value
534
+ .get("__version")
535
+ .and_then(Value::as_u64)
536
+ .unwrap_or(1);
537
+ let current_epoch = current
538
+ .value
539
+ .pointer("/authority/epoch")
540
+ .and_then(Value::as_u64)
541
+ .unwrap_or(0);
542
+ if current_version != expected_version {
543
+ return Ok(JsonCasResult::VersionConflict {
544
+ current_version,
545
+ current_epoch,
546
+ });
547
+ }
548
+ if expected_epoch.is_some_and(|expected| expected != current_epoch) {
549
+ return Ok(JsonCasResult::AuthorityConflict {
550
+ current_version,
551
+ current_epoch,
552
+ });
553
+ }
554
+ let persisted_lease = current.value.get("lease").filter(|lease| !lease.is_null());
555
+ let lease_matches = match (persisted_lease, expected_lease_id) {
556
+ (None, None) => true,
557
+ (None, Some(_)) => false,
558
+ (Some(lease), Some(expected_id)) => {
559
+ lease.get("leaseId").and_then(Value::as_str) == Some(expected_id)
560
+ && lease
561
+ .get("expiresAt")
562
+ .and_then(Value::as_u64)
563
+ .is_some_and(|expires_at| expires_at > now_ms() as u64)
564
+ }
565
+ (Some(lease), None) => lease
566
+ .get("expiresAt")
567
+ .and_then(Value::as_u64)
568
+ .is_some_and(|expires_at| expires_at <= now_ms() as u64),
569
+ };
570
+ if !lease_matches {
571
+ return Ok(JsonCasResult::LeaseConflict {
572
+ current_version,
573
+ current_epoch,
574
+ });
575
+ }
576
+ if reject_if_diverged && capability == "_cells" {
577
+ let replica_key = key
578
+ .strip_prefix("_cells:")
579
+ .map(|suffix| format!("_replicas:{suffix}"));
580
+ let divergent = replica_key.as_ref().and_then(|replica_key| {
581
+ inner
582
+ .rows
583
+ .get("_replicas")
584
+ .and_then(|rows| rows.get(replica_key))
585
+ .and_then(|row| row.value.get("divergent"))
586
+ .and_then(Value::as_bool)
587
+ }) == Some(true);
588
+ if divergent {
589
+ return Ok(JsonCasResult::ReplicationConflict {
590
+ current_version,
591
+ current_epoch,
592
+ });
593
+ }
594
+ }
595
+
596
+ let object = value.as_object_mut().ok_or_else(|| {
597
+ FlowError::CapabilityError("CAS value must be a JSON object".to_string())
598
+ })?;
599
+ object.insert("__version".to_string(), Value::from(expected_version + 1));
600
+
601
+ inner.sequence += 1;
602
+ inner.sync_state.increment_vector_clock();
603
+ let vector_clock = inner.sync_state.get_or_init_vector_clock().clone();
604
+ let operation = Operation::update(
605
+ inner.sequence,
606
+ inner.instance_id.clone(),
607
+ inner.sequence,
608
+ key.to_string(),
609
+ value.clone(),
610
+ rust_type.clone(),
611
+ capability.clone(),
612
+ )
613
+ .with_vector_clock(vector_clock);
614
+ let row = StoredRow {
615
+ capability: capability.clone(),
616
+ key: key.to_string(),
617
+ rust_type: rust_type.clone(),
618
+ value: value.clone(),
619
+ unix_ms: now_ms(),
620
+ content_hash: Some(operation.content_hash.clone()),
621
+ flow_ref: None,
622
+ deleted: false,
623
+ operation: Some(operation.clone()),
624
+ };
625
+ append_event(&inner.path, &row)?;
626
+ inner.change_log.add_operation(operation);
627
+ inner
628
+ .rows
629
+ .entry(capability.clone())
630
+ .or_default()
631
+ .insert(key.to_string(), row.clone());
632
+ let event = ChangeEvent {
633
+ capability,
634
+ key: key.to_string(),
635
+ rust_type,
636
+ unix_ms: row.unix_ms,
637
+ };
638
+ (
639
+ JsonCasResult::Updated {
640
+ current_version: expected_version + 1,
641
+ current_epoch: value
642
+ .pointer("/authority/epoch")
643
+ .and_then(Value::as_u64)
644
+ .unwrap_or(current_epoch),
645
+ value,
646
+ },
647
+ event,
648
+ )
649
+ };
650
+ let _ = self.event_tx.send(event);
651
+ Ok(result)
652
+ }
653
+
481
654
  /// Delete a record with vector clock tracking
482
655
  pub fn delete(&self, key: &str) -> Result<()> {
483
656
  let capability = key
@@ -0,0 +1,231 @@
1
+ use std::{fs, path::PathBuf};
2
+
3
+ use serde_json::json;
4
+
5
+ use crate::{FeltDb, JsonCasResult};
6
+
7
+ fn database(name: &str) -> (FeltDb, PathBuf) {
8
+ let path = std::env::temp_dir().join(format!(
9
+ "feltdb-managed-cas-{name}-{}-{}.log",
10
+ std::process::id(),
11
+ std::time::SystemTime::now()
12
+ .duration_since(std::time::UNIX_EPOCH)
13
+ .unwrap()
14
+ .as_nanos()
15
+ ));
16
+ (FeltDb::open(&path).unwrap(), path)
17
+ }
18
+
19
+ #[test]
20
+ fn version_and_epoch_are_one_atomic_predicate() {
21
+ let (db, path) = database("predicate");
22
+ let key = "_cells:cell-1";
23
+ db.insert(
24
+ key,
25
+ json!({
26
+ "id": "cell-1",
27
+ "version": 42,
28
+ "value": "before",
29
+ "authority": { "owner": "A", "epoch": 7, "lifecycle": "active" },
30
+ "__version": 1
31
+ }),
32
+ )
33
+ .unwrap();
34
+
35
+ let stale_epoch = db
36
+ .compare_and_set_json(
37
+ key,
38
+ 1,
39
+ Some(6),
40
+ None,
41
+ false,
42
+ json!({
43
+ "id": "cell-1",
44
+ "version": 42,
45
+ "value": "stale",
46
+ "authority": { "owner": "stale", "epoch": 7, "lifecycle": "active" }
47
+ }),
48
+ )
49
+ .unwrap();
50
+ assert_eq!(
51
+ stale_epoch,
52
+ JsonCasResult::AuthorityConflict {
53
+ current_version: 1,
54
+ current_epoch: 7,
55
+ }
56
+ );
57
+ assert_eq!(db.get_value(key).unwrap().unwrap()["value"], "before");
58
+
59
+ let updated = db
60
+ .compare_and_set_json(
61
+ key,
62
+ 1,
63
+ Some(7),
64
+ None,
65
+ false,
66
+ json!({
67
+ "id": "cell-1",
68
+ "version": 42,
69
+ "value": "after",
70
+ "authority": { "owner": "B", "epoch": 8, "lifecycle": "active" }
71
+ }),
72
+ )
73
+ .unwrap();
74
+ assert!(matches!(
75
+ updated,
76
+ JsonCasResult::Updated {
77
+ current_version: 2,
78
+ current_epoch: 8,
79
+ ..
80
+ }
81
+ ));
82
+
83
+ let stale_version = db
84
+ .compare_and_set_json(key, 1, Some(8), None, false, json!({ "value": "lost" }))
85
+ .unwrap();
86
+ assert_eq!(
87
+ stale_version,
88
+ JsonCasResult::VersionConflict {
89
+ current_version: 2,
90
+ current_epoch: 8,
91
+ }
92
+ );
93
+ let final_value = db.get_value(key).unwrap().unwrap();
94
+ assert_eq!(final_value["value"], "after");
95
+ assert_eq!(final_value["__version"], 2);
96
+ assert_eq!(final_value["authority"]["epoch"], 8);
97
+
98
+ drop(db);
99
+ let _ = fs::remove_file(path);
100
+ }
101
+
102
+ #[test]
103
+ fn lease_is_checked_with_the_server_clock_inside_cas() {
104
+ let (db, path) = database("lease");
105
+ let key = "_cells:leased";
106
+ let future = crate::now_ms() as u64 + 60_000;
107
+ db.insert(
108
+ key,
109
+ json!({
110
+ "value": "before",
111
+ "authority": { "epoch": 3 },
112
+ "lease": { "leaseId": "lease-good", "expiresAt": future },
113
+ "__version": 1
114
+ }),
115
+ )
116
+ .unwrap();
117
+
118
+ for expected_lease_id in [None, Some("lease-wrong")] {
119
+ assert_eq!(
120
+ db.compare_and_set_json(
121
+ key,
122
+ 1,
123
+ Some(3),
124
+ expected_lease_id,
125
+ false,
126
+ json!({ "value": "rejected", "authority": { "epoch": 3 } }),
127
+ )
128
+ .unwrap(),
129
+ JsonCasResult::LeaseConflict {
130
+ current_version: 1,
131
+ current_epoch: 3,
132
+ }
133
+ );
134
+ assert_eq!(db.get_value(key).unwrap().unwrap()["value"], "before");
135
+ }
136
+
137
+ assert!(matches!(
138
+ db.compare_and_set_json(
139
+ key,
140
+ 1,
141
+ Some(3),
142
+ Some("lease-good"),
143
+ false,
144
+ json!({ "value": "accepted", "authority": { "epoch": 3 }, "lease": { "leaseId": "lease-good", "expiresAt": future } }),
145
+ )
146
+ .unwrap(),
147
+ JsonCasResult::Updated { .. }
148
+ ));
149
+
150
+ let expired_key = "_cells:expired";
151
+ db.insert(
152
+ expired_key,
153
+ json!({
154
+ "value": "before",
155
+ "authority": { "epoch": 3 },
156
+ "lease": { "leaseId": "lease-expired", "expiresAt": 1 },
157
+ "__version": 1
158
+ }),
159
+ )
160
+ .unwrap();
161
+ assert_eq!(
162
+ db.compare_and_set_json(
163
+ expired_key,
164
+ 1,
165
+ Some(3),
166
+ Some("lease-expired"),
167
+ false,
168
+ json!({ "value": "rejected", "authority": { "epoch": 3 } }),
169
+ )
170
+ .unwrap(),
171
+ JsonCasResult::LeaseConflict {
172
+ current_version: 1,
173
+ current_epoch: 3,
174
+ }
175
+ );
176
+ assert_eq!(db.get_value(expired_key).unwrap().unwrap()["value"], "before");
177
+
178
+ drop(db);
179
+ let _ = fs::remove_file(path);
180
+ }
181
+
182
+ #[test]
183
+ fn divergent_replica_blocks_promotion_inside_cas() {
184
+ let (db, path) = database("divergence");
185
+ let cell_key = "_cells:replica";
186
+ let replica_key = "_replicas:replica";
187
+ db.insert(
188
+ cell_key,
189
+ json!({
190
+ "version": 42,
191
+ "value": "A",
192
+ "authority": { "owner": "A", "epoch": 7 },
193
+ "lease": null,
194
+ "__version": 42
195
+ }),
196
+ )
197
+ .unwrap();
198
+ db.insert(
199
+ replica_key,
200
+ json!({ "version": 42, "value": "B", "divergent": true }),
201
+ )
202
+ .unwrap();
203
+ let sequence_before = db.sequence().unwrap();
204
+ let value_before = db.get_value(cell_key).unwrap().unwrap();
205
+
206
+ assert_eq!(
207
+ db.compare_and_set_json(
208
+ cell_key,
209
+ 42,
210
+ Some(7),
211
+ None,
212
+ true,
213
+ json!({
214
+ "version": 43,
215
+ "value": "A",
216
+ "authority": { "owner": "B", "epoch": 8 },
217
+ "lease": null
218
+ }),
219
+ )
220
+ .unwrap(),
221
+ JsonCasResult::ReplicationConflict {
222
+ current_version: 42,
223
+ current_epoch: 7,
224
+ }
225
+ );
226
+ assert_eq!(db.sequence().unwrap(), sequence_before);
227
+ assert_eq!(db.get_value(cell_key).unwrap().unwrap(), value_before);
228
+
229
+ drop(db);
230
+ let _ = fs::remove_file(path);
231
+ }