@feltdb/core 0.5.0 → 0.5.2

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 (52) hide show
  1. package/README.md +24 -14
  2. package/dist/cell.d.ts +233 -0
  3. package/dist/cell.d.ts.map +1 -0
  4. package/dist/cell.js +1078 -0
  5. package/dist/cli/index.js +1 -1
  6. package/dist/collection.d.ts +48 -1
  7. package/dist/collection.d.ts.map +1 -1
  8. package/dist/collection.js +95 -10
  9. package/dist/create/package-versions.js +1 -1
  10. package/dist/create/server-source/crates/feltdb/src/application.rs +183 -8
  11. package/dist/create/server-source/crates/feltdb/src/lib.rs +173 -0
  12. package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +231 -0
  13. package/dist/create/server-source/crates/feltdb-server/src/main.rs +314 -46
  14. package/dist/create/server-source/crates/feltdb-server/src/request_telemetry.rs +1 -1
  15. package/dist/create/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +219 -0
  16. package/dist/db.d.ts +16 -0
  17. package/dist/db.d.ts.map +1 -1
  18. package/dist/db.js +21 -0
  19. package/dist/feltdb.d.ts +7 -1
  20. package/dist/feltdb.d.ts.map +1 -1
  21. package/dist/file-db.d.ts +5 -0
  22. package/dist/file-db.d.ts.map +1 -1
  23. package/dist/file-db.js +72 -9
  24. package/dist/http-db.d.ts +13 -0
  25. package/dist/http-db.d.ts.map +1 -1
  26. package/dist/http-db.js +41 -0
  27. package/dist/http-server.d.ts +42 -0
  28. package/dist/http-server.d.ts.map +1 -0
  29. package/dist/http-server.js +182 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/indexeddb-conformance.spec.d.ts +21 -0
  34. package/dist/indexeddb-conformance.spec.d.ts.map +1 -0
  35. package/dist/indexeddb-conformance.spec.js +103 -0
  36. package/dist/indexeddb-db.d.ts +8 -0
  37. package/dist/indexeddb-db.d.ts.map +1 -1
  38. package/dist/indexeddb-db.js +69 -0
  39. package/dist/memory-db.d.ts +14 -0
  40. package/dist/memory-db.d.ts.map +1 -1
  41. package/dist/memory-db.js +42 -0
  42. package/dist/state-contract.d.ts +1 -1
  43. package/dist/state-contract.d.ts.map +1 -1
  44. package/dist/state-contract.js +1 -1
  45. package/dist/studio-app/assets/{feltdb_wasm-CBGD0zRu.js → feltdb_wasm-h9mxesnH.js} +1 -1
  46. package/dist/studio-app/assets/feltdb_wasm_bg-B8U4A1n1.wasm +0 -0
  47. package/dist/studio-app/assets/index-BMaQv3zF.js +28 -0
  48. package/dist/studio-app/index.html +1 -1
  49. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  50. package/package.json +1 -1
  51. package/dist/studio-app/assets/feltdb_wasm_bg-C6ATF9mJ.wasm +0 -0
  52. package/dist/studio-app/assets/index-3cvTQ0Mv.js +0 -28
@@ -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
+ }