@feltdb/core 0.11.9 → 0.12.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 (56) hide show
  1. package/dist/agent-state.d.ts +489 -0
  2. package/dist/agent-state.d.ts.map +1 -0
  3. package/dist/agent-state.js +993 -0
  4. package/dist/build-identity.json +19 -0
  5. package/dist/cli/index.js +0 -0
  6. package/dist/create/build-identity.json +19 -0
  7. package/dist/create/package-versions.js +1 -1
  8. package/dist/create/server-source/crates/feltdb/src/lib.rs +11 -0
  9. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +4 -1
  10. package/dist/create/server-source/crates/feltdb/src/state_model.rs +854 -155
  11. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +31 -13
  12. package/dist/create/server-source/crates/feltdb/tests/state_ref_authority.rs +918 -0
  13. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +15 -2
  14. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +4 -0
  15. package/dist/create/server-source/crates/feltdb-server/benches/query_shape_observer.rs +136 -0
  16. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +3 -0
  17. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
  18. package/dist/create/server-source/crates/feltdb-server/src/main.rs +74 -5
  19. package/dist/create/server-source/crates/feltdb-server/src/query_shape.rs +1137 -0
  20. package/dist/create/server-source/crates/feltdb-server/tests/fixtures/query-shape-trace.expected.json +300 -0
  21. package/dist/create/server-source/crates/feltdb-server/tests/fixtures/query-shape-trace.json +25 -0
  22. package/dist/create/server-source/crates/feltdb-server/tests/query_shape_trace.rs +128 -0
  23. package/dist/create/server-source/crates/feltdb-wasm/test.db +0 -0
  24. package/dist/index-core.d.ts +1 -0
  25. package/dist/index-core.d.ts.map +1 -1
  26. package/dist/index-core.js +1 -0
  27. package/dist/studio/app.d.ts.map +1 -1
  28. package/dist/studio/components/GlobalSearch.d.ts.map +1 -1
  29. package/dist/studio/components/index.js +1 -1
  30. package/dist/studio/{components-BHTKlUgT.js → components-Bivm2lX6.js} +2 -1
  31. package/dist/studio/control-plane/Agents.d.ts.map +1 -1
  32. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  33. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  34. package/dist/studio/control-plane/Overview.d.ts.map +1 -1
  35. package/dist/studio/control-plane/Schema.d.ts.map +1 -1
  36. package/dist/studio/control-plane/State.d.ts +11 -0
  37. package/dist/studio/control-plane/State.d.ts.map +1 -1
  38. package/dist/studio/control-plane/index.d.ts +1 -1
  39. package/dist/studio/control-plane/index.d.ts.map +1 -1
  40. package/dist/studio/control-plane/index.js +2 -2
  41. package/dist/studio/control-plane/primitives.d.ts +14 -2
  42. package/dist/studio/control-plane/primitives.d.ts.map +1 -1
  43. package/dist/studio/control-plane-CLm69XwX.js +4121 -0
  44. package/dist/studio/index.js +266 -235
  45. package/dist/studio/studio.css +1 -1
  46. package/dist/studio-app/assets/{feltdb_wasm-DGGqm9GK.js → feltdb_wasm-DACcD-zs.js} +1 -1
  47. package/dist/studio-app/assets/feltdb_wasm_bg-BejThWID.wasm +0 -0
  48. package/dist/studio-app/assets/index-CiC8VY-a.css +1 -0
  49. package/dist/studio-app/assets/index-CoZaxnlm.js +30 -0
  50. package/dist/studio-app/index.html +2 -2
  51. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  52. package/package.json +2 -2
  53. package/dist/studio/control-plane-DP8ljTDO.js +0 -3207
  54. package/dist/studio-app/assets/feltdb_wasm_bg-BYJo87WY.wasm +0 -0
  55. package/dist/studio-app/assets/index-BrsF-kWL.css +0 -1
  56. package/dist/studio-app/assets/index-qLeZcPW_.js +0 -30
@@ -1371,6 +1371,553 @@ fn current_time_ms() -> u64 {
1371
1371
  .as_millis() as u64
1372
1372
  }
1373
1373
 
1374
+ /// How long a revision is protected from garbage collection after it is
1375
+ /// minted, measured on its own `timestamp_ms`. A candidate an agent has just
1376
+ /// committed but not yet promoted is a root for this long, so a collection that
1377
+ /// races the promotion cannot reclaim it out from under the caller.
1378
+ pub(crate) const GC_GRACE_MS: u64 = 15 * 60 * 1000;
1379
+
1380
+ /// Durable state-model writes as FeltDB transactions.
1381
+ ///
1382
+ /// # Ordering
1383
+ ///
1384
+ /// Every operation here follows the same three steps:
1385
+ ///
1386
+ /// 1. **Read** the `state` rows the decision depends on.
1387
+ /// 2. **Decide** in Rust: versions, presence, integrity.
1388
+ /// 3. **Commit** with `FeltDb::apply_atomic_transaction_guarded`, carrying a
1389
+ /// record precondition for *every row read in step 1*. FeltDB evaluates the
1390
+ /// preconditions inside its commit lock, before staging anything, and
1391
+ /// applies the mutations — one log record, `sync_data` before returning —
1392
+ /// only if every row is still as it was read. Otherwise nothing is applied
1393
+ /// and the operation starts again from step 1.
1394
+ ///
1395
+ /// So a decision is only ever applied to the state it was made against. The
1396
+ /// mutual exclusion is FeltDB's `Inner` mutex, shared by every `StateStore`,
1397
+ /// every `Arc<FeltDb>` clone and every `FeltDb::open` of the same path in one
1398
+ /// process; the per-instance `mutation_lock` plays no part on this backing.
1399
+ ///
1400
+ /// # How a row is pinned
1401
+ ///
1402
+ /// FeltDB's record precondition compares `__version`, reading a row without one
1403
+ /// as version 1, and stamps `N + 1` on a row written under `expected_version =
1404
+ /// N` (`require_absent` writes 1). That gives three kinds of pin:
1405
+ ///
1406
+ /// - **Versioned rows** (ref, ref incarnation) are only ever written under a
1407
+ /// precondition, so their `__version` advances on every write and
1408
+ /// `expected_version = <as read>` pins them; an absent one is pinned by
1409
+ /// `require_absent`.
1410
+ /// - **Immutable rows** (revision, content object) are never versioned, so
1411
+ /// `expected_version = 1` means exactly "still present" and writes nothing.
1412
+ /// - **The generation row** (`state:_gcgen`) is rewritten by every ref change
1413
+ /// and every committed revision *without* a precondition, carrying a
1414
+ /// `__version` unique to that transaction (an unconditional write is stored
1415
+ /// verbatim). Writers therefore never conflict with each other through it,
1416
+ /// while a collection that pins the value it read is refused if any of them
1417
+ /// committed in between.
1418
+ ///
1419
+ /// | operation | pinned | writes |
1420
+ /// | --- | --- | --- |
1421
+ /// | create ref | ref (absent), incarnation, target | ref, incarnation, generation |
1422
+ /// | CAS ref | ref, incarnation, target | ref, generation |
1423
+ /// | delete ref | ref, incarnation | (ref removed), incarnation, generation |
1424
+ /// | commit revision | revision (absent), content object, parent | object (if new), revision, generation |
1425
+ /// | collect | generation | (unreachable revisions and objects removed) |
1426
+ ///
1427
+ /// The incarnation row is pinned alongside the ref because a delete followed by
1428
+ /// a create returns the ref row to `__version` 1; the incarnation row is never
1429
+ /// deleted, so its version only grows and the pair cannot repeat.
1430
+ ///
1431
+ /// # The races this closes
1432
+ ///
1433
+ /// - *Concurrent CAS.* Both read the ref at `__version` N; the first commit
1434
+ /// stamps N + 1; the second's precondition fails, it re-reads, and reports
1435
+ /// `VersionConflict`.
1436
+ /// - *GC vs promotion.* If the promotion commits first, the generation moved
1437
+ /// and GC re-plans with the target as a ref root. If GC commits first, the
1438
+ /// promotion's target pin fails, and on re-read it reports `UnknownTarget`.
1439
+ /// - *GC vs commit of a child.* If the commit lands first, the generation
1440
+ /// moved. If GC lands first, the commit's parent pin fails and it reports
1441
+ /// `UnknownParent`.
1442
+ ///
1443
+ /// Revisions minted by the record write path are not committed here; each is a
1444
+ /// child of that path's current head, which collection always treats as a
1445
+ /// root, so they cannot gain a collected parent.
1446
+ ///
1447
+ /// # Idempotent ref requests
1448
+ ///
1449
+ /// Ref transactions carry deterministic ids derived from the ref, its
1450
+ /// *incarnation* and the version they act on, and a payload hash of the rest
1451
+ /// of the request. FeltDB binds an id to its payload durably, across restart
1452
+ /// and compaction. A repeat of a committed request finds its own payload under
1453
+ /// its id and returns `Ok` without writing; any other request for the same slot
1454
+ /// finds a different payload and is a conflict. The incarnation
1455
+ /// (`state:refinc:…`, advanced by every create) keeps a deleted-and-recreated
1456
+ /// ref, back at version 1, from matching a transaction of its predecessor. Rows
1457
+ /// written before this scheme have no incarnation and are incarnation 0.
1458
+ pub(crate) mod durable {
1459
+ use super::*;
1460
+ use crate::{AtomicMutation, FeltDb, FlowError, PreconditionFailure, RecordPrecondition};
1461
+ use serde_json::json;
1462
+ use std::sync::atomic::{AtomicU64, Ordering};
1463
+
1464
+ /// Fresh reads and a new attempt after a refused precondition. Each refusal
1465
+ /// means a competing write committed, so the system as a whole makes
1466
+ /// progress; the bound only turns pathological contention into an error.
1467
+ const ATTEMPTS: usize = 64;
1468
+
1469
+ pub(super) const GENERATION_KEY: &str = "state:_gcgen";
1470
+
1471
+ fn incarnation_key(namespace: &str, name: &str) -> String {
1472
+ format!(
1473
+ "state:refinc:{}:{}",
1474
+ encode_ref_part(namespace),
1475
+ encode_ref_part(name)
1476
+ )
1477
+ }
1478
+
1479
+ fn put(key: &str, value: Value) -> AtomicMutation {
1480
+ AtomicMutation {
1481
+ capability: "state".into(),
1482
+ key: key.into(),
1483
+ value: Some(value),
1484
+ }
1485
+ }
1486
+
1487
+ fn remove(key: &str) -> AtomicMutation {
1488
+ AtomicMutation {
1489
+ capability: "state".into(),
1490
+ key: key.into(),
1491
+ value: None,
1492
+ }
1493
+ }
1494
+
1495
+ /// The version FeltDB's record precondition reads for a row.
1496
+ fn row_version(row: &Value) -> u64 {
1497
+ row.get("__version").and_then(Value::as_u64).unwrap_or(1)
1498
+ }
1499
+
1500
+ /// Pin a row to exactly what was read: absent, or this version.
1501
+ fn unchanged(key: &str, read: Option<&Value>) -> RecordPrecondition {
1502
+ RecordPrecondition {
1503
+ capability: "state".into(),
1504
+ key: key.into(),
1505
+ require_absent: read.is_none(),
1506
+ expected_version: read.map(row_version),
1507
+ ..RecordPrecondition::default()
1508
+ }
1509
+ }
1510
+
1511
+ /// Pin an immutable (never versioned) row as present.
1512
+ fn present(key: &str) -> RecordPrecondition {
1513
+ RecordPrecondition {
1514
+ capability: "state".into(),
1515
+ key: key.into(),
1516
+ expected_version: Some(1),
1517
+ ..RecordPrecondition::default()
1518
+ }
1519
+ }
1520
+
1521
+ /// The generation row's new value: a `__version` unique to this transaction.
1522
+ /// Transaction ids are never applied twice, so every rewrite changes it.
1523
+ fn advance_generation(transaction_id: &str) -> AtomicMutation {
1524
+ let digest = Sha256::digest(transaction_id.as_bytes());
1525
+ let mut bytes = [0u8; 8];
1526
+ bytes.copy_from_slice(&digest[..8]);
1527
+ put(
1528
+ GENERATION_KEY,
1529
+ json!({ "__version": u64::from_be_bytes(bytes), "transaction": transaction_id }),
1530
+ )
1531
+ }
1532
+
1533
+ fn unique_suffix() -> String {
1534
+ static COUNTER: AtomicU64 = AtomicU64::new(0);
1535
+ let nanos = std::time::SystemTime::now()
1536
+ .duration_since(std::time::UNIX_EPOCH)
1537
+ .unwrap_or_default()
1538
+ .as_nanos();
1539
+ format!(
1540
+ "{}-{nanos}-{}",
1541
+ std::process::id(),
1542
+ COUNTER.fetch_add(1, Ordering::Relaxed)
1543
+ )
1544
+ }
1545
+
1546
+ fn ref_transaction_id(
1547
+ kind: &str,
1548
+ namespace: &str,
1549
+ name: &str,
1550
+ incarnation: u64,
1551
+ version: Option<u64>,
1552
+ ) -> String {
1553
+ let mut hasher = Sha256::new();
1554
+ for part in [
1555
+ "feltdb.state.ref.v1",
1556
+ kind,
1557
+ namespace,
1558
+ name,
1559
+ &incarnation.to_string(),
1560
+ &version.map(|v| v.to_string()).unwrap_or_default(),
1561
+ ] {
1562
+ hasher.update(part.as_bytes());
1563
+ hasher.update([0u8]);
1564
+ }
1565
+ format!("state-ref:{:x}", hasher.finalize())
1566
+ }
1567
+
1568
+ /// serde_json maps are ordered (no `preserve_order`), so this is canonical.
1569
+ fn payload_hash(payload: &Value) -> String {
1570
+ format!("sha256:{:x}", Sha256::digest(payload.to_string().as_bytes()))
1571
+ }
1572
+
1573
+ fn incarnation_of(row: &Value) -> u64 {
1574
+ row.get("__incarnation").and_then(Value::as_u64).unwrap_or(0)
1575
+ }
1576
+
1577
+ fn incarnation_in(row: Option<&Value>) -> u64 {
1578
+ row.and_then(|row| row.get("incarnation").and_then(Value::as_u64))
1579
+ .unwrap_or(0)
1580
+ }
1581
+
1582
+ fn ref_row(reference: &StateRef, incarnation: u64) -> Value {
1583
+ let mut row = serde_json::to_value(reference).unwrap_or(Value::Null);
1584
+ if let Some(object) = row.as_object_mut() {
1585
+ object.insert("__incarnation".into(), Value::from(incarnation));
1586
+ }
1587
+ row
1588
+ }
1589
+
1590
+ enum Refusal {
1591
+ /// A pinned row changed: re-read and decide again.
1592
+ Changed,
1593
+ /// The id is already bound to another payload: re-read and decide again.
1594
+ OtherRequest,
1595
+ Failed(String),
1596
+ }
1597
+
1598
+ fn refusal(error: FlowError) -> Refusal {
1599
+ match error {
1600
+ FlowError::PreconditionFailed(failure) => match *failure {
1601
+ PreconditionFailure::Present { .. }
1602
+ | PreconditionFailure::Missing { .. }
1603
+ | PreconditionFailure::Version { .. } => Refusal::Changed,
1604
+ other => Refusal::Failed(format!("{other:?}")),
1605
+ },
1606
+ FlowError::CapabilityError(message)
1607
+ if message.starts_with("TRANSACTION_ID_PAYLOAD_MISMATCH") =>
1608
+ {
1609
+ Refusal::OtherRequest
1610
+ }
1611
+ other => Refusal::Failed(other.to_string()),
1612
+ }
1613
+ }
1614
+
1615
+ fn storage(error: impl std::fmt::Display) -> RefError {
1616
+ RefError::Storage(error.to_string())
1617
+ }
1618
+
1619
+ fn contended(what: &str) -> String {
1620
+ format!(
1621
+ "{what} was refused by concurrent changes on {ATTEMPTS} consecutive attempts; nothing was applied"
1622
+ )
1623
+ }
1624
+
1625
+ fn row(db: &FeltDb, key: &str) -> Result<Option<Value>, RefError> {
1626
+ db.get::<Value>(key).map_err(storage)
1627
+ }
1628
+
1629
+ fn committed_payload(db: &FeltDb, id: &str) -> Result<Option<String>, RefError> {
1630
+ db.applied_transaction_payload_hash(id).map_err(storage)
1631
+ }
1632
+
1633
+ fn decode(row: Value) -> Result<StateRef, RefError> {
1634
+ serde_json::from_value(row).map_err(storage)
1635
+ }
1636
+
1637
+ fn commit(
1638
+ db: &FeltDb,
1639
+ id: &str,
1640
+ payload: Option<&str>,
1641
+ pins: &[RecordPrecondition],
1642
+ mutations: &[AtomicMutation],
1643
+ ) -> Result<(), Refusal> {
1644
+ db.apply_atomic_transaction_guarded(id, payload, None, &[], pins, mutations, None)
1645
+ .map(|_| ())
1646
+ .map_err(refusal)
1647
+ }
1648
+
1649
+ pub(super) fn create_ref(
1650
+ db: &FeltDb,
1651
+ namespace: &str,
1652
+ name: &str,
1653
+ target: StateId,
1654
+ authority: String,
1655
+ metadata: BTreeMap<String, Value>,
1656
+ ) -> Result<StateRef, RefError> {
1657
+ let ref_k = ref_key(namespace, name);
1658
+ let inc_k = incarnation_key(namespace, name);
1659
+ let target_k = revision_key(&target);
1660
+ let payload = payload_hash(&json!({
1661
+ "target": target.as_hex(),
1662
+ "authority": authority,
1663
+ "metadata": metadata,
1664
+ }));
1665
+ for _ in 0..ATTEMPTS {
1666
+ let incarnation_row = row(db, &inc_k)?;
1667
+ if let Some(existing) = row(db, &ref_k)? {
1668
+ // The create that made this incarnation, repeated, is that create.
1669
+ let id = ref_transaction_id("create", namespace, name, incarnation_of(&existing), None);
1670
+ if committed_payload(db, &id)?.as_deref() == Some(payload.as_str()) {
1671
+ return decode(existing);
1672
+ }
1673
+ return Err(RefError::AlreadyExists);
1674
+ }
1675
+ if row(db, &target_k)?.is_none() {
1676
+ return Err(RefError::UnknownTarget(target));
1677
+ }
1678
+ let incarnation = incarnation_in(incarnation_row.as_ref()) + 1;
1679
+ let id = ref_transaction_id("create", namespace, name, incarnation, None);
1680
+ let reference = StateRef {
1681
+ namespace: namespace.to_string(),
1682
+ name: name.to_string(),
1683
+ target: target.clone(),
1684
+ version: 1,
1685
+ authority: authority.clone(),
1686
+ updated_at_ms: current_time_ms(),
1687
+ metadata: metadata.clone(),
1688
+ };
1689
+ let pins = [
1690
+ unchanged(&ref_k, None),
1691
+ unchanged(&inc_k, incarnation_row.as_ref()),
1692
+ present(&target_k),
1693
+ ];
1694
+ let mutations = [
1695
+ put(&ref_k, ref_row(&reference, incarnation)),
1696
+ put(&inc_k, json!({ "incarnation": incarnation })),
1697
+ advance_generation(&id),
1698
+ ];
1699
+ match commit(db, &id, Some(&payload), &pins, &mutations) {
1700
+ Ok(()) => return Ok(reference),
1701
+ Err(Refusal::Changed | Refusal::OtherRequest) => continue,
1702
+ Err(Refusal::Failed(message)) => return Err(RefError::Storage(message)),
1703
+ }
1704
+ }
1705
+ Err(RefError::Storage(contended("ref create")))
1706
+ }
1707
+
1708
+ pub(super) fn compare_and_swap_ref(
1709
+ db: &FeltDb,
1710
+ namespace: &str,
1711
+ name: &str,
1712
+ expected_version: u64,
1713
+ target: StateId,
1714
+ authority: String,
1715
+ metadata: Option<BTreeMap<String, Value>>,
1716
+ ) -> Result<StateRef, RefError> {
1717
+ let ref_k = ref_key(namespace, name);
1718
+ let inc_k = incarnation_key(namespace, name);
1719
+ let target_k = revision_key(&target);
1720
+ let payload = payload_hash(&json!({
1721
+ "target": target.as_hex(),
1722
+ "authority": authority,
1723
+ "metadata": metadata,
1724
+ }));
1725
+ for _ in 0..ATTEMPTS {
1726
+ let incarnation_row = row(db, &inc_k)?;
1727
+ let Some(ref_read) = row(db, &ref_k)? else {
1728
+ return Err(RefError::NotFound);
1729
+ };
1730
+ let incarnation = incarnation_of(&ref_read);
1731
+ let current = decode(ref_read.clone())?;
1732
+ let id = ref_transaction_id("cas", namespace, name, incarnation, Some(expected_version));
1733
+ if current.version != expected_version {
1734
+ // Either this exact request already committed (a retry), or
1735
+ // another one did: the payload bound to the id says which.
1736
+ if committed_payload(db, &id)?.as_deref() == Some(payload.as_str()) {
1737
+ if current.version == expected_version + 1 && current.target == target {
1738
+ return Ok(current);
1739
+ }
1740
+ // Committed, and the ref has moved on since. Describe the
1741
+ // transition this request made; `updated_at_ms` is now.
1742
+ return Ok(StateRef {
1743
+ version: expected_version + 1,
1744
+ target,
1745
+ authority,
1746
+ updated_at_ms: current_time_ms(),
1747
+ metadata: metadata.unwrap_or(current.metadata),
1748
+ ..current
1749
+ });
1750
+ }
1751
+ return Err(RefError::VersionConflict {
1752
+ expected: expected_version,
1753
+ current: current.version,
1754
+ });
1755
+ }
1756
+ if row(db, &target_k)?.is_none() {
1757
+ return Err(RefError::UnknownTarget(target));
1758
+ }
1759
+ let next = StateRef {
1760
+ version: expected_version + 1,
1761
+ target: target.clone(),
1762
+ authority: authority.clone(),
1763
+ updated_at_ms: current_time_ms(),
1764
+ metadata: metadata.clone().unwrap_or_else(|| current.metadata.clone()),
1765
+ ..current
1766
+ };
1767
+ let pins = [
1768
+ unchanged(&ref_k, Some(&ref_read)),
1769
+ unchanged(&inc_k, incarnation_row.as_ref()),
1770
+ present(&target_k),
1771
+ ];
1772
+ let mutations = [put(&ref_k, ref_row(&next, incarnation)), advance_generation(&id)];
1773
+ match commit(db, &id, Some(&payload), &pins, &mutations) {
1774
+ Ok(()) => return Ok(next),
1775
+ Err(Refusal::Changed | Refusal::OtherRequest) => continue,
1776
+ Err(Refusal::Failed(message)) => return Err(RefError::Storage(message)),
1777
+ }
1778
+ }
1779
+ Err(RefError::Storage(contended("ref compare-and-swap")))
1780
+ }
1781
+
1782
+ pub(super) fn delete_ref(
1783
+ db: &FeltDb,
1784
+ namespace: &str,
1785
+ name: &str,
1786
+ expected_version: Option<u64>,
1787
+ ) -> Result<(), RefError> {
1788
+ let ref_k = ref_key(namespace, name);
1789
+ let inc_k = incarnation_key(namespace, name);
1790
+ let payload = payload_hash(&json!({ "expected_version": expected_version }));
1791
+ for _ in 0..ATTEMPTS {
1792
+ let incarnation_row = row(db, &inc_k)?;
1793
+ let Some(ref_read) = row(db, &ref_k)? else {
1794
+ // A repeat of a delete that already removed this incarnation.
1795
+ let id = ref_transaction_id(
1796
+ "delete",
1797
+ namespace,
1798
+ name,
1799
+ incarnation_in(incarnation_row.as_ref()),
1800
+ None,
1801
+ );
1802
+ if committed_payload(db, &id)?.as_deref() == Some(payload.as_str()) {
1803
+ return Ok(());
1804
+ }
1805
+ return Err(RefError::NotFound);
1806
+ };
1807
+ let incarnation = incarnation_of(&ref_read);
1808
+ let current = decode(ref_read.clone())?;
1809
+ if let Some(expected) = expected_version {
1810
+ if current.version != expected {
1811
+ return Err(RefError::VersionConflict {
1812
+ expected,
1813
+ current: current.version,
1814
+ });
1815
+ }
1816
+ }
1817
+ let id = ref_transaction_id("delete", namespace, name, incarnation, None);
1818
+ let pins = [
1819
+ unchanged(&ref_k, Some(&ref_read)),
1820
+ unchanged(&inc_k, incarnation_row.as_ref()),
1821
+ ];
1822
+ let mutations = [
1823
+ remove(&ref_k),
1824
+ put(&inc_k, json!({ "incarnation": incarnation })),
1825
+ advance_generation(&id),
1826
+ ];
1827
+ match commit(db, &id, Some(&payload), &pins, &mutations) {
1828
+ Ok(()) => return Ok(()),
1829
+ Err(Refusal::Changed | Refusal::OtherRequest) => continue,
1830
+ Err(Refusal::Failed(message)) => return Err(RefError::Storage(message)),
1831
+ }
1832
+ }
1833
+ Err(RefError::Storage(contended("ref delete")))
1834
+ }
1835
+
1836
+ pub(super) fn commit_revision(
1837
+ db: &FeltDb,
1838
+ revision: StateRevision,
1839
+ ) -> Result<StateRevision, RevisionError> {
1840
+ let fail = |error: &dyn std::fmt::Display| RevisionError::Storage(error.to_string());
1841
+ let object = StateObject::new(revision.content.clone());
1842
+ let rev_k = revision_key(&revision.id);
1843
+ let obj_k = object_key(&object.id);
1844
+ let parent_k = revision.parent_id.as_ref().map(revision_key);
1845
+ for _ in 0..ATTEMPTS {
1846
+ if let Some(existing) = db.get::<StateRevision>(&rev_k).map_err(|e| fail(&e))? {
1847
+ let same = existing.resource == revision.resource
1848
+ && existing.content_id == revision.content_id
1849
+ && existing.parent_id == revision.parent_id
1850
+ && existing.sequence == revision.sequence;
1851
+ return if same {
1852
+ Ok(existing)
1853
+ } else {
1854
+ Err(RevisionError::AlreadyCommitted(revision.id.clone()))
1855
+ };
1856
+ }
1857
+ let mut pins = vec![unchanged(&rev_k, None)];
1858
+ if let (Some(parent), Some(parent_k)) = (&revision.parent_id, &parent_k) {
1859
+ if db.get::<Value>(parent_k).map_err(|e| fail(&e))?.is_none() {
1860
+ return Err(RevisionError::UnknownParent(parent.clone()));
1861
+ }
1862
+ pins.push(present(parent_k));
1863
+ }
1864
+ let id = format!("state-revision:{}:{}", revision.id.as_hex(), unique_suffix());
1865
+ let mut mutations = Vec::with_capacity(3);
1866
+ match db.get::<StateObject>(&obj_k).map_err(|e| fail(&e))? {
1867
+ Some(existing) if !existing.verify_integrity() => {
1868
+ return Err(RevisionError::Storage(
1869
+ "Stored state object failed integrity verification".to_string(),
1870
+ ));
1871
+ }
1872
+ Some(_) => pins.push(present(&obj_k)),
1873
+ None => {
1874
+ pins.push(unchanged(&obj_k, None));
1875
+ mutations.push(put(&obj_k, serde_json::to_value(&object).map_err(|e| fail(&e))?));
1876
+ }
1877
+ }
1878
+ mutations.push(put(&rev_k, serde_json::to_value(&revision).map_err(|e| fail(&e))?));
1879
+ mutations.push(advance_generation(&id));
1880
+ match commit(db, &id, None, &pins, &mutations) {
1881
+ Ok(()) => return Ok(revision),
1882
+ Err(Refusal::Changed | Refusal::OtherRequest) => continue,
1883
+ Err(Refusal::Failed(message)) => return Err(RevisionError::Storage(message)),
1884
+ }
1885
+ }
1886
+ Err(RevisionError::Storage(contended("revision commit")))
1887
+ }
1888
+
1889
+ pub(super) fn collect(
1890
+ store: &StateStore,
1891
+ db: &FeltDb,
1892
+ now_ms: u64,
1893
+ ) -> Result<GarbageCollectionReport, GarbageCollectionError> {
1894
+ let fail = |error: &dyn std::fmt::Display| GarbageCollectionError::Storage(error.to_string());
1895
+ for _ in 0..ATTEMPTS {
1896
+ // The generation first: everything planned below was read after it,
1897
+ // so a write that lands after this read moves it.
1898
+ let generation = db.get::<Value>(GENERATION_KEY).map_err(|e| fail(&e))?;
1899
+ let report = store.plan_collection(now_ms, &db.revision_head_ids());
1900
+ if report.collected_revisions.is_empty() && report.collected_objects.is_empty() {
1901
+ return Ok(report);
1902
+ }
1903
+ let pins = [unchanged(GENERATION_KEY, generation.as_ref())];
1904
+ let mutations: Vec<AtomicMutation> = report
1905
+ .collected_revisions
1906
+ .iter()
1907
+ .map(|id| remove(&revision_key(id)))
1908
+ .chain(report.collected_objects.iter().map(|id| remove(&object_key(id))))
1909
+ .collect();
1910
+ let id = format!("state-gc:{}", unique_suffix());
1911
+ match commit(db, &id, None, &pins, &mutations) {
1912
+ Ok(()) => return Ok(report),
1913
+ Err(Refusal::Changed | Refusal::OtherRequest) => continue,
1914
+ Err(Refusal::Failed(message)) => return Err(GarbageCollectionError::Storage(message)),
1915
+ }
1916
+ }
1917
+ Err(GarbageCollectionError::Storage(contended("garbage collection")))
1918
+ }
1919
+ }
1920
+
1374
1921
  fn collect_referenced_objects(revision: &StateRevision) -> BTreeSet<StateId> {
1375
1922
  let mut ids = BTreeSet::from([revision.content_id.clone()]);
1376
1923
  if let Some(values) = revision
@@ -1692,34 +2239,7 @@ impl StateStore {
1692
2239
  state.revisions.insert(revision.id.clone(), revision.clone());
1693
2240
  }
1694
2241
  Backing::Durable(feltdb) => {
1695
- let object = StateObject::new(revision.content.clone());
1696
- if let Some(existing) = feltdb
1697
- .get::<StateObject>(&object_key(&object.id))
1698
- .map_err(|error| {
1699
- RevisionError::Storage(format!("Failed to read state object: {error}"))
1700
- })?
1701
- {
1702
- if !existing.verify_integrity() {
1703
- return Err(RevisionError::Storage(
1704
- "Stored state object failed integrity verification".to_string(),
1705
- ));
1706
- }
1707
- } else {
1708
- feltdb
1709
- .insert(&object_key(&object.id), object)
1710
- .map_err(|error| {
1711
- RevisionError::Storage(format!(
1712
- "Failed to persist state object to FeltDB: {error}"
1713
- ))
1714
- })?;
1715
- }
1716
- feltdb
1717
- .insert(&revision_key(&revision.id), revision.clone())
1718
- .map_err(|error| {
1719
- RevisionError::Storage(format!(
1720
- "Failed to persist revision to FeltDB: {error}"
1721
- ))
1722
- })?;
2242
+ return durable::commit_revision(feltdb, revision);
1723
2243
  }
1724
2244
  }
1725
2245
  Ok(revision)
@@ -1816,6 +2336,12 @@ impl StateStore {
1816
2336
  }
1817
2337
 
1818
2338
  /// Create a mutable reference in a namespace.
2339
+ ///
2340
+ /// On the durable backing this is one fenced FeltDB transaction (see
2341
+ /// [`durable`]): the ref is created at version 1 only if, at the moment of
2342
+ /// commit, it is still absent and its target revision is still present.
2343
+ /// Repeating an identical create that already succeeded returns the ref it
2344
+ /// created; a different create of an existing ref is `AlreadyExists`.
1819
2345
  pub fn create_ref(
1820
2346
  &self,
1821
2347
  namespace: &str,
@@ -1827,24 +2353,31 @@ impl StateStore {
1827
2353
  if !valid_ref_component(namespace) || !valid_ref_component(name) {
1828
2354
  return Err(RefError::InvalidIdentifier);
1829
2355
  }
1830
- if !self.exists(&target) {
1831
- return Err(RefError::UnknownTarget(target));
1832
- }
1833
- let _guard = self.mutation_lock.lock().unwrap();
1834
- if self.get_ref(namespace, name).is_some() {
1835
- return Err(RefError::AlreadyExists);
2356
+ match &self.backing {
2357
+ Backing::Durable(feltdb) => {
2358
+ durable::create_ref(feltdb, namespace, name, target, authority, metadata)
2359
+ }
2360
+ Backing::Volatile(_) => {
2361
+ let _guard = self.mutation_lock.lock().unwrap();
2362
+ if !self.exists(&target) {
2363
+ return Err(RefError::UnknownTarget(target));
2364
+ }
2365
+ if self.get_ref(namespace, name).is_some() {
2366
+ return Err(RefError::AlreadyExists);
2367
+ }
2368
+ let reference = StateRef {
2369
+ namespace: namespace.to_string(),
2370
+ name: name.to_string(),
2371
+ target,
2372
+ version: 1,
2373
+ authority,
2374
+ updated_at_ms: current_time_ms(),
2375
+ metadata,
2376
+ };
2377
+ self.write_volatile_ref(&reference);
2378
+ Ok(reference)
2379
+ }
1836
2380
  }
1837
- let reference = StateRef {
1838
- namespace: namespace.to_string(),
1839
- name: name.to_string(),
1840
- target,
1841
- version: 1,
1842
- authority,
1843
- updated_at_ms: current_time_ms(),
1844
- metadata,
1845
- };
1846
- self.write_ref(&reference)?;
1847
- Ok(reference)
1848
2381
  }
1849
2382
 
1850
2383
  /// Read a reference.
@@ -1879,6 +2412,17 @@ impl StateStore {
1879
2412
  }
1880
2413
 
1881
2414
  /// Compare-and-swap move for a reference.
2415
+ ///
2416
+ /// On the durable backing this is one fenced FeltDB transaction: it commits
2417
+ /// only if, at the moment of commit, the ref is still at
2418
+ /// `expected_version` and the target revision is still present, whichever
2419
+ /// `StateStore` or `FeltDb` handle the competing writes came through.
2420
+ ///
2421
+ /// The request `(ref incarnation, expected_version, target, authority,
2422
+ /// metadata)` is idempotent: repeating one that already committed — after a
2423
+ /// lost response, a restart or a compaction — returns `Ok` and does not
2424
+ /// advance the ref again. Any other request for the same expected version
2425
+ /// is a `VersionConflict`.
1882
2426
  pub fn compare_and_swap_ref(
1883
2427
  &self,
1884
2428
  namespace: &str,
@@ -1891,29 +2435,45 @@ impl StateStore {
1891
2435
  if !valid_ref_component(namespace) || !valid_ref_component(name) {
1892
2436
  return Err(RefError::InvalidIdentifier);
1893
2437
  }
1894
- if !self.exists(&target) {
1895
- return Err(RefError::UnknownTarget(target));
1896
- }
1897
- let _guard = self.mutation_lock.lock().unwrap();
1898
- let mut current = self.get_ref(namespace, name).ok_or(RefError::NotFound)?;
1899
- if current.version != expected_version {
1900
- return Err(RefError::VersionConflict {
1901
- expected: expected_version,
1902
- current: current.version,
1903
- });
1904
- }
1905
- current.target = target;
1906
- current.version += 1;
1907
- current.authority = authority;
1908
- current.updated_at_ms = current_time_ms();
1909
- if let Some(metadata) = metadata {
1910
- current.metadata = metadata;
2438
+ match &self.backing {
2439
+ Backing::Durable(feltdb) => durable::compare_and_swap_ref(
2440
+ feltdb,
2441
+ namespace,
2442
+ name,
2443
+ expected_version,
2444
+ target,
2445
+ authority,
2446
+ metadata,
2447
+ ),
2448
+ Backing::Volatile(_) => {
2449
+ let _guard = self.mutation_lock.lock().unwrap();
2450
+ let mut current = self.get_ref(namespace, name).ok_or(RefError::NotFound)?;
2451
+ if current.version != expected_version {
2452
+ return Err(RefError::VersionConflict {
2453
+ expected: expected_version,
2454
+ current: current.version,
2455
+ });
2456
+ }
2457
+ if !self.exists(&target) {
2458
+ return Err(RefError::UnknownTarget(target));
2459
+ }
2460
+ current.target = target;
2461
+ current.version += 1;
2462
+ current.authority = authority;
2463
+ current.updated_at_ms = current_time_ms();
2464
+ if let Some(metadata) = metadata {
2465
+ current.metadata = metadata;
2466
+ }
2467
+ self.write_volatile_ref(&current);
2468
+ Ok(current)
2469
+ }
1911
2470
  }
1912
- self.write_ref(&current)?;
1913
- Ok(current)
1914
2471
  }
1915
2472
 
1916
2473
  /// Delete a reference, optionally enforcing the current version.
2474
+ ///
2475
+ /// On the durable backing this is one fenced FeltDB transaction, and a
2476
+ /// repeat of an identical delete that already committed returns `Ok`.
1917
2477
  pub fn delete_ref(
1918
2478
  &self,
1919
2479
  namespace: &str,
@@ -1923,17 +2483,29 @@ impl StateStore {
1923
2483
  if !valid_ref_component(namespace) || !valid_ref_component(name) {
1924
2484
  return Err(RefError::InvalidIdentifier);
1925
2485
  }
1926
- let _guard = self.mutation_lock.lock().unwrap();
1927
- let current = self.get_ref(namespace, name).ok_or(RefError::NotFound)?;
1928
- if let Some(expected) = expected_version {
1929
- if current.version != expected {
1930
- return Err(RefError::VersionConflict {
1931
- expected,
1932
- current: current.version,
1933
- });
2486
+ match &self.backing {
2487
+ Backing::Durable(feltdb) => {
2488
+ durable::delete_ref(feltdb, namespace, name, expected_version)
2489
+ }
2490
+ Backing::Volatile(state) => {
2491
+ let _guard = self.mutation_lock.lock().unwrap();
2492
+ let current = self.get_ref(namespace, name).ok_or(RefError::NotFound)?;
2493
+ if let Some(expected) = expected_version {
2494
+ if current.version != expected {
2495
+ return Err(RefError::VersionConflict {
2496
+ expected,
2497
+ current: current.version,
2498
+ });
2499
+ }
2500
+ }
2501
+ state
2502
+ .lock()
2503
+ .unwrap()
2504
+ .refs
2505
+ .remove(&(namespace.to_string(), name.to_string()));
2506
+ Ok(())
1934
2507
  }
1935
2508
  }
1936
- self.remove_ref(namespace, name)
1937
2509
  }
1938
2510
 
1939
2511
  /// Enumerate refs, optionally scoped to one namespace.
@@ -1991,95 +2563,122 @@ impl StateStore {
1991
2563
  Ok(projected)
1992
2564
  }
1993
2565
 
1994
- /// Collect revisions and objects unreachable from current refs.
2566
+ /// Collect revisions and objects that no root can reach.
2567
+ ///
2568
+ /// **Roots** are: every ref target; every resource's latest revision (each
2569
+ /// revision at the resource's highest sequence, and the head the record
2570
+ /// write path will mint its next revision from); and every revision
2571
+ /// younger than 15 minutes (`GC_GRACE_MS`). **Reachable** is the roots and all their
2572
+ /// ancestors by `parent_id`, because history, diff, rollback and replicated
2573
+ /// reconstruction all walk that chain. Only a revision that is old, named
2574
+ /// by no ref, not latest, and an ancestor of none of those is collected —
2575
+ /// in practice, an abandoned fork. Retention (`keep_last`) is unaffected:
2576
+ /// it is positional and runs at the mint, not here.
2577
+ ///
2578
+ /// On the durable backing every deletion commits in one FeltDB transaction
2579
+ /// fenced on the state generation, which every ref change and every
2580
+ /// committed revision advances. A concurrent change aborts the collection
2581
+ /// and it is re-planned from fresh reads; see [`durable`].
1995
2582
  pub fn collect_unreachable(&self) -> Result<GarbageCollectionReport, GarbageCollectionError> {
1996
- let _guard = self.mutation_lock.lock().unwrap();
2583
+ self.collect_unreachable_at(current_time_ms())
2584
+ }
2585
+
2586
+ /// [`collect_unreachable`](Self::collect_unreachable) with an explicit
2587
+ /// clock, so the grace period can be tested without waiting for it.
2588
+ pub(crate) fn collect_unreachable_at(
2589
+ &self,
2590
+ now_ms: u64,
2591
+ ) -> Result<GarbageCollectionReport, GarbageCollectionError> {
2592
+ match &self.backing {
2593
+ Backing::Volatile(state) => {
2594
+ let _guard = self.mutation_lock.lock().unwrap();
2595
+ let report = self.plan_collection(now_ms, &[]);
2596
+ let mut state = state.lock().unwrap();
2597
+ for id in &report.collected_revisions {
2598
+ state.revisions.remove(id);
2599
+ }
2600
+ for id in &report.collected_objects {
2601
+ state.objects.remove(id);
2602
+ }
2603
+ Ok(report)
2604
+ }
2605
+ Backing::Durable(feltdb) => durable::collect(self, feltdb, now_ms),
2606
+ }
2607
+ }
2608
+
2609
+ /// What a collection at `now_ms` would keep and delete, from one read of
2610
+ /// refs and revisions. `extra_roots` are heads known outside `StateStore`.
2611
+ fn plan_collection(&self, now_ms: u64, extra_roots: &[StateId]) -> GarbageCollectionReport {
1997
2612
  let roots = self.list_refs(None);
2613
+ let revisions = self.all_revisions();
2614
+ let by_id: HashMap<StateId, &StateRevision> =
2615
+ revisions.iter().map(|revision| (revision.id.clone(), revision)).collect();
2616
+
2617
+ let mut latest: HashMap<&str, u64> = HashMap::new();
2618
+ for revision in &revisions {
2619
+ let entry = latest.entry(revision.resource.as_str()).or_insert(revision.sequence);
2620
+ if revision.sequence > *entry {
2621
+ *entry = revision.sequence;
2622
+ }
2623
+ }
2624
+
2625
+ let mut pending: Vec<StateId> = roots.iter().map(|reference| reference.target.clone()).collect();
2626
+ pending.extend(extra_roots.iter().cloned());
2627
+ for revision in &revisions {
2628
+ let is_latest = latest.get(revision.resource.as_str()) == Some(&revision.sequence);
2629
+ let is_young = now_ms.saturating_sub(revision.timestamp_ms) < GC_GRACE_MS;
2630
+ if is_latest || is_young {
2631
+ pending.push(revision.id.clone());
2632
+ }
2633
+ }
2634
+
1998
2635
  let mut reachable_revisions = BTreeSet::new();
1999
2636
  let mut reachable_objects = BTreeSet::new();
2000
- let mut pending: Vec<StateId> = roots.iter().map(|reference| reference.target.clone()).collect();
2001
2637
  while let Some(id) = pending.pop() {
2638
+ let Some(revision) = by_id.get(&id) else {
2639
+ // A root or parent that is not held (expired by retention, or
2640
+ // never committed) ends the walk; it is not an error.
2641
+ continue;
2642
+ };
2002
2643
  if !reachable_revisions.insert(id.clone()) {
2003
2644
  continue;
2004
2645
  }
2005
- let Some(revision) = self.get(&id) else {
2006
- continue;
2007
- };
2008
2646
  if let Some(parent_id) = revision.parent_id.clone() {
2009
2647
  pending.push(parent_id);
2010
2648
  }
2011
- for object_id in collect_referenced_objects(&revision) {
2012
- reachable_objects.insert(object_id);
2013
- }
2014
- }
2015
-
2016
- let mut all_revisions: Vec<StateId> = self.all_revisions().into_iter().map(|r| r.id).collect();
2017
- all_revisions.sort();
2018
- let mut collected_revisions = Vec::new();
2019
- for revision_id in all_revisions {
2020
- if !reachable_revisions.contains(&revision_id) {
2021
- self.remove(&revision_id)
2022
- .map_err(|error| GarbageCollectionError::Storage(error.to_string()))?;
2023
- collected_revisions.push(revision_id);
2024
- }
2649
+ reachable_objects.extend(collect_referenced_objects(revision));
2025
2650
  }
2026
2651
 
2027
- let mut all_objects: Vec<StateId> = self.all_objects().into_iter().map(|o| o.id).collect();
2028
- all_objects.sort();
2029
- let mut collected_objects = Vec::new();
2030
- for object_id in all_objects {
2031
- if !reachable_objects.contains(&object_id) {
2032
- self.remove_object(&object_id)?;
2033
- collected_objects.push(object_id);
2034
- }
2035
- }
2652
+ let mut collected_revisions: Vec<StateId> = revisions
2653
+ .iter()
2654
+ .map(|revision| revision.id.clone())
2655
+ .filter(|id| !reachable_revisions.contains(id))
2656
+ .collect();
2657
+ collected_revisions.sort();
2658
+ let mut collected_objects: Vec<StateId> = self
2659
+ .all_objects()
2660
+ .into_iter()
2661
+ .map(|object| object.id)
2662
+ .filter(|id| !reachable_objects.contains(id))
2663
+ .collect();
2664
+ collected_objects.sort();
2036
2665
 
2037
- let mut reachable_revisions_out: Vec<_> = reachable_revisions.into_iter().collect();
2038
- reachable_revisions_out.sort();
2039
- let mut reachable_objects_out: Vec<_> = reachable_objects.into_iter().collect();
2040
- reachable_objects_out.sort();
2041
- Ok(GarbageCollectionReport {
2666
+ GarbageCollectionReport {
2042
2667
  roots,
2043
- reachable_revisions: reachable_revisions_out,
2044
- reachable_objects: reachable_objects_out,
2668
+ reachable_revisions: reachable_revisions.into_iter().collect(),
2669
+ reachable_objects: reachable_objects.into_iter().collect(),
2045
2670
  collected_revisions,
2046
2671
  collected_objects,
2047
- })
2048
- }
2049
-
2050
- fn write_ref(&self, reference: &StateRef) -> Result<(), RefError> {
2051
- match &self.backing {
2052
- Backing::Volatile(state) => {
2053
- state.lock().unwrap().refs.insert(
2054
- (reference.namespace.clone(), reference.name.clone()),
2055
- reference.clone(),
2056
- );
2057
- }
2058
- Backing::Durable(feltdb) => {
2059
- feltdb
2060
- .insert(&ref_key(&reference.namespace, &reference.name), reference.clone())
2061
- .map_err(|error| RefError::Storage(error.to_string()))?;
2062
- }
2063
2672
  }
2064
- Ok(())
2065
2673
  }
2066
2674
 
2067
- fn remove_ref(&self, namespace: &str, name: &str) -> Result<(), RefError> {
2068
- match &self.backing {
2069
- Backing::Volatile(state) => {
2070
- state
2071
- .lock()
2072
- .unwrap()
2073
- .refs
2074
- .remove(&(namespace.to_string(), name.to_string()));
2075
- }
2076
- Backing::Durable(feltdb) => {
2077
- feltdb
2078
- .delete(&ref_key(namespace, name))
2079
- .map_err(|error| RefError::Storage(error.to_string()))?;
2080
- }
2675
+ fn write_volatile_ref(&self, reference: &StateRef) {
2676
+ if let Backing::Volatile(state) = &self.backing {
2677
+ state.lock().unwrap().refs.insert(
2678
+ (reference.namespace.clone(), reference.name.clone()),
2679
+ reference.clone(),
2680
+ );
2081
2681
  }
2082
- Ok(())
2083
2682
  }
2084
2683
 
2085
2684
  fn all_revisions(&self) -> Vec<StateRevision> {
@@ -2234,20 +2833,6 @@ impl StateStore {
2234
2833
  Ok(())
2235
2834
  }
2236
2835
 
2237
- fn remove_object(&self, id: &StateId) -> Result<(), GarbageCollectionError> {
2238
- match &self.backing {
2239
- Backing::Volatile(state) => {
2240
- state.lock().unwrap().objects.remove(id);
2241
- }
2242
- Backing::Durable(feltdb) => {
2243
- feltdb
2244
- .delete(&object_key(id))
2245
- .map_err(|error| GarbageCollectionError::Storage(error.to_string()))?;
2246
- }
2247
- }
2248
- Ok(())
2249
- }
2250
-
2251
2836
  fn retention_state(&self, resource: &str) -> RetentionState {
2252
2837
  match &self.backing {
2253
2838
  Backing::Volatile(state) => state
@@ -2316,6 +2901,120 @@ mod tests {
2316
2901
  use super::*;
2317
2902
  use serde_json::json;
2318
2903
 
2904
+ // -- garbage collection eligibility, with an explicit clock -------------
2905
+
2906
+ /// `A(0) → B(1) → C(2)` on one resource, plus a fork `F(1)` off `A`.
2907
+ fn forked(store: &StateStore) -> (StateRevision, StateRevision, StateRevision, StateRevision) {
2908
+ let a = store.create("doc:gc", json!({"n": "a"}).to_string(), "t".into()).unwrap();
2909
+ let b = store.commit(json!({"n": "b"}).to_string(), &a, "t".into()).unwrap();
2910
+ let c = store.commit(json!({"n": "c"}).to_string(), &b, "t".into()).unwrap();
2911
+ let f = store.commit(json!({"n": "f"}).to_string(), &a, "t".into()).unwrap();
2912
+ (a, b, c, f)
2913
+ }
2914
+
2915
+ #[test]
2916
+ fn gc_grace_boundary_is_exclusive() {
2917
+ let store = StateStore::new_volatile();
2918
+ let (_, _, _, f) = forked(&store);
2919
+ let inside = store.collect_unreachable_at(f.timestamp_ms + GC_GRACE_MS - 1).unwrap();
2920
+ assert!(inside.collected_revisions.is_empty(), "younger than the grace: a root");
2921
+ let outside = store.collect_unreachable_at(f.timestamp_ms + GC_GRACE_MS).unwrap();
2922
+ assert_eq!(outside.collected_revisions, vec![f.id.clone()]);
2923
+ assert!(!store.exists(&f.id));
2924
+ }
2925
+
2926
+ #[test]
2927
+ fn gc_keeps_latest_revisions_refs_and_all_their_ancestors() {
2928
+ for store in [StateStore::new_volatile(), durable_store().1] {
2929
+ let (a, b, c, f) = forked(&store);
2930
+ let g = store.commit(json!({"n": "g"}).to_string(), &f, "t".into()).unwrap();
2931
+ // g ties c at the highest sequence (2): both are "latest".
2932
+ let far = c.timestamp_ms + 365 * 24 * 60 * 60 * 1000;
2933
+ let report = store.collect_unreachable_at(far).unwrap();
2934
+ assert!(report.collected_revisions.is_empty(), "every revision is a root or an ancestor");
2935
+
2936
+ // A later revision on the main line makes f and g a stale fork …
2937
+ let d = store.commit(json!({"n": "d"}).to_string(), &c, "t".into()).unwrap();
2938
+ // … but a ref on g keeps g and its ancestor f.
2939
+ store
2940
+ .create_ref("candidates", "g", g.id.clone(), "t".into(), BTreeMap::new())
2941
+ .unwrap();
2942
+ assert!(store.collect_unreachable_at(far).unwrap().collected_revisions.is_empty());
2943
+
2944
+ store.delete_ref("candidates", "g", None).unwrap();
2945
+ let mut collected = store.collect_unreachable_at(far).unwrap().collected_revisions;
2946
+ collected.sort();
2947
+ let mut expected = vec![f.id.clone(), g.id.clone()];
2948
+ expected.sort();
2949
+ assert_eq!(collected, expected, "only the unreferenced stale fork");
2950
+ for kept in [&a, &b, &c, &d] {
2951
+ assert!(store.exists(&kept.id));
2952
+ }
2953
+ // The fork's content objects went with it; the kept ones stayed.
2954
+ let objects: BTreeSet<StateId> = store.all_objects().into_iter().map(|o| o.id).collect();
2955
+ for kept in [&a, &b, &c, &d] {
2956
+ assert!(objects.contains(&kept.content_id), "object of a kept revision");
2957
+ }
2958
+ for gone in [&f, &g] {
2959
+ assert!(!objects.contains(&gone.content_id), "object of a collected revision");
2960
+ }
2961
+ }
2962
+ }
2963
+
2964
+ fn durable_store() -> (tempfile::TempDir, StateStore, Arc<crate::FeltDb>) {
2965
+ let dir = tempfile::TempDir::new().unwrap();
2966
+ let db = Arc::new(crate::FeltDb::open(dir.path().join("gc.log")).unwrap());
2967
+ let store = StateStore::with_feltdb(db.clone()).unwrap();
2968
+ (dir, store, db)
2969
+ }
2970
+
2971
+ /// The record write path mints from its own head, which an explicit
2972
+ /// `StateStore::commit` fork can overtake by sequence. That head must stay
2973
+ /// a root, or the next record write would mint a child of a deleted parent.
2974
+ #[test]
2975
+ fn gc_keeps_the_write_path_head_even_when_a_fork_is_longer() {
2976
+ let (_dir, store, db) = durable_store();
2977
+ db.insert("tasks:1", json!({"n": 0})).unwrap();
2978
+ db.update("tasks:1", json!({"n": 1})).unwrap();
2979
+ let resource = store.resources().into_iter().find(|r| r.contains("tasks")).unwrap();
2980
+ let history = store.history_of(&resource);
2981
+ assert_eq!(history.len(), 2, "the write path minted two revisions");
2982
+ let (w0, w1) = (history[0].clone(), history[1].clone());
2983
+
2984
+ // A longer explicit fork off w0: x(1) → x2(2) overtakes w1(1).
2985
+ let x = store.commit(json!({"x": 1}).to_string(), &w0, "t".into()).unwrap();
2986
+ let x2 = store.commit(json!({"x": 2}).to_string(), &x, "t".into()).unwrap();
2987
+ assert_eq!(store.head_of(&resource).unwrap().id, x2.id);
2988
+ assert!(db.revision_head_ids().contains(&w1.id), "the write path still mints from w1");
2989
+
2990
+ let far = x2.timestamp_ms + 365 * 24 * 60 * 60 * 1000;
2991
+ let report = store.collect_unreachable_at(far).unwrap();
2992
+ assert!(!report.collected_revisions.contains(&w1.id), "w1 is the write path's head");
2993
+ assert!(store.exists(&w1.id));
2994
+
2995
+ db.update("tasks:1", json!({"n": 2})).unwrap();
2996
+ let w2 = store
2997
+ .history_of(&resource)
2998
+ .into_iter()
2999
+ .find(|r| r.parent_id.as_ref() == Some(&w1.id))
3000
+ .expect("the next write minted a child of w1");
3001
+ assert_eq!(store.parent_of(&w2.id), Some(ParentLookup::Revision(Box::new(w1))));
3002
+ }
3003
+
3004
+ /// The generation row, the incarnation rows and the ref rows the durable
3005
+ /// path writes are invisible to the history and ref listings.
3006
+ #[test]
3007
+ fn internal_rows_do_not_appear_as_history_or_refs() {
3008
+ let (_dir, store, _db) = durable_store();
3009
+ let (a, _, _, _) = forked(&store);
3010
+ store.create_ref("heads", "main", a.id.clone(), "t".into(), BTreeMap::new()).unwrap();
3011
+ store.delete_ref("heads", "main", Some(1)).unwrap();
3012
+ store.create_ref("heads", "main", a.id.clone(), "t".into(), BTreeMap::new()).unwrap();
3013
+ assert_eq!(store.list_refs(None).len(), 1);
3014
+ assert_eq!(store.resources(), vec!["doc:gc".to_string()]);
3015
+ assert_eq!(store.history_of("doc:gc").len(), 4);
3016
+ }
3017
+
2319
3018
  #[test]
2320
3019
  fn test_state_id_deterministic() {
2321
3020
  let json = r#"{"name":"Alice","age":30}"#;