@feltdb/core 0.8.4 → 0.8.6
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.
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +165 -0
- package/dist/create/server-source/Cargo.toml +9 -0
- package/dist/create/server-source/crates/feltdb/Cargo.toml +3 -0
- package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +44 -44
- package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +12 -24
- package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +7 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +13 -10
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +12 -9
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +78 -25
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +16 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +13 -5
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +41 -22
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +33 -15
- package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +100 -33
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +56 -20
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +116 -41
- package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +186 -55
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +64 -26
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +46 -15
- package/dist/create/server-source/crates/feltdb/src/admission.rs +8 -15
- package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +43 -13
- package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +15 -42
- package/dist/create/server-source/crates/feltdb/src/analytics.rs +65 -19
- package/dist/create/server-source/crates/feltdb/src/application.rs +113 -30
- package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +475 -140
- package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +17 -15
- package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +266 -114
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +25 -7
- package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +10 -15
- package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +3 -11
- package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +14 -5
- package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +21 -25
- package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +14 -11
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +103 -22
- package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +18 -15
- package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +12 -8
- package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +1 -5
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +87 -23
- package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +3 -7
- package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +10 -9
- package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +1 -6
- package/dist/create/server-source/crates/feltdb/src/indexing.rs +35 -38
- package/dist/create/server-source/crates/feltdb/src/lib.rs +1648 -46
- package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +4 -1
- package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +1 -2
- package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +107 -30
- package/dist/create/server-source/crates/feltdb/src/observability.rs +19 -6
- package/dist/create/server-source/crates/feltdb/src/operation.rs +39 -0
- package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +12 -11
- package/dist/create/server-source/crates/feltdb/src/operation_log.rs +9 -4
- package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +65 -18
- package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +193 -57
- package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +37 -27
- package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +38 -10
- package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +20 -14
- package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +394 -229
- package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +8 -6
- package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +11 -13
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +79 -70
- package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +3 -3
- package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +33 -11
- package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +6 -6
- package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +14 -56
- package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +29 -27
- package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +35 -17
- package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +8 -2
- package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +70 -17
- package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +23 -6
- package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +12 -3
- package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +41 -11
- package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +56 -25
- package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +701 -245
- package/dist/create/server-source/crates/feltdb/src/production_api.rs +31 -13
- package/dist/create/server-source/crates/feltdb/src/query_performance.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +5 -5
- package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +48 -18
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +30 -11
- package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +4 -3
- package/dist/create/server-source/crates/feltdb/src/sharding.rs +36 -10
- package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +516 -0
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +13 -4
- package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +222 -0
- package/dist/create/server-source/crates/feltdb/src/state_facade.rs +82 -54
- package/dist/create/server-source/crates/feltdb/src/state_hash.rs +2 -2
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +1565 -536
- package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +672 -0
- package/dist/create/server-source/crates/feltdb/src/storage.rs +9 -3
- package/dist/create/server-source/crates/feltdb/src/submission.rs +5 -11
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +24 -35
- package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +24 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +248 -59
- package/dist/create/server-source/crates/feltdb/src/transactions.rs +17 -20
- package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +749 -0
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
- package/dist/create/server-source/crates/feltdb/src/workload.rs +512 -4
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +3 -4
- package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +132 -0
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +272 -0
- package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +467 -0
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +309 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +445 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +518 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +392 -0
- package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +436 -220
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +1916 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +1878 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +1862 -0
- package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +278 -0
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +2 -1
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +80 -25
- package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +5 -8
- package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +52 -44
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +33 -16
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +22 -7
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +41 -22
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +25 -8
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +9 -6
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +120 -69
- package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +21 -10
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +1365 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +868 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +221 -0
- package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +417 -0
- package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +338 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +1039 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +739 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +427 -0
- package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +3 -3
- package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +25 -22
- package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +1799 -0
- package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +1316 -0
- package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +53 -61
- package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +156 -61
- package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +255 -0
- package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +249 -0
- package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +994 -0
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +442 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +16 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +695 -47
- package/dist/create/server-source/crates/feltdb-server/src/metrics.rs +21 -0
- package/dist/studio-app/assets/{feltdb_wasm-CVQWgXO-.js → feltdb_wasm-C1VhI-U5.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-C8HXbAXb.wasm +0 -0
- package/dist/studio-app/assets/{index-DwgNAIIX.js → index-Bbos1m2U.js} +1 -1
- package/dist/studio-app/index.html +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workload.d.ts +2 -1
- package/dist/workload.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
|
@@ -20,7 +20,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|
|
20
20
|
use std::sync::{Arc, Mutex};
|
|
21
21
|
|
|
22
22
|
// Forward declaration to avoid circular imports
|
|
23
|
-
pub struct FeltDb;
|
|
23
|
+
pub struct FeltDb; // Will be linked at compile time from lib.rs
|
|
24
24
|
|
|
25
25
|
/// Version constant for state model contracts
|
|
26
26
|
pub const STATE_MODEL_VERSION: u32 = 1;
|
|
@@ -66,15 +66,41 @@ impl std::fmt::Display for StateId {
|
|
|
66
66
|
// StateRevision: Immutable State Snapshot with Ancestry
|
|
67
67
|
// ============================================================================
|
|
68
68
|
|
|
69
|
-
///
|
|
69
|
+
/// An immutable, resource-scoped historical state.
|
|
70
|
+
///
|
|
71
|
+
/// A revision is a revision **of a resource**. Two resources holding identical
|
|
72
|
+
/// content are distinct revisions, and a resource that returns to a value it
|
|
73
|
+
/// previously held records a *new* revision rather than resurrecting the old
|
|
74
|
+
/// one.
|
|
75
|
+
///
|
|
76
|
+
/// # Two identities, deliberately
|
|
77
|
+
///
|
|
78
|
+
/// `id` is the **revision identity**: what distinguishes one historical
|
|
79
|
+
/// occurrence from another. It is computed over the resource, the content, the
|
|
80
|
+
/// parent and a per-resource sequence, so content equality is not identity.
|
|
81
|
+
///
|
|
82
|
+
/// `content_id` is the **content identity**, the hash of the canonical content
|
|
83
|
+
/// alone. It is what makes a revision verifiable against its own bytes, and it
|
|
84
|
+
/// is what deduplicates *content* comparisons — two revisions of the same state
|
|
85
|
+
/// share a `content_id` and differ in `id`.
|
|
86
|
+
///
|
|
87
|
+
/// Conflating these two was the defect the identity contract found: a revert
|
|
88
|
+
/// produced a revision whose identity already existed, so committing it
|
|
89
|
+
/// overwrote the earlier one and closed a cycle in the parent chain.
|
|
70
90
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
71
91
|
pub struct StateRevision {
|
|
72
|
-
///
|
|
92
|
+
/// Revision identity — resource, content, parent and sequence.
|
|
73
93
|
pub id: StateId,
|
|
94
|
+
/// The resource this is a revision of.
|
|
95
|
+
pub resource: String,
|
|
74
96
|
/// Canonical JSON representation (representation-sensitive)
|
|
75
97
|
pub content: String,
|
|
76
|
-
///
|
|
98
|
+
/// Content identity — the hash of `content` alone.
|
|
99
|
+
pub content_id: StateId,
|
|
100
|
+
/// Parent revision id. `None` only for a resource's first revision.
|
|
77
101
|
pub parent_id: Option<StateId>,
|
|
102
|
+
/// Position in this resource's history. Strictly increasing, never reused.
|
|
103
|
+
pub sequence: u64,
|
|
78
104
|
/// Authority that produced this revision
|
|
79
105
|
pub authority: String,
|
|
80
106
|
/// Timestamp when revision was created (informational only, not used for ordering)
|
|
@@ -83,60 +109,106 @@ pub struct StateRevision {
|
|
|
83
109
|
pub metadata: BTreeMap<String, Value>,
|
|
84
110
|
}
|
|
85
111
|
|
|
112
|
+
/// Compute a revision identity.
|
|
113
|
+
///
|
|
114
|
+
/// Every component participates: the same content is a different revision on a
|
|
115
|
+
/// different resource, from a different parent, or at a different point in the
|
|
116
|
+
/// resource's history. The parts are length-prefixed so no two distinct tuples
|
|
117
|
+
/// can produce the same input string.
|
|
118
|
+
fn compute_revision_id(
|
|
119
|
+
resource: &str,
|
|
120
|
+
content_id: &StateId,
|
|
121
|
+
parent_id: Option<&StateId>,
|
|
122
|
+
sequence: u64,
|
|
123
|
+
) -> StateId {
|
|
124
|
+
let parent = parent_id.map(StateId::as_hex).unwrap_or("");
|
|
125
|
+
let material = format!(
|
|
126
|
+
"feltdb.revision.v2\n{}:{}\n{}:{}\n{}:{}\n{}",
|
|
127
|
+
resource.len(),
|
|
128
|
+
resource,
|
|
129
|
+
content_id.as_hex().len(),
|
|
130
|
+
content_id.as_hex(),
|
|
131
|
+
parent.len(),
|
|
132
|
+
parent,
|
|
133
|
+
sequence,
|
|
134
|
+
);
|
|
135
|
+
StateId::compute(&material)
|
|
136
|
+
}
|
|
137
|
+
|
|
86
138
|
impl StateRevision {
|
|
87
|
-
/// Create
|
|
88
|
-
pub fn initial(content: String, authority: String) -> Self {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
.duration_since(std::time::UNIX_EPOCH)
|
|
92
|
-
.unwrap()
|
|
93
|
-
.as_millis() as u64;
|
|
139
|
+
/// Create a resource's first revision.
|
|
140
|
+
pub fn initial(resource: String, content: String, authority: String) -> Self {
|
|
141
|
+
Self::at(resource, content, None, 0, authority)
|
|
142
|
+
}
|
|
94
143
|
|
|
95
|
-
|
|
96
|
-
|
|
144
|
+
/// Create a revision descending from `parent`, on the parent's resource.
|
|
145
|
+
pub fn child(content: String, parent: &StateRevision, authority: String) -> Self {
|
|
146
|
+
Self::at(
|
|
147
|
+
parent.resource.clone(),
|
|
97
148
|
content,
|
|
98
|
-
|
|
149
|
+
Some(parent.id.clone()),
|
|
150
|
+
parent.sequence + 1,
|
|
99
151
|
authority,
|
|
100
|
-
|
|
101
|
-
metadata: BTreeMap::new(),
|
|
102
|
-
}
|
|
152
|
+
)
|
|
103
153
|
}
|
|
104
154
|
|
|
105
|
-
/// Create
|
|
106
|
-
pub fn
|
|
155
|
+
/// Create a revision at an explicit position in a resource's history.
|
|
156
|
+
pub fn at(
|
|
157
|
+
resource: String,
|
|
107
158
|
content: String,
|
|
108
|
-
|
|
159
|
+
parent_id: Option<StateId>,
|
|
160
|
+
sequence: u64,
|
|
109
161
|
authority: String,
|
|
110
162
|
) -> Self {
|
|
111
|
-
let
|
|
163
|
+
let content_id = StateId::compute(&content);
|
|
164
|
+
let id = compute_revision_id(&resource, &content_id, parent_id.as_ref(), sequence);
|
|
112
165
|
let timestamp_ms = std::time::SystemTime::now()
|
|
113
166
|
.duration_since(std::time::UNIX_EPOCH)
|
|
114
|
-
.
|
|
167
|
+
.unwrap_or_default()
|
|
115
168
|
.as_millis() as u64;
|
|
116
169
|
|
|
117
170
|
StateRevision {
|
|
118
171
|
id,
|
|
172
|
+
resource,
|
|
119
173
|
content,
|
|
120
|
-
|
|
174
|
+
content_id,
|
|
175
|
+
parent_id,
|
|
176
|
+
sequence,
|
|
121
177
|
authority,
|
|
122
178
|
timestamp_ms,
|
|
123
179
|
metadata: BTreeMap::new(),
|
|
124
180
|
}
|
|
125
181
|
}
|
|
126
182
|
|
|
127
|
-
/// Verify
|
|
183
|
+
/// Verify that both identities match what they are computed from.
|
|
128
184
|
pub fn verify_integrity(&self) -> bool {
|
|
129
|
-
StateId::compute(&self.content)
|
|
185
|
+
self.content_id == StateId::compute(&self.content)
|
|
186
|
+
&& self.id
|
|
187
|
+
== compute_revision_id(
|
|
188
|
+
&self.resource,
|
|
189
|
+
&self.content_id,
|
|
190
|
+
self.parent_id.as_ref(),
|
|
191
|
+
self.sequence,
|
|
192
|
+
)
|
|
130
193
|
}
|
|
131
194
|
|
|
132
|
-
/// Get parent id if this is not
|
|
195
|
+
/// Get parent id if this is not a resource's first revision.
|
|
133
196
|
pub fn parent(&self) -> Option<&StateId> {
|
|
134
197
|
self.parent_id.as_ref()
|
|
135
198
|
}
|
|
136
199
|
|
|
137
|
-
///
|
|
200
|
+
/// Whether two revisions are the same revision.
|
|
201
|
+
///
|
|
202
|
+
/// Revision identity, not content equality: two revisions of the same state
|
|
203
|
+
/// on the same resource are not equal.
|
|
138
204
|
pub fn equals(&self, other: &StateRevision) -> bool {
|
|
139
|
-
self.id == other.id
|
|
205
|
+
self.id == other.id
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// Whether two revisions hold the same state, regardless of where in
|
|
209
|
+
/// history they occur.
|
|
210
|
+
pub fn same_content_as(&self, other: &StateRevision) -> bool {
|
|
211
|
+
self.content_id == other.content_id
|
|
140
212
|
}
|
|
141
213
|
}
|
|
142
214
|
|
|
@@ -191,11 +263,19 @@ impl StateTopology {
|
|
|
191
263
|
if a == b {
|
|
192
264
|
return true;
|
|
193
265
|
}
|
|
266
|
+
// The revision model makes this graph acyclic, so the visited set is a
|
|
267
|
+
// safety belt rather than the thing that makes the walk correct: a
|
|
268
|
+
// topology assembled by hand, or read from an older log, must not be
|
|
269
|
+
// able to hang a caller.
|
|
270
|
+
let mut visited = BTreeSet::new();
|
|
194
271
|
let mut current = b.clone();
|
|
195
272
|
while let Some(parent_id) = self.parents.get(¤t) {
|
|
196
273
|
if parent_id == a {
|
|
197
274
|
return true;
|
|
198
275
|
}
|
|
276
|
+
if !visited.insert(current.clone()) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
199
279
|
current = parent_id.clone();
|
|
200
280
|
}
|
|
201
281
|
false
|
|
@@ -204,9 +284,13 @@ impl StateTopology {
|
|
|
204
284
|
/// Get all ancestors of a revision (excluding self)
|
|
205
285
|
pub fn ancestors(&self, id: &StateId) -> Vec<StateId> {
|
|
206
286
|
let mut ancestors = Vec::new();
|
|
287
|
+
let mut visited = BTreeSet::new();
|
|
207
288
|
let mut current = id.clone();
|
|
208
289
|
|
|
209
290
|
while let Some(parent_id) = self.parents.get(¤t) {
|
|
291
|
+
if !visited.insert(current.clone()) {
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
210
294
|
ancestors.push(parent_id.clone());
|
|
211
295
|
current = parent_id.clone();
|
|
212
296
|
}
|
|
@@ -222,11 +306,15 @@ impl StateTopology {
|
|
|
222
306
|
|
|
223
307
|
let ancestors_a: BTreeSet<_> = self.ancestors(a).iter().cloned().collect();
|
|
224
308
|
|
|
309
|
+
let mut visited = BTreeSet::new();
|
|
225
310
|
let mut current = b.clone();
|
|
226
311
|
while let Some(parent_id) = self.parents.get(¤t) {
|
|
227
312
|
if ancestors_a.contains(parent_id) {
|
|
228
313
|
return Some(parent_id.clone());
|
|
229
314
|
}
|
|
315
|
+
if !visited.insert(current.clone()) {
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
230
318
|
current = parent_id.clone();
|
|
231
319
|
}
|
|
232
320
|
|
|
@@ -275,6 +363,70 @@ pub enum PathComponent {
|
|
|
275
363
|
Index(usize),
|
|
276
364
|
}
|
|
277
365
|
|
|
366
|
+
/// How two changed paths relate to one another.
|
|
367
|
+
///
|
|
368
|
+
/// Conflict classification needs this because two changes interact whenever one
|
|
369
|
+
/// path contains the other, not only when the paths are equal. Comparison is
|
|
370
|
+
/// structural, over whole [`PathComponent`]s: `["a", "b"]` and `["a", "bc"]` are
|
|
371
|
+
/// [`PathRelation::Disjoint`], even though one rendering of them shares a string
|
|
372
|
+
/// prefix, and `["a"]` with `Key("a")` never matches `Index(0)`.
|
|
373
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
374
|
+
pub enum PathRelation {
|
|
375
|
+
/// The same path.
|
|
376
|
+
Same,
|
|
377
|
+
/// The first path contains the second: `a` against `a.b`.
|
|
378
|
+
Ancestor,
|
|
379
|
+
/// The first path is contained by the second: `a.b` against `a`.
|
|
380
|
+
Descendant,
|
|
381
|
+
/// Neither contains the other: siblings, or unrelated subtrees.
|
|
382
|
+
Disjoint,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/// Whether `candidate` begins with every component of `prefix`, structurally.
|
|
386
|
+
fn path_starts_with(candidate: &[PathComponent], prefix: &[PathComponent]) -> bool {
|
|
387
|
+
candidate.len() >= prefix.len() && candidate[..prefix.len()] == *prefix
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/// Classifies how two changed paths relate.
|
|
391
|
+
///
|
|
392
|
+
/// Pure, total, and independent of any state: it reads only the two paths.
|
|
393
|
+
pub fn path_relation(one: &[PathComponent], two: &[PathComponent]) -> PathRelation {
|
|
394
|
+
if one == two {
|
|
395
|
+
PathRelation::Same
|
|
396
|
+
} else if path_starts_with(two, one) {
|
|
397
|
+
PathRelation::Ancestor
|
|
398
|
+
} else if path_starts_with(one, two) {
|
|
399
|
+
PathRelation::Descendant
|
|
400
|
+
} else {
|
|
401
|
+
PathRelation::Disjoint
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/// Whether two changed paths interact: equal, or one containing the other.
|
|
406
|
+
///
|
|
407
|
+
/// This is the predicate conflict classification is defined over. Two changes
|
|
408
|
+
/// at overlapping paths cannot both be applied — the outer one determines the
|
|
409
|
+
/// value the inner one was editing — so they are never independent.
|
|
410
|
+
pub fn paths_overlap(one: &[PathComponent], two: &[PathComponent]) -> bool {
|
|
411
|
+
!matches!(path_relation(one, two), PathRelation::Disjoint)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/// Reads the value a path names, or `None` when the path does not resolve.
|
|
415
|
+
///
|
|
416
|
+
/// Object members are addressed by [`PathComponent::Key`] and array elements by
|
|
417
|
+
/// [`PathComponent::Index`], exactly as [`SemanticDiff`] emits them, so a path
|
|
418
|
+
/// taken from a diff resolves in the state that diff was computed from.
|
|
419
|
+
pub fn resolve_path<'a>(value: &'a Value, path: &[PathComponent]) -> Option<&'a Value> {
|
|
420
|
+
let mut current = value;
|
|
421
|
+
for component in path {
|
|
422
|
+
current = match component {
|
|
423
|
+
PathComponent::Key(key) => current.as_object()?.get(key)?,
|
|
424
|
+
PathComponent::Index(index) => current.as_array()?.get(*index)?,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
Some(current)
|
|
428
|
+
}
|
|
429
|
+
|
|
278
430
|
/// Semantic change at a specific path (deterministically ordered)
|
|
279
431
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
280
432
|
pub struct SemanticChange {
|
|
@@ -305,10 +457,7 @@ impl SemanticDiff {
|
|
|
305
457
|
Self::diff_recursive(old, new, &mut vec![], &mut changes);
|
|
306
458
|
|
|
307
459
|
// Sort deterministically
|
|
308
|
-
changes.sort_by(|a, b|
|
|
309
|
-
a.path.cmp(&b.path)
|
|
310
|
-
.then_with(|| a.kind.cmp(&b.kind))
|
|
311
|
-
});
|
|
460
|
+
changes.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.kind.cmp(&b.kind)));
|
|
312
461
|
|
|
313
462
|
SemanticDiff {
|
|
314
463
|
changes,
|
|
@@ -411,6 +560,11 @@ impl SemanticDiff {
|
|
|
411
560
|
// ============================================================================
|
|
412
561
|
|
|
413
562
|
/// Classification of conflict between two changes
|
|
563
|
+
///
|
|
564
|
+
/// `Independent` carries the strong meaning its name and the state-model
|
|
565
|
+
/// graduation audit both assert: the two branches' changes at this path do not
|
|
566
|
+
/// overlap, so both can be applied without either overwriting or invalidating
|
|
567
|
+
/// the other. It is not merely "the paths differ" — see [`paths_overlap`].
|
|
414
568
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
415
569
|
pub enum ConflictClass {
|
|
416
570
|
/// Changes don't overlap; can merge automatically
|
|
@@ -426,8 +580,18 @@ pub enum ConflictClass {
|
|
|
426
580
|
pub struct PathConflict {
|
|
427
581
|
pub path: Vec<PathComponent>,
|
|
428
582
|
pub classification: ConflictClass,
|
|
583
|
+
/// The common ancestor's value at this path, or `None` when the path does
|
|
584
|
+
/// not resolve in the base.
|
|
585
|
+
///
|
|
586
|
+
/// Read from the base state itself. It was previously read from the left
|
|
587
|
+
/// branch's change, which reported `None` for a path only the right branch
|
|
588
|
+
/// touched even when the base held a value there.
|
|
429
589
|
pub base_value: Option<Value>,
|
|
590
|
+
/// The left branch's value at this path, or `None` if the left branch did
|
|
591
|
+
/// not change it or removed it.
|
|
430
592
|
pub left_value: Option<Value>,
|
|
593
|
+
/// The right branch's value at this path, or `None` if the right branch did
|
|
594
|
+
/// not change it or removed it.
|
|
431
595
|
pub right_value: Option<Value>,
|
|
432
596
|
}
|
|
433
597
|
|
|
@@ -442,69 +606,96 @@ pub struct ConflictClassification {
|
|
|
442
606
|
|
|
443
607
|
impl ConflictClassification {
|
|
444
608
|
/// Classify conflicts from three-way merge
|
|
445
|
-
///
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
609
|
+
///
|
|
610
|
+
/// `base` is the common ancestor, `left` the first branch, `right` the
|
|
611
|
+
/// second. Only each revision's `content` is read.
|
|
612
|
+
///
|
|
613
|
+
/// Two changes interact whenever their paths **overlap** — equal, or one
|
|
614
|
+
/// containing the other — not only when they are equal. A branch that
|
|
615
|
+
/// replaces `o` and a branch that edits `o.a` cannot both be applied: the
|
|
616
|
+
/// first determines the value the second was editing. Both such paths are
|
|
617
|
+
/// reported as [`ConflictClass::Conflict`].
|
|
618
|
+
///
|
|
619
|
+
/// An overlap that is not at the same path can never be convergent. For a
|
|
620
|
+
/// descendant path to exist, the base must hold a container there; a change
|
|
621
|
+
/// reported *at* the ancestor path means that branch no longer holds a
|
|
622
|
+
/// container of that kind, while the branch editing inside it still does.
|
|
623
|
+
/// The two values therefore always differ. `classify` does not rely on that
|
|
624
|
+
/// argument — it compares the two branches' resulting values — but the
|
|
625
|
+
/// conformance suite asserts it, so the reasoning stays checked.
|
|
626
|
+
pub fn classify(base: &StateRevision, left: &StateRevision, right: &StateRevision) -> Self {
|
|
451
627
|
use serde_json::Value;
|
|
452
628
|
|
|
453
|
-
let base_json: Value = serde_json::from_str(&base.content)
|
|
454
|
-
|
|
455
|
-
let
|
|
456
|
-
.unwrap_or(Value::Null);
|
|
457
|
-
let right_json: Value = serde_json::from_str(&right.content)
|
|
458
|
-
.unwrap_or(Value::Null);
|
|
629
|
+
let base_json: Value = serde_json::from_str(&base.content).unwrap_or(Value::Null);
|
|
630
|
+
let left_json: Value = serde_json::from_str(&left.content).unwrap_or(Value::Null);
|
|
631
|
+
let right_json: Value = serde_json::from_str(&right.content).unwrap_or(Value::Null);
|
|
459
632
|
|
|
460
|
-
let
|
|
633
|
+
let left_diff = SemanticDiff::compute(&base_json, &left_json);
|
|
461
634
|
let right_diff = SemanticDiff::compute(&base_json, &right_json);
|
|
462
635
|
|
|
463
636
|
let mut path_conflicts = Vec::new();
|
|
464
637
|
let mut has_conflict = false;
|
|
465
638
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
let right_paths: BTreeMap<Vec<PathComponent>, &SemanticChange> =
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
639
|
+
let left_paths: BTreeMap<Vec<PathComponent>, &SemanticChange> = left_diff
|
|
640
|
+
.changes
|
|
641
|
+
.iter()
|
|
642
|
+
.map(|c| (c.path.clone(), c))
|
|
643
|
+
.collect();
|
|
644
|
+
let right_paths: BTreeMap<Vec<PathComponent>, &SemanticChange> = right_diff
|
|
645
|
+
.changes
|
|
646
|
+
.iter()
|
|
647
|
+
.map(|c| (c.path.clone(), c))
|
|
648
|
+
.collect();
|
|
475
649
|
|
|
476
650
|
// Find all unique paths
|
|
477
|
-
let all_paths: BTreeSet<_> =
|
|
651
|
+
let all_paths: BTreeSet<_> = left_paths
|
|
652
|
+
.keys()
|
|
478
653
|
.chain(right_paths.keys())
|
|
479
654
|
.cloned()
|
|
480
655
|
.collect();
|
|
481
656
|
|
|
482
657
|
for path in all_paths {
|
|
483
|
-
let left_change =
|
|
658
|
+
let left_change = left_paths.get(&path);
|
|
484
659
|
let right_change = right_paths.get(&path);
|
|
485
660
|
|
|
486
661
|
let classification = match (left_change, right_change) {
|
|
487
|
-
(None, None) => ConflictClass::Independent,
|
|
488
|
-
(Some(lc), None) => ConflictClass::Independent,
|
|
489
|
-
(None, Some(rc)) => ConflictClass::Independent,
|
|
490
662
|
(Some(lc), Some(rc)) => {
|
|
491
|
-
if lc.kind == rc.kind
|
|
492
|
-
&& lc.new_value == rc.new_value
|
|
493
|
-
{
|
|
663
|
+
if lc.kind == rc.kind && lc.new_value == rc.new_value {
|
|
494
664
|
ConflictClass::Convergent
|
|
495
665
|
} else {
|
|
496
666
|
has_conflict = true;
|
|
497
667
|
ConflictClass::Conflict
|
|
498
668
|
}
|
|
499
669
|
}
|
|
670
|
+
// Only one branch changed this path. It is independent only if
|
|
671
|
+
// the other branch changed nothing that contains it or that it
|
|
672
|
+
// contains.
|
|
673
|
+
(Some(_), None) => {
|
|
674
|
+
if overlapping_change_exists(&right_paths, &path) {
|
|
675
|
+
has_conflict = true;
|
|
676
|
+
ConflictClass::Conflict
|
|
677
|
+
} else {
|
|
678
|
+
ConflictClass::Independent
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
(None, Some(_)) => {
|
|
682
|
+
if overlapping_change_exists(&left_paths, &path) {
|
|
683
|
+
has_conflict = true;
|
|
684
|
+
ConflictClass::Conflict
|
|
685
|
+
} else {
|
|
686
|
+
ConflictClass::Independent
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// Unreachable: every path came from one of the two maps.
|
|
690
|
+
(None, None) => ConflictClass::Independent,
|
|
500
691
|
};
|
|
501
692
|
|
|
502
693
|
path_conflicts.push(PathConflict {
|
|
503
|
-
path,
|
|
694
|
+
path: path.clone(),
|
|
504
695
|
classification,
|
|
505
|
-
base_value:
|
|
506
|
-
left_value: left_change.
|
|
507
|
-
right_value: right_change.
|
|
696
|
+
base_value: resolve_path(&base_json, &path).cloned(),
|
|
697
|
+
left_value: left_change.and_then(|c| c.new_value.clone()),
|
|
698
|
+
right_value: right_change.and_then(|c| c.new_value.clone()),
|
|
508
699
|
});
|
|
509
700
|
}
|
|
510
701
|
|
|
@@ -513,7 +704,10 @@ impl ConflictClassification {
|
|
|
513
704
|
|
|
514
705
|
let overall = if has_conflict {
|
|
515
706
|
ConflictClass::Conflict
|
|
516
|
-
} else if path_conflicts
|
|
707
|
+
} else if path_conflicts
|
|
708
|
+
.iter()
|
|
709
|
+
.any(|c| c.classification == ConflictClass::Convergent)
|
|
710
|
+
{
|
|
517
711
|
ConflictClass::Convergent
|
|
518
712
|
} else {
|
|
519
713
|
ConflictClass::Independent
|
|
@@ -526,6 +720,28 @@ impl ConflictClassification {
|
|
|
526
720
|
}
|
|
527
721
|
}
|
|
528
722
|
|
|
723
|
+
/// Whether `changes` holds any change at a path overlapping `path`, other than
|
|
724
|
+
/// at `path` itself.
|
|
725
|
+
///
|
|
726
|
+
/// Ancestors are the proper prefixes of `path`, so there are at most as many as
|
|
727
|
+
/// the path is deep. Descendants are contiguous in sorted order — every
|
|
728
|
+
/// sequence beginning with `path` sorts together, directly after `path` — so the
|
|
729
|
+
/// first key strictly greater than `path` decides whether any exists.
|
|
730
|
+
fn overlapping_change_exists(
|
|
731
|
+
changes: &BTreeMap<Vec<PathComponent>, &SemanticChange>,
|
|
732
|
+
path: &[PathComponent],
|
|
733
|
+
) -> bool {
|
|
734
|
+
for depth in 0..path.len() {
|
|
735
|
+
if changes.contains_key(&path[..depth].to_vec()) {
|
|
736
|
+
return true;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
changes
|
|
740
|
+
.range(path.to_vec()..)
|
|
741
|
+
.find(|(candidate, _)| candidate.as_slice() != path)
|
|
742
|
+
.is_some_and(|(candidate, _)| path_starts_with(candidate, path))
|
|
743
|
+
}
|
|
744
|
+
|
|
529
745
|
// ============================================================================
|
|
530
746
|
// Reconciliation: Explicit Parent Choice Mechanism
|
|
531
747
|
// ============================================================================
|
|
@@ -547,12 +763,7 @@ pub struct ReconciliationPlan {
|
|
|
547
763
|
|
|
548
764
|
impl ReconciliationPlan {
|
|
549
765
|
/// Create reconciliation plan with explicit parent choice
|
|
550
|
-
pub fn new(
|
|
551
|
-
left_id: StateId,
|
|
552
|
-
right_id: StateId,
|
|
553
|
-
base_id: StateId,
|
|
554
|
-
parent_choice: bool,
|
|
555
|
-
) -> Self {
|
|
766
|
+
pub fn new(left_id: StateId, right_id: StateId, base_id: StateId, parent_choice: bool) -> Self {
|
|
556
767
|
ReconciliationPlan {
|
|
557
768
|
left_id,
|
|
558
769
|
right_id,
|
|
@@ -586,301 +797,928 @@ pub struct StateReconciliationResult {
|
|
|
586
797
|
pub plan: ReconciliationPlan,
|
|
587
798
|
}
|
|
588
799
|
|
|
800
|
+
// ----------------------------------------------------------------------------
|
|
801
|
+
// Applying a reconciliation plan
|
|
802
|
+
// ----------------------------------------------------------------------------
|
|
803
|
+
//
|
|
804
|
+
// The plan says what the caller decided. Application produces the state that
|
|
805
|
+
// decision names, and nothing else:
|
|
806
|
+
//
|
|
807
|
+
// Classification — which changes can coexist?
|
|
808
|
+
// Plan — what happens to the ones that cannot?
|
|
809
|
+
// Application — produce the resulting state from that decision.
|
|
810
|
+
//
|
|
811
|
+
// Nothing here merges. Nothing here resolves a conflict on the caller's behalf.
|
|
812
|
+
// A path the plan does not mention keeps whatever the *selected parent* holds,
|
|
813
|
+
// which is the caller's decision too — it is what `parent_choice` selects.
|
|
814
|
+
|
|
815
|
+
/// The plan does not reference three usable revisions.
|
|
816
|
+
pub const RECONCILE_PLAN_INVALID: &str = "RECONCILE_PLAN_INVALID";
|
|
817
|
+
/// A supplied revision is not the one the plan names.
|
|
818
|
+
pub const RECONCILE_REVISION_MISMATCH: &str = "RECONCILE_REVISION_MISMATCH";
|
|
819
|
+
/// The selected parent's content is not JSON, so there is no state to start from.
|
|
820
|
+
pub const RECONCILE_CONTENT_UNPARSEABLE: &str = "RECONCILE_CONTENT_UNPARSEABLE";
|
|
821
|
+
/// Two override paths overlap, so the plan does not say what the result is.
|
|
822
|
+
pub const RECONCILE_OVERRIDE_PATHS_OVERLAP: &str = "RECONCILE_OVERRIDE_PATHS_OVERLAP";
|
|
823
|
+
/// An override path does not name an assignable location in the selected parent.
|
|
824
|
+
pub const RECONCILE_OVERRIDE_UNADDRESSABLE: &str = "RECONCILE_OVERRIDE_UNADDRESSABLE";
|
|
825
|
+
|
|
826
|
+
impl ReconciliationPlan {
|
|
827
|
+
/// The branch this plan selects as the state to start from.
|
|
828
|
+
///
|
|
829
|
+
/// `parent_choice` is a `bool` and so has exactly two values: `true` is the
|
|
830
|
+
/// left branch, `false` the right. The ancestor is not a choice, and there
|
|
831
|
+
/// is no third value for this to reject.
|
|
832
|
+
pub fn selected_parent<'a>(
|
|
833
|
+
&self,
|
|
834
|
+
left: &'a StateRevision,
|
|
835
|
+
right: &'a StateRevision,
|
|
836
|
+
) -> &'a StateRevision {
|
|
837
|
+
if self.parent_choice {
|
|
838
|
+
left
|
|
839
|
+
} else {
|
|
840
|
+
right
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/// The conflicting paths this plan supplies no override for.
|
|
845
|
+
///
|
|
846
|
+
/// Descriptive, not enforced. Application does **not** require an override
|
|
847
|
+
/// for every conflict: leaving one unaddressed resolves it to whatever the
|
|
848
|
+
/// selected parent holds, and selecting that parent is itself an explicit
|
|
849
|
+
/// caller decision. A caller that wants every conflict addressed
|
|
850
|
+
/// individually can require this to be empty before applying.
|
|
851
|
+
pub fn unresolved_conflicts(
|
|
852
|
+
&self,
|
|
853
|
+
classification: &ConflictClassification,
|
|
854
|
+
) -> Vec<Vec<PathComponent>> {
|
|
855
|
+
classification
|
|
856
|
+
.path_conflicts
|
|
857
|
+
.iter()
|
|
858
|
+
.filter(|entry| entry.classification == ConflictClass::Conflict)
|
|
859
|
+
.map(|entry| entry.path.clone())
|
|
860
|
+
.filter(|path| !self.path_overrides.contains_key(path))
|
|
861
|
+
.collect()
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/// Applies a reconciliation plan, producing canonical reconciled content.
|
|
866
|
+
///
|
|
867
|
+
/// Pure and total with respect to its inputs: it reads the three revisions and
|
|
868
|
+
/// the plan, mutates none of them, consults no clock, filesystem, network or
|
|
869
|
+
/// global state, and returns either canonical JSON or a stable error code.
|
|
870
|
+
///
|
|
871
|
+
/// # What it does
|
|
872
|
+
///
|
|
873
|
+
/// 1. Checks the plan validates and names the three revisions supplied.
|
|
874
|
+
/// 2. Takes the state of the branch `parent_choice` selects.
|
|
875
|
+
/// 3. Assigns each override into it.
|
|
876
|
+
/// 4. Serializes the result canonically.
|
|
877
|
+
///
|
|
878
|
+
/// # What it does not do
|
|
879
|
+
///
|
|
880
|
+
/// It does not merge, and it does not consult the other branch or the ancestor
|
|
881
|
+
/// for anything but identity. A conflicting path with no override keeps the
|
|
882
|
+
/// selected parent's value.
|
|
883
|
+
///
|
|
884
|
+
/// # Overrides
|
|
885
|
+
///
|
|
886
|
+
/// `path_overrides` is a map keyed by structural path, so a repeated path
|
|
887
|
+
/// cannot exist — the type forecloses it. What the type does not foreclose is
|
|
888
|
+
/// two paths where one *contains* the other, and such a plan does not say what
|
|
889
|
+
/// the result should be: does the inner override refine the outer value, or was
|
|
890
|
+
/// the outer one meant to stand? Rather than invent a precedence the plan never
|
|
891
|
+
/// stated, overlapping override paths are rejected. Overlap is the same
|
|
892
|
+
/// structural relation conflict classification uses, so `a.b` and `a.bc` are
|
|
893
|
+
/// not an overlap.
|
|
894
|
+
///
|
|
895
|
+
/// Because overlaps are rejected, the order overrides are applied in cannot
|
|
896
|
+
/// affect the result.
|
|
897
|
+
///
|
|
898
|
+
/// An override path must be *addressable* in the selected state: every proper
|
|
899
|
+
/// prefix must already resolve to a container of the matching kind. Assigning a
|
|
900
|
+
/// member to an existing object is allowed whether or not the member is already
|
|
901
|
+
/// there, because it creates no structure. Assigning past the end of an array is
|
|
902
|
+
/// not, because it would require inventing the elements in between. To change an
|
|
903
|
+
/// array's length, or to remove a member, override the container itself — a
|
|
904
|
+
/// `Value` cannot express absence, so removal has no other encoding in this
|
|
905
|
+
/// plan.
|
|
906
|
+
pub fn apply_reconciliation_plan(
|
|
907
|
+
base: &StateRevision,
|
|
908
|
+
left: &StateRevision,
|
|
909
|
+
right: &StateRevision,
|
|
910
|
+
plan: &ReconciliationPlan,
|
|
911
|
+
) -> Result<String, String> {
|
|
912
|
+
if !plan.validate() {
|
|
913
|
+
return Err(format!(
|
|
914
|
+
"{RECONCILE_PLAN_INVALID}: the plan does not reference three revisions"
|
|
915
|
+
));
|
|
916
|
+
}
|
|
917
|
+
for (label, expected, supplied) in [
|
|
918
|
+
("base", &plan.base_id, &base.id),
|
|
919
|
+
("left", &plan.left_id, &left.id),
|
|
920
|
+
("right", &plan.right_id, &right.id),
|
|
921
|
+
] {
|
|
922
|
+
if expected != supplied {
|
|
923
|
+
return Err(format!(
|
|
924
|
+
"{RECONCILE_REVISION_MISMATCH}: the plan names a different {label} revision"
|
|
925
|
+
));
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
let parent = plan.selected_parent(left, right);
|
|
930
|
+
let mut state: Value = serde_json::from_str(&parent.content).map_err(|error| {
|
|
931
|
+
// Classification tolerates unparseable content by reading it as JSON
|
|
932
|
+
// null, because it only describes. Materializing a state from content
|
|
933
|
+
// nobody can parse would be producing arbitrary state, so this refuses.
|
|
934
|
+
format!(
|
|
935
|
+
"{RECONCILE_CONTENT_UNPARSEABLE}: the selected parent's content is not JSON: {error}"
|
|
936
|
+
)
|
|
937
|
+
})?;
|
|
938
|
+
|
|
939
|
+
// `BTreeMap` sorts its keys, and every sequence with a given prefix sorts
|
|
940
|
+
// contiguously after it, so any containment relation shows up between two
|
|
941
|
+
// consecutive keys.
|
|
942
|
+
let paths: Vec<&Vec<PathComponent>> = plan.path_overrides.keys().collect();
|
|
943
|
+
for pair in paths.windows(2) {
|
|
944
|
+
if paths_overlap(pair[0], pair[1]) {
|
|
945
|
+
return Err(format!(
|
|
946
|
+
"{RECONCILE_OVERRIDE_PATHS_OVERLAP}: {:?} and {:?} overlap, so the plan does \
|
|
947
|
+
not say what the result is",
|
|
948
|
+
pair[0], pair[1]
|
|
949
|
+
));
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
for (path, value) in &plan.path_overrides {
|
|
954
|
+
assign_path(&mut state, path, value.clone())?;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
serde_json::to_string(&state)
|
|
958
|
+
.map_err(|error| format!("{RECONCILE_CONTENT_UNPARSEABLE}: {error}"))
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/// Assigns `value` at `path`, or reports why the path is not assignable.
|
|
962
|
+
fn assign_path(state: &mut Value, path: &[PathComponent], value: Value) -> Result<(), String> {
|
|
963
|
+
let Some((last, prefix)) = path.split_last() else {
|
|
964
|
+
// The empty path is the state itself.
|
|
965
|
+
*state = value;
|
|
966
|
+
return Ok(());
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
let mut current = state;
|
|
970
|
+
for (depth, component) in prefix.iter().enumerate() {
|
|
971
|
+
current = match component {
|
|
972
|
+
PathComponent::Key(key) => current.as_object_mut().and_then(|o| o.get_mut(key)),
|
|
973
|
+
PathComponent::Index(index) => current.as_array_mut().and_then(|a| a.get_mut(*index)),
|
|
974
|
+
}
|
|
975
|
+
.ok_or_else(|| {
|
|
976
|
+
format!(
|
|
977
|
+
"{RECONCILE_OVERRIDE_UNADDRESSABLE}: {path:?} does not resolve at \
|
|
978
|
+
component {depth} ({component:?})"
|
|
979
|
+
)
|
|
980
|
+
})?;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
match last {
|
|
984
|
+
// A new member of an existing object creates no structure.
|
|
985
|
+
PathComponent::Key(key) => match current.as_object_mut() {
|
|
986
|
+
Some(members) => {
|
|
987
|
+
members.insert(key.clone(), value);
|
|
988
|
+
Ok(())
|
|
989
|
+
}
|
|
990
|
+
None => Err(format!(
|
|
991
|
+
"{RECONCILE_OVERRIDE_UNADDRESSABLE}: {path:?} names a member of \
|
|
992
|
+
something that is not an object"
|
|
993
|
+
)),
|
|
994
|
+
},
|
|
995
|
+
// An index past the end would require inventing the elements before it.
|
|
996
|
+
PathComponent::Index(index) => match current.as_array_mut() {
|
|
997
|
+
Some(items) if *index < items.len() => {
|
|
998
|
+
items[*index] = value;
|
|
999
|
+
Ok(())
|
|
1000
|
+
}
|
|
1001
|
+
Some(items) => Err(format!(
|
|
1002
|
+
"{RECONCILE_OVERRIDE_UNADDRESSABLE}: {path:?} is past the end of an \
|
|
1003
|
+
array of length {}",
|
|
1004
|
+
items.len()
|
|
1005
|
+
)),
|
|
1006
|
+
None => Err(format!(
|
|
1007
|
+
"{RECONCILE_OVERRIDE_UNADDRESSABLE}: {path:?} indexes something that \
|
|
1008
|
+
is not an array"
|
|
1009
|
+
)),
|
|
1010
|
+
},
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/// Applies a plan and materializes the result as a new revision.
|
|
1015
|
+
///
|
|
1016
|
+
/// The revision is built with the existing [`StateRevision::child`] constructor,
|
|
1017
|
+
/// so it carries the existing content-addressed identity and takes the selected
|
|
1018
|
+
/// parent as its parent. Nothing here is a new identity algorithm, and nothing
|
|
1019
|
+
/// here moves a branch head, writes to a store, or touches replication: the
|
|
1020
|
+
/// result is a value the caller decides what to do with.
|
|
1021
|
+
///
|
|
1022
|
+
/// Unlike [`apply_reconciliation_plan`], this is not pure — `StateRevision`
|
|
1023
|
+
/// timestamps come from the clock, as they do for every other revision this
|
|
1024
|
+
/// model creates. The application semantics stay in the pure function.
|
|
1025
|
+
pub fn reconcile(
|
|
1026
|
+
base: &StateRevision,
|
|
1027
|
+
left: &StateRevision,
|
|
1028
|
+
right: &StateRevision,
|
|
1029
|
+
plan: &ReconciliationPlan,
|
|
1030
|
+
authority: String,
|
|
1031
|
+
) -> Result<StateReconciliationResult, String> {
|
|
1032
|
+
let content = apply_reconciliation_plan(base, left, right, plan)?;
|
|
1033
|
+
let parent = plan.selected_parent(left, right);
|
|
1034
|
+
Ok(StateReconciliationResult {
|
|
1035
|
+
materialized_state: StateRevision::child(content, parent, authority),
|
|
1036
|
+
plan: plan.clone(),
|
|
1037
|
+
})
|
|
1038
|
+
}
|
|
1039
|
+
|
|
589
1040
|
// ============================================================================
|
|
590
1041
|
// ============================================================================
|
|
591
1042
|
// StateStore: Durable State Persistence and Retrieval
|
|
592
1043
|
// ============================================================================
|
|
593
1044
|
|
|
594
|
-
///
|
|
595
|
-
///
|
|
596
|
-
/// StateStore
|
|
597
|
-
/// Revisions are persisted through FeltDB's operation log with key schema:
|
|
598
|
-
/// - "state:revision:{hex_id}" → StateRevision
|
|
599
|
-
/// - "state:current" → current pointer
|
|
600
|
-
/// - "state:branch:{name}" → branch pointers
|
|
1045
|
+
/// Durable, content-addressed revision storage.
|
|
1046
|
+
///
|
|
1047
|
+
/// `StateStore` answers exactly one question:
|
|
601
1048
|
///
|
|
602
|
-
///
|
|
603
|
-
/// `StateStore::with_feltdb()`. This ensures all mutations are persisted
|
|
604
|
-
/// through FeltDB's canonical operation log.
|
|
1049
|
+
/// > **Do I have revision X?**
|
|
605
1050
|
///
|
|
606
|
-
///
|
|
607
|
-
///
|
|
1051
|
+
/// It deliberately does **not** answer *"what is the current revision?"*. That
|
|
1052
|
+
/// separation is the point of this type. State history and currentness are
|
|
1053
|
+
/// different concerns, and conflating them is what produced the two defects the
|
|
1054
|
+
/// branching audit recorded: a global `current` that moved on every commit
|
|
1055
|
+
/// regardless of which parent was named, and a branch map sharing one replicated
|
|
1056
|
+
/// record with it. Both lived in a single `state:current` row that nothing in
|
|
1057
|
+
/// the reconciliation chain ever read.
|
|
1058
|
+
///
|
|
1059
|
+
/// # The contract
|
|
1060
|
+
///
|
|
1061
|
+
/// | operation | method |
|
|
1062
|
+
/// | --- | --- |
|
|
1063
|
+
/// | put | [`create`](Self::create), [`commit`](Self::commit) |
|
|
1064
|
+
/// | get | [`get`](Self::get) |
|
|
1065
|
+
///
|
|
1066
|
+
/// Ancestry is not a third operation: it is [`get`](Self::get) followed by
|
|
1067
|
+
/// `parent_id`, repeated. [`parent`](Self::parent), [`exists`](Self::exists) and
|
|
1068
|
+
/// [`metadata`](Self::metadata) are conveniences over `get`, not new powers.
|
|
1069
|
+
///
|
|
1070
|
+
/// # Key schema
|
|
1071
|
+
///
|
|
1072
|
+
/// - `state:revision:{hex_id}` → [`StateRevision`]
|
|
1073
|
+
///
|
|
1074
|
+
/// That is the entire schema. A record keyed by the hash of its own content
|
|
1075
|
+
/// cannot be stale and needs no reconstruction, so there is **no recovery
|
|
1076
|
+
/// path**: [`with_feltdb`](Self::with_feltdb) does not scan, rebuild an index,
|
|
1077
|
+
/// or validate history before returning.
|
|
1078
|
+
///
|
|
1079
|
+
/// # What this deliberately does not provide
|
|
1080
|
+
///
|
|
1081
|
+
/// No `current`, no branches, no branch heads, no "latest". Nothing above the
|
|
1082
|
+
/// history layer is invented here. Something must eventually own *"which
|
|
1083
|
+
/// revision represents the current application state"* — a ref, an application
|
|
1084
|
+
/// record, a per-resource pointer, a transaction head — and that owner is not
|
|
1085
|
+
/// this type. Until it exists, callers hold the ids they care about.
|
|
1086
|
+
///
|
|
1087
|
+
/// # Non-claims
|
|
1088
|
+
///
|
|
1089
|
+
/// Proven for this type: durable single-writer append, retrieval by identity
|
|
1090
|
+
/// across restart, ancestry by parent traversal, and independence between
|
|
1091
|
+
/// records. **Not** proven and not claimed: multi-writer persistence semantics,
|
|
1092
|
+
/// concurrent revision creation, authorization, resource isolation, garbage
|
|
1093
|
+
/// collection, server lifecycle integration, current/head semantics, and
|
|
1094
|
+
/// distributed replication of revision history.
|
|
608
1095
|
pub struct StateStore {
|
|
609
|
-
|
|
610
|
-
current_id: Arc<Mutex<Option<StateId>>>,
|
|
611
|
-
branches: Arc<Mutex<HashMap<String, StateId>>>,
|
|
612
|
-
/// FeltDB reference for durable persistence
|
|
613
|
-
/// For production: REQUIRED (Some)
|
|
614
|
-
/// For testing: Optional (None)
|
|
615
|
-
/// Mutations silently succeed with None, losing persistence.
|
|
616
|
-
/// Production code must verify Some before use.
|
|
617
|
-
feltdb: Option<Arc<crate::FeltDb>>,
|
|
1096
|
+
backing: Backing,
|
|
618
1097
|
}
|
|
619
1098
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
///
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
1099
|
+
/// Where revisions actually live.
|
|
1100
|
+
///
|
|
1101
|
+
/// There is exactly one copy of a revision in either mode. The durable mode
|
|
1102
|
+
/// keeps no second in-memory index beside FeltDB's own rows.
|
|
1103
|
+
enum Backing {
|
|
1104
|
+
/// In-memory only, for testing state semantics in isolation.
|
|
1105
|
+
Volatile(Arc<Mutex<VolatileState>>),
|
|
1106
|
+
/// FeltDB is the storage, and keyed reads are the authoritative read path.
|
|
1107
|
+
Durable(Arc<crate::FeltDb>),
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/// The volatile backing's contents: revisions, and per-resource retention.
|
|
1111
|
+
#[derive(Default)]
|
|
1112
|
+
struct VolatileState {
|
|
1113
|
+
revisions: HashMap<StateId, StateRevision>,
|
|
1114
|
+
retention: HashMap<String, RetentionState>,
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
impl Clone for Backing {
|
|
1118
|
+
fn clone(&self) -> Self {
|
|
1119
|
+
match self {
|
|
1120
|
+
Backing::Volatile(map) => Backing::Volatile(map.clone()),
|
|
1121
|
+
Backing::Durable(db) => Backing::Durable(db.clone()),
|
|
633
1122
|
}
|
|
634
1123
|
}
|
|
1124
|
+
}
|
|
635
1125
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
///
|
|
654
|
-
/// Recovery reconstructs the full state model from FeltDB records:
|
|
655
|
-
/// 1. Queries all StateRevision objects and deserializes them
|
|
656
|
-
/// 2. Retrieves "state:current" to restore the current pointer and branches
|
|
657
|
-
/// 3. Validates all parent references point to existing revisions
|
|
658
|
-
/// 4. Validates each StateRevision's content matches its StateId
|
|
659
|
-
/// 5. Validates topology consistency
|
|
1126
|
+
/// The durable key for a revision.
|
|
1127
|
+
fn revision_key(id: &StateId) -> String {
|
|
1128
|
+
format!("state:revision:{}", id.as_hex())
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/// Where a resource's retention watermark lives.
|
|
1132
|
+
fn retention_key(resource: &str) -> String {
|
|
1133
|
+
format!("_retention:{resource}")
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/// How much of a resource's history is kept.
|
|
1137
|
+
///
|
|
1138
|
+
/// Count-based only. Age-based retention is deliberately not implemented:
|
|
1139
|
+
/// nothing in the system needs it yet, and inventing a second policy dimension
|
|
1140
|
+
/// before the first has been measured would be guesswork.
|
|
1141
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
1142
|
+
pub struct RetentionPolicy {
|
|
1143
|
+
/// Newest revisions to keep, or `None` to keep every revision.
|
|
660
1144
|
///
|
|
661
|
-
///
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
// Step 1: Recover all state revisions from FeltDB
|
|
665
|
-
// Query returns all StateRevision objects stored in FeltDB
|
|
666
|
-
let revisions: Vec<StateRevision> = feltdb
|
|
667
|
-
.query(|_rev: &StateRevision| true)
|
|
668
|
-
.map_err(|e| format!("Failed to query revisions from FeltDB: {}", e))?;
|
|
669
|
-
|
|
670
|
-
let mut revisions_map = HashMap::new();
|
|
671
|
-
for revision in revisions {
|
|
672
|
-
// Validate content matches id (this is the integrity check)
|
|
673
|
-
if !revision.verify_integrity() {
|
|
674
|
-
return Err(format!(
|
|
675
|
-
"StateId mismatch during recovery: stored={}, content_hash={}",
|
|
676
|
-
revision.id.as_hex(),
|
|
677
|
-
StateId::compute(&revision.content).as_hex()
|
|
678
|
-
));
|
|
679
|
-
}
|
|
1145
|
+
/// Clamped to at least 1: **a resource's current revision always survives.**
|
|
1146
|
+
pub keep_last: Option<usize>,
|
|
1147
|
+
}
|
|
680
1148
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1149
|
+
impl RetentionPolicy {
|
|
1150
|
+
/// Keep everything. The default, because silently discarding history would
|
|
1151
|
+
/// be a worse surprise than unbounded growth.
|
|
1152
|
+
pub const fn unbounded() -> Self {
|
|
1153
|
+
RetentionPolicy { keep_last: None }
|
|
1154
|
+
}
|
|
686
1155
|
|
|
687
|
-
|
|
1156
|
+
/// Keep the newest `count` revisions of a resource.
|
|
1157
|
+
pub fn keep_last(count: usize) -> Self {
|
|
1158
|
+
RetentionPolicy {
|
|
1159
|
+
keep_last: Some(count.max(1)),
|
|
688
1160
|
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
689
1163
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
"Parent reference missing during recovery: revision={}, parent={}",
|
|
696
|
-
revision.id.as_hex(),
|
|
697
|
-
parent_id.as_hex()
|
|
698
|
-
));
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
}
|
|
1164
|
+
impl Default for RetentionPolicy {
|
|
1165
|
+
fn default() -> Self {
|
|
1166
|
+
Self::unbounded()
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
702
1169
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1170
|
+
/// A resource's retention state: its policy, and how far its history has been
|
|
1171
|
+
/// expired.
|
|
1172
|
+
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
1173
|
+
struct RetentionState {
|
|
1174
|
+
policy: RetentionPolicy,
|
|
1175
|
+
/// The lowest sequence still retained. Everything below it was expired on
|
|
1176
|
+
/// purpose, and is distinguishable from a parent that never existed.
|
|
1177
|
+
horizon: u64,
|
|
1178
|
+
}
|
|
707
1179
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
1180
|
+
/// Decide what a retention policy expires, given a resource's history.
|
|
1181
|
+
///
|
|
1182
|
+
/// Pure, and the single definition of what retention *means*, so the explicit
|
|
1183
|
+
/// store API and the automatic mint at the write boundary cannot drift apart.
|
|
1184
|
+
///
|
|
1185
|
+
/// `history` is `(id, sequence)` oldest first. Returns the revisions to expire
|
|
1186
|
+
/// and the new horizon — the lowest sequence still retained.
|
|
1187
|
+
///
|
|
1188
|
+
/// Deletion is from the oldest end only, so what survives is always a suffix of
|
|
1189
|
+
/// the resource's timeline and **the current revision always survives**.
|
|
1190
|
+
pub fn revisions_to_expire(
|
|
1191
|
+
history: &[(StateId, u64)],
|
|
1192
|
+
keep_last: Option<usize>,
|
|
1193
|
+
) -> (Vec<StateId>, Option<u64>) {
|
|
1194
|
+
let Some(keep) = keep_last else {
|
|
1195
|
+
return (Vec::new(), None);
|
|
1196
|
+
};
|
|
1197
|
+
let keep = keep.max(1);
|
|
1198
|
+
if history.len() <= keep {
|
|
1199
|
+
return (Vec::new(), None);
|
|
1200
|
+
}
|
|
1201
|
+
let split = history.len() - keep;
|
|
1202
|
+
let expire = history[..split].iter().map(|(id, _)| id.clone()).collect();
|
|
1203
|
+
let horizon = history[split].1;
|
|
1204
|
+
(expire, Some(horizon))
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/// Why a revision could not be minted.
|
|
1208
|
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
1209
|
+
pub enum RevisionError {
|
|
1210
|
+
/// Invariant 2: the named parent is not a revision this store holds.
|
|
1211
|
+
UnknownParent(StateId),
|
|
1212
|
+
/// Invariant 1: that identity is already committed, and commits are final.
|
|
1213
|
+
///
|
|
1214
|
+
/// Under the resource-scoped model this should be unreachable through
|
|
1215
|
+
/// `mint`, because the sequence is allocated from the resource's own head.
|
|
1216
|
+
/// It is enforced anyway: immutability is the invariant, not a consequence.
|
|
1217
|
+
AlreadyCommitted(StateId),
|
|
1218
|
+
/// A parent belonging to a different resource.
|
|
1219
|
+
ForeignParent { expected: String, found: String },
|
|
1220
|
+
/// The resource already has a first revision; a resource has one beginning.
|
|
1221
|
+
ResourceAlreadyBegun { resource: String, head: StateId },
|
|
1222
|
+
/// The revision's own identity does not match what it is computed from.
|
|
1223
|
+
IntegrityFailed,
|
|
1224
|
+
/// The backing store could not be read or written.
|
|
1225
|
+
Storage(String),
|
|
1226
|
+
}
|
|
722
1227
|
|
|
723
|
-
|
|
1228
|
+
impl std::fmt::Display for RevisionError {
|
|
1229
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
1230
|
+
match self {
|
|
1231
|
+
RevisionError::UnknownParent(id) => {
|
|
1232
|
+
write!(f, "parent {id} is not a revision this store holds")
|
|
1233
|
+
}
|
|
1234
|
+
RevisionError::AlreadyCommitted(id) => {
|
|
1235
|
+
write!(
|
|
1236
|
+
f,
|
|
1237
|
+
"revision {id} is already committed and cannot be rewritten"
|
|
1238
|
+
)
|
|
1239
|
+
}
|
|
1240
|
+
RevisionError::ForeignParent { expected, found } => {
|
|
1241
|
+
write!(f, "parent belongs to resource {found}, not {expected}")
|
|
724
1242
|
}
|
|
1243
|
+
RevisionError::ResourceAlreadyBegun { resource, head } => {
|
|
1244
|
+
write!(f, "resource {resource} already begins at revision {head}")
|
|
1245
|
+
}
|
|
1246
|
+
RevisionError::IntegrityFailed => write!(f, "state integrity verification failed"),
|
|
1247
|
+
RevisionError::Storage(message) => write!(f, "{message}"),
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
725
1251
|
|
|
726
|
-
|
|
727
|
-
for (branch_name, branch_id_hex) in recovered_branches {
|
|
728
|
-
let parsed_id = StateId::from_hex(branch_id_hex.clone());
|
|
1252
|
+
impl std::error::Error for RevisionError {}
|
|
729
1253
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1254
|
+
/// What following a revision's parent link found.
|
|
1255
|
+
///
|
|
1256
|
+
/// The three outcomes are deliberately distinct. An **expired** parent is a
|
|
1257
|
+
/// retention decision the system made on purpose; an **unknown** one is a bug.
|
|
1258
|
+
/// Collapsing them would recreate the dangling-parent defect at the other end
|
|
1259
|
+
/// of the system, where retention rather than minting produced it.
|
|
1260
|
+
#[derive(Clone, Debug)]
|
|
1261
|
+
pub enum ParentLookup {
|
|
1262
|
+
/// This revision began its resource's history.
|
|
1263
|
+
Root,
|
|
1264
|
+
/// The parent, still retained.
|
|
1265
|
+
Revision(Box<StateRevision>),
|
|
1266
|
+
/// The parent was expired by retention. Its identity is still named by the
|
|
1267
|
+
/// child, and the child's ancestry is still true — it is simply no longer
|
|
1268
|
+
/// stored.
|
|
1269
|
+
Expired(StateId),
|
|
1270
|
+
/// The parent is named but absent and *not* below the retention horizon.
|
|
1271
|
+
/// The model does not permit minting one; finding one means damage.
|
|
1272
|
+
Missing(StateId),
|
|
1273
|
+
}
|
|
737
1274
|
|
|
738
|
-
|
|
739
|
-
|
|
1275
|
+
impl ParentLookup {
|
|
1276
|
+
/// The identity this lookup names, if it names one.
|
|
1277
|
+
pub fn id(&self) -> Option<&StateId> {
|
|
1278
|
+
match self {
|
|
1279
|
+
ParentLookup::Root => None,
|
|
1280
|
+
ParentLookup::Revision(revision) => Some(&revision.id),
|
|
1281
|
+
ParentLookup::Expired(id) | ParentLookup::Missing(id) => Some(id),
|
|
740
1282
|
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
741
1285
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1286
|
+
/// Equality is over the outcome and the identity named, not the whole revision.
|
|
1287
|
+
impl PartialEq for ParentLookup {
|
|
1288
|
+
fn eq(&self, other: &Self) -> bool {
|
|
1289
|
+
std::mem::discriminant(self) == std::mem::discriminant(other) && self.id() == other.id()
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
impl StateStore {
|
|
1294
|
+
/// A volatile (in-memory) store, for testing state semantics in isolation.
|
|
1295
|
+
///
|
|
1296
|
+
/// Revisions are lost when the store is dropped. Production must use
|
|
1297
|
+
/// [`with_feltdb`](Self::with_feltdb).
|
|
1298
|
+
pub fn new_volatile() -> Self {
|
|
1299
|
+
StateStore {
|
|
1300
|
+
backing: Backing::Volatile(Arc::new(Mutex::new(VolatileState::default()))),
|
|
746
1301
|
}
|
|
1302
|
+
}
|
|
747
1303
|
|
|
748
|
-
|
|
1304
|
+
/// A store backed by FeltDB's canonical persistence.
|
|
1305
|
+
///
|
|
1306
|
+
/// This performs **no recovery**. Revisions are records under
|
|
1307
|
+
/// `state:revision:{hex}`; they are already durable, so there is no index to
|
|
1308
|
+
/// rebuild and nothing to validate up front. A revision's integrity is
|
|
1309
|
+
/// checked when it is written, and can be rechecked by whoever reads it.
|
|
1310
|
+
///
|
|
1311
|
+
/// The `Result` is retained because callers depend on it; today it is always
|
|
1312
|
+
/// `Ok`.
|
|
1313
|
+
pub fn with_feltdb(feltdb: Arc<crate::FeltDb>) -> Result<Self, String> {
|
|
749
1314
|
Ok(StateStore {
|
|
750
|
-
|
|
751
|
-
current_id: Arc::new(Mutex::new(current_id)),
|
|
752
|
-
branches: Arc::new(Mutex::new(branches_map)),
|
|
753
|
-
feltdb: Some(feltdb),
|
|
1315
|
+
backing: Backing::Durable(feltdb),
|
|
754
1316
|
})
|
|
755
1317
|
}
|
|
756
1318
|
|
|
757
|
-
|
|
1319
|
+
// -- minting ------------------------------------------------------------
|
|
1320
|
+
|
|
1321
|
+
/// Begin a resource's history.
|
|
1322
|
+
///
|
|
1323
|
+
/// Refused if the resource already has one. A resource has a single
|
|
1324
|
+
/// beginning; without that, `history_of` would return a forest rather than
|
|
1325
|
+
/// a chain and "the previous state of this resource" would be ambiguous
|
|
1326
|
+
/// again. To extend an existing history, use [`commit`](Self::commit) or
|
|
1327
|
+
/// [`mint`](Self::mint).
|
|
758
1328
|
pub fn create(
|
|
759
1329
|
&self,
|
|
1330
|
+
resource: &str,
|
|
760
1331
|
content: String,
|
|
761
1332
|
authority: String,
|
|
762
|
-
) -> Result<StateRevision,
|
|
763
|
-
let
|
|
764
|
-
|
|
1333
|
+
) -> Result<StateRevision, RevisionError> {
|
|
1334
|
+
if let Some(head) = self.head_of(resource) {
|
|
1335
|
+
return Err(RevisionError::ResourceAlreadyBegun {
|
|
1336
|
+
resource: resource.to_string(),
|
|
1337
|
+
head: head.id,
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
self.mint(resource, content, None, authority)
|
|
765
1341
|
}
|
|
766
1342
|
|
|
767
|
-
/// Commit a
|
|
1343
|
+
/// Commit a revision descending from `parent`, on the parent's resource.
|
|
768
1344
|
pub fn commit(
|
|
769
1345
|
&self,
|
|
770
1346
|
content: String,
|
|
771
1347
|
parent: &StateRevision,
|
|
772
1348
|
authority: String,
|
|
773
|
-
) -> Result<StateRevision,
|
|
774
|
-
let
|
|
775
|
-
self.
|
|
1349
|
+
) -> Result<StateRevision, RevisionError> {
|
|
1350
|
+
let resource = parent.resource.clone();
|
|
1351
|
+
self.mint(&resource, content, Some(parent), authority)
|
|
776
1352
|
}
|
|
777
1353
|
|
|
778
|
-
///
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1354
|
+
/// Mint a revision of `resource`, optionally descending from `parent`.
|
|
1355
|
+
///
|
|
1356
|
+
/// Enforces both invariants at the only point where they can be enforced:
|
|
1357
|
+
///
|
|
1358
|
+
/// 1. A parent must resolve to a revision this store holds, and must belong
|
|
1359
|
+
/// to the same resource. Parent existence is part of what makes a
|
|
1360
|
+
/// revision valid, not a lookup applied afterwards.
|
|
1361
|
+
/// 2. A committed revision is never rewritten. The sequence is allocated
|
|
1362
|
+
/// from the resource's own head, so a state reached twice is two
|
|
1363
|
+
/// revisions with two identities.
|
|
1364
|
+
///
|
|
1365
|
+
/// Retention is applied to the resource afterwards, so a configured policy
|
|
1366
|
+
/// bounds history at the moment history is created rather than at some
|
|
1367
|
+
/// later maintenance pass that may never run.
|
|
1368
|
+
pub fn mint(
|
|
1369
|
+
&self,
|
|
1370
|
+
resource: &str,
|
|
1371
|
+
content: String,
|
|
1372
|
+
parent: Option<&StateRevision>,
|
|
1373
|
+
authority: String,
|
|
1374
|
+
) -> Result<StateRevision, RevisionError> {
|
|
1375
|
+
if let Some(parent) = parent {
|
|
1376
|
+
if parent.resource != resource {
|
|
1377
|
+
return Err(RevisionError::ForeignParent {
|
|
1378
|
+
expected: resource.to_string(),
|
|
1379
|
+
found: parent.resource.clone(),
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
if !self.exists(&parent.id) {
|
|
1383
|
+
return Err(RevisionError::UnknownParent(parent.id.clone()));
|
|
1384
|
+
}
|
|
782
1385
|
}
|
|
783
1386
|
|
|
784
|
-
let
|
|
785
|
-
|
|
786
|
-
|
|
1387
|
+
let sequence = match parent {
|
|
1388
|
+
Some(parent) => parent.sequence + 1,
|
|
1389
|
+
None => 0,
|
|
1390
|
+
};
|
|
1391
|
+
let revision = StateRevision::at(
|
|
1392
|
+
resource.to_string(),
|
|
1393
|
+
content,
|
|
1394
|
+
parent.map(|parent| parent.id.clone()),
|
|
1395
|
+
sequence,
|
|
1396
|
+
authority,
|
|
1397
|
+
);
|
|
1398
|
+
let committed = self.commit_revision(revision)?;
|
|
1399
|
+
self.apply_retention(resource)?;
|
|
1400
|
+
Ok(committed)
|
|
1401
|
+
}
|
|
787
1402
|
|
|
788
|
-
|
|
789
|
-
|
|
1403
|
+
/// Store a revision, after checking its integrity and that it is new.
|
|
1404
|
+
fn commit_revision(&self, revision: StateRevision) -> Result<StateRevision, RevisionError> {
|
|
1405
|
+
if !revision.verify_integrity() {
|
|
1406
|
+
return Err(RevisionError::IntegrityFailed);
|
|
1407
|
+
}
|
|
1408
|
+
// Re-committing an identical historical fact is a no-op, not a rewrite:
|
|
1409
|
+
// the identity is a function of the resource, the content, the parent
|
|
1410
|
+
// and the sequence, so an existing revision with this identity records
|
|
1411
|
+
// exactly what is being committed. Returning it keeps immutability
|
|
1412
|
+
// without making a harmless repeat an error.
|
|
1413
|
+
if let Some(existing) = self.get(&revision.id) {
|
|
1414
|
+
let same = existing.resource == revision.resource
|
|
1415
|
+
&& existing.content_id == revision.content_id
|
|
1416
|
+
&& existing.parent_id == revision.parent_id
|
|
1417
|
+
&& existing.sequence == revision.sequence;
|
|
1418
|
+
return if same {
|
|
1419
|
+
Ok(existing)
|
|
1420
|
+
} else {
|
|
1421
|
+
Err(RevisionError::AlreadyCommitted(revision.id.clone()))
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
match &self.backing {
|
|
1425
|
+
Backing::Volatile(state) => {
|
|
1426
|
+
state
|
|
1427
|
+
.lock()
|
|
1428
|
+
.unwrap()
|
|
1429
|
+
.revisions
|
|
1430
|
+
.insert(revision.id.clone(), revision.clone());
|
|
1431
|
+
}
|
|
1432
|
+
Backing::Durable(feltdb) => {
|
|
1433
|
+
feltdb
|
|
1434
|
+
.insert(&revision_key(&revision.id), revision.clone())
|
|
1435
|
+
.map_err(|error| {
|
|
1436
|
+
RevisionError::Storage(format!(
|
|
1437
|
+
"Failed to persist revision to FeltDB: {error}"
|
|
1438
|
+
))
|
|
1439
|
+
})?;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
Ok(revision)
|
|
1443
|
+
}
|
|
790
1444
|
|
|
791
|
-
|
|
792
|
-
if let Some(feltdb) = &self.feltdb {
|
|
793
|
-
let revision_key = format!("state:revision:{}", id.as_hex());
|
|
794
|
-
|
|
795
|
-
// Persist the revision through FeltDB's operation log
|
|
796
|
-
feltdb.insert(&revision_key, revision.clone())
|
|
797
|
-
.map_err(|e| format!("Failed to persist revision to FeltDB: {}", e))?;
|
|
1445
|
+
// -- reading ------------------------------------------------------------
|
|
798
1446
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
1447
|
+
/// Retrieve a revision by identity. This is the authoritative read path.
|
|
1448
|
+
pub fn get(&self, id: &StateId) -> Option<StateRevision> {
|
|
1449
|
+
match &self.backing {
|
|
1450
|
+
Backing::Volatile(state) => state.lock().unwrap().revisions.get(id).cloned(),
|
|
1451
|
+
Backing::Durable(feltdb) => feltdb
|
|
1452
|
+
.get::<StateRevision>(&revision_key(id))
|
|
1453
|
+
.ok()
|
|
1454
|
+
.flatten(),
|
|
804
1455
|
}
|
|
1456
|
+
}
|
|
805
1457
|
|
|
806
|
-
|
|
1458
|
+
/// Whether a revision is present.
|
|
1459
|
+
pub fn exists(&self, id: &StateId) -> bool {
|
|
1460
|
+
self.get(id).is_some()
|
|
807
1461
|
}
|
|
808
1462
|
|
|
809
|
-
///
|
|
810
|
-
pub fn
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1463
|
+
/// Metadata for a revision.
|
|
1464
|
+
pub fn metadata(&self, id: &StateId) -> Option<BTreeMap<String, Value>> {
|
|
1465
|
+
self.get(id).map(|revision| revision.metadata)
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/// The parent of a revision, and — when it is not there — why.
|
|
1469
|
+
pub fn parent_of(&self, id: &StateId) -> Option<ParentLookup> {
|
|
1470
|
+
let revision = self.get(id)?;
|
|
1471
|
+
let Some(parent_id) = revision.parent_id else {
|
|
1472
|
+
return Some(ParentLookup::Root);
|
|
1473
|
+
};
|
|
1474
|
+
if let Some(parent) = self.get(&parent_id) {
|
|
1475
|
+
return Some(ParentLookup::Revision(Box::new(parent)));
|
|
1476
|
+
}
|
|
1477
|
+
let horizon = self.retention_state(&revision.resource).horizon;
|
|
1478
|
+
Some(if revision.sequence <= horizon {
|
|
1479
|
+
ParentLookup::Expired(parent_id)
|
|
815
1480
|
} else {
|
|
816
|
-
|
|
1481
|
+
ParentLookup::Missing(parent_id)
|
|
1482
|
+
})
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/// The parent of a revision, if it is still retained.
|
|
1486
|
+
///
|
|
1487
|
+
/// Repeating this walks the ancestry. It is a traversal, not a primitive.
|
|
1488
|
+
/// Use [`parent_of`](Self::parent_of) to tell a resource's beginning from a
|
|
1489
|
+
/// horizon.
|
|
1490
|
+
pub fn parent(&self, id: &StateId) -> Option<StateRevision> {
|
|
1491
|
+
match self.parent_of(id)? {
|
|
1492
|
+
ParentLookup::Revision(parent) => Some(*parent),
|
|
1493
|
+
_ => None,
|
|
817
1494
|
}
|
|
818
1495
|
}
|
|
819
1496
|
|
|
820
|
-
///
|
|
821
|
-
pub fn
|
|
822
|
-
let
|
|
823
|
-
|
|
1497
|
+
/// Every retained revision of one resource, oldest first.
|
|
1498
|
+
pub fn history_of(&self, resource: &str) -> Vec<StateRevision> {
|
|
1499
|
+
let mut history: Vec<StateRevision> = self
|
|
1500
|
+
.all_revisions()
|
|
1501
|
+
.into_iter()
|
|
1502
|
+
.filter(|revision| revision.resource == resource)
|
|
1503
|
+
.collect();
|
|
1504
|
+
history.sort_by_key(|revision| revision.sequence);
|
|
1505
|
+
history
|
|
824
1506
|
}
|
|
825
1507
|
|
|
826
|
-
///
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1508
|
+
/// A resource's newest retained revision.
|
|
1509
|
+
///
|
|
1510
|
+
/// Automatic minting produces a chain, so this is unambiguous for anything
|
|
1511
|
+
/// the write path created. Explicit `commit` may fork a resource — which is
|
|
1512
|
+
/// exactly what divergence and three-way reconciliation need — and where it
|
|
1513
|
+
/// has, this is the newest by sequence, ties broken by identity so the
|
|
1514
|
+
/// answer is deterministic rather than incidental.
|
|
1515
|
+
pub fn head_of(&self, resource: &str) -> Option<StateRevision> {
|
|
1516
|
+
self.history_of(resource).into_iter().max_by(|one, two| {
|
|
1517
|
+
one.sequence
|
|
1518
|
+
.cmp(&two.sequence)
|
|
1519
|
+
.then_with(|| one.id.as_hex().cmp(two.id.as_hex()))
|
|
1520
|
+
})
|
|
830
1521
|
}
|
|
831
1522
|
|
|
832
|
-
///
|
|
833
|
-
pub fn
|
|
834
|
-
let
|
|
835
|
-
|
|
1523
|
+
/// Every resource this store holds history for.
|
|
1524
|
+
pub fn resources(&self) -> Vec<String> {
|
|
1525
|
+
let mut names: Vec<String> = self
|
|
1526
|
+
.all_revisions()
|
|
1527
|
+
.into_iter()
|
|
1528
|
+
.map(|revision| revision.resource)
|
|
1529
|
+
.collect();
|
|
1530
|
+
names.sort();
|
|
1531
|
+
names.dedup();
|
|
1532
|
+
names
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
fn all_revisions(&self) -> Vec<StateRevision> {
|
|
1536
|
+
match &self.backing {
|
|
1537
|
+
Backing::Volatile(state) => state.lock().unwrap().revisions.values().cloned().collect(),
|
|
1538
|
+
Backing::Durable(feltdb) => feltdb
|
|
1539
|
+
.list_collection("state")
|
|
1540
|
+
.unwrap_or_default()
|
|
1541
|
+
.into_iter()
|
|
1542
|
+
.filter(|row| row.key.starts_with("state:revision:") && !row.deleted)
|
|
1543
|
+
.filter_map(|row| serde_json::from_value::<StateRevision>(row.value).ok())
|
|
1544
|
+
.collect(),
|
|
1545
|
+
}
|
|
836
1546
|
}
|
|
837
1547
|
|
|
838
|
-
///
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1548
|
+
/// A digest of this store's **history**: the revision graph and retention
|
|
1549
|
+
/// state, and nothing else.
|
|
1550
|
+
///
|
|
1551
|
+
/// Deliberately independent of a digest over current records. Two databases
|
|
1552
|
+
/// can hold identical current state and completely different histories —
|
|
1553
|
+
/// one that recorded every step and one that only ever saw the last — and
|
|
1554
|
+
/// any equivalence check that cannot tell those apart is not checking
|
|
1555
|
+
/// history at all.
|
|
1556
|
+
///
|
|
1557
|
+
/// Covers, for every revision: its resource, identity, parent, sequence,
|
|
1558
|
+
/// content identity and the authority that committed it; and for every
|
|
1559
|
+
/// resource: its retention policy and horizon. It excludes wall-clock
|
|
1560
|
+
/// timestamps, which two authorities holding the same history legitimately
|
|
1561
|
+
/// disagree about.
|
|
1562
|
+
pub fn history_digest(&self) -> String {
|
|
1563
|
+
let mut entries: Vec<String> = self
|
|
1564
|
+
.all_revisions()
|
|
1565
|
+
.into_iter()
|
|
1566
|
+
.map(|revision| {
|
|
1567
|
+
format!(
|
|
1568
|
+
"{}|{}|{}|{}|{}|{}",
|
|
1569
|
+
revision.resource,
|
|
1570
|
+
revision.id.as_hex(),
|
|
1571
|
+
revision
|
|
1572
|
+
.parent_id
|
|
1573
|
+
.as_ref()
|
|
1574
|
+
.map(StateId::as_hex)
|
|
1575
|
+
.unwrap_or("-"),
|
|
1576
|
+
revision.sequence,
|
|
1577
|
+
revision.content_id.as_hex(),
|
|
1578
|
+
revision.authority,
|
|
1579
|
+
)
|
|
1580
|
+
})
|
|
1581
|
+
.collect();
|
|
1582
|
+
for resource in self.resources() {
|
|
1583
|
+
let state = self.retention_state(&resource);
|
|
1584
|
+
entries.push(format!(
|
|
1585
|
+
"retention|{}|{:?}|{}",
|
|
1586
|
+
resource, state.policy.keep_last, state.horizon
|
|
1587
|
+
));
|
|
845
1588
|
}
|
|
846
|
-
|
|
1589
|
+
entries.sort();
|
|
1590
|
+
|
|
1591
|
+
let mut hasher = Sha256::new();
|
|
1592
|
+
hasher.update(b"feltdb.history.v1");
|
|
1593
|
+
for entry in entries {
|
|
1594
|
+
hasher.update(entry.as_bytes());
|
|
1595
|
+
hasher.update([0u8]);
|
|
1596
|
+
}
|
|
1597
|
+
format!("{:x}", hasher.finalize())
|
|
847
1598
|
}
|
|
848
1599
|
|
|
849
|
-
|
|
850
|
-
pub fn create_branch(&self, name: String, revision_id: StateId) -> Result<(), String> {
|
|
851
|
-
let mut branches = self.branches.lock().unwrap();
|
|
852
|
-
branches.insert(name.clone(), revision_id.clone());
|
|
1600
|
+
// -- retention ----------------------------------------------------------
|
|
853
1601
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1602
|
+
/// The retention policy in force for a resource.
|
|
1603
|
+
pub fn retention_policy(&self, resource: &str) -> RetentionPolicy {
|
|
1604
|
+
self.retention_state(resource).policy
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/// The lowest sequence still retained for a resource.
|
|
1608
|
+
///
|
|
1609
|
+
/// Revisions below it were expired deliberately. This is what separates an
|
|
1610
|
+
/// expired parent from a missing one.
|
|
1611
|
+
pub fn retention_horizon(&self, resource: &str) -> u64 {
|
|
1612
|
+
self.retention_state(resource).horizon
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/// Set a resource's retention policy, and apply it immediately.
|
|
1616
|
+
///
|
|
1617
|
+
/// Returns how many revisions the policy expired on application.
|
|
1618
|
+
pub fn set_retention_policy(
|
|
1619
|
+
&self,
|
|
1620
|
+
resource: &str,
|
|
1621
|
+
policy: RetentionPolicy,
|
|
1622
|
+
) -> Result<usize, RevisionError> {
|
|
1623
|
+
let mut state = self.retention_state(resource);
|
|
1624
|
+
state.policy = policy;
|
|
1625
|
+
self.write_retention_state(resource, &state)?;
|
|
1626
|
+
self.apply_retention(resource)
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/// Expire whatever the resource's policy no longer retains.
|
|
1630
|
+
///
|
|
1631
|
+
/// Deletes from the **oldest** end only, so the surviving history is always
|
|
1632
|
+
/// a suffix of the resource's timeline and the current revision always
|
|
1633
|
+
/// survives. A surviving revision's `parent_id` is never rewritten — that
|
|
1634
|
+
/// would violate immutability — so the horizon is recorded instead, and the
|
|
1635
|
+
/// oldest survivor reports an `Expired` parent rather than a missing one.
|
|
1636
|
+
pub fn apply_retention(&self, resource: &str) -> Result<usize, RevisionError> {
|
|
1637
|
+
let mut state = self.retention_state(resource);
|
|
1638
|
+
if state.policy.keep_last.is_none() {
|
|
1639
|
+
return Ok(0);
|
|
1640
|
+
}
|
|
1641
|
+
let history: Vec<(StateId, u64)> = self
|
|
1642
|
+
.history_of(resource)
|
|
1643
|
+
.into_iter()
|
|
1644
|
+
.map(|revision| (revision.id, revision.sequence))
|
|
1645
|
+
.collect();
|
|
1646
|
+
let (expire, horizon) = revisions_to_expire(&history, state.policy.keep_last);
|
|
1647
|
+
if expire.is_empty() {
|
|
1648
|
+
return Ok(0);
|
|
1649
|
+
}
|
|
1650
|
+
for id in &expire {
|
|
1651
|
+
self.remove(id)?;
|
|
865
1652
|
}
|
|
1653
|
+
if let Some(horizon) = horizon {
|
|
1654
|
+
state.horizon = horizon;
|
|
1655
|
+
}
|
|
1656
|
+
self.write_retention_state(resource, &state)?;
|
|
1657
|
+
Ok(expire.len())
|
|
1658
|
+
}
|
|
866
1659
|
|
|
1660
|
+
fn remove(&self, id: &StateId) -> Result<(), RevisionError> {
|
|
1661
|
+
match &self.backing {
|
|
1662
|
+
Backing::Volatile(state) => {
|
|
1663
|
+
state.lock().unwrap().revisions.remove(id);
|
|
1664
|
+
}
|
|
1665
|
+
Backing::Durable(feltdb) => {
|
|
1666
|
+
feltdb.delete(&revision_key(id)).map_err(|error| {
|
|
1667
|
+
RevisionError::Storage(format!("Failed to expire revision: {error}"))
|
|
1668
|
+
})?;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
867
1671
|
Ok(())
|
|
868
1672
|
}
|
|
869
1673
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1674
|
+
fn retention_state(&self, resource: &str) -> RetentionState {
|
|
1675
|
+
match &self.backing {
|
|
1676
|
+
Backing::Volatile(state) => state
|
|
1677
|
+
.lock()
|
|
1678
|
+
.unwrap()
|
|
1679
|
+
.retention
|
|
1680
|
+
.get(resource)
|
|
1681
|
+
.cloned()
|
|
1682
|
+
.unwrap_or_default(),
|
|
1683
|
+
Backing::Durable(feltdb) => feltdb
|
|
1684
|
+
.get::<RetentionState>(&retention_key(resource))
|
|
1685
|
+
.ok()
|
|
1686
|
+
.flatten()
|
|
1687
|
+
.unwrap_or_default(),
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
fn write_retention_state(
|
|
1692
|
+
&self,
|
|
1693
|
+
resource: &str,
|
|
1694
|
+
state: &RetentionState,
|
|
1695
|
+
) -> Result<(), RevisionError> {
|
|
1696
|
+
match &self.backing {
|
|
1697
|
+
Backing::Volatile(volatile) => {
|
|
1698
|
+
volatile
|
|
1699
|
+
.lock()
|
|
1700
|
+
.unwrap()
|
|
1701
|
+
.retention
|
|
1702
|
+
.insert(resource.to_string(), state.clone());
|
|
1703
|
+
}
|
|
1704
|
+
Backing::Durable(feltdb) => {
|
|
1705
|
+
feltdb
|
|
1706
|
+
.insert(&retention_key(resource), state.clone())
|
|
1707
|
+
.map_err(|error| {
|
|
1708
|
+
RevisionError::Storage(format!(
|
|
1709
|
+
"Failed to persist retention state: {error}"
|
|
1710
|
+
))
|
|
1711
|
+
})?;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
Ok(())
|
|
874
1715
|
}
|
|
875
1716
|
}
|
|
876
1717
|
|
|
877
1718
|
impl Clone for StateStore {
|
|
878
1719
|
fn clone(&self) -> Self {
|
|
879
1720
|
StateStore {
|
|
880
|
-
|
|
881
|
-
current_id: self.current_id.clone(),
|
|
882
|
-
branches: self.branches.clone(),
|
|
883
|
-
feltdb: self.feltdb.clone(),
|
|
1721
|
+
backing: self.backing.clone(),
|
|
884
1722
|
}
|
|
885
1723
|
}
|
|
886
1724
|
}
|
|
@@ -921,6 +1759,7 @@ mod tests {
|
|
|
921
1759
|
#[test]
|
|
922
1760
|
fn test_state_revision_initial() {
|
|
923
1761
|
let rev = StateRevision::initial(
|
|
1762
|
+
"docs:1".to_string(),
|
|
924
1763
|
r#"{"key":"value"}"#.to_string(),
|
|
925
1764
|
"test-authority".to_string(),
|
|
926
1765
|
);
|
|
@@ -932,6 +1771,7 @@ mod tests {
|
|
|
932
1771
|
#[test]
|
|
933
1772
|
fn test_state_revision_child() {
|
|
934
1773
|
let parent = StateRevision::initial(
|
|
1774
|
+
"docs:1".to_string(),
|
|
935
1775
|
r#"{"key":"value"}"#.to_string(),
|
|
936
1776
|
"test-authority".to_string(),
|
|
937
1777
|
);
|
|
@@ -949,14 +1789,11 @@ mod tests {
|
|
|
949
1789
|
let mut topo = StateTopology::new();
|
|
950
1790
|
|
|
951
1791
|
let rev1 = StateRevision::initial(
|
|
1792
|
+
"docs:1".to_string(),
|
|
952
1793
|
r#"{"v":1}"#.to_string(),
|
|
953
1794
|
"auth".to_string(),
|
|
954
1795
|
);
|
|
955
|
-
let rev2 = StateRevision::child(
|
|
956
|
-
r#"{"v":2}"#.to_string(),
|
|
957
|
-
&rev1,
|
|
958
|
-
"auth".to_string(),
|
|
959
|
-
);
|
|
1796
|
+
let rev2 = StateRevision::child(r#"{"v":2}"#.to_string(), &rev1, "auth".to_string());
|
|
960
1797
|
|
|
961
1798
|
topo.add_revision(rev1.clone());
|
|
962
1799
|
topo.add_revision(rev2.clone());
|
|
@@ -979,50 +1816,142 @@ mod tests {
|
|
|
979
1816
|
}
|
|
980
1817
|
|
|
981
1818
|
#[test]
|
|
982
|
-
fn
|
|
983
|
-
let
|
|
984
|
-
|
|
985
|
-
|
|
1819
|
+
fn path_relation_is_structural() {
|
|
1820
|
+
let key = |name: &str| PathComponent::Key(name.to_string());
|
|
1821
|
+
|
|
1822
|
+
assert_eq!(path_relation(&[key("a")], &[key("a")]), PathRelation::Same);
|
|
1823
|
+
assert_eq!(
|
|
1824
|
+
path_relation(&[key("a")], &[key("a"), key("b")]),
|
|
1825
|
+
PathRelation::Ancestor
|
|
1826
|
+
);
|
|
1827
|
+
assert_eq!(
|
|
1828
|
+
path_relation(&[key("a"), key("b")], &[key("a")]),
|
|
1829
|
+
PathRelation::Descendant
|
|
1830
|
+
);
|
|
1831
|
+
assert_eq!(
|
|
1832
|
+
path_relation(&[key("a"), key("b")], &[key("a"), key("c")]),
|
|
1833
|
+
PathRelation::Disjoint
|
|
1834
|
+
);
|
|
1835
|
+
// Components are compared whole. `a.b` and `a.bc` share a string prefix
|
|
1836
|
+
// under any flattened rendering and are still disjoint.
|
|
1837
|
+
assert_eq!(
|
|
1838
|
+
path_relation(&[key("a"), key("b")], &[key("a"), key("bc")]),
|
|
1839
|
+
PathRelation::Disjoint
|
|
1840
|
+
);
|
|
1841
|
+
// A key never matches an index, whatever they look like as text.
|
|
1842
|
+
assert_eq!(
|
|
1843
|
+
path_relation(&[key("0")], &[PathComponent::Index(0)]),
|
|
1844
|
+
PathRelation::Disjoint
|
|
1845
|
+
);
|
|
1846
|
+
// The empty path is the whole state, and contains every other path.
|
|
1847
|
+
assert_eq!(path_relation(&[], &[key("a")]), PathRelation::Ancestor);
|
|
1848
|
+
assert_eq!(path_relation(&[], &[]), PathRelation::Same);
|
|
1849
|
+
|
|
1850
|
+
assert!(paths_overlap(&[key("a")], &[key("a"), key("b")]));
|
|
1851
|
+
assert!(paths_overlap(&[key("a"), key("b")], &[key("a")]));
|
|
1852
|
+
assert!(!paths_overlap(
|
|
1853
|
+
&[key("a"), key("b")],
|
|
1854
|
+
&[key("a"), key("bc")]
|
|
1855
|
+
));
|
|
1856
|
+
assert!(!paths_overlap(&[key("a")], &[key("b")]));
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
#[test]
|
|
1860
|
+
fn resolve_path_reads_objects_and_arrays() {
|
|
1861
|
+
let value = json!({"o": {"a": [10, {"b": 1}]}, "n": null});
|
|
1862
|
+
let key = |name: &str| PathComponent::Key(name.to_string());
|
|
1863
|
+
|
|
1864
|
+
assert_eq!(resolve_path(&value, &[]), Some(&value));
|
|
1865
|
+
assert_eq!(resolve_path(&value, &[key("n")]), Some(&Value::Null));
|
|
1866
|
+
assert_eq!(
|
|
1867
|
+
resolve_path(&value, &[key("o"), key("a"), PathComponent::Index(0)]),
|
|
1868
|
+
Some(&json!(10))
|
|
1869
|
+
);
|
|
1870
|
+
assert_eq!(
|
|
1871
|
+
resolve_path(
|
|
1872
|
+
&value,
|
|
1873
|
+
&[key("o"), key("a"), PathComponent::Index(1), key("b")]
|
|
1874
|
+
),
|
|
1875
|
+
Some(&json!(1))
|
|
1876
|
+
);
|
|
1877
|
+
// A path that does not resolve, and one that uses the wrong component
|
|
1878
|
+
// kind for the container it meets.
|
|
1879
|
+
assert_eq!(resolve_path(&value, &[key("missing")]), None);
|
|
1880
|
+
assert_eq!(resolve_path(&value, &[PathComponent::Index(0)]), None);
|
|
1881
|
+
assert_eq!(
|
|
1882
|
+
resolve_path(&value, &[key("o"), key("a"), PathComponent::Index(9)]),
|
|
1883
|
+
None
|
|
986
1884
|
);
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1885
|
+
assert_eq!(resolve_path(&value, &[key("o"), key("a"), key("0")]), None);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
#[test]
|
|
1889
|
+
fn overlapping_changes_are_never_independent() {
|
|
1890
|
+
// The reported defect: a branch replacing `o` and a branch editing
|
|
1891
|
+
// `o.a` cannot both be applied, so neither path is independent.
|
|
1892
|
+
let base = StateRevision::initial(
|
|
1893
|
+
"docs:1".to_string(),
|
|
1894
|
+
r#"{"o":{"a":1}}"#.to_string(),
|
|
990
1895
|
"auth".to_string(),
|
|
991
1896
|
);
|
|
992
|
-
let
|
|
993
|
-
|
|
994
|
-
|
|
1897
|
+
let left = StateRevision::child(r#"{"o":7}"#.to_string(), &base, "auth".to_string());
|
|
1898
|
+
let right = StateRevision::child(r#"{"o":{"a":2}}"#.to_string(), &base, "auth".to_string());
|
|
1899
|
+
|
|
1900
|
+
let classification = ConflictClassification::classify(&base, &left, &right);
|
|
1901
|
+
assert_eq!(classification.overall, ConflictClass::Conflict);
|
|
1902
|
+
assert!(classification
|
|
1903
|
+
.path_conflicts
|
|
1904
|
+
.iter()
|
|
1905
|
+
.all(|entry| entry.classification == ConflictClass::Conflict));
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
#[test]
|
|
1909
|
+
fn test_conflict_classification_independent() {
|
|
1910
|
+
let base = StateRevision::initial(
|
|
1911
|
+
"docs:1".to_string(),
|
|
1912
|
+
r#"{"a":1,"b":1}"#.to_string(),
|
|
995
1913
|
"auth".to_string(),
|
|
996
1914
|
);
|
|
1915
|
+
let left = StateRevision::child(r#"{"a":2,"b":1}"#.to_string(), &base, "auth".to_string());
|
|
1916
|
+
let right = StateRevision::child(r#"{"a":1,"b":2}"#.to_string(), &base, "auth".to_string());
|
|
997
1917
|
|
|
998
1918
|
let classification = ConflictClassification::classify(&base, &left, &right);
|
|
999
1919
|
assert_eq!(classification.overall, ConflictClass::Independent);
|
|
1000
1920
|
}
|
|
1001
1921
|
|
|
1002
1922
|
#[test]
|
|
1003
|
-
fn
|
|
1923
|
+
fn test_state_store_create_and_get() {
|
|
1004
1924
|
let store = StateStore::new_volatile();
|
|
1005
1925
|
let rev = store
|
|
1006
|
-
.create(
|
|
1926
|
+
.create(
|
|
1927
|
+
"docs:1",
|
|
1928
|
+
r#"{"data":"initial"}"#.to_string(),
|
|
1929
|
+
"auth".to_string(),
|
|
1930
|
+
)
|
|
1007
1931
|
.unwrap();
|
|
1008
1932
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1933
|
+
// The store answers "do I have revision X?", not "what is current?".
|
|
1934
|
+
let stored = store.get(&rev.id).unwrap();
|
|
1935
|
+
assert_eq!(stored.id, rev.id);
|
|
1936
|
+
assert_eq!(stored.content, rev.content);
|
|
1011
1937
|
}
|
|
1012
1938
|
|
|
1013
1939
|
#[test]
|
|
1014
1940
|
fn test_state_store_commit() {
|
|
1015
1941
|
let store = StateStore::new_volatile();
|
|
1016
1942
|
let rev1 = store
|
|
1017
|
-
.create(r#"{"v":1}"#.to_string(), "auth".to_string())
|
|
1943
|
+
.create("docs:1", r#"{"v":1}"#.to_string(), "auth".to_string())
|
|
1018
1944
|
.unwrap();
|
|
1019
1945
|
let rev2 = store
|
|
1020
1946
|
.commit(r#"{"v":2}"#.to_string(), &rev1, "auth".to_string())
|
|
1021
1947
|
.unwrap();
|
|
1022
1948
|
|
|
1023
1949
|
assert_eq!(rev2.parent_id, Some(rev1.id.clone()));
|
|
1024
|
-
|
|
1025
|
-
|
|
1950
|
+
// Both revisions remain retrievable; committing a child does not
|
|
1951
|
+
// displace its parent, because there is no pointer to displace.
|
|
1952
|
+
assert_eq!(store.get(&rev2.id).unwrap().id, rev2.id);
|
|
1953
|
+
assert_eq!(store.get(&rev1.id).unwrap().id, rev1.id);
|
|
1954
|
+
assert_eq!(store.parent(&rev2.id).unwrap().id, rev1.id);
|
|
1026
1955
|
}
|
|
1027
1956
|
|
|
1028
1957
|
#[test]
|
|
@@ -1048,29 +1977,35 @@ mod tests {
|
|
|
1048
1977
|
let db_path = std::env::temp_dir().join("test_recovery_root.log");
|
|
1049
1978
|
let _ = std::fs::remove_file(&db_path); // Clean up any previous test
|
|
1050
1979
|
|
|
1980
|
+
let initial_id;
|
|
1981
|
+
|
|
1051
1982
|
// Phase 1: Create and persist root state
|
|
1052
1983
|
{
|
|
1053
1984
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1054
1985
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1055
1986
|
|
|
1056
|
-
let initial = store
|
|
1987
|
+
let initial = store
|
|
1988
|
+
.create(
|
|
1989
|
+
"docs:1",
|
|
1990
|
+
r#"{"data":"root"}"#.to_string(),
|
|
1991
|
+
"auth".to_string(),
|
|
1992
|
+
)
|
|
1057
1993
|
.expect("create initial");
|
|
1058
1994
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1995
|
+
assert_eq!(store.get(&initial.id).unwrap().id, initial.id);
|
|
1996
|
+
initial_id = initial.id;
|
|
1061
1997
|
} // Database closes, data persists to disk
|
|
1062
1998
|
|
|
1063
|
-
// Phase 2: Reopen and
|
|
1999
|
+
// Phase 2: Reopen and read it back by identity. No recovery step runs.
|
|
1064
2000
|
{
|
|
1065
2001
|
let db = crate::FeltDb::open(&db_path).expect("open db again");
|
|
1066
|
-
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store
|
|
2002
|
+
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store over same log");
|
|
1067
2003
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
assert!(current.parent_id.is_none());
|
|
2004
|
+
let root = store.get(&initial_id).expect("the root survives restart");
|
|
2005
|
+
assert_eq!(root.content, r#"{"data":"root"}"#);
|
|
2006
|
+
assert_eq!(root.authority, "auth");
|
|
2007
|
+
assert!(root.parent_id.is_none());
|
|
2008
|
+
assert!(root.verify_integrity());
|
|
1074
2009
|
}
|
|
1075
2010
|
|
|
1076
2011
|
let _ = std::fs::remove_file(&db_path); // Clean up
|
|
@@ -1092,20 +2027,23 @@ mod tests {
|
|
|
1092
2027
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1093
2028
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1094
2029
|
|
|
1095
|
-
let rev1 = store
|
|
2030
|
+
let rev1 = store
|
|
2031
|
+
.create("docs:1", r#"{"v":1}"#.to_string(), "auth".to_string())
|
|
1096
2032
|
.expect("create rev1");
|
|
1097
2033
|
rev1_id = rev1.id.clone();
|
|
1098
2034
|
|
|
1099
|
-
let rev2 = store
|
|
2035
|
+
let rev2 = store
|
|
2036
|
+
.commit(r#"{"v":2}"#.to_string(), &rev1, "auth".to_string())
|
|
1100
2037
|
.expect("create rev2");
|
|
1101
2038
|
rev2_id = rev2.id.clone();
|
|
1102
2039
|
|
|
1103
|
-
let rev3 = store
|
|
2040
|
+
let rev3 = store
|
|
2041
|
+
.commit(r#"{"v":3}"#.to_string(), &rev2, "auth".to_string())
|
|
1104
2042
|
.expect("create rev3");
|
|
1105
2043
|
rev3_id = rev3.id.clone();
|
|
1106
2044
|
|
|
1107
2045
|
// Verify before close
|
|
1108
|
-
assert_eq!(store.
|
|
2046
|
+
assert_eq!(store.get(&rev3_id).unwrap().id, rev3_id);
|
|
1109
2047
|
assert_eq!(store.parent(&rev3_id).unwrap().id, rev2_id);
|
|
1110
2048
|
assert_eq!(store.parent(&rev2_id).unwrap().id, rev1_id);
|
|
1111
2049
|
}
|
|
@@ -1113,7 +2051,8 @@ mod tests {
|
|
|
1113
2051
|
// Phase 2: Reopen and verify full history recovered
|
|
1114
2052
|
{
|
|
1115
2053
|
let db = crate::FeltDb::open(&db_path).expect("open db again");
|
|
1116
|
-
let store =
|
|
2054
|
+
let store =
|
|
2055
|
+
StateStore::with_feltdb(Arc::new(db)).expect("create store from recovered db");
|
|
1117
2056
|
|
|
1118
2057
|
// Verify all three revisions exist
|
|
1119
2058
|
assert!(store.exists(&rev1_id));
|
|
@@ -1121,7 +2060,7 @@ mod tests {
|
|
|
1121
2060
|
assert!(store.exists(&rev3_id));
|
|
1122
2061
|
|
|
1123
2062
|
// Verify ancestry
|
|
1124
|
-
assert_eq!(store.
|
|
2063
|
+
assert_eq!(store.get(&rev3_id).unwrap().id, rev3_id);
|
|
1125
2064
|
assert_eq!(store.parent(&rev3_id).unwrap().id, rev2_id);
|
|
1126
2065
|
assert_eq!(store.parent(&rev2_id).unwrap().id, rev1_id);
|
|
1127
2066
|
assert!(store.parent(&rev1_id).is_none());
|
|
@@ -1136,7 +2075,7 @@ mod tests {
|
|
|
1136
2075
|
}
|
|
1137
2076
|
|
|
1138
2077
|
#[test]
|
|
1139
|
-
fn
|
|
2078
|
+
fn test_divergent_siblings_survive_restart_independently() {
|
|
1140
2079
|
use std::sync::Arc;
|
|
1141
2080
|
|
|
1142
2081
|
let db_path = std::env::temp_dir().join("test_recovery_branches.log");
|
|
@@ -1150,36 +2089,35 @@ mod tests {
|
|
|
1150
2089
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1151
2090
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1152
2091
|
|
|
1153
|
-
let root = store
|
|
2092
|
+
let root = store
|
|
2093
|
+
.create("docs:1", r#"{"base":true}"#.to_string(), "auth".to_string())
|
|
1154
2094
|
.expect("create root");
|
|
1155
2095
|
root_id = root.id.clone();
|
|
1156
2096
|
|
|
1157
|
-
let branch1 = store
|
|
2097
|
+
let branch1 = store
|
|
2098
|
+
.commit(r#"{"path":"a"}"#.to_string(), &root, "auth".to_string())
|
|
1158
2099
|
.expect("create branch1");
|
|
1159
2100
|
branch1_id = branch1.id.clone();
|
|
1160
|
-
|
|
1161
|
-
// Create named branch reference
|
|
1162
|
-
store.create_branch("feature-a".to_string(), branch1_id.clone()).expect("create feature-a");
|
|
1163
2101
|
}
|
|
1164
2102
|
|
|
1165
|
-
// Phase 2: Reopen
|
|
2103
|
+
// Phase 2: Reopen. There is no branch to recover, and none is needed:
|
|
2104
|
+
// a divergent revision is reachable by its own identity, and its
|
|
2105
|
+
// ancestry is its parent link.
|
|
1166
2106
|
{
|
|
1167
2107
|
let db = crate::FeltDb::open(&db_path).expect("open db again");
|
|
1168
|
-
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store
|
|
2108
|
+
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store over same log");
|
|
1169
2109
|
|
|
1170
|
-
// Verify branch exists and points correctly
|
|
1171
|
-
assert_eq!(store.branch_head("feature-a"), Some(branch1_id.clone()));
|
|
1172
|
-
|
|
1173
|
-
// Verify both revisions exist
|
|
1174
2110
|
assert!(store.exists(&root_id));
|
|
1175
2111
|
assert!(store.exists(&branch1_id));
|
|
2112
|
+
assert_eq!(store.parent(&branch1_id).unwrap().id, root_id);
|
|
2113
|
+
assert_eq!(store.get(&branch1_id).unwrap().content, r#"{"path":"a"}"#);
|
|
1176
2114
|
}
|
|
1177
2115
|
|
|
1178
2116
|
let _ = std::fs::remove_file(&db_path);
|
|
1179
2117
|
}
|
|
1180
2118
|
|
|
1181
2119
|
#[test]
|
|
1182
|
-
fn
|
|
2120
|
+
fn test_every_revision_stays_retrievable_by_identity_after_restart() {
|
|
1183
2121
|
use std::sync::Arc;
|
|
1184
2122
|
|
|
1185
2123
|
let db_path = std::env::temp_dir().join("test_recovery_current.log");
|
|
@@ -1188,34 +2126,36 @@ mod tests {
|
|
|
1188
2126
|
let rev1_id;
|
|
1189
2127
|
let rev2_id;
|
|
1190
2128
|
|
|
1191
|
-
// Phase 1:
|
|
2129
|
+
// Phase 1: Two revisions, one descending from the other.
|
|
1192
2130
|
{
|
|
1193
2131
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1194
2132
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1195
2133
|
|
|
1196
|
-
let rev1 = store
|
|
2134
|
+
let rev1 = store
|
|
2135
|
+
.create("docs:1", r#"{"state":"1"}"#.to_string(), "auth".to_string())
|
|
1197
2136
|
.expect("create rev1");
|
|
1198
2137
|
rev1_id = rev1.id.clone();
|
|
1199
2138
|
|
|
1200
|
-
|
|
1201
|
-
assert_eq!(store.current().unwrap().id, rev1_id);
|
|
2139
|
+
assert_eq!(store.get(&rev1_id).unwrap().id, rev1_id);
|
|
1202
2140
|
|
|
1203
|
-
let rev2 = store
|
|
2141
|
+
let rev2 = store
|
|
2142
|
+
.commit(r#"{"state":"2"}"#.to_string(), &rev1, "auth".to_string())
|
|
1204
2143
|
.expect("create rev2");
|
|
1205
2144
|
rev2_id = rev2.id.clone();
|
|
1206
2145
|
|
|
1207
|
-
//
|
|
1208
|
-
assert_eq!(store.
|
|
2146
|
+
// Committing a child does not displace the parent.
|
|
2147
|
+
assert_eq!(store.get(&rev2_id).unwrap().id, rev2_id);
|
|
2148
|
+
assert_eq!(store.get(&rev1_id).unwrap().id, rev1_id);
|
|
1209
2149
|
}
|
|
1210
2150
|
|
|
1211
|
-
// Phase 2:
|
|
2151
|
+
// Phase 2: both survive, and neither is privileged over the other.
|
|
1212
2152
|
{
|
|
1213
2153
|
let db = crate::FeltDb::open(&db_path).expect("open db again");
|
|
1214
|
-
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store
|
|
2154
|
+
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store over same log");
|
|
1215
2155
|
|
|
1216
|
-
|
|
1217
|
-
assert_eq!(store.
|
|
1218
|
-
assert_eq!(store.
|
|
2156
|
+
assert_eq!(store.get(&rev1_id).unwrap().content, r#"{"state":"1"}"#);
|
|
2157
|
+
assert_eq!(store.get(&rev2_id).unwrap().content, r#"{"state":"2"}"#);
|
|
2158
|
+
assert_eq!(store.parent(&rev2_id).unwrap().id, rev1_id);
|
|
1219
2159
|
}
|
|
1220
2160
|
|
|
1221
2161
|
let _ = std::fs::remove_file(&db_path);
|
|
@@ -1237,27 +2177,35 @@ mod tests {
|
|
|
1237
2177
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1238
2178
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1239
2179
|
|
|
1240
|
-
let root = store
|
|
2180
|
+
let root = store
|
|
2181
|
+
.create("docs:1", r#"{"v":0}"#.to_string(), "auth".to_string())
|
|
1241
2182
|
.expect("create root");
|
|
1242
2183
|
root_id = root.id.clone();
|
|
1243
2184
|
|
|
1244
|
-
let rev_a = store
|
|
2185
|
+
let rev_a = store
|
|
2186
|
+
.commit(
|
|
2187
|
+
r#"{"v":1,"stage":"a"}"#.to_string(),
|
|
2188
|
+
&root,
|
|
2189
|
+
"auth".to_string(),
|
|
2190
|
+
)
|
|
1245
2191
|
.expect("create rev_a");
|
|
1246
2192
|
rev_a_id = rev_a.id.clone();
|
|
1247
2193
|
|
|
1248
|
-
let rev_b = store
|
|
2194
|
+
let rev_b = store
|
|
2195
|
+
.commit(
|
|
2196
|
+
r#"{"v":2,"stage":"b"}"#.to_string(),
|
|
2197
|
+
&rev_a,
|
|
2198
|
+
"auth".to_string(),
|
|
2199
|
+
)
|
|
1249
2200
|
.expect("create rev_b");
|
|
1250
2201
|
rev_b_id = rev_b.id.clone();
|
|
1251
|
-
|
|
1252
|
-
// Create named branches at different points in history
|
|
1253
|
-
store.create_branch("checkpoint-a".to_string(), rev_a_id.clone()).expect("create checkpoint-a");
|
|
1254
|
-
store.create_branch("checkpoint-b".to_string(), rev_b_id.clone()).expect("create checkpoint-b");
|
|
1255
2202
|
}
|
|
1256
2203
|
|
|
1257
2204
|
// Phase 2: Verify complex topology recovered
|
|
1258
2205
|
{
|
|
1259
2206
|
let db = crate::FeltDb::open(&db_path).expect("open db again");
|
|
1260
|
-
let store =
|
|
2207
|
+
let store =
|
|
2208
|
+
StateStore::with_feltdb(Arc::new(db)).expect("create store from recovered db");
|
|
1261
2209
|
|
|
1262
2210
|
// Verify all revisions
|
|
1263
2211
|
assert!(store.exists(&root_id));
|
|
@@ -1268,67 +2216,75 @@ mod tests {
|
|
|
1268
2216
|
assert_eq!(store.parent(&rev_b_id).unwrap().id, rev_a_id);
|
|
1269
2217
|
assert_eq!(store.parent(&rev_a_id).unwrap().id, root_id);
|
|
1270
2218
|
|
|
1271
|
-
//
|
|
1272
|
-
|
|
1273
|
-
assert_eq!(
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
2219
|
+
// Any point in the history is a checkpoint, addressed by its own
|
|
2220
|
+
// identity. Naming one adds nothing the id does not already give.
|
|
2221
|
+
assert_eq!(
|
|
2222
|
+
store.get(&rev_a_id).unwrap().content,
|
|
2223
|
+
r#"{"v":1,"stage":"a"}"#
|
|
2224
|
+
);
|
|
2225
|
+
assert_eq!(
|
|
2226
|
+
store.get(&rev_b_id).unwrap().content,
|
|
2227
|
+
r#"{"v":2,"stage":"b"}"#
|
|
2228
|
+
);
|
|
1277
2229
|
}
|
|
1278
2230
|
|
|
1279
2231
|
let _ = std::fs::remove_file(&db_path);
|
|
1280
2232
|
}
|
|
1281
2233
|
|
|
1282
2234
|
#[test]
|
|
1283
|
-
fn
|
|
2235
|
+
fn test_revisions_survive_multiple_restart_cycles() {
|
|
1284
2236
|
use std::sync::Arc;
|
|
1285
2237
|
|
|
1286
2238
|
let db_path = std::env::temp_dir().join("test_recovery_cycles.log");
|
|
1287
2239
|
let _ = std::fs::remove_file(&db_path);
|
|
1288
2240
|
|
|
2241
|
+
// The caller carries the identities it cares about across restarts.
|
|
2242
|
+
// That is the whole of what replaces a `current` pointer here.
|
|
2243
|
+
let rev1_id;
|
|
2244
|
+
let rev2_id;
|
|
2245
|
+
|
|
1289
2246
|
// Cycle 1: Create root
|
|
1290
2247
|
{
|
|
1291
2248
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1292
2249
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1293
|
-
store
|
|
2250
|
+
rev1_id = store
|
|
2251
|
+
.create("docs:1", r#"{"cycle":1}"#.to_string(), "auth".to_string())
|
|
2252
|
+
.expect("create")
|
|
2253
|
+
.id;
|
|
1294
2254
|
}
|
|
1295
2255
|
|
|
1296
|
-
// Cycle 2: Restart,
|
|
2256
|
+
// Cycle 2: Restart, read by identity, and commit onto it
|
|
1297
2257
|
{
|
|
1298
2258
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1299
2259
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1300
|
-
assert!(store.current().is_some());
|
|
1301
2260
|
|
|
1302
|
-
let rev1 = store.
|
|
2261
|
+
let rev1 = store.get(&rev1_id).expect("rev1 survives");
|
|
1303
2262
|
assert_eq!(rev1.content, r#"{"cycle":1}"#);
|
|
1304
|
-
|
|
1305
|
-
|
|
2263
|
+
|
|
2264
|
+
rev2_id = store
|
|
2265
|
+
.commit(r#"{"cycle":2}"#.to_string(), &rev1, "auth".to_string())
|
|
2266
|
+
.expect("commit 2")
|
|
2267
|
+
.id;
|
|
1306
2268
|
}
|
|
1307
2269
|
|
|
1308
|
-
// Cycle 3: Restart,
|
|
2270
|
+
// Cycle 3: Restart, both are still there
|
|
1309
2271
|
{
|
|
1310
2272
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1311
2273
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1312
|
-
assert!(store.current().is_some());
|
|
1313
2274
|
|
|
1314
|
-
|
|
1315
|
-
assert_eq!(
|
|
1316
|
-
|
|
1317
|
-
store.create_branch("stable".to_string(), current.id.clone()).expect("create branch");
|
|
2275
|
+
assert_eq!(store.get(&rev2_id).unwrap().content, r#"{"cycle":2}"#);
|
|
2276
|
+
assert_eq!(store.parent(&rev2_id).unwrap().id, rev1_id);
|
|
1318
2277
|
}
|
|
1319
2278
|
|
|
1320
|
-
// Cycle 4: Final restart
|
|
2279
|
+
// Cycle 4: Final restart, ancestry intact end to end
|
|
1321
2280
|
{
|
|
1322
2281
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1323
2282
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1324
2283
|
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
assert_eq!(
|
|
1328
|
-
assert!(
|
|
1329
|
-
|
|
1330
|
-
// Should have branch
|
|
1331
|
-
assert_eq!(store.branch_head("stable"), Some(current.id.clone()));
|
|
2284
|
+
let rev2 = store.get(&rev2_id).expect("rev2 survives every cycle");
|
|
2285
|
+
assert_eq!(rev2.content, r#"{"cycle":2}"#);
|
|
2286
|
+
assert_eq!(rev2.parent_id, Some(rev1_id.clone()));
|
|
2287
|
+
assert!(store.parent(&rev1_id).is_none(), "the root has no parent");
|
|
1332
2288
|
}
|
|
1333
2289
|
|
|
1334
2290
|
let _ = std::fs::remove_file(&db_path);
|
|
@@ -1357,10 +2313,13 @@ mod tests {
|
|
|
1357
2313
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1358
2314
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1359
2315
|
|
|
1360
|
-
let rev = store
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
2316
|
+
let rev = store
|
|
2317
|
+
.create(
|
|
2318
|
+
"docs:1",
|
|
2319
|
+
r#"{"task":"write PR18 proof"}"#.to_string(),
|
|
2320
|
+
"system".to_string(),
|
|
2321
|
+
)
|
|
2322
|
+
.expect("write");
|
|
1364
2323
|
|
|
1365
2324
|
rev.id.clone()
|
|
1366
2325
|
}; // Force drop of database, persist to disk
|
|
@@ -1370,15 +2329,19 @@ mod tests {
|
|
|
1370
2329
|
let db = crate::FeltDb::open(&db_path).expect("reopen db");
|
|
1371
2330
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover store");
|
|
1372
2331
|
|
|
1373
|
-
// VERIFY:
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
2332
|
+
// VERIFY: the revision is retrievable by identity after restart
|
|
2333
|
+
let recovered = store
|
|
2334
|
+
.get(&written_id)
|
|
2335
|
+
.expect("State should exist after restart");
|
|
2336
|
+
assert_eq!(recovered.id, written_id, "State ID should match");
|
|
1377
2337
|
assert_eq!(
|
|
1378
|
-
|
|
1379
|
-
r#"{"task":"write PR18 proof"}"#,
|
|
2338
|
+
recovered.content, r#"{"task":"write PR18 proof"}"#,
|
|
1380
2339
|
"State content should survive restart"
|
|
1381
2340
|
);
|
|
2341
|
+
assert!(
|
|
2342
|
+
recovered.verify_integrity(),
|
|
2343
|
+
"content must still hash to its identity"
|
|
2344
|
+
);
|
|
1382
2345
|
}
|
|
1383
2346
|
|
|
1384
2347
|
let _ = std::fs::remove_file(&db_path);
|
|
@@ -1398,24 +2361,31 @@ mod tests {
|
|
|
1398
2361
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1399
2362
|
|
|
1400
2363
|
// Create project
|
|
1401
|
-
let project = store
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
2364
|
+
let project = store
|
|
2365
|
+
.create(
|
|
2366
|
+
"docs:1",
|
|
2367
|
+
r#"{"type":"project","name":"FeltDB"}"#.to_string(),
|
|
2368
|
+
"system".to_string(),
|
|
2369
|
+
)
|
|
2370
|
+
.expect("create project");
|
|
1405
2371
|
|
|
1406
2372
|
// Create membership (parent = project)
|
|
1407
|
-
let membership = store
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
2373
|
+
let membership = store
|
|
2374
|
+
.commit(
|
|
2375
|
+
r#"{"type":"membership","project":"FeltDB","user":"alice"}"#.to_string(),
|
|
2376
|
+
&project,
|
|
2377
|
+
"system".to_string(),
|
|
2378
|
+
)
|
|
2379
|
+
.expect("create membership");
|
|
1412
2380
|
|
|
1413
2381
|
// Write audit event (parent = membership)
|
|
1414
|
-
let event = store
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
2382
|
+
let event = store
|
|
2383
|
+
.commit(
|
|
2384
|
+
r#"{"type":"audit","action":"project_created"}"#.to_string(),
|
|
2385
|
+
&membership,
|
|
2386
|
+
"system".to_string(),
|
|
2387
|
+
)
|
|
2388
|
+
.expect("create event");
|
|
1419
2389
|
|
|
1420
2390
|
(project.id.clone(), membership.id.clone(), event.id.clone())
|
|
1421
2391
|
}; // Force persist
|
|
@@ -1425,12 +2395,13 @@ mod tests {
|
|
|
1425
2395
|
let db = crate::FeltDb::open(&db_path).expect("reopen db");
|
|
1426
2396
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover store");
|
|
1427
2397
|
|
|
1428
|
-
let
|
|
1429
|
-
assert_eq!(
|
|
2398
|
+
let event = store.get(&event_id).expect("event should exist");
|
|
2399
|
+
assert_eq!(event.id, event_id);
|
|
1430
2400
|
|
|
1431
2401
|
// Verify chain: event → membership → project
|
|
1432
2402
|
assert_eq!(
|
|
1433
|
-
|
|
2403
|
+
event.parent_id,
|
|
2404
|
+
Some(membership_id.clone()),
|
|
1434
2405
|
"Event parent should be membership"
|
|
1435
2406
|
);
|
|
1436
2407
|
|
|
@@ -1454,22 +2425,29 @@ mod tests {
|
|
|
1454
2425
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1455
2426
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1456
2427
|
|
|
1457
|
-
let root = store
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
&root,
|
|
1465
|
-
"genesis".to_string(),
|
|
1466
|
-
).expect("create child");
|
|
2428
|
+
let root = store
|
|
2429
|
+
.create(
|
|
2430
|
+
"docs:1",
|
|
2431
|
+
r#"{"generation":"root"}"#.to_string(),
|
|
2432
|
+
"genesis".to_string(),
|
|
2433
|
+
)
|
|
2434
|
+
.expect("create root");
|
|
1467
2435
|
|
|
1468
|
-
let
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
2436
|
+
let child = store
|
|
2437
|
+
.commit(
|
|
2438
|
+
r#"{"generation":"child"}"#.to_string(),
|
|
2439
|
+
&root,
|
|
2440
|
+
"genesis".to_string(),
|
|
2441
|
+
)
|
|
2442
|
+
.expect("create child");
|
|
2443
|
+
|
|
2444
|
+
let grandchild = store
|
|
2445
|
+
.commit(
|
|
2446
|
+
r#"{"generation":"grandchild"}"#.to_string(),
|
|
2447
|
+
&child,
|
|
2448
|
+
"genesis".to_string(),
|
|
2449
|
+
)
|
|
2450
|
+
.expect("create grandchild");
|
|
1473
2451
|
|
|
1474
2452
|
(root.id.clone(), child.id.clone(), grandchild.id.clone())
|
|
1475
2453
|
};
|
|
@@ -1479,12 +2457,12 @@ mod tests {
|
|
|
1479
2457
|
let db = crate::FeltDb::open(&db_path).expect("reopen db");
|
|
1480
2458
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover store");
|
|
1481
2459
|
|
|
1482
|
-
let
|
|
1483
|
-
assert_eq!(
|
|
2460
|
+
let grandchild = store.get(&grandchild_id).expect("grandchild exists");
|
|
2461
|
+
assert_eq!(grandchild.id, grandchild_id);
|
|
1484
2462
|
|
|
1485
2463
|
// Trace ancestry: grandchild → child → root
|
|
1486
2464
|
let mut ancestry_count = 0;
|
|
1487
|
-
let mut current_id = Some(
|
|
2465
|
+
let mut current_id = Some(grandchild.id.clone());
|
|
1488
2466
|
|
|
1489
2467
|
// Count links in ancestry (stop after finding root)
|
|
1490
2468
|
while let Some(id) = current_id {
|
|
@@ -1517,10 +2495,9 @@ mod tests {
|
|
|
1517
2495
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1518
2496
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1519
2497
|
|
|
1520
|
-
let rev = store
|
|
1521
|
-
r#"{"cycle":1}"#.to_string(),
|
|
1522
|
-
"
|
|
1523
|
-
).expect("create initial");
|
|
2498
|
+
let rev = store
|
|
2499
|
+
.create("docs:1", r#"{"cycle":1}"#.to_string(), "system".to_string())
|
|
2500
|
+
.expect("create initial");
|
|
1524
2501
|
|
|
1525
2502
|
rev.id.clone()
|
|
1526
2503
|
};
|
|
@@ -1530,16 +2507,18 @@ mod tests {
|
|
|
1530
2507
|
let db = crate::FeltDb::open(&db_path).expect("reopen db");
|
|
1531
2508
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover store");
|
|
1532
2509
|
|
|
1533
|
-
// Verify previous state exists
|
|
1534
|
-
let
|
|
1535
|
-
assert_eq!(
|
|
2510
|
+
// Verify previous state exists, read back by the id we carried
|
|
2511
|
+
let previous = store.get(&state_id).expect("previous state should exist");
|
|
2512
|
+
assert_eq!(previous.id, state_id);
|
|
1536
2513
|
|
|
1537
2514
|
// Update state
|
|
1538
|
-
let rev = store
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
2515
|
+
let rev = store
|
|
2516
|
+
.commit(
|
|
2517
|
+
format!(r#"{{"cycle":{}}}"#, cycle),
|
|
2518
|
+
&previous,
|
|
2519
|
+
"system".to_string(),
|
|
2520
|
+
)
|
|
2521
|
+
.expect("create update");
|
|
1543
2522
|
|
|
1544
2523
|
state_id = rev.id.clone();
|
|
1545
2524
|
}
|
|
@@ -1549,7 +2528,7 @@ mod tests {
|
|
|
1549
2528
|
let db = crate::FeltDb::open(&db_path).expect("final reopen");
|
|
1550
2529
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("final recovery");
|
|
1551
2530
|
|
|
1552
|
-
let final_state = store.
|
|
2531
|
+
let final_state = store.get(&state_id).expect("final state exists");
|
|
1553
2532
|
assert_eq!(final_state.id, state_id, "Final state should match");
|
|
1554
2533
|
}
|
|
1555
2534
|
|
|
@@ -1569,10 +2548,13 @@ mod tests {
|
|
|
1569
2548
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1570
2549
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1571
2550
|
|
|
1572
|
-
let rev = store
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
2551
|
+
let rev = store
|
|
2552
|
+
.create(
|
|
2553
|
+
"docs:1",
|
|
2554
|
+
r#"{"status":"initial"}"#.to_string(),
|
|
2555
|
+
"system".to_string(),
|
|
2556
|
+
)
|
|
2557
|
+
.expect("create initial");
|
|
1576
2558
|
|
|
1577
2559
|
rev.id.clone()
|
|
1578
2560
|
};
|
|
@@ -1586,11 +2568,18 @@ mod tests {
|
|
|
1586
2568
|
let db = crate::FeltDb::open(&db_path).expect("reopen");
|
|
1587
2569
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover");
|
|
1588
2570
|
|
|
1589
|
-
if let Some(
|
|
2571
|
+
if let Some(recovered) = store.get(&initial_id) {
|
|
1590
2572
|
// State exists: must be complete
|
|
1591
|
-
assert!(
|
|
1592
|
-
|
|
1593
|
-
|
|
2573
|
+
assert!(
|
|
2574
|
+
recovered.content.contains("status"),
|
|
2575
|
+
"State must be complete"
|
|
2576
|
+
);
|
|
2577
|
+
assert!(
|
|
2578
|
+
recovered.content.contains("initial"),
|
|
2579
|
+
"Attributes must be present"
|
|
2580
|
+
);
|
|
2581
|
+
assert_eq!(recovered.id, initial_id, "ID must match");
|
|
2582
|
+
assert!(recovered.verify_integrity(), "and never partially written");
|
|
1594
2583
|
} else {
|
|
1595
2584
|
// State doesn't exist: acceptable
|
|
1596
2585
|
// But not: partially exist with missing fields
|
|
@@ -1614,10 +2603,13 @@ mod tests {
|
|
|
1614
2603
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1615
2604
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1616
2605
|
|
|
1617
|
-
let rev = store
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
2606
|
+
let rev = store
|
|
2607
|
+
.create(
|
|
2608
|
+
"docs:1",
|
|
2609
|
+
expected_content.to_string(),
|
|
2610
|
+
"integrity_check".to_string(),
|
|
2611
|
+
)
|
|
2612
|
+
.expect("create");
|
|
1621
2613
|
|
|
1622
2614
|
rev.id.clone()
|
|
1623
2615
|
};
|
|
@@ -1627,14 +2619,15 @@ mod tests {
|
|
|
1627
2619
|
let db = crate::FeltDb::open(&db_path).expect("reopen");
|
|
1628
2620
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover");
|
|
1629
2621
|
|
|
1630
|
-
let
|
|
2622
|
+
let recovered = store.get(&state_id).expect("state exists");
|
|
1631
2623
|
|
|
1632
2624
|
// Verify integrity: ID should still match content
|
|
1633
|
-
assert_eq!(
|
|
1634
|
-
assert_eq!(
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
2625
|
+
assert_eq!(recovered.content, expected_content, "Content should match");
|
|
2626
|
+
assert_eq!(recovered.id, state_id, "ID should match");
|
|
2627
|
+
assert!(
|
|
2628
|
+
recovered.verify_integrity(),
|
|
2629
|
+
"Content hash should still be valid"
|
|
2630
|
+
);
|
|
1638
2631
|
}
|
|
1639
2632
|
|
|
1640
2633
|
let _ = std::fs::remove_file(&db_path);
|
|
@@ -1644,59 +2637,72 @@ mod tests {
|
|
|
1644
2637
|
fn pr19_proof_concurrent_write_safety() {
|
|
1645
2638
|
// PROVES: Concurrent writes don't produce impossible partial states
|
|
1646
2639
|
// Simulates multiple threads writing to the same StateStore
|
|
1647
|
-
|
|
2640
|
+
|
|
1648
2641
|
use std::sync::{Arc, Mutex};
|
|
1649
2642
|
use std::thread;
|
|
1650
|
-
|
|
2643
|
+
|
|
1651
2644
|
let db_path = std::env::temp_dir().join("pr19_concurrent_writes.log");
|
|
1652
2645
|
let _ = std::fs::remove_file(&db_path);
|
|
1653
|
-
|
|
2646
|
+
|
|
1654
2647
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1655
2648
|
let store = Arc::new(StateStore::with_feltdb(Arc::new(db)).expect("create store"));
|
|
1656
|
-
|
|
2649
|
+
|
|
1657
2650
|
let write_count = Arc::new(Mutex::new(0usize));
|
|
1658
2651
|
let mut handles = vec![];
|
|
1659
|
-
|
|
2652
|
+
|
|
1660
2653
|
// Spawn multiple writers
|
|
1661
2654
|
for i in 0..5 {
|
|
1662
2655
|
let store_clone = Arc::clone(&store);
|
|
1663
2656
|
let count_clone = Arc::clone(&write_count);
|
|
1664
|
-
|
|
2657
|
+
|
|
1665
2658
|
let handle = thread::spawn(move || {
|
|
1666
2659
|
let content = format!(r#"{{"writer":{},"timestamp":{}}}"#, i, 1000 + i);
|
|
1667
|
-
let rev = store_clone
|
|
2660
|
+
let rev = store_clone
|
|
2661
|
+
.create(&format!("writer:{i}"), content, format!("writer_{}", i))
|
|
1668
2662
|
.expect("write should succeed");
|
|
1669
|
-
|
|
2663
|
+
|
|
1670
2664
|
let mut count = count_clone.lock().unwrap();
|
|
1671
2665
|
*count += 1;
|
|
1672
|
-
|
|
2666
|
+
|
|
1673
2667
|
rev.id.clone()
|
|
1674
2668
|
});
|
|
1675
|
-
|
|
2669
|
+
|
|
1676
2670
|
handles.push(handle);
|
|
1677
2671
|
}
|
|
1678
|
-
|
|
2672
|
+
|
|
1679
2673
|
// Wait for all writers
|
|
1680
2674
|
let mut written_ids = vec![];
|
|
1681
2675
|
for handle in handles {
|
|
1682
2676
|
let id = handle.join().expect("thread should complete");
|
|
1683
2677
|
written_ids.push(id);
|
|
1684
2678
|
}
|
|
1685
|
-
|
|
1686
|
-
// Verify no partial states were created
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
2679
|
+
|
|
2680
|
+
// Verify no partial states were created: every id a writer returned is
|
|
2681
|
+
// readable, complete, and hashes to its own content.
|
|
2682
|
+
for id in &written_ids {
|
|
2683
|
+
let written = store
|
|
2684
|
+
.get(id)
|
|
2685
|
+
.expect("every concurrently written revision should exist");
|
|
2686
|
+
assert!(
|
|
2687
|
+
written.verify_integrity(),
|
|
2688
|
+
"no writer produced a partial revision"
|
|
2689
|
+
);
|
|
2690
|
+
}
|
|
2691
|
+
|
|
1692
2692
|
// Verify we can still write after concurrent writes
|
|
1693
|
-
let final_write = store
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
2693
|
+
let final_write = store
|
|
2694
|
+
.create(
|
|
2695
|
+
"docs:1",
|
|
2696
|
+
r#"{"final":"write","success":true}"#.to_string(),
|
|
2697
|
+
"system".to_string(),
|
|
2698
|
+
)
|
|
2699
|
+
.expect("final write should succeed");
|
|
2700
|
+
|
|
2701
|
+
assert!(
|
|
2702
|
+
final_write.verify_integrity(),
|
|
2703
|
+
"Final write should be valid"
|
|
2704
|
+
);
|
|
2705
|
+
|
|
1700
2706
|
let _ = std::fs::remove_file(&db_path);
|
|
1701
2707
|
}
|
|
1702
2708
|
|
|
@@ -1706,23 +2712,26 @@ mod tests {
|
|
|
1706
2712
|
// Simulates: write → sync starts → crash → restart
|
|
1707
2713
|
// Expected: Either data is on disk (fully recovered) or not (nothing recovered)
|
|
1708
2714
|
// Never: Partial/corrupted data
|
|
1709
|
-
|
|
2715
|
+
|
|
1710
2716
|
let db_path = std::env::temp_dir().join("pr19_crash_during_sync.log");
|
|
1711
2717
|
let _ = std::fs::remove_file(&db_path);
|
|
1712
|
-
|
|
2718
|
+
|
|
1713
2719
|
// First write: known-good state
|
|
1714
2720
|
let initial_id = {
|
|
1715
2721
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1716
2722
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1717
|
-
|
|
1718
|
-
let rev = store
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
2723
|
+
|
|
2724
|
+
let rev = store
|
|
2725
|
+
.create(
|
|
2726
|
+
"docs:1",
|
|
2727
|
+
r#"{"status":"pre-crash","integrity":"good"}"#.to_string(),
|
|
2728
|
+
"system".to_string(),
|
|
2729
|
+
)
|
|
2730
|
+
.expect("write");
|
|
2731
|
+
|
|
1723
2732
|
rev.id.clone()
|
|
1724
2733
|
};
|
|
1725
|
-
|
|
2734
|
+
|
|
1726
2735
|
// After restart, verify state is either:
|
|
1727
2736
|
// A) Initial state is still there (sync didn't complete), OR
|
|
1728
2737
|
// B) New state is there and fully valid (sync completed), OR
|
|
@@ -1730,12 +2739,17 @@ mod tests {
|
|
|
1730
2739
|
{
|
|
1731
2740
|
let db = crate::FeltDb::open(&db_path).expect("reopen");
|
|
1732
2741
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover");
|
|
1733
|
-
|
|
1734
|
-
let current = store
|
|
1735
|
-
|
|
2742
|
+
|
|
2743
|
+
let current = store
|
|
2744
|
+
.get(&initial_id)
|
|
2745
|
+
.expect("the initial state should still exist");
|
|
2746
|
+
|
|
1736
2747
|
// Verify state integrity
|
|
1737
|
-
assert!(
|
|
1738
|
-
|
|
2748
|
+
assert!(
|
|
2749
|
+
current.verify_integrity(),
|
|
2750
|
+
"State should be internally consistent"
|
|
2751
|
+
);
|
|
2752
|
+
|
|
1739
2753
|
// State should either be the initial write or a valid new write
|
|
1740
2754
|
// But never partial or corrupted
|
|
1741
2755
|
assert!(
|
|
@@ -1743,7 +2757,7 @@ mod tests {
|
|
|
1743
2757
|
"Recovered state must be either initial (sync failed) or new+valid (sync succeeded)"
|
|
1744
2758
|
);
|
|
1745
2759
|
}
|
|
1746
|
-
|
|
2760
|
+
|
|
1747
2761
|
let _ = std::fs::remove_file(&db_path);
|
|
1748
2762
|
}
|
|
1749
2763
|
|
|
@@ -1752,30 +2766,31 @@ mod tests {
|
|
|
1752
2766
|
// PROVES: Query execution uses indexes to reduce complexity
|
|
1753
2767
|
// Currently this is unproven - indexes are data structures but
|
|
1754
2768
|
// query execution path is not proven to use them
|
|
1755
|
-
|
|
2769
|
+
|
|
1756
2770
|
let db_path = std::env::temp_dir().join("pr19_query_index.log");
|
|
1757
2771
|
let _ = std::fs::remove_file(&db_path);
|
|
1758
|
-
|
|
2772
|
+
|
|
1759
2773
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1760
2774
|
let store = Arc::new(StateStore::with_feltdb(Arc::new(db)).expect("create store"));
|
|
1761
|
-
|
|
2775
|
+
|
|
1762
2776
|
// Create records with indexed fields
|
|
1763
2777
|
for i in 0..100 {
|
|
1764
2778
|
let content = format!(r#"{{"user_id":"user_{}","active":true}}"#, i % 10);
|
|
1765
|
-
let _ = store
|
|
2779
|
+
let _ = store
|
|
2780
|
+
.create(&format!("record:{i}"), content, format!("creator_{}", i))
|
|
1766
2781
|
.expect("write record");
|
|
1767
2782
|
}
|
|
1768
|
-
|
|
2783
|
+
|
|
1769
2784
|
// Query by indexed field (user_id in this case)
|
|
1770
2785
|
// If indexes are used, this should be O(log n)
|
|
1771
2786
|
// If not, this is O(n)
|
|
1772
|
-
|
|
2787
|
+
|
|
1773
2788
|
// LIMITATION: No way to measure index usage in this test
|
|
1774
2789
|
// This proves index data structures exist, not that they're used
|
|
1775
|
-
|
|
2790
|
+
|
|
1776
2791
|
// TODO: Measure query latency and verify index benefit
|
|
1777
2792
|
// TODO: Add query optimizer tracing to prove index selection
|
|
1778
|
-
|
|
2793
|
+
|
|
1779
2794
|
let _ = std::fs::remove_file(&db_path);
|
|
1780
2795
|
}
|
|
1781
2796
|
|
|
@@ -1784,36 +2799,45 @@ mod tests {
|
|
|
1784
2799
|
// PROVES: Basic multi-tenant isolation at storage layer
|
|
1785
2800
|
// Note: This tests storage isolation, not authorization layer
|
|
1786
2801
|
// Authorization layer tests are in authorization_security_tests.rs
|
|
1787
|
-
|
|
2802
|
+
|
|
1788
2803
|
let db_path = std::env::temp_dir().join("pr19_multi_tenant.log");
|
|
1789
2804
|
let _ = std::fs::remove_file(&db_path);
|
|
1790
|
-
|
|
2805
|
+
|
|
1791
2806
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1792
2807
|
let store = Arc::new(StateStore::with_feltdb(Arc::new(db)).expect("create store"));
|
|
1793
|
-
|
|
2808
|
+
|
|
1794
2809
|
// Create tenant-scoped records
|
|
1795
2810
|
// Tenant A writes a record
|
|
1796
|
-
let tenant_a_record = store
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
2811
|
+
let tenant_a_record = store
|
|
2812
|
+
.create(
|
|
2813
|
+
"tenant_a:1",
|
|
2814
|
+
r#"{"tenant":"tenant_a","data":"secret_a"}"#.to_string(),
|
|
2815
|
+
"tenant_a".to_string(),
|
|
2816
|
+
)
|
|
2817
|
+
.expect("tenant_a write");
|
|
2818
|
+
|
|
1801
2819
|
// Tenant B writes a record
|
|
1802
|
-
let tenant_b_record = store
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
2820
|
+
let tenant_b_record = store
|
|
2821
|
+
.create(
|
|
2822
|
+
"tenant_b:1",
|
|
2823
|
+
r#"{"tenant":"tenant_b","data":"secret_b"}"#.to_string(),
|
|
2824
|
+
"tenant_b".to_string(),
|
|
2825
|
+
)
|
|
2826
|
+
.expect("tenant_b write");
|
|
2827
|
+
|
|
1807
2828
|
// Verify both records are stored (this proves storage works)
|
|
1808
|
-
assert_ne!(
|
|
1809
|
-
|
|
2829
|
+
assert_ne!(
|
|
2830
|
+
tenant_a_record.id, tenant_b_record.id,
|
|
2831
|
+
"Records should have different IDs"
|
|
2832
|
+
);
|
|
2833
|
+
|
|
1810
2834
|
// Current limitation: StateStore doesn't enforce tenant boundaries
|
|
1811
2835
|
// Tenant isolation is enforced at authorization layer, not storage layer
|
|
1812
2836
|
// This test proves that separate StateIds are created for each record
|
|
1813
|
-
|
|
2837
|
+
|
|
1814
2838
|
// TODO: Test authorization layer rejection of cross-tenant access
|
|
1815
2839
|
// See authorization_security_tests.rs
|
|
1816
|
-
|
|
2840
|
+
|
|
1817
2841
|
let _ = std::fs::remove_file(&db_path);
|
|
1818
2842
|
}
|
|
1819
2843
|
|
|
@@ -1821,36 +2845,41 @@ mod tests {
|
|
|
1821
2845
|
fn pr19_proof_atomicity_requires_sync_return() {
|
|
1822
2846
|
// PROVES: Atomicity guarantee only holds if sync() succeeds
|
|
1823
2847
|
// If sync() fails, no durability guarantee
|
|
1824
|
-
|
|
2848
|
+
|
|
1825
2849
|
let db_path = std::env::temp_dir().join("pr19_atomicity_sync.log");
|
|
1826
2850
|
let _ = std::fs::remove_file(&db_path);
|
|
1827
|
-
|
|
2851
|
+
|
|
1828
2852
|
let rev_id = {
|
|
1829
2853
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1830
2854
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1831
|
-
|
|
2855
|
+
|
|
1832
2856
|
// Write should be durable only if it returns Ok
|
|
1833
2857
|
let result = store.create(
|
|
2858
|
+
"docs:1",
|
|
1834
2859
|
r#"{"atomicity":"requires_sync_success"}"#.to_string(),
|
|
1835
2860
|
"system".to_string(),
|
|
1836
2861
|
);
|
|
1837
|
-
|
|
2862
|
+
|
|
1838
2863
|
// If create() returns Ok, durability is guaranteed
|
|
1839
2864
|
match result {
|
|
1840
2865
|
Ok(rev) => Some(rev.id.clone()),
|
|
1841
2866
|
Err(_) => None,
|
|
1842
2867
|
}
|
|
1843
2868
|
}; // Force drop of db to simulate restart
|
|
1844
|
-
|
|
2869
|
+
|
|
1845
2870
|
if let Some(rev_id) = rev_id {
|
|
1846
2871
|
// Durability guaranteed: restart should see this
|
|
1847
2872
|
let db2 = crate::FeltDb::open(&db_path).expect("reopen");
|
|
1848
2873
|
let store2 = StateStore::with_feltdb(Arc::new(db2)).expect("recover");
|
|
1849
|
-
|
|
1850
|
-
assert_eq!(
|
|
2874
|
+
|
|
2875
|
+
assert_eq!(
|
|
2876
|
+
store2.get(&rev_id).unwrap().id,
|
|
2877
|
+
rev_id,
|
|
2878
|
+
"Durability guaranteed"
|
|
2879
|
+
);
|
|
1851
2880
|
}
|
|
1852
2881
|
// If None, no durability guarantee - sync failed
|
|
1853
|
-
|
|
2882
|
+
|
|
1854
2883
|
let _ = std::fs::remove_file(&db_path);
|
|
1855
2884
|
}
|
|
1856
2885
|
}
|