@feltdb/core 0.12.0 → 0.12.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 (138) 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 +28 -0
  5. package/dist/cli/commands.js +88 -43
  6. package/dist/collection.d.ts +41 -4
  7. package/dist/collection.d.ts.map +1 -1
  8. package/dist/collection.js +116 -47
  9. package/dist/composition.d.ts.map +1 -1
  10. package/dist/composition.js +11 -1
  11. package/dist/create/build-identity.json +28 -0
  12. package/dist/create/cli.js +3 -3
  13. package/dist/create/create.js +17 -19
  14. package/dist/create/package-versions.js +1 -1
  15. package/dist/create/server-source/Cargo.lock +18 -3
  16. package/dist/create/server-source/Cargo.toml +4 -2
  17. package/dist/create/server-source/crates/feltdb/Cargo.toml +20 -0
  18. package/dist/create/server-source/crates/feltdb/LICENSE +21 -0
  19. package/dist/create/server-source/crates/feltdb/README.md +75 -0
  20. package/dist/create/server-source/crates/feltdb/src/agent_state.rs +320 -0
  21. package/dist/create/server-source/crates/feltdb/src/agent_transition_contract.rs +1545 -0
  22. package/dist/create/server-source/crates/feltdb/src/authority.rs +145 -15
  23. package/dist/create/server-source/crates/feltdb/src/lib.rs +238 -23
  24. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +4 -1
  25. package/dist/create/server-source/crates/feltdb/src/state_model.rs +850 -155
  26. package/dist/create/server-source/crates/feltdb/tests/agent_state_native.rs +1188 -0
  27. package/dist/create/server-source/crates/feltdb/tests/agent_transition_contract_conformance.rs +2884 -0
  28. package/dist/create/server-source/crates/feltdb/tests/authority_transaction_identity.rs +534 -0
  29. package/dist/create/server-source/crates/feltdb/tests/fixtures/agent_transition_corpus.json +23340 -0
  30. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +31 -13
  31. package/dist/create/server-source/crates/feltdb/tests/state_ref_authority.rs +918 -0
  32. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +15 -2
  33. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +29 -0
  34. package/dist/create/server-source/crates/feltdb-client/LICENSE +21 -0
  35. package/dist/create/server-source/crates/feltdb-client/README.md +70 -0
  36. package/dist/create/server-source/crates/feltdb-client/src/application.rs +527 -0
  37. package/dist/create/server-source/crates/feltdb-client/src/authority.rs +679 -0
  38. package/dist/create/server-source/crates/feltdb-client/src/control.rs +292 -0
  39. package/dist/create/server-source/crates/feltdb-client/src/error.rs +227 -0
  40. package/dist/create/server-source/crates/feltdb-client/src/events.rs +146 -0
  41. package/dist/create/server-source/crates/feltdb-client/src/lib.rs +466 -0
  42. package/dist/create/server-source/crates/feltdb-client/tests/fixtures/README.md +4 -0
  43. package/dist/create/server-source/crates/feltdb-client/tests/fixtures/private-ca.pem +20 -0
  44. package/dist/create/server-source/crates/feltdb-client/tests/fixtures/server-key.pem +28 -0
  45. package/dist/create/server-source/crates/feltdb-client/tests/fixtures/server.pem +20 -0
  46. package/dist/create/server-source/crates/feltdb-client/tests/private_ca.rs +95 -0
  47. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +24 -1
  48. package/dist/create/server-source/crates/feltdb-server/LICENSE +21 -0
  49. package/dist/create/server-source/crates/feltdb-server/README.md +12 -1
  50. package/dist/create/server-source/crates/feltdb-server/benches/query_shape_observer.rs +136 -0
  51. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +3 -0
  52. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +6 -2
  53. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +21 -0
  54. package/dist/create/server-source/crates/feltdb-server/src/main.rs +75 -8
  55. package/dist/create/server-source/crates/feltdb-server/src/query_shape.rs +1137 -0
  56. package/dist/create/server-source/crates/feltdb-server/tests/fixtures/query-shape-trace.expected.json +300 -0
  57. package/dist/create/server-source/crates/feltdb-server/tests/fixtures/query-shape-trace.json +25 -0
  58. package/dist/create/server-source/crates/feltdb-server/tests/query_shape_trace.rs +128 -0
  59. package/dist/create/server-source/crates/feltdb-server/tests/rust_client.rs +683 -0
  60. package/dist/create/server-source/crates/feltdb-wasm/Cargo.toml +3 -1
  61. package/dist/create/template/default-project/README.md +12 -7
  62. package/dist/create/template/default-project/src/pages/Dashboard.tsx +1 -1
  63. package/dist/create/template/dot-feltdb-README.md +3 -3
  64. package/dist/db.d.ts.map +1 -1
  65. package/dist/db.js +43 -17
  66. package/dist/development-runtime-bridge.d.ts.map +1 -1
  67. package/dist/development-runtime-bridge.js +22 -1
  68. package/dist/embedded-transaction.d.ts +75 -8
  69. package/dist/embedded-transaction.d.ts.map +1 -1
  70. package/dist/embedded-transaction.js +124 -18
  71. package/dist/error-codes.d.ts +2 -0
  72. package/dist/error-codes.d.ts.map +1 -1
  73. package/dist/error-codes.js +2 -0
  74. package/dist/feltdb.d.ts +1 -11
  75. package/dist/feltdb.d.ts.map +1 -1
  76. package/dist/file-db.d.ts +6 -0
  77. package/dist/file-db.d.ts.map +1 -1
  78. package/dist/file-db.js +43 -11
  79. package/dist/index-core.d.ts +1 -0
  80. package/dist/index-core.d.ts.map +1 -1
  81. package/dist/index-core.js +1 -0
  82. package/dist/indexeddb-db.d.ts.map +1 -1
  83. package/dist/indexeddb-db.js +65 -41
  84. package/dist/memory-db.d.ts.map +1 -1
  85. package/dist/memory-db.js +29 -8
  86. package/dist/studio/{KeyManagementPanel-DZuSeWBK.js → KeyManagementPanel-D5FMvvU9.js} +1 -0
  87. package/dist/studio/app.d.ts.map +1 -1
  88. package/dist/studio/components/GlobalSearch.d.ts.map +1 -1
  89. package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
  90. package/dist/studio/components/KeyManagementPanel.js +1 -1
  91. package/dist/studio/components/RouteErrorBoundary.d.ts +5 -0
  92. package/dist/studio/components/RouteErrorBoundary.d.ts.map +1 -0
  93. package/dist/studio/components/index.js +2 -2
  94. package/dist/studio/{components-BHTKlUgT.js → components-B4eKIUN5.js} +3 -2
  95. package/dist/studio/control-plane/Agents.d.ts.map +1 -1
  96. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  97. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  98. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  99. package/dist/studio/control-plane/Overview.d.ts.map +1 -1
  100. package/dist/studio/control-plane/Schema.d.ts.map +1 -1
  101. package/dist/studio/control-plane/State.d.ts +11 -0
  102. package/dist/studio/control-plane/State.d.ts.map +1 -1
  103. package/dist/studio/control-plane/context.d.ts +4 -1
  104. package/dist/studio/control-plane/context.d.ts.map +1 -1
  105. package/dist/studio/control-plane/index.d.ts +1 -1
  106. package/dist/studio/control-plane/index.d.ts.map +1 -1
  107. package/dist/studio/control-plane/index.js +2 -2
  108. package/dist/studio/control-plane/primitives.d.ts +22 -2
  109. package/dist/studio/control-plane/primitives.d.ts.map +1 -1
  110. package/dist/studio/control-plane-fxMcJn74.js +4173 -0
  111. package/dist/studio/index.js +307 -235
  112. package/dist/studio/studio.css +1 -1
  113. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  114. package/dist/studio/utils/control-plane-api.js +4 -3
  115. package/dist/studio/utils/service-api.d.ts.map +1 -1
  116. package/dist/studio/utils/service-api.js +5 -4
  117. package/dist/studio-app/assets/{feltdb_wasm-DYfWAV0E.js → feltdb_wasm-BatwwogY.js} +1 -1
  118. package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
  119. package/dist/studio-app/assets/index-CiC8VY-a.css +1 -0
  120. package/dist/studio-app/assets/index-DoVvbIay.js +30 -0
  121. package/dist/studio-app/index.html +2 -2
  122. package/dist/telemetry.d.ts +1 -0
  123. package/dist/telemetry.d.ts.map +1 -1
  124. package/dist/telemetry.js +4 -3
  125. package/dist/transaction-identity.d.ts +62 -0
  126. package/dist/transaction-identity.d.ts.map +1 -0
  127. package/dist/transaction-identity.js +138 -0
  128. package/dist/transaction.d.ts +50 -5
  129. package/dist/transaction.d.ts.map +1 -1
  130. package/dist/transaction.js +28 -0
  131. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  132. package/dist/wasm/package.json +1 -1
  133. package/package.json +2 -2
  134. package/dist/create/server-source/crates/feltdb-wasm/test.db +0 -0
  135. package/dist/studio/control-plane-DP8ljTDO.js +0 -3207
  136. package/dist/studio-app/assets/feltdb_wasm_bg-DHiOUhoQ.wasm +0 -0
  137. package/dist/studio-app/assets/index-6f6KVx5R.js +0 -30
  138. package/dist/studio-app/assets/index-BrsF-kWL.css +0 -1
@@ -1371,6 +1371,549 @@ 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::TransactionIdPayloadMismatch { .. } => Refusal::OtherRequest,
1607
+ other => Refusal::Failed(other.to_string()),
1608
+ }
1609
+ }
1610
+
1611
+ fn storage(error: impl std::fmt::Display) -> RefError {
1612
+ RefError::Storage(error.to_string())
1613
+ }
1614
+
1615
+ fn contended(what: &str) -> String {
1616
+ format!(
1617
+ "{what} was refused by concurrent changes on {ATTEMPTS} consecutive attempts; nothing was applied"
1618
+ )
1619
+ }
1620
+
1621
+ fn row(db: &FeltDb, key: &str) -> Result<Option<Value>, RefError> {
1622
+ db.get::<Value>(key).map_err(storage)
1623
+ }
1624
+
1625
+ fn committed_payload(db: &FeltDb, id: &str) -> Result<Option<String>, RefError> {
1626
+ db.applied_transaction_payload_hash(id).map_err(storage)
1627
+ }
1628
+
1629
+ fn decode(row: Value) -> Result<StateRef, RefError> {
1630
+ serde_json::from_value(row).map_err(storage)
1631
+ }
1632
+
1633
+ fn commit(
1634
+ db: &FeltDb,
1635
+ id: &str,
1636
+ payload: Option<&str>,
1637
+ pins: &[RecordPrecondition],
1638
+ mutations: &[AtomicMutation],
1639
+ ) -> Result<(), Refusal> {
1640
+ db.apply_atomic_transaction_guarded(id, payload, None, &[], pins, mutations, None)
1641
+ .map(|_| ())
1642
+ .map_err(refusal)
1643
+ }
1644
+
1645
+ pub(super) fn create_ref(
1646
+ db: &FeltDb,
1647
+ namespace: &str,
1648
+ name: &str,
1649
+ target: StateId,
1650
+ authority: String,
1651
+ metadata: BTreeMap<String, Value>,
1652
+ ) -> Result<StateRef, RefError> {
1653
+ let ref_k = ref_key(namespace, name);
1654
+ let inc_k = incarnation_key(namespace, name);
1655
+ let target_k = revision_key(&target);
1656
+ let payload = payload_hash(&json!({
1657
+ "target": target.as_hex(),
1658
+ "authority": authority,
1659
+ "metadata": metadata,
1660
+ }));
1661
+ for _ in 0..ATTEMPTS {
1662
+ let incarnation_row = row(db, &inc_k)?;
1663
+ if let Some(existing) = row(db, &ref_k)? {
1664
+ // The create that made this incarnation, repeated, is that create.
1665
+ let id = ref_transaction_id("create", namespace, name, incarnation_of(&existing), None);
1666
+ if committed_payload(db, &id)?.as_deref() == Some(payload.as_str()) {
1667
+ return decode(existing);
1668
+ }
1669
+ return Err(RefError::AlreadyExists);
1670
+ }
1671
+ if row(db, &target_k)?.is_none() {
1672
+ return Err(RefError::UnknownTarget(target));
1673
+ }
1674
+ let incarnation = incarnation_in(incarnation_row.as_ref()) + 1;
1675
+ let id = ref_transaction_id("create", namespace, name, incarnation, None);
1676
+ let reference = StateRef {
1677
+ namespace: namespace.to_string(),
1678
+ name: name.to_string(),
1679
+ target: target.clone(),
1680
+ version: 1,
1681
+ authority: authority.clone(),
1682
+ updated_at_ms: current_time_ms(),
1683
+ metadata: metadata.clone(),
1684
+ };
1685
+ let pins = [
1686
+ unchanged(&ref_k, None),
1687
+ unchanged(&inc_k, incarnation_row.as_ref()),
1688
+ present(&target_k),
1689
+ ];
1690
+ let mutations = [
1691
+ put(&ref_k, ref_row(&reference, incarnation)),
1692
+ put(&inc_k, json!({ "incarnation": incarnation })),
1693
+ advance_generation(&id),
1694
+ ];
1695
+ match commit(db, &id, Some(&payload), &pins, &mutations) {
1696
+ Ok(()) => return Ok(reference),
1697
+ Err(Refusal::Changed | Refusal::OtherRequest) => continue,
1698
+ Err(Refusal::Failed(message)) => return Err(RefError::Storage(message)),
1699
+ }
1700
+ }
1701
+ Err(RefError::Storage(contended("ref create")))
1702
+ }
1703
+
1704
+ pub(super) fn compare_and_swap_ref(
1705
+ db: &FeltDb,
1706
+ namespace: &str,
1707
+ name: &str,
1708
+ expected_version: u64,
1709
+ target: StateId,
1710
+ authority: String,
1711
+ metadata: Option<BTreeMap<String, Value>>,
1712
+ ) -> Result<StateRef, RefError> {
1713
+ let ref_k = ref_key(namespace, name);
1714
+ let inc_k = incarnation_key(namespace, name);
1715
+ let target_k = revision_key(&target);
1716
+ let payload = payload_hash(&json!({
1717
+ "target": target.as_hex(),
1718
+ "authority": authority,
1719
+ "metadata": metadata,
1720
+ }));
1721
+ for _ in 0..ATTEMPTS {
1722
+ let incarnation_row = row(db, &inc_k)?;
1723
+ let Some(ref_read) = row(db, &ref_k)? else {
1724
+ return Err(RefError::NotFound);
1725
+ };
1726
+ let incarnation = incarnation_of(&ref_read);
1727
+ let current = decode(ref_read.clone())?;
1728
+ let id = ref_transaction_id("cas", namespace, name, incarnation, Some(expected_version));
1729
+ if current.version != expected_version {
1730
+ // Either this exact request already committed (a retry), or
1731
+ // another one did: the payload bound to the id says which.
1732
+ if committed_payload(db, &id)?.as_deref() == Some(payload.as_str()) {
1733
+ if current.version == expected_version + 1 && current.target == target {
1734
+ return Ok(current);
1735
+ }
1736
+ // Committed, and the ref has moved on since. Describe the
1737
+ // transition this request made; `updated_at_ms` is now.
1738
+ return Ok(StateRef {
1739
+ version: expected_version + 1,
1740
+ target,
1741
+ authority,
1742
+ updated_at_ms: current_time_ms(),
1743
+ metadata: metadata.unwrap_or(current.metadata),
1744
+ ..current
1745
+ });
1746
+ }
1747
+ return Err(RefError::VersionConflict {
1748
+ expected: expected_version,
1749
+ current: current.version,
1750
+ });
1751
+ }
1752
+ if row(db, &target_k)?.is_none() {
1753
+ return Err(RefError::UnknownTarget(target));
1754
+ }
1755
+ let next = StateRef {
1756
+ version: expected_version + 1,
1757
+ target: target.clone(),
1758
+ authority: authority.clone(),
1759
+ updated_at_ms: current_time_ms(),
1760
+ metadata: metadata.clone().unwrap_or_else(|| current.metadata.clone()),
1761
+ ..current
1762
+ };
1763
+ let pins = [
1764
+ unchanged(&ref_k, Some(&ref_read)),
1765
+ unchanged(&inc_k, incarnation_row.as_ref()),
1766
+ present(&target_k),
1767
+ ];
1768
+ let mutations = [put(&ref_k, ref_row(&next, incarnation)), advance_generation(&id)];
1769
+ match commit(db, &id, Some(&payload), &pins, &mutations) {
1770
+ Ok(()) => return Ok(next),
1771
+ Err(Refusal::Changed | Refusal::OtherRequest) => continue,
1772
+ Err(Refusal::Failed(message)) => return Err(RefError::Storage(message)),
1773
+ }
1774
+ }
1775
+ Err(RefError::Storage(contended("ref compare-and-swap")))
1776
+ }
1777
+
1778
+ pub(super) fn delete_ref(
1779
+ db: &FeltDb,
1780
+ namespace: &str,
1781
+ name: &str,
1782
+ expected_version: Option<u64>,
1783
+ ) -> Result<(), RefError> {
1784
+ let ref_k = ref_key(namespace, name);
1785
+ let inc_k = incarnation_key(namespace, name);
1786
+ let payload = payload_hash(&json!({ "expected_version": expected_version }));
1787
+ for _ in 0..ATTEMPTS {
1788
+ let incarnation_row = row(db, &inc_k)?;
1789
+ let Some(ref_read) = row(db, &ref_k)? else {
1790
+ // A repeat of a delete that already removed this incarnation.
1791
+ let id = ref_transaction_id(
1792
+ "delete",
1793
+ namespace,
1794
+ name,
1795
+ incarnation_in(incarnation_row.as_ref()),
1796
+ None,
1797
+ );
1798
+ if committed_payload(db, &id)?.as_deref() == Some(payload.as_str()) {
1799
+ return Ok(());
1800
+ }
1801
+ return Err(RefError::NotFound);
1802
+ };
1803
+ let incarnation = incarnation_of(&ref_read);
1804
+ let current = decode(ref_read.clone())?;
1805
+ if let Some(expected) = expected_version {
1806
+ if current.version != expected {
1807
+ return Err(RefError::VersionConflict {
1808
+ expected,
1809
+ current: current.version,
1810
+ });
1811
+ }
1812
+ }
1813
+ let id = ref_transaction_id("delete", namespace, name, incarnation, None);
1814
+ let pins = [
1815
+ unchanged(&ref_k, Some(&ref_read)),
1816
+ unchanged(&inc_k, incarnation_row.as_ref()),
1817
+ ];
1818
+ let mutations = [
1819
+ remove(&ref_k),
1820
+ put(&inc_k, json!({ "incarnation": incarnation })),
1821
+ advance_generation(&id),
1822
+ ];
1823
+ match commit(db, &id, Some(&payload), &pins, &mutations) {
1824
+ Ok(()) => return Ok(()),
1825
+ Err(Refusal::Changed | Refusal::OtherRequest) => continue,
1826
+ Err(Refusal::Failed(message)) => return Err(RefError::Storage(message)),
1827
+ }
1828
+ }
1829
+ Err(RefError::Storage(contended("ref delete")))
1830
+ }
1831
+
1832
+ pub(super) fn commit_revision(
1833
+ db: &FeltDb,
1834
+ revision: StateRevision,
1835
+ ) -> Result<StateRevision, RevisionError> {
1836
+ let fail = |error: &dyn std::fmt::Display| RevisionError::Storage(error.to_string());
1837
+ let object = StateObject::new(revision.content.clone());
1838
+ let rev_k = revision_key(&revision.id);
1839
+ let obj_k = object_key(&object.id);
1840
+ let parent_k = revision.parent_id.as_ref().map(revision_key);
1841
+ for _ in 0..ATTEMPTS {
1842
+ if let Some(existing) = db.get::<StateRevision>(&rev_k).map_err(|e| fail(&e))? {
1843
+ let same = existing.resource == revision.resource
1844
+ && existing.content_id == revision.content_id
1845
+ && existing.parent_id == revision.parent_id
1846
+ && existing.sequence == revision.sequence;
1847
+ return if same {
1848
+ Ok(existing)
1849
+ } else {
1850
+ Err(RevisionError::AlreadyCommitted(revision.id.clone()))
1851
+ };
1852
+ }
1853
+ let mut pins = vec![unchanged(&rev_k, None)];
1854
+ if let (Some(parent), Some(parent_k)) = (&revision.parent_id, &parent_k) {
1855
+ if db.get::<Value>(parent_k).map_err(|e| fail(&e))?.is_none() {
1856
+ return Err(RevisionError::UnknownParent(parent.clone()));
1857
+ }
1858
+ pins.push(present(parent_k));
1859
+ }
1860
+ let id = format!("state-revision:{}:{}", revision.id.as_hex(), unique_suffix());
1861
+ let mut mutations = Vec::with_capacity(3);
1862
+ match db.get::<StateObject>(&obj_k).map_err(|e| fail(&e))? {
1863
+ Some(existing) if !existing.verify_integrity() => {
1864
+ return Err(RevisionError::Storage(
1865
+ "Stored state object failed integrity verification".to_string(),
1866
+ ));
1867
+ }
1868
+ Some(_) => pins.push(present(&obj_k)),
1869
+ None => {
1870
+ pins.push(unchanged(&obj_k, None));
1871
+ mutations.push(put(&obj_k, serde_json::to_value(&object).map_err(|e| fail(&e))?));
1872
+ }
1873
+ }
1874
+ mutations.push(put(&rev_k, serde_json::to_value(&revision).map_err(|e| fail(&e))?));
1875
+ mutations.push(advance_generation(&id));
1876
+ match commit(db, &id, None, &pins, &mutations) {
1877
+ Ok(()) => return Ok(revision),
1878
+ Err(Refusal::Changed | Refusal::OtherRequest) => continue,
1879
+ Err(Refusal::Failed(message)) => return Err(RevisionError::Storage(message)),
1880
+ }
1881
+ }
1882
+ Err(RevisionError::Storage(contended("revision commit")))
1883
+ }
1884
+
1885
+ pub(super) fn collect(
1886
+ store: &StateStore,
1887
+ db: &FeltDb,
1888
+ now_ms: u64,
1889
+ ) -> Result<GarbageCollectionReport, GarbageCollectionError> {
1890
+ let fail = |error: &dyn std::fmt::Display| GarbageCollectionError::Storage(error.to_string());
1891
+ for _ in 0..ATTEMPTS {
1892
+ // The generation first: everything planned below was read after it,
1893
+ // so a write that lands after this read moves it.
1894
+ let generation = db.get::<Value>(GENERATION_KEY).map_err(|e| fail(&e))?;
1895
+ let report = store.plan_collection(now_ms, &db.revision_head_ids());
1896
+ if report.collected_revisions.is_empty() && report.collected_objects.is_empty() {
1897
+ return Ok(report);
1898
+ }
1899
+ let pins = [unchanged(GENERATION_KEY, generation.as_ref())];
1900
+ let mutations: Vec<AtomicMutation> = report
1901
+ .collected_revisions
1902
+ .iter()
1903
+ .map(|id| remove(&revision_key(id)))
1904
+ .chain(report.collected_objects.iter().map(|id| remove(&object_key(id))))
1905
+ .collect();
1906
+ let id = format!("state-gc:{}", unique_suffix());
1907
+ match commit(db, &id, None, &pins, &mutations) {
1908
+ Ok(()) => return Ok(report),
1909
+ Err(Refusal::Changed | Refusal::OtherRequest) => continue,
1910
+ Err(Refusal::Failed(message)) => return Err(GarbageCollectionError::Storage(message)),
1911
+ }
1912
+ }
1913
+ Err(GarbageCollectionError::Storage(contended("garbage collection")))
1914
+ }
1915
+ }
1916
+
1374
1917
  fn collect_referenced_objects(revision: &StateRevision) -> BTreeSet<StateId> {
1375
1918
  let mut ids = BTreeSet::from([revision.content_id.clone()]);
1376
1919
  if let Some(values) = revision
@@ -1692,34 +2235,7 @@ impl StateStore {
1692
2235
  state.revisions.insert(revision.id.clone(), revision.clone());
1693
2236
  }
1694
2237
  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
- })?;
2238
+ return durable::commit_revision(feltdb, revision);
1723
2239
  }
1724
2240
  }
1725
2241
  Ok(revision)
@@ -1816,6 +2332,12 @@ impl StateStore {
1816
2332
  }
1817
2333
 
1818
2334
  /// Create a mutable reference in a namespace.
2335
+ ///
2336
+ /// On the durable backing this is one fenced FeltDB transaction (see
2337
+ /// [`durable`]): the ref is created at version 1 only if, at the moment of
2338
+ /// commit, it is still absent and its target revision is still present.
2339
+ /// Repeating an identical create that already succeeded returns the ref it
2340
+ /// created; a different create of an existing ref is `AlreadyExists`.
1819
2341
  pub fn create_ref(
1820
2342
  &self,
1821
2343
  namespace: &str,
@@ -1827,24 +2349,31 @@ impl StateStore {
1827
2349
  if !valid_ref_component(namespace) || !valid_ref_component(name) {
1828
2350
  return Err(RefError::InvalidIdentifier);
1829
2351
  }
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);
2352
+ match &self.backing {
2353
+ Backing::Durable(feltdb) => {
2354
+ durable::create_ref(feltdb, namespace, name, target, authority, metadata)
2355
+ }
2356
+ Backing::Volatile(_) => {
2357
+ let _guard = self.mutation_lock.lock().unwrap();
2358
+ if !self.exists(&target) {
2359
+ return Err(RefError::UnknownTarget(target));
2360
+ }
2361
+ if self.get_ref(namespace, name).is_some() {
2362
+ return Err(RefError::AlreadyExists);
2363
+ }
2364
+ let reference = StateRef {
2365
+ namespace: namespace.to_string(),
2366
+ name: name.to_string(),
2367
+ target,
2368
+ version: 1,
2369
+ authority,
2370
+ updated_at_ms: current_time_ms(),
2371
+ metadata,
2372
+ };
2373
+ self.write_volatile_ref(&reference);
2374
+ Ok(reference)
2375
+ }
1836
2376
  }
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
2377
  }
1849
2378
 
1850
2379
  /// Read a reference.
@@ -1879,6 +2408,17 @@ impl StateStore {
1879
2408
  }
1880
2409
 
1881
2410
  /// Compare-and-swap move for a reference.
2411
+ ///
2412
+ /// On the durable backing this is one fenced FeltDB transaction: it commits
2413
+ /// only if, at the moment of commit, the ref is still at
2414
+ /// `expected_version` and the target revision is still present, whichever
2415
+ /// `StateStore` or `FeltDb` handle the competing writes came through.
2416
+ ///
2417
+ /// The request `(ref incarnation, expected_version, target, authority,
2418
+ /// metadata)` is idempotent: repeating one that already committed — after a
2419
+ /// lost response, a restart or a compaction — returns `Ok` and does not
2420
+ /// advance the ref again. Any other request for the same expected version
2421
+ /// is a `VersionConflict`.
1882
2422
  pub fn compare_and_swap_ref(
1883
2423
  &self,
1884
2424
  namespace: &str,
@@ -1891,29 +2431,45 @@ impl StateStore {
1891
2431
  if !valid_ref_component(namespace) || !valid_ref_component(name) {
1892
2432
  return Err(RefError::InvalidIdentifier);
1893
2433
  }
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;
2434
+ match &self.backing {
2435
+ Backing::Durable(feltdb) => durable::compare_and_swap_ref(
2436
+ feltdb,
2437
+ namespace,
2438
+ name,
2439
+ expected_version,
2440
+ target,
2441
+ authority,
2442
+ metadata,
2443
+ ),
2444
+ Backing::Volatile(_) => {
2445
+ let _guard = self.mutation_lock.lock().unwrap();
2446
+ let mut current = self.get_ref(namespace, name).ok_or(RefError::NotFound)?;
2447
+ if current.version != expected_version {
2448
+ return Err(RefError::VersionConflict {
2449
+ expected: expected_version,
2450
+ current: current.version,
2451
+ });
2452
+ }
2453
+ if !self.exists(&target) {
2454
+ return Err(RefError::UnknownTarget(target));
2455
+ }
2456
+ current.target = target;
2457
+ current.version += 1;
2458
+ current.authority = authority;
2459
+ current.updated_at_ms = current_time_ms();
2460
+ if let Some(metadata) = metadata {
2461
+ current.metadata = metadata;
2462
+ }
2463
+ self.write_volatile_ref(&current);
2464
+ Ok(current)
2465
+ }
1911
2466
  }
1912
- self.write_ref(&current)?;
1913
- Ok(current)
1914
2467
  }
1915
2468
 
1916
2469
  /// Delete a reference, optionally enforcing the current version.
2470
+ ///
2471
+ /// On the durable backing this is one fenced FeltDB transaction, and a
2472
+ /// repeat of an identical delete that already committed returns `Ok`.
1917
2473
  pub fn delete_ref(
1918
2474
  &self,
1919
2475
  namespace: &str,
@@ -1923,17 +2479,29 @@ impl StateStore {
1923
2479
  if !valid_ref_component(namespace) || !valid_ref_component(name) {
1924
2480
  return Err(RefError::InvalidIdentifier);
1925
2481
  }
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
- });
2482
+ match &self.backing {
2483
+ Backing::Durable(feltdb) => {
2484
+ durable::delete_ref(feltdb, namespace, name, expected_version)
2485
+ }
2486
+ Backing::Volatile(state) => {
2487
+ let _guard = self.mutation_lock.lock().unwrap();
2488
+ let current = self.get_ref(namespace, name).ok_or(RefError::NotFound)?;
2489
+ if let Some(expected) = expected_version {
2490
+ if current.version != expected {
2491
+ return Err(RefError::VersionConflict {
2492
+ expected,
2493
+ current: current.version,
2494
+ });
2495
+ }
2496
+ }
2497
+ state
2498
+ .lock()
2499
+ .unwrap()
2500
+ .refs
2501
+ .remove(&(namespace.to_string(), name.to_string()));
2502
+ Ok(())
1934
2503
  }
1935
2504
  }
1936
- self.remove_ref(namespace, name)
1937
2505
  }
1938
2506
 
1939
2507
  /// Enumerate refs, optionally scoped to one namespace.
@@ -1991,95 +2559,122 @@ impl StateStore {
1991
2559
  Ok(projected)
1992
2560
  }
1993
2561
 
1994
- /// Collect revisions and objects unreachable from current refs.
2562
+ /// Collect revisions and objects that no root can reach.
2563
+ ///
2564
+ /// **Roots** are: every ref target; every resource's latest revision (each
2565
+ /// revision at the resource's highest sequence, and the head the record
2566
+ /// write path will mint its next revision from); and every revision
2567
+ /// younger than 15 minutes (`GC_GRACE_MS`). **Reachable** is the roots and all their
2568
+ /// ancestors by `parent_id`, because history, diff, rollback and replicated
2569
+ /// reconstruction all walk that chain. Only a revision that is old, named
2570
+ /// by no ref, not latest, and an ancestor of none of those is collected —
2571
+ /// in practice, an abandoned fork. Retention (`keep_last`) is unaffected:
2572
+ /// it is positional and runs at the mint, not here.
2573
+ ///
2574
+ /// On the durable backing every deletion commits in one FeltDB transaction
2575
+ /// fenced on the state generation, which every ref change and every
2576
+ /// committed revision advances. A concurrent change aborts the collection
2577
+ /// and it is re-planned from fresh reads; see [`durable`].
1995
2578
  pub fn collect_unreachable(&self) -> Result<GarbageCollectionReport, GarbageCollectionError> {
1996
- let _guard = self.mutation_lock.lock().unwrap();
2579
+ self.collect_unreachable_at(current_time_ms())
2580
+ }
2581
+
2582
+ /// [`collect_unreachable`](Self::collect_unreachable) with an explicit
2583
+ /// clock, so the grace period can be tested without waiting for it.
2584
+ pub(crate) fn collect_unreachable_at(
2585
+ &self,
2586
+ now_ms: u64,
2587
+ ) -> Result<GarbageCollectionReport, GarbageCollectionError> {
2588
+ match &self.backing {
2589
+ Backing::Volatile(state) => {
2590
+ let _guard = self.mutation_lock.lock().unwrap();
2591
+ let report = self.plan_collection(now_ms, &[]);
2592
+ let mut state = state.lock().unwrap();
2593
+ for id in &report.collected_revisions {
2594
+ state.revisions.remove(id);
2595
+ }
2596
+ for id in &report.collected_objects {
2597
+ state.objects.remove(id);
2598
+ }
2599
+ Ok(report)
2600
+ }
2601
+ Backing::Durable(feltdb) => durable::collect(self, feltdb, now_ms),
2602
+ }
2603
+ }
2604
+
2605
+ /// What a collection at `now_ms` would keep and delete, from one read of
2606
+ /// refs and revisions. `extra_roots` are heads known outside `StateStore`.
2607
+ fn plan_collection(&self, now_ms: u64, extra_roots: &[StateId]) -> GarbageCollectionReport {
1997
2608
  let roots = self.list_refs(None);
2609
+ let revisions = self.all_revisions();
2610
+ let by_id: HashMap<StateId, &StateRevision> =
2611
+ revisions.iter().map(|revision| (revision.id.clone(), revision)).collect();
2612
+
2613
+ let mut latest: HashMap<&str, u64> = HashMap::new();
2614
+ for revision in &revisions {
2615
+ let entry = latest.entry(revision.resource.as_str()).or_insert(revision.sequence);
2616
+ if revision.sequence > *entry {
2617
+ *entry = revision.sequence;
2618
+ }
2619
+ }
2620
+
2621
+ let mut pending: Vec<StateId> = roots.iter().map(|reference| reference.target.clone()).collect();
2622
+ pending.extend(extra_roots.iter().cloned());
2623
+ for revision in &revisions {
2624
+ let is_latest = latest.get(revision.resource.as_str()) == Some(&revision.sequence);
2625
+ let is_young = now_ms.saturating_sub(revision.timestamp_ms) < GC_GRACE_MS;
2626
+ if is_latest || is_young {
2627
+ pending.push(revision.id.clone());
2628
+ }
2629
+ }
2630
+
1998
2631
  let mut reachable_revisions = BTreeSet::new();
1999
2632
  let mut reachable_objects = BTreeSet::new();
2000
- let mut pending: Vec<StateId> = roots.iter().map(|reference| reference.target.clone()).collect();
2001
2633
  while let Some(id) = pending.pop() {
2634
+ let Some(revision) = by_id.get(&id) else {
2635
+ // A root or parent that is not held (expired by retention, or
2636
+ // never committed) ends the walk; it is not an error.
2637
+ continue;
2638
+ };
2002
2639
  if !reachable_revisions.insert(id.clone()) {
2003
2640
  continue;
2004
2641
  }
2005
- let Some(revision) = self.get(&id) else {
2006
- continue;
2007
- };
2008
2642
  if let Some(parent_id) = revision.parent_id.clone() {
2009
2643
  pending.push(parent_id);
2010
2644
  }
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
- }
2645
+ reachable_objects.extend(collect_referenced_objects(revision));
2025
2646
  }
2026
2647
 
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
- }
2648
+ let mut collected_revisions: Vec<StateId> = revisions
2649
+ .iter()
2650
+ .map(|revision| revision.id.clone())
2651
+ .filter(|id| !reachable_revisions.contains(id))
2652
+ .collect();
2653
+ collected_revisions.sort();
2654
+ let mut collected_objects: Vec<StateId> = self
2655
+ .all_objects()
2656
+ .into_iter()
2657
+ .map(|object| object.id)
2658
+ .filter(|id| !reachable_objects.contains(id))
2659
+ .collect();
2660
+ collected_objects.sort();
2036
2661
 
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 {
2662
+ GarbageCollectionReport {
2042
2663
  roots,
2043
- reachable_revisions: reachable_revisions_out,
2044
- reachable_objects: reachable_objects_out,
2664
+ reachable_revisions: reachable_revisions.into_iter().collect(),
2665
+ reachable_objects: reachable_objects.into_iter().collect(),
2045
2666
  collected_revisions,
2046
2667
  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
2668
  }
2064
- Ok(())
2065
2669
  }
2066
2670
 
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
- }
2671
+ fn write_volatile_ref(&self, reference: &StateRef) {
2672
+ if let Backing::Volatile(state) = &self.backing {
2673
+ state.lock().unwrap().refs.insert(
2674
+ (reference.namespace.clone(), reference.name.clone()),
2675
+ reference.clone(),
2676
+ );
2081
2677
  }
2082
- Ok(())
2083
2678
  }
2084
2679
 
2085
2680
  fn all_revisions(&self) -> Vec<StateRevision> {
@@ -2234,20 +2829,6 @@ impl StateStore {
2234
2829
  Ok(())
2235
2830
  }
2236
2831
 
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
2832
  fn retention_state(&self, resource: &str) -> RetentionState {
2252
2833
  match &self.backing {
2253
2834
  Backing::Volatile(state) => state
@@ -2316,6 +2897,120 @@ mod tests {
2316
2897
  use super::*;
2317
2898
  use serde_json::json;
2318
2899
 
2900
+ // -- garbage collection eligibility, with an explicit clock -------------
2901
+
2902
+ /// `A(0) → B(1) → C(2)` on one resource, plus a fork `F(1)` off `A`.
2903
+ fn forked(store: &StateStore) -> (StateRevision, StateRevision, StateRevision, StateRevision) {
2904
+ let a = store.create("doc:gc", json!({"n": "a"}).to_string(), "t".into()).unwrap();
2905
+ let b = store.commit(json!({"n": "b"}).to_string(), &a, "t".into()).unwrap();
2906
+ let c = store.commit(json!({"n": "c"}).to_string(), &b, "t".into()).unwrap();
2907
+ let f = store.commit(json!({"n": "f"}).to_string(), &a, "t".into()).unwrap();
2908
+ (a, b, c, f)
2909
+ }
2910
+
2911
+ #[test]
2912
+ fn gc_grace_boundary_is_exclusive() {
2913
+ let store = StateStore::new_volatile();
2914
+ let (_, _, _, f) = forked(&store);
2915
+ let inside = store.collect_unreachable_at(f.timestamp_ms + GC_GRACE_MS - 1).unwrap();
2916
+ assert!(inside.collected_revisions.is_empty(), "younger than the grace: a root");
2917
+ let outside = store.collect_unreachable_at(f.timestamp_ms + GC_GRACE_MS).unwrap();
2918
+ assert_eq!(outside.collected_revisions, vec![f.id.clone()]);
2919
+ assert!(!store.exists(&f.id));
2920
+ }
2921
+
2922
+ #[test]
2923
+ fn gc_keeps_latest_revisions_refs_and_all_their_ancestors() {
2924
+ for store in [StateStore::new_volatile(), durable_store().1] {
2925
+ let (a, b, c, f) = forked(&store);
2926
+ let g = store.commit(json!({"n": "g"}).to_string(), &f, "t".into()).unwrap();
2927
+ // g ties c at the highest sequence (2): both are "latest".
2928
+ let far = c.timestamp_ms + 365 * 24 * 60 * 60 * 1000;
2929
+ let report = store.collect_unreachable_at(far).unwrap();
2930
+ assert!(report.collected_revisions.is_empty(), "every revision is a root or an ancestor");
2931
+
2932
+ // A later revision on the main line makes f and g a stale fork …
2933
+ let d = store.commit(json!({"n": "d"}).to_string(), &c, "t".into()).unwrap();
2934
+ // … but a ref on g keeps g and its ancestor f.
2935
+ store
2936
+ .create_ref("candidates", "g", g.id.clone(), "t".into(), BTreeMap::new())
2937
+ .unwrap();
2938
+ assert!(store.collect_unreachable_at(far).unwrap().collected_revisions.is_empty());
2939
+
2940
+ store.delete_ref("candidates", "g", None).unwrap();
2941
+ let mut collected = store.collect_unreachable_at(far).unwrap().collected_revisions;
2942
+ collected.sort();
2943
+ let mut expected = vec![f.id.clone(), g.id.clone()];
2944
+ expected.sort();
2945
+ assert_eq!(collected, expected, "only the unreferenced stale fork");
2946
+ for kept in [&a, &b, &c, &d] {
2947
+ assert!(store.exists(&kept.id));
2948
+ }
2949
+ // The fork's content objects went with it; the kept ones stayed.
2950
+ let objects: BTreeSet<StateId> = store.all_objects().into_iter().map(|o| o.id).collect();
2951
+ for kept in [&a, &b, &c, &d] {
2952
+ assert!(objects.contains(&kept.content_id), "object of a kept revision");
2953
+ }
2954
+ for gone in [&f, &g] {
2955
+ assert!(!objects.contains(&gone.content_id), "object of a collected revision");
2956
+ }
2957
+ }
2958
+ }
2959
+
2960
+ fn durable_store() -> (tempfile::TempDir, StateStore, Arc<crate::FeltDb>) {
2961
+ let dir = tempfile::TempDir::new().unwrap();
2962
+ let db = Arc::new(crate::FeltDb::open(dir.path().join("gc.log")).unwrap());
2963
+ let store = StateStore::with_feltdb(db.clone()).unwrap();
2964
+ (dir, store, db)
2965
+ }
2966
+
2967
+ /// The record write path mints from its own head, which an explicit
2968
+ /// `StateStore::commit` fork can overtake by sequence. That head must stay
2969
+ /// a root, or the next record write would mint a child of a deleted parent.
2970
+ #[test]
2971
+ fn gc_keeps_the_write_path_head_even_when_a_fork_is_longer() {
2972
+ let (_dir, store, db) = durable_store();
2973
+ db.insert("tasks:1", json!({"n": 0})).unwrap();
2974
+ db.update("tasks:1", json!({"n": 1})).unwrap();
2975
+ let resource = store.resources().into_iter().find(|r| r.contains("tasks")).unwrap();
2976
+ let history = store.history_of(&resource);
2977
+ assert_eq!(history.len(), 2, "the write path minted two revisions");
2978
+ let (w0, w1) = (history[0].clone(), history[1].clone());
2979
+
2980
+ // A longer explicit fork off w0: x(1) → x2(2) overtakes w1(1).
2981
+ let x = store.commit(json!({"x": 1}).to_string(), &w0, "t".into()).unwrap();
2982
+ let x2 = store.commit(json!({"x": 2}).to_string(), &x, "t".into()).unwrap();
2983
+ assert_eq!(store.head_of(&resource).unwrap().id, x2.id);
2984
+ assert!(db.revision_head_ids().contains(&w1.id), "the write path still mints from w1");
2985
+
2986
+ let far = x2.timestamp_ms + 365 * 24 * 60 * 60 * 1000;
2987
+ let report = store.collect_unreachable_at(far).unwrap();
2988
+ assert!(!report.collected_revisions.contains(&w1.id), "w1 is the write path's head");
2989
+ assert!(store.exists(&w1.id));
2990
+
2991
+ db.update("tasks:1", json!({"n": 2})).unwrap();
2992
+ let w2 = store
2993
+ .history_of(&resource)
2994
+ .into_iter()
2995
+ .find(|r| r.parent_id.as_ref() == Some(&w1.id))
2996
+ .expect("the next write minted a child of w1");
2997
+ assert_eq!(store.parent_of(&w2.id), Some(ParentLookup::Revision(Box::new(w1))));
2998
+ }
2999
+
3000
+ /// The generation row, the incarnation rows and the ref rows the durable
3001
+ /// path writes are invisible to the history and ref listings.
3002
+ #[test]
3003
+ fn internal_rows_do_not_appear_as_history_or_refs() {
3004
+ let (_dir, store, _db) = durable_store();
3005
+ let (a, _, _, _) = forked(&store);
3006
+ store.create_ref("heads", "main", a.id.clone(), "t".into(), BTreeMap::new()).unwrap();
3007
+ store.delete_ref("heads", "main", Some(1)).unwrap();
3008
+ store.create_ref("heads", "main", a.id.clone(), "t".into(), BTreeMap::new()).unwrap();
3009
+ assert_eq!(store.list_refs(None).len(), 1);
3010
+ assert_eq!(store.resources(), vec!["doc:gc".to_string()]);
3011
+ assert_eq!(store.history_of("doc:gc").len(), 4);
3012
+ }
3013
+
2319
3014
  #[test]
2320
3015
  fn test_state_id_deterministic() {
2321
3016
  let json = r#"{"name":"Alice","age":30}"#;