@feltdb/core 0.8.3 → 0.8.5
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/cli/commands.js +4 -1
- package/dist/cli/provisioning-neutrality.js +79 -0
- package/dist/collection.d.ts +43 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +192 -22
- package/dist/create/create.js +25 -21
- package/dist/create/managed-account.js +11 -0
- 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/equality_index.rs +595 -0
- 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 +1050 -117
- 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_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 +90 -72
- 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_execution_diagnostics.rs +126 -0
- 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 +305 -6
- 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 +1514 -537
- 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/submission.rs +5 -11
- package/dist/create/server-source/crates/feltdb/src/sync.rs +12 -0
- 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 +442 -0
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +288 -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/pr34_query_collection.rs +234 -0
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +947 -0
- 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 +1032 -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/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-server/src/app_state.rs +2 -1
- package/dist/create/server-source/crates/feltdb-server/src/audit.rs +1137 -29
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +800 -34
- package/dist/db.d.ts +33 -34
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +74 -20
- package/dist/deployment.d.ts +30 -0
- package/dist/deployment.d.ts.map +1 -0
- package/dist/deployment.js +130 -0
- package/dist/embedded-transaction.d.ts +22 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +51 -5
- package/dist/feltdb.d.ts +14 -2
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.js +1 -1
- package/dist/http-client.d.ts +14 -0
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +23 -5
- package/dist/http-db.d.ts +119 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +346 -31
- package/dist/index-core.d.ts +2 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/indexeddb-db.d.ts.map +1 -1
- package/dist/indexeddb-db.js +35 -21
- package/dist/managed-recovery.d.ts +192 -0
- package/dist/managed-recovery.d.ts.map +1 -0
- package/dist/managed-recovery.js +242 -0
- package/dist/memory-db.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-DB8cX151.js → feltdb_wasm-DaNwCLRX.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
- package/dist/studio-app/assets/index-j8IlhNqJ.js +29 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +30 -0
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +41 -0
- 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-ClhDHp0S.wasm +0 -0
- package/dist/studio-app/assets/index-B0k4UAlI.js +0 -29
|
@@ -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,876 @@ 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
|
-
///
|
|
598
|
-
///
|
|
599
|
-
///
|
|
600
|
-
///
|
|
1045
|
+
/// Durable, content-addressed revision storage.
|
|
1046
|
+
///
|
|
1047
|
+
/// `StateStore` answers exactly one question:
|
|
1048
|
+
///
|
|
1049
|
+
/// > **Do I have revision X?**
|
|
1050
|
+
///
|
|
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.
|
|
601
1086
|
///
|
|
602
|
-
///
|
|
603
|
-
/// `StateStore::with_feltdb()`. This ensures all mutations are persisted
|
|
604
|
-
/// through FeltDB's canonical operation log.
|
|
1087
|
+
/// # Non-claims
|
|
605
1088
|
///
|
|
606
|
-
///
|
|
607
|
-
///
|
|
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}")
|
|
1242
|
+
}
|
|
1243
|
+
RevisionError::ResourceAlreadyBegun { resource, head } => {
|
|
1244
|
+
write!(f, "resource {resource} already begins at revision {head}")
|
|
724
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
|
+
}
|
|
1285
|
+
|
|
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
|
+
}
|
|
741
1292
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
let
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
+
}
|
|
798
1402
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
+
}
|
|
804
1441
|
}
|
|
805
|
-
|
|
806
1442
|
Ok(revision)
|
|
807
1443
|
}
|
|
808
1444
|
|
|
809
|
-
|
|
810
|
-
pub fn current(&self) -> Option<StateRevision> {
|
|
811
|
-
let current_id = self.current_id.lock().unwrap();
|
|
812
|
-
if let Some(id) = &*current_id {
|
|
813
|
-
let revisions = self.revisions.lock().unwrap();
|
|
814
|
-
revisions.get(id).cloned()
|
|
815
|
-
} else {
|
|
816
|
-
None
|
|
817
|
-
}
|
|
818
|
-
}
|
|
1445
|
+
// -- reading ------------------------------------------------------------
|
|
819
1446
|
|
|
820
|
-
///
|
|
1447
|
+
/// Retrieve a revision by identity. This is the authoritative read path.
|
|
821
1448
|
pub fn get(&self, id: &StateId) -> Option<StateRevision> {
|
|
822
|
-
|
|
823
|
-
|
|
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(),
|
|
1455
|
+
}
|
|
824
1456
|
}
|
|
825
1457
|
|
|
826
|
-
///
|
|
1458
|
+
/// Whether a revision is present.
|
|
827
1459
|
pub fn exists(&self, id: &StateId) -> bool {
|
|
828
|
-
|
|
829
|
-
revisions.contains_key(id)
|
|
1460
|
+
self.get(id).is_some()
|
|
830
1461
|
}
|
|
831
1462
|
|
|
832
|
-
///
|
|
1463
|
+
/// Metadata for a revision.
|
|
833
1464
|
pub fn metadata(&self, id: &StateId) -> Option<BTreeMap<String, Value>> {
|
|
834
|
-
|
|
835
|
-
revisions.get(id).map(|r| r.metadata.clone())
|
|
1465
|
+
self.get(id).map(|revision| revision.metadata)
|
|
836
1466
|
}
|
|
837
1467
|
|
|
838
|
-
///
|
|
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)
|
|
1480
|
+
} else {
|
|
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.
|
|
839
1490
|
pub fn parent(&self, id: &StateId) -> Option<StateRevision> {
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
return revisions.get(parent_id).cloned();
|
|
844
|
-
}
|
|
1491
|
+
match self.parent_of(id)? {
|
|
1492
|
+
ParentLookup::Revision(parent) => Some(*parent),
|
|
1493
|
+
_ => None,
|
|
845
1494
|
}
|
|
846
|
-
None
|
|
847
1495
|
}
|
|
848
1496
|
|
|
849
|
-
///
|
|
850
|
-
pub fn
|
|
851
|
-
let mut
|
|
852
|
-
|
|
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
|
|
1506
|
+
}
|
|
853
1507
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
.
|
|
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
|
+
})
|
|
1521
|
+
}
|
|
1522
|
+
|
|
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(),
|
|
865
1545
|
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// -- retention ----------------------------------------------------------
|
|
866
1549
|
|
|
1550
|
+
/// The retention policy in force for a resource.
|
|
1551
|
+
pub fn retention_policy(&self, resource: &str) -> RetentionPolicy {
|
|
1552
|
+
self.retention_state(resource).policy
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/// The lowest sequence still retained for a resource.
|
|
1556
|
+
///
|
|
1557
|
+
/// Revisions below it were expired deliberately. This is what separates an
|
|
1558
|
+
/// expired parent from a missing one.
|
|
1559
|
+
pub fn retention_horizon(&self, resource: &str) -> u64 {
|
|
1560
|
+
self.retention_state(resource).horizon
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/// Set a resource's retention policy, and apply it immediately.
|
|
1564
|
+
///
|
|
1565
|
+
/// Returns how many revisions the policy expired on application.
|
|
1566
|
+
pub fn set_retention_policy(
|
|
1567
|
+
&self,
|
|
1568
|
+
resource: &str,
|
|
1569
|
+
policy: RetentionPolicy,
|
|
1570
|
+
) -> Result<usize, RevisionError> {
|
|
1571
|
+
let mut state = self.retention_state(resource);
|
|
1572
|
+
state.policy = policy;
|
|
1573
|
+
self.write_retention_state(resource, &state)?;
|
|
1574
|
+
self.apply_retention(resource)
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/// Expire whatever the resource's policy no longer retains.
|
|
1578
|
+
///
|
|
1579
|
+
/// Deletes from the **oldest** end only, so the surviving history is always
|
|
1580
|
+
/// a suffix of the resource's timeline and the current revision always
|
|
1581
|
+
/// survives. A surviving revision's `parent_id` is never rewritten — that
|
|
1582
|
+
/// would violate immutability — so the horizon is recorded instead, and the
|
|
1583
|
+
/// oldest survivor reports an `Expired` parent rather than a missing one.
|
|
1584
|
+
pub fn apply_retention(&self, resource: &str) -> Result<usize, RevisionError> {
|
|
1585
|
+
let mut state = self.retention_state(resource);
|
|
1586
|
+
if state.policy.keep_last.is_none() {
|
|
1587
|
+
return Ok(0);
|
|
1588
|
+
}
|
|
1589
|
+
let history: Vec<(StateId, u64)> = self
|
|
1590
|
+
.history_of(resource)
|
|
1591
|
+
.into_iter()
|
|
1592
|
+
.map(|revision| (revision.id, revision.sequence))
|
|
1593
|
+
.collect();
|
|
1594
|
+
let (expire, horizon) = revisions_to_expire(&history, state.policy.keep_last);
|
|
1595
|
+
if expire.is_empty() {
|
|
1596
|
+
return Ok(0);
|
|
1597
|
+
}
|
|
1598
|
+
for id in &expire {
|
|
1599
|
+
self.remove(id)?;
|
|
1600
|
+
}
|
|
1601
|
+
if let Some(horizon) = horizon {
|
|
1602
|
+
state.horizon = horizon;
|
|
1603
|
+
}
|
|
1604
|
+
self.write_retention_state(resource, &state)?;
|
|
1605
|
+
Ok(expire.len())
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
fn remove(&self, id: &StateId) -> Result<(), RevisionError> {
|
|
1609
|
+
match &self.backing {
|
|
1610
|
+
Backing::Volatile(state) => {
|
|
1611
|
+
state.lock().unwrap().revisions.remove(id);
|
|
1612
|
+
}
|
|
1613
|
+
Backing::Durable(feltdb) => {
|
|
1614
|
+
feltdb.delete(&revision_key(id)).map_err(|error| {
|
|
1615
|
+
RevisionError::Storage(format!("Failed to expire revision: {error}"))
|
|
1616
|
+
})?;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
867
1619
|
Ok(())
|
|
868
1620
|
}
|
|
869
1621
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1622
|
+
fn retention_state(&self, resource: &str) -> RetentionState {
|
|
1623
|
+
match &self.backing {
|
|
1624
|
+
Backing::Volatile(state) => state
|
|
1625
|
+
.lock()
|
|
1626
|
+
.unwrap()
|
|
1627
|
+
.retention
|
|
1628
|
+
.get(resource)
|
|
1629
|
+
.cloned()
|
|
1630
|
+
.unwrap_or_default(),
|
|
1631
|
+
Backing::Durable(feltdb) => feltdb
|
|
1632
|
+
.get::<RetentionState>(&retention_key(resource))
|
|
1633
|
+
.ok()
|
|
1634
|
+
.flatten()
|
|
1635
|
+
.unwrap_or_default(),
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
fn write_retention_state(
|
|
1640
|
+
&self,
|
|
1641
|
+
resource: &str,
|
|
1642
|
+
state: &RetentionState,
|
|
1643
|
+
) -> Result<(), RevisionError> {
|
|
1644
|
+
match &self.backing {
|
|
1645
|
+
Backing::Volatile(volatile) => {
|
|
1646
|
+
volatile
|
|
1647
|
+
.lock()
|
|
1648
|
+
.unwrap()
|
|
1649
|
+
.retention
|
|
1650
|
+
.insert(resource.to_string(), state.clone());
|
|
1651
|
+
}
|
|
1652
|
+
Backing::Durable(feltdb) => {
|
|
1653
|
+
feltdb
|
|
1654
|
+
.insert(&retention_key(resource), state.clone())
|
|
1655
|
+
.map_err(|error| {
|
|
1656
|
+
RevisionError::Storage(format!(
|
|
1657
|
+
"Failed to persist retention state: {error}"
|
|
1658
|
+
))
|
|
1659
|
+
})?;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
Ok(())
|
|
874
1663
|
}
|
|
875
1664
|
}
|
|
876
1665
|
|
|
877
1666
|
impl Clone for StateStore {
|
|
878
1667
|
fn clone(&self) -> Self {
|
|
879
1668
|
StateStore {
|
|
880
|
-
|
|
881
|
-
current_id: self.current_id.clone(),
|
|
882
|
-
branches: self.branches.clone(),
|
|
883
|
-
feltdb: self.feltdb.clone(),
|
|
1669
|
+
backing: self.backing.clone(),
|
|
884
1670
|
}
|
|
885
1671
|
}
|
|
886
1672
|
}
|
|
@@ -921,6 +1707,7 @@ mod tests {
|
|
|
921
1707
|
#[test]
|
|
922
1708
|
fn test_state_revision_initial() {
|
|
923
1709
|
let rev = StateRevision::initial(
|
|
1710
|
+
"docs:1".to_string(),
|
|
924
1711
|
r#"{"key":"value"}"#.to_string(),
|
|
925
1712
|
"test-authority".to_string(),
|
|
926
1713
|
);
|
|
@@ -932,6 +1719,7 @@ mod tests {
|
|
|
932
1719
|
#[test]
|
|
933
1720
|
fn test_state_revision_child() {
|
|
934
1721
|
let parent = StateRevision::initial(
|
|
1722
|
+
"docs:1".to_string(),
|
|
935
1723
|
r#"{"key":"value"}"#.to_string(),
|
|
936
1724
|
"test-authority".to_string(),
|
|
937
1725
|
);
|
|
@@ -949,14 +1737,11 @@ mod tests {
|
|
|
949
1737
|
let mut topo = StateTopology::new();
|
|
950
1738
|
|
|
951
1739
|
let rev1 = StateRevision::initial(
|
|
1740
|
+
"docs:1".to_string(),
|
|
952
1741
|
r#"{"v":1}"#.to_string(),
|
|
953
1742
|
"auth".to_string(),
|
|
954
1743
|
);
|
|
955
|
-
let rev2 = StateRevision::child(
|
|
956
|
-
r#"{"v":2}"#.to_string(),
|
|
957
|
-
&rev1,
|
|
958
|
-
"auth".to_string(),
|
|
959
|
-
);
|
|
1744
|
+
let rev2 = StateRevision::child(r#"{"v":2}"#.to_string(), &rev1, "auth".to_string());
|
|
960
1745
|
|
|
961
1746
|
topo.add_revision(rev1.clone());
|
|
962
1747
|
topo.add_revision(rev2.clone());
|
|
@@ -979,50 +1764,142 @@ mod tests {
|
|
|
979
1764
|
}
|
|
980
1765
|
|
|
981
1766
|
#[test]
|
|
982
|
-
fn
|
|
983
|
-
let
|
|
984
|
-
|
|
985
|
-
|
|
1767
|
+
fn path_relation_is_structural() {
|
|
1768
|
+
let key = |name: &str| PathComponent::Key(name.to_string());
|
|
1769
|
+
|
|
1770
|
+
assert_eq!(path_relation(&[key("a")], &[key("a")]), PathRelation::Same);
|
|
1771
|
+
assert_eq!(
|
|
1772
|
+
path_relation(&[key("a")], &[key("a"), key("b")]),
|
|
1773
|
+
PathRelation::Ancestor
|
|
1774
|
+
);
|
|
1775
|
+
assert_eq!(
|
|
1776
|
+
path_relation(&[key("a"), key("b")], &[key("a")]),
|
|
1777
|
+
PathRelation::Descendant
|
|
1778
|
+
);
|
|
1779
|
+
assert_eq!(
|
|
1780
|
+
path_relation(&[key("a"), key("b")], &[key("a"), key("c")]),
|
|
1781
|
+
PathRelation::Disjoint
|
|
986
1782
|
);
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1783
|
+
// Components are compared whole. `a.b` and `a.bc` share a string prefix
|
|
1784
|
+
// under any flattened rendering and are still disjoint.
|
|
1785
|
+
assert_eq!(
|
|
1786
|
+
path_relation(&[key("a"), key("b")], &[key("a"), key("bc")]),
|
|
1787
|
+
PathRelation::Disjoint
|
|
1788
|
+
);
|
|
1789
|
+
// A key never matches an index, whatever they look like as text.
|
|
1790
|
+
assert_eq!(
|
|
1791
|
+
path_relation(&[key("0")], &[PathComponent::Index(0)]),
|
|
1792
|
+
PathRelation::Disjoint
|
|
1793
|
+
);
|
|
1794
|
+
// The empty path is the whole state, and contains every other path.
|
|
1795
|
+
assert_eq!(path_relation(&[], &[key("a")]), PathRelation::Ancestor);
|
|
1796
|
+
assert_eq!(path_relation(&[], &[]), PathRelation::Same);
|
|
1797
|
+
|
|
1798
|
+
assert!(paths_overlap(&[key("a")], &[key("a"), key("b")]));
|
|
1799
|
+
assert!(paths_overlap(&[key("a"), key("b")], &[key("a")]));
|
|
1800
|
+
assert!(!paths_overlap(
|
|
1801
|
+
&[key("a"), key("b")],
|
|
1802
|
+
&[key("a"), key("bc")]
|
|
1803
|
+
));
|
|
1804
|
+
assert!(!paths_overlap(&[key("a")], &[key("b")]));
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
#[test]
|
|
1808
|
+
fn resolve_path_reads_objects_and_arrays() {
|
|
1809
|
+
let value = json!({"o": {"a": [10, {"b": 1}]}, "n": null});
|
|
1810
|
+
let key = |name: &str| PathComponent::Key(name.to_string());
|
|
1811
|
+
|
|
1812
|
+
assert_eq!(resolve_path(&value, &[]), Some(&value));
|
|
1813
|
+
assert_eq!(resolve_path(&value, &[key("n")]), Some(&Value::Null));
|
|
1814
|
+
assert_eq!(
|
|
1815
|
+
resolve_path(&value, &[key("o"), key("a"), PathComponent::Index(0)]),
|
|
1816
|
+
Some(&json!(10))
|
|
1817
|
+
);
|
|
1818
|
+
assert_eq!(
|
|
1819
|
+
resolve_path(
|
|
1820
|
+
&value,
|
|
1821
|
+
&[key("o"), key("a"), PathComponent::Index(1), key("b")]
|
|
1822
|
+
),
|
|
1823
|
+
Some(&json!(1))
|
|
1824
|
+
);
|
|
1825
|
+
// A path that does not resolve, and one that uses the wrong component
|
|
1826
|
+
// kind for the container it meets.
|
|
1827
|
+
assert_eq!(resolve_path(&value, &[key("missing")]), None);
|
|
1828
|
+
assert_eq!(resolve_path(&value, &[PathComponent::Index(0)]), None);
|
|
1829
|
+
assert_eq!(
|
|
1830
|
+
resolve_path(&value, &[key("o"), key("a"), PathComponent::Index(9)]),
|
|
1831
|
+
None
|
|
1832
|
+
);
|
|
1833
|
+
assert_eq!(resolve_path(&value, &[key("o"), key("a"), key("0")]), None);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
#[test]
|
|
1837
|
+
fn overlapping_changes_are_never_independent() {
|
|
1838
|
+
// The reported defect: a branch replacing `o` and a branch editing
|
|
1839
|
+
// `o.a` cannot both be applied, so neither path is independent.
|
|
1840
|
+
let base = StateRevision::initial(
|
|
1841
|
+
"docs:1".to_string(),
|
|
1842
|
+
r#"{"o":{"a":1}}"#.to_string(),
|
|
990
1843
|
"auth".to_string(),
|
|
991
1844
|
);
|
|
992
|
-
let
|
|
993
|
-
|
|
994
|
-
|
|
1845
|
+
let left = StateRevision::child(r#"{"o":7}"#.to_string(), &base, "auth".to_string());
|
|
1846
|
+
let right = StateRevision::child(r#"{"o":{"a":2}}"#.to_string(), &base, "auth".to_string());
|
|
1847
|
+
|
|
1848
|
+
let classification = ConflictClassification::classify(&base, &left, &right);
|
|
1849
|
+
assert_eq!(classification.overall, ConflictClass::Conflict);
|
|
1850
|
+
assert!(classification
|
|
1851
|
+
.path_conflicts
|
|
1852
|
+
.iter()
|
|
1853
|
+
.all(|entry| entry.classification == ConflictClass::Conflict));
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
#[test]
|
|
1857
|
+
fn test_conflict_classification_independent() {
|
|
1858
|
+
let base = StateRevision::initial(
|
|
1859
|
+
"docs:1".to_string(),
|
|
1860
|
+
r#"{"a":1,"b":1}"#.to_string(),
|
|
995
1861
|
"auth".to_string(),
|
|
996
1862
|
);
|
|
1863
|
+
let left = StateRevision::child(r#"{"a":2,"b":1}"#.to_string(), &base, "auth".to_string());
|
|
1864
|
+
let right = StateRevision::child(r#"{"a":1,"b":2}"#.to_string(), &base, "auth".to_string());
|
|
997
1865
|
|
|
998
1866
|
let classification = ConflictClassification::classify(&base, &left, &right);
|
|
999
1867
|
assert_eq!(classification.overall, ConflictClass::Independent);
|
|
1000
1868
|
}
|
|
1001
1869
|
|
|
1002
1870
|
#[test]
|
|
1003
|
-
fn
|
|
1871
|
+
fn test_state_store_create_and_get() {
|
|
1004
1872
|
let store = StateStore::new_volatile();
|
|
1005
1873
|
let rev = store
|
|
1006
|
-
.create(
|
|
1874
|
+
.create(
|
|
1875
|
+
"docs:1",
|
|
1876
|
+
r#"{"data":"initial"}"#.to_string(),
|
|
1877
|
+
"auth".to_string(),
|
|
1878
|
+
)
|
|
1007
1879
|
.unwrap();
|
|
1008
1880
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1881
|
+
// The store answers "do I have revision X?", not "what is current?".
|
|
1882
|
+
let stored = store.get(&rev.id).unwrap();
|
|
1883
|
+
assert_eq!(stored.id, rev.id);
|
|
1884
|
+
assert_eq!(stored.content, rev.content);
|
|
1011
1885
|
}
|
|
1012
1886
|
|
|
1013
1887
|
#[test]
|
|
1014
1888
|
fn test_state_store_commit() {
|
|
1015
1889
|
let store = StateStore::new_volatile();
|
|
1016
1890
|
let rev1 = store
|
|
1017
|
-
.create(r#"{"v":1}"#.to_string(), "auth".to_string())
|
|
1891
|
+
.create("docs:1", r#"{"v":1}"#.to_string(), "auth".to_string())
|
|
1018
1892
|
.unwrap();
|
|
1019
1893
|
let rev2 = store
|
|
1020
1894
|
.commit(r#"{"v":2}"#.to_string(), &rev1, "auth".to_string())
|
|
1021
1895
|
.unwrap();
|
|
1022
1896
|
|
|
1023
1897
|
assert_eq!(rev2.parent_id, Some(rev1.id.clone()));
|
|
1024
|
-
|
|
1025
|
-
|
|
1898
|
+
// Both revisions remain retrievable; committing a child does not
|
|
1899
|
+
// displace its parent, because there is no pointer to displace.
|
|
1900
|
+
assert_eq!(store.get(&rev2.id).unwrap().id, rev2.id);
|
|
1901
|
+
assert_eq!(store.get(&rev1.id).unwrap().id, rev1.id);
|
|
1902
|
+
assert_eq!(store.parent(&rev2.id).unwrap().id, rev1.id);
|
|
1026
1903
|
}
|
|
1027
1904
|
|
|
1028
1905
|
#[test]
|
|
@@ -1048,29 +1925,35 @@ mod tests {
|
|
|
1048
1925
|
let db_path = std::env::temp_dir().join("test_recovery_root.log");
|
|
1049
1926
|
let _ = std::fs::remove_file(&db_path); // Clean up any previous test
|
|
1050
1927
|
|
|
1928
|
+
let initial_id;
|
|
1929
|
+
|
|
1051
1930
|
// Phase 1: Create and persist root state
|
|
1052
1931
|
{
|
|
1053
1932
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1054
1933
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1055
1934
|
|
|
1056
|
-
let initial = store
|
|
1935
|
+
let initial = store
|
|
1936
|
+
.create(
|
|
1937
|
+
"docs:1",
|
|
1938
|
+
r#"{"data":"root"}"#.to_string(),
|
|
1939
|
+
"auth".to_string(),
|
|
1940
|
+
)
|
|
1057
1941
|
.expect("create initial");
|
|
1058
1942
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1943
|
+
assert_eq!(store.get(&initial.id).unwrap().id, initial.id);
|
|
1944
|
+
initial_id = initial.id;
|
|
1061
1945
|
} // Database closes, data persists to disk
|
|
1062
1946
|
|
|
1063
|
-
// Phase 2: Reopen and
|
|
1947
|
+
// Phase 2: Reopen and read it back by identity. No recovery step runs.
|
|
1064
1948
|
{
|
|
1065
1949
|
let db = crate::FeltDb::open(&db_path).expect("open db again");
|
|
1066
|
-
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store
|
|
1950
|
+
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store over same log");
|
|
1067
1951
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
assert!(current.parent_id.is_none());
|
|
1952
|
+
let root = store.get(&initial_id).expect("the root survives restart");
|
|
1953
|
+
assert_eq!(root.content, r#"{"data":"root"}"#);
|
|
1954
|
+
assert_eq!(root.authority, "auth");
|
|
1955
|
+
assert!(root.parent_id.is_none());
|
|
1956
|
+
assert!(root.verify_integrity());
|
|
1074
1957
|
}
|
|
1075
1958
|
|
|
1076
1959
|
let _ = std::fs::remove_file(&db_path); // Clean up
|
|
@@ -1092,20 +1975,23 @@ mod tests {
|
|
|
1092
1975
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1093
1976
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1094
1977
|
|
|
1095
|
-
let rev1 = store
|
|
1978
|
+
let rev1 = store
|
|
1979
|
+
.create("docs:1", r#"{"v":1}"#.to_string(), "auth".to_string())
|
|
1096
1980
|
.expect("create rev1");
|
|
1097
1981
|
rev1_id = rev1.id.clone();
|
|
1098
1982
|
|
|
1099
|
-
let rev2 = store
|
|
1983
|
+
let rev2 = store
|
|
1984
|
+
.commit(r#"{"v":2}"#.to_string(), &rev1, "auth".to_string())
|
|
1100
1985
|
.expect("create rev2");
|
|
1101
1986
|
rev2_id = rev2.id.clone();
|
|
1102
1987
|
|
|
1103
|
-
let rev3 = store
|
|
1988
|
+
let rev3 = store
|
|
1989
|
+
.commit(r#"{"v":3}"#.to_string(), &rev2, "auth".to_string())
|
|
1104
1990
|
.expect("create rev3");
|
|
1105
1991
|
rev3_id = rev3.id.clone();
|
|
1106
1992
|
|
|
1107
1993
|
// Verify before close
|
|
1108
|
-
assert_eq!(store.
|
|
1994
|
+
assert_eq!(store.get(&rev3_id).unwrap().id, rev3_id);
|
|
1109
1995
|
assert_eq!(store.parent(&rev3_id).unwrap().id, rev2_id);
|
|
1110
1996
|
assert_eq!(store.parent(&rev2_id).unwrap().id, rev1_id);
|
|
1111
1997
|
}
|
|
@@ -1113,7 +1999,8 @@ mod tests {
|
|
|
1113
1999
|
// Phase 2: Reopen and verify full history recovered
|
|
1114
2000
|
{
|
|
1115
2001
|
let db = crate::FeltDb::open(&db_path).expect("open db again");
|
|
1116
|
-
let store =
|
|
2002
|
+
let store =
|
|
2003
|
+
StateStore::with_feltdb(Arc::new(db)).expect("create store from recovered db");
|
|
1117
2004
|
|
|
1118
2005
|
// Verify all three revisions exist
|
|
1119
2006
|
assert!(store.exists(&rev1_id));
|
|
@@ -1121,7 +2008,7 @@ mod tests {
|
|
|
1121
2008
|
assert!(store.exists(&rev3_id));
|
|
1122
2009
|
|
|
1123
2010
|
// Verify ancestry
|
|
1124
|
-
assert_eq!(store.
|
|
2011
|
+
assert_eq!(store.get(&rev3_id).unwrap().id, rev3_id);
|
|
1125
2012
|
assert_eq!(store.parent(&rev3_id).unwrap().id, rev2_id);
|
|
1126
2013
|
assert_eq!(store.parent(&rev2_id).unwrap().id, rev1_id);
|
|
1127
2014
|
assert!(store.parent(&rev1_id).is_none());
|
|
@@ -1136,7 +2023,7 @@ mod tests {
|
|
|
1136
2023
|
}
|
|
1137
2024
|
|
|
1138
2025
|
#[test]
|
|
1139
|
-
fn
|
|
2026
|
+
fn test_divergent_siblings_survive_restart_independently() {
|
|
1140
2027
|
use std::sync::Arc;
|
|
1141
2028
|
|
|
1142
2029
|
let db_path = std::env::temp_dir().join("test_recovery_branches.log");
|
|
@@ -1150,36 +2037,35 @@ mod tests {
|
|
|
1150
2037
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1151
2038
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1152
2039
|
|
|
1153
|
-
let root = store
|
|
2040
|
+
let root = store
|
|
2041
|
+
.create("docs:1", r#"{"base":true}"#.to_string(), "auth".to_string())
|
|
1154
2042
|
.expect("create root");
|
|
1155
2043
|
root_id = root.id.clone();
|
|
1156
2044
|
|
|
1157
|
-
let branch1 = store
|
|
2045
|
+
let branch1 = store
|
|
2046
|
+
.commit(r#"{"path":"a"}"#.to_string(), &root, "auth".to_string())
|
|
1158
2047
|
.expect("create branch1");
|
|
1159
2048
|
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
2049
|
}
|
|
1164
2050
|
|
|
1165
|
-
// Phase 2: Reopen
|
|
2051
|
+
// Phase 2: Reopen. There is no branch to recover, and none is needed:
|
|
2052
|
+
// a divergent revision is reachable by its own identity, and its
|
|
2053
|
+
// ancestry is its parent link.
|
|
1166
2054
|
{
|
|
1167
2055
|
let db = crate::FeltDb::open(&db_path).expect("open db again");
|
|
1168
|
-
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store
|
|
1169
|
-
|
|
1170
|
-
// Verify branch exists and points correctly
|
|
1171
|
-
assert_eq!(store.branch_head("feature-a"), Some(branch1_id.clone()));
|
|
2056
|
+
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store over same log");
|
|
1172
2057
|
|
|
1173
|
-
// Verify both revisions exist
|
|
1174
2058
|
assert!(store.exists(&root_id));
|
|
1175
2059
|
assert!(store.exists(&branch1_id));
|
|
2060
|
+
assert_eq!(store.parent(&branch1_id).unwrap().id, root_id);
|
|
2061
|
+
assert_eq!(store.get(&branch1_id).unwrap().content, r#"{"path":"a"}"#);
|
|
1176
2062
|
}
|
|
1177
2063
|
|
|
1178
2064
|
let _ = std::fs::remove_file(&db_path);
|
|
1179
2065
|
}
|
|
1180
2066
|
|
|
1181
2067
|
#[test]
|
|
1182
|
-
fn
|
|
2068
|
+
fn test_every_revision_stays_retrievable_by_identity_after_restart() {
|
|
1183
2069
|
use std::sync::Arc;
|
|
1184
2070
|
|
|
1185
2071
|
let db_path = std::env::temp_dir().join("test_recovery_current.log");
|
|
@@ -1188,34 +2074,36 @@ mod tests {
|
|
|
1188
2074
|
let rev1_id;
|
|
1189
2075
|
let rev2_id;
|
|
1190
2076
|
|
|
1191
|
-
// Phase 1:
|
|
2077
|
+
// Phase 1: Two revisions, one descending from the other.
|
|
1192
2078
|
{
|
|
1193
2079
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1194
2080
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1195
2081
|
|
|
1196
|
-
let rev1 = store
|
|
2082
|
+
let rev1 = store
|
|
2083
|
+
.create("docs:1", r#"{"state":"1"}"#.to_string(), "auth".to_string())
|
|
1197
2084
|
.expect("create rev1");
|
|
1198
2085
|
rev1_id = rev1.id.clone();
|
|
1199
2086
|
|
|
1200
|
-
|
|
1201
|
-
assert_eq!(store.current().unwrap().id, rev1_id);
|
|
2087
|
+
assert_eq!(store.get(&rev1_id).unwrap().id, rev1_id);
|
|
1202
2088
|
|
|
1203
|
-
let rev2 = store
|
|
2089
|
+
let rev2 = store
|
|
2090
|
+
.commit(r#"{"state":"2"}"#.to_string(), &rev1, "auth".to_string())
|
|
1204
2091
|
.expect("create rev2");
|
|
1205
2092
|
rev2_id = rev2.id.clone();
|
|
1206
2093
|
|
|
1207
|
-
//
|
|
1208
|
-
assert_eq!(store.
|
|
2094
|
+
// Committing a child does not displace the parent.
|
|
2095
|
+
assert_eq!(store.get(&rev2_id).unwrap().id, rev2_id);
|
|
2096
|
+
assert_eq!(store.get(&rev1_id).unwrap().id, rev1_id);
|
|
1209
2097
|
}
|
|
1210
2098
|
|
|
1211
|
-
// Phase 2:
|
|
2099
|
+
// Phase 2: both survive, and neither is privileged over the other.
|
|
1212
2100
|
{
|
|
1213
2101
|
let db = crate::FeltDb::open(&db_path).expect("open db again");
|
|
1214
|
-
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store
|
|
2102
|
+
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store over same log");
|
|
1215
2103
|
|
|
1216
|
-
|
|
1217
|
-
assert_eq!(store.
|
|
1218
|
-
assert_eq!(store.
|
|
2104
|
+
assert_eq!(store.get(&rev1_id).unwrap().content, r#"{"state":"1"}"#);
|
|
2105
|
+
assert_eq!(store.get(&rev2_id).unwrap().content, r#"{"state":"2"}"#);
|
|
2106
|
+
assert_eq!(store.parent(&rev2_id).unwrap().id, rev1_id);
|
|
1219
2107
|
}
|
|
1220
2108
|
|
|
1221
2109
|
let _ = std::fs::remove_file(&db_path);
|
|
@@ -1237,27 +2125,35 @@ mod tests {
|
|
|
1237
2125
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1238
2126
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1239
2127
|
|
|
1240
|
-
let root = store
|
|
2128
|
+
let root = store
|
|
2129
|
+
.create("docs:1", r#"{"v":0}"#.to_string(), "auth".to_string())
|
|
1241
2130
|
.expect("create root");
|
|
1242
2131
|
root_id = root.id.clone();
|
|
1243
2132
|
|
|
1244
|
-
let rev_a = store
|
|
2133
|
+
let rev_a = store
|
|
2134
|
+
.commit(
|
|
2135
|
+
r#"{"v":1,"stage":"a"}"#.to_string(),
|
|
2136
|
+
&root,
|
|
2137
|
+
"auth".to_string(),
|
|
2138
|
+
)
|
|
1245
2139
|
.expect("create rev_a");
|
|
1246
2140
|
rev_a_id = rev_a.id.clone();
|
|
1247
2141
|
|
|
1248
|
-
let rev_b = store
|
|
2142
|
+
let rev_b = store
|
|
2143
|
+
.commit(
|
|
2144
|
+
r#"{"v":2,"stage":"b"}"#.to_string(),
|
|
2145
|
+
&rev_a,
|
|
2146
|
+
"auth".to_string(),
|
|
2147
|
+
)
|
|
1249
2148
|
.expect("create rev_b");
|
|
1250
2149
|
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
2150
|
}
|
|
1256
2151
|
|
|
1257
2152
|
// Phase 2: Verify complex topology recovered
|
|
1258
2153
|
{
|
|
1259
2154
|
let db = crate::FeltDb::open(&db_path).expect("open db again");
|
|
1260
|
-
let store =
|
|
2155
|
+
let store =
|
|
2156
|
+
StateStore::with_feltdb(Arc::new(db)).expect("create store from recovered db");
|
|
1261
2157
|
|
|
1262
2158
|
// Verify all revisions
|
|
1263
2159
|
assert!(store.exists(&root_id));
|
|
@@ -1268,67 +2164,75 @@ mod tests {
|
|
|
1268
2164
|
assert_eq!(store.parent(&rev_b_id).unwrap().id, rev_a_id);
|
|
1269
2165
|
assert_eq!(store.parent(&rev_a_id).unwrap().id, root_id);
|
|
1270
2166
|
|
|
1271
|
-
//
|
|
1272
|
-
|
|
1273
|
-
assert_eq!(
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
2167
|
+
// Any point in the history is a checkpoint, addressed by its own
|
|
2168
|
+
// identity. Naming one adds nothing the id does not already give.
|
|
2169
|
+
assert_eq!(
|
|
2170
|
+
store.get(&rev_a_id).unwrap().content,
|
|
2171
|
+
r#"{"v":1,"stage":"a"}"#
|
|
2172
|
+
);
|
|
2173
|
+
assert_eq!(
|
|
2174
|
+
store.get(&rev_b_id).unwrap().content,
|
|
2175
|
+
r#"{"v":2,"stage":"b"}"#
|
|
2176
|
+
);
|
|
1277
2177
|
}
|
|
1278
2178
|
|
|
1279
2179
|
let _ = std::fs::remove_file(&db_path);
|
|
1280
2180
|
}
|
|
1281
2181
|
|
|
1282
2182
|
#[test]
|
|
1283
|
-
fn
|
|
2183
|
+
fn test_revisions_survive_multiple_restart_cycles() {
|
|
1284
2184
|
use std::sync::Arc;
|
|
1285
2185
|
|
|
1286
2186
|
let db_path = std::env::temp_dir().join("test_recovery_cycles.log");
|
|
1287
2187
|
let _ = std::fs::remove_file(&db_path);
|
|
1288
2188
|
|
|
2189
|
+
// The caller carries the identities it cares about across restarts.
|
|
2190
|
+
// That is the whole of what replaces a `current` pointer here.
|
|
2191
|
+
let rev1_id;
|
|
2192
|
+
let rev2_id;
|
|
2193
|
+
|
|
1289
2194
|
// Cycle 1: Create root
|
|
1290
2195
|
{
|
|
1291
2196
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1292
2197
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1293
|
-
store
|
|
2198
|
+
rev1_id = store
|
|
2199
|
+
.create("docs:1", r#"{"cycle":1}"#.to_string(), "auth".to_string())
|
|
2200
|
+
.expect("create")
|
|
2201
|
+
.id;
|
|
1294
2202
|
}
|
|
1295
2203
|
|
|
1296
|
-
// Cycle 2: Restart,
|
|
2204
|
+
// Cycle 2: Restart, read by identity, and commit onto it
|
|
1297
2205
|
{
|
|
1298
2206
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1299
2207
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1300
|
-
assert!(store.current().is_some());
|
|
1301
2208
|
|
|
1302
|
-
let rev1 = store.
|
|
2209
|
+
let rev1 = store.get(&rev1_id).expect("rev1 survives");
|
|
1303
2210
|
assert_eq!(rev1.content, r#"{"cycle":1}"#);
|
|
1304
|
-
|
|
1305
|
-
|
|
2211
|
+
|
|
2212
|
+
rev2_id = store
|
|
2213
|
+
.commit(r#"{"cycle":2}"#.to_string(), &rev1, "auth".to_string())
|
|
2214
|
+
.expect("commit 2")
|
|
2215
|
+
.id;
|
|
1306
2216
|
}
|
|
1307
2217
|
|
|
1308
|
-
// Cycle 3: Restart,
|
|
2218
|
+
// Cycle 3: Restart, both are still there
|
|
1309
2219
|
{
|
|
1310
2220
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1311
2221
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1312
|
-
assert!(store.current().is_some());
|
|
1313
2222
|
|
|
1314
|
-
|
|
1315
|
-
assert_eq!(
|
|
1316
|
-
|
|
1317
|
-
store.create_branch("stable".to_string(), current.id.clone()).expect("create branch");
|
|
2223
|
+
assert_eq!(store.get(&rev2_id).unwrap().content, r#"{"cycle":2}"#);
|
|
2224
|
+
assert_eq!(store.parent(&rev2_id).unwrap().id, rev1_id);
|
|
1318
2225
|
}
|
|
1319
2226
|
|
|
1320
|
-
// Cycle 4: Final restart
|
|
2227
|
+
// Cycle 4: Final restart, ancestry intact end to end
|
|
1321
2228
|
{
|
|
1322
2229
|
let db = crate::FeltDb::open(&db_path).expect("open db");
|
|
1323
2230
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1324
2231
|
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
assert_eq!(
|
|
1328
|
-
assert!(
|
|
1329
|
-
|
|
1330
|
-
// Should have branch
|
|
1331
|
-
assert_eq!(store.branch_head("stable"), Some(current.id.clone()));
|
|
2232
|
+
let rev2 = store.get(&rev2_id).expect("rev2 survives every cycle");
|
|
2233
|
+
assert_eq!(rev2.content, r#"{"cycle":2}"#);
|
|
2234
|
+
assert_eq!(rev2.parent_id, Some(rev1_id.clone()));
|
|
2235
|
+
assert!(store.parent(&rev1_id).is_none(), "the root has no parent");
|
|
1332
2236
|
}
|
|
1333
2237
|
|
|
1334
2238
|
let _ = std::fs::remove_file(&db_path);
|
|
@@ -1357,10 +2261,13 @@ mod tests {
|
|
|
1357
2261
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1358
2262
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1359
2263
|
|
|
1360
|
-
let rev = store
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
2264
|
+
let rev = store
|
|
2265
|
+
.create(
|
|
2266
|
+
"docs:1",
|
|
2267
|
+
r#"{"task":"write PR18 proof"}"#.to_string(),
|
|
2268
|
+
"system".to_string(),
|
|
2269
|
+
)
|
|
2270
|
+
.expect("write");
|
|
1364
2271
|
|
|
1365
2272
|
rev.id.clone()
|
|
1366
2273
|
}; // Force drop of database, persist to disk
|
|
@@ -1370,15 +2277,19 @@ mod tests {
|
|
|
1370
2277
|
let db = crate::FeltDb::open(&db_path).expect("reopen db");
|
|
1371
2278
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover store");
|
|
1372
2279
|
|
|
1373
|
-
// VERIFY:
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
2280
|
+
// VERIFY: the revision is retrievable by identity after restart
|
|
2281
|
+
let recovered = store
|
|
2282
|
+
.get(&written_id)
|
|
2283
|
+
.expect("State should exist after restart");
|
|
2284
|
+
assert_eq!(recovered.id, written_id, "State ID should match");
|
|
1377
2285
|
assert_eq!(
|
|
1378
|
-
|
|
1379
|
-
r#"{"task":"write PR18 proof"}"#,
|
|
2286
|
+
recovered.content, r#"{"task":"write PR18 proof"}"#,
|
|
1380
2287
|
"State content should survive restart"
|
|
1381
2288
|
);
|
|
2289
|
+
assert!(
|
|
2290
|
+
recovered.verify_integrity(),
|
|
2291
|
+
"content must still hash to its identity"
|
|
2292
|
+
);
|
|
1382
2293
|
}
|
|
1383
2294
|
|
|
1384
2295
|
let _ = std::fs::remove_file(&db_path);
|
|
@@ -1398,24 +2309,31 @@ mod tests {
|
|
|
1398
2309
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1399
2310
|
|
|
1400
2311
|
// Create project
|
|
1401
|
-
let project = store
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
2312
|
+
let project = store
|
|
2313
|
+
.create(
|
|
2314
|
+
"docs:1",
|
|
2315
|
+
r#"{"type":"project","name":"FeltDB"}"#.to_string(),
|
|
2316
|
+
"system".to_string(),
|
|
2317
|
+
)
|
|
2318
|
+
.expect("create project");
|
|
1405
2319
|
|
|
1406
2320
|
// Create membership (parent = project)
|
|
1407
|
-
let membership = store
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
2321
|
+
let membership = store
|
|
2322
|
+
.commit(
|
|
2323
|
+
r#"{"type":"membership","project":"FeltDB","user":"alice"}"#.to_string(),
|
|
2324
|
+
&project,
|
|
2325
|
+
"system".to_string(),
|
|
2326
|
+
)
|
|
2327
|
+
.expect("create membership");
|
|
1412
2328
|
|
|
1413
2329
|
// Write audit event (parent = membership)
|
|
1414
|
-
let event = store
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
2330
|
+
let event = store
|
|
2331
|
+
.commit(
|
|
2332
|
+
r#"{"type":"audit","action":"project_created"}"#.to_string(),
|
|
2333
|
+
&membership,
|
|
2334
|
+
"system".to_string(),
|
|
2335
|
+
)
|
|
2336
|
+
.expect("create event");
|
|
1419
2337
|
|
|
1420
2338
|
(project.id.clone(), membership.id.clone(), event.id.clone())
|
|
1421
2339
|
}; // Force persist
|
|
@@ -1425,12 +2343,13 @@ mod tests {
|
|
|
1425
2343
|
let db = crate::FeltDb::open(&db_path).expect("reopen db");
|
|
1426
2344
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover store");
|
|
1427
2345
|
|
|
1428
|
-
let
|
|
1429
|
-
assert_eq!(
|
|
2346
|
+
let event = store.get(&event_id).expect("event should exist");
|
|
2347
|
+
assert_eq!(event.id, event_id);
|
|
1430
2348
|
|
|
1431
2349
|
// Verify chain: event → membership → project
|
|
1432
2350
|
assert_eq!(
|
|
1433
|
-
|
|
2351
|
+
event.parent_id,
|
|
2352
|
+
Some(membership_id.clone()),
|
|
1434
2353
|
"Event parent should be membership"
|
|
1435
2354
|
);
|
|
1436
2355
|
|
|
@@ -1454,22 +2373,29 @@ mod tests {
|
|
|
1454
2373
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1455
2374
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1456
2375
|
|
|
1457
|
-
let root = store
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
&root,
|
|
1465
|
-
"genesis".to_string(),
|
|
1466
|
-
).expect("create child");
|
|
2376
|
+
let root = store
|
|
2377
|
+
.create(
|
|
2378
|
+
"docs:1",
|
|
2379
|
+
r#"{"generation":"root"}"#.to_string(),
|
|
2380
|
+
"genesis".to_string(),
|
|
2381
|
+
)
|
|
2382
|
+
.expect("create root");
|
|
1467
2383
|
|
|
1468
|
-
let
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
2384
|
+
let child = store
|
|
2385
|
+
.commit(
|
|
2386
|
+
r#"{"generation":"child"}"#.to_string(),
|
|
2387
|
+
&root,
|
|
2388
|
+
"genesis".to_string(),
|
|
2389
|
+
)
|
|
2390
|
+
.expect("create child");
|
|
2391
|
+
|
|
2392
|
+
let grandchild = store
|
|
2393
|
+
.commit(
|
|
2394
|
+
r#"{"generation":"grandchild"}"#.to_string(),
|
|
2395
|
+
&child,
|
|
2396
|
+
"genesis".to_string(),
|
|
2397
|
+
)
|
|
2398
|
+
.expect("create grandchild");
|
|
1473
2399
|
|
|
1474
2400
|
(root.id.clone(), child.id.clone(), grandchild.id.clone())
|
|
1475
2401
|
};
|
|
@@ -1479,12 +2405,12 @@ mod tests {
|
|
|
1479
2405
|
let db = crate::FeltDb::open(&db_path).expect("reopen db");
|
|
1480
2406
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover store");
|
|
1481
2407
|
|
|
1482
|
-
let
|
|
1483
|
-
assert_eq!(
|
|
2408
|
+
let grandchild = store.get(&grandchild_id).expect("grandchild exists");
|
|
2409
|
+
assert_eq!(grandchild.id, grandchild_id);
|
|
1484
2410
|
|
|
1485
2411
|
// Trace ancestry: grandchild → child → root
|
|
1486
2412
|
let mut ancestry_count = 0;
|
|
1487
|
-
let mut current_id = Some(
|
|
2413
|
+
let mut current_id = Some(grandchild.id.clone());
|
|
1488
2414
|
|
|
1489
2415
|
// Count links in ancestry (stop after finding root)
|
|
1490
2416
|
while let Some(id) = current_id {
|
|
@@ -1517,10 +2443,9 @@ mod tests {
|
|
|
1517
2443
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1518
2444
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1519
2445
|
|
|
1520
|
-
let rev = store
|
|
1521
|
-
r#"{"cycle":1}"#.to_string(),
|
|
1522
|
-
"
|
|
1523
|
-
).expect("create initial");
|
|
2446
|
+
let rev = store
|
|
2447
|
+
.create("docs:1", r#"{"cycle":1}"#.to_string(), "system".to_string())
|
|
2448
|
+
.expect("create initial");
|
|
1524
2449
|
|
|
1525
2450
|
rev.id.clone()
|
|
1526
2451
|
};
|
|
@@ -1530,16 +2455,18 @@ mod tests {
|
|
|
1530
2455
|
let db = crate::FeltDb::open(&db_path).expect("reopen db");
|
|
1531
2456
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover store");
|
|
1532
2457
|
|
|
1533
|
-
// Verify previous state exists
|
|
1534
|
-
let
|
|
1535
|
-
assert_eq!(
|
|
2458
|
+
// Verify previous state exists, read back by the id we carried
|
|
2459
|
+
let previous = store.get(&state_id).expect("previous state should exist");
|
|
2460
|
+
assert_eq!(previous.id, state_id);
|
|
1536
2461
|
|
|
1537
2462
|
// Update state
|
|
1538
|
-
let rev = store
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
2463
|
+
let rev = store
|
|
2464
|
+
.commit(
|
|
2465
|
+
format!(r#"{{"cycle":{}}}"#, cycle),
|
|
2466
|
+
&previous,
|
|
2467
|
+
"system".to_string(),
|
|
2468
|
+
)
|
|
2469
|
+
.expect("create update");
|
|
1543
2470
|
|
|
1544
2471
|
state_id = rev.id.clone();
|
|
1545
2472
|
}
|
|
@@ -1549,7 +2476,7 @@ mod tests {
|
|
|
1549
2476
|
let db = crate::FeltDb::open(&db_path).expect("final reopen");
|
|
1550
2477
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("final recovery");
|
|
1551
2478
|
|
|
1552
|
-
let final_state = store.
|
|
2479
|
+
let final_state = store.get(&state_id).expect("final state exists");
|
|
1553
2480
|
assert_eq!(final_state.id, state_id, "Final state should match");
|
|
1554
2481
|
}
|
|
1555
2482
|
|
|
@@ -1569,10 +2496,13 @@ mod tests {
|
|
|
1569
2496
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1570
2497
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1571
2498
|
|
|
1572
|
-
let rev = store
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
2499
|
+
let rev = store
|
|
2500
|
+
.create(
|
|
2501
|
+
"docs:1",
|
|
2502
|
+
r#"{"status":"initial"}"#.to_string(),
|
|
2503
|
+
"system".to_string(),
|
|
2504
|
+
)
|
|
2505
|
+
.expect("create initial");
|
|
1576
2506
|
|
|
1577
2507
|
rev.id.clone()
|
|
1578
2508
|
};
|
|
@@ -1586,11 +2516,18 @@ mod tests {
|
|
|
1586
2516
|
let db = crate::FeltDb::open(&db_path).expect("reopen");
|
|
1587
2517
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover");
|
|
1588
2518
|
|
|
1589
|
-
if let Some(
|
|
2519
|
+
if let Some(recovered) = store.get(&initial_id) {
|
|
1590
2520
|
// State exists: must be complete
|
|
1591
|
-
assert!(
|
|
1592
|
-
|
|
1593
|
-
|
|
2521
|
+
assert!(
|
|
2522
|
+
recovered.content.contains("status"),
|
|
2523
|
+
"State must be complete"
|
|
2524
|
+
);
|
|
2525
|
+
assert!(
|
|
2526
|
+
recovered.content.contains("initial"),
|
|
2527
|
+
"Attributes must be present"
|
|
2528
|
+
);
|
|
2529
|
+
assert_eq!(recovered.id, initial_id, "ID must match");
|
|
2530
|
+
assert!(recovered.verify_integrity(), "and never partially written");
|
|
1594
2531
|
} else {
|
|
1595
2532
|
// State doesn't exist: acceptable
|
|
1596
2533
|
// But not: partially exist with missing fields
|
|
@@ -1614,10 +2551,13 @@ mod tests {
|
|
|
1614
2551
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1615
2552
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1616
2553
|
|
|
1617
|
-
let rev = store
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
2554
|
+
let rev = store
|
|
2555
|
+
.create(
|
|
2556
|
+
"docs:1",
|
|
2557
|
+
expected_content.to_string(),
|
|
2558
|
+
"integrity_check".to_string(),
|
|
2559
|
+
)
|
|
2560
|
+
.expect("create");
|
|
1621
2561
|
|
|
1622
2562
|
rev.id.clone()
|
|
1623
2563
|
};
|
|
@@ -1627,14 +2567,15 @@ mod tests {
|
|
|
1627
2567
|
let db = crate::FeltDb::open(&db_path).expect("reopen");
|
|
1628
2568
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover");
|
|
1629
2569
|
|
|
1630
|
-
let
|
|
2570
|
+
let recovered = store.get(&state_id).expect("state exists");
|
|
1631
2571
|
|
|
1632
2572
|
// Verify integrity: ID should still match content
|
|
1633
|
-
assert_eq!(
|
|
1634
|
-
assert_eq!(
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
2573
|
+
assert_eq!(recovered.content, expected_content, "Content should match");
|
|
2574
|
+
assert_eq!(recovered.id, state_id, "ID should match");
|
|
2575
|
+
assert!(
|
|
2576
|
+
recovered.verify_integrity(),
|
|
2577
|
+
"Content hash should still be valid"
|
|
2578
|
+
);
|
|
1638
2579
|
}
|
|
1639
2580
|
|
|
1640
2581
|
let _ = std::fs::remove_file(&db_path);
|
|
@@ -1644,59 +2585,72 @@ mod tests {
|
|
|
1644
2585
|
fn pr19_proof_concurrent_write_safety() {
|
|
1645
2586
|
// PROVES: Concurrent writes don't produce impossible partial states
|
|
1646
2587
|
// Simulates multiple threads writing to the same StateStore
|
|
1647
|
-
|
|
2588
|
+
|
|
1648
2589
|
use std::sync::{Arc, Mutex};
|
|
1649
2590
|
use std::thread;
|
|
1650
|
-
|
|
2591
|
+
|
|
1651
2592
|
let db_path = std::env::temp_dir().join("pr19_concurrent_writes.log");
|
|
1652
2593
|
let _ = std::fs::remove_file(&db_path);
|
|
1653
|
-
|
|
2594
|
+
|
|
1654
2595
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1655
2596
|
let store = Arc::new(StateStore::with_feltdb(Arc::new(db)).expect("create store"));
|
|
1656
|
-
|
|
2597
|
+
|
|
1657
2598
|
let write_count = Arc::new(Mutex::new(0usize));
|
|
1658
2599
|
let mut handles = vec![];
|
|
1659
|
-
|
|
2600
|
+
|
|
1660
2601
|
// Spawn multiple writers
|
|
1661
2602
|
for i in 0..5 {
|
|
1662
2603
|
let store_clone = Arc::clone(&store);
|
|
1663
2604
|
let count_clone = Arc::clone(&write_count);
|
|
1664
|
-
|
|
2605
|
+
|
|
1665
2606
|
let handle = thread::spawn(move || {
|
|
1666
2607
|
let content = format!(r#"{{"writer":{},"timestamp":{}}}"#, i, 1000 + i);
|
|
1667
|
-
let rev = store_clone
|
|
2608
|
+
let rev = store_clone
|
|
2609
|
+
.create(&format!("writer:{i}"), content, format!("writer_{}", i))
|
|
1668
2610
|
.expect("write should succeed");
|
|
1669
|
-
|
|
2611
|
+
|
|
1670
2612
|
let mut count = count_clone.lock().unwrap();
|
|
1671
2613
|
*count += 1;
|
|
1672
|
-
|
|
2614
|
+
|
|
1673
2615
|
rev.id.clone()
|
|
1674
2616
|
});
|
|
1675
|
-
|
|
2617
|
+
|
|
1676
2618
|
handles.push(handle);
|
|
1677
2619
|
}
|
|
1678
|
-
|
|
2620
|
+
|
|
1679
2621
|
// Wait for all writers
|
|
1680
2622
|
let mut written_ids = vec![];
|
|
1681
2623
|
for handle in handles {
|
|
1682
2624
|
let id = handle.join().expect("thread should complete");
|
|
1683
2625
|
written_ids.push(id);
|
|
1684
2626
|
}
|
|
1685
|
-
|
|
1686
|
-
// Verify no partial states were created
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
2627
|
+
|
|
2628
|
+
// Verify no partial states were created: every id a writer returned is
|
|
2629
|
+
// readable, complete, and hashes to its own content.
|
|
2630
|
+
for id in &written_ids {
|
|
2631
|
+
let written = store
|
|
2632
|
+
.get(id)
|
|
2633
|
+
.expect("every concurrently written revision should exist");
|
|
2634
|
+
assert!(
|
|
2635
|
+
written.verify_integrity(),
|
|
2636
|
+
"no writer produced a partial revision"
|
|
2637
|
+
);
|
|
2638
|
+
}
|
|
2639
|
+
|
|
1692
2640
|
// Verify we can still write after concurrent writes
|
|
1693
|
-
let final_write = store
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
2641
|
+
let final_write = store
|
|
2642
|
+
.create(
|
|
2643
|
+
"docs:1",
|
|
2644
|
+
r#"{"final":"write","success":true}"#.to_string(),
|
|
2645
|
+
"system".to_string(),
|
|
2646
|
+
)
|
|
2647
|
+
.expect("final write should succeed");
|
|
2648
|
+
|
|
2649
|
+
assert!(
|
|
2650
|
+
final_write.verify_integrity(),
|
|
2651
|
+
"Final write should be valid"
|
|
2652
|
+
);
|
|
2653
|
+
|
|
1700
2654
|
let _ = std::fs::remove_file(&db_path);
|
|
1701
2655
|
}
|
|
1702
2656
|
|
|
@@ -1706,23 +2660,26 @@ mod tests {
|
|
|
1706
2660
|
// Simulates: write → sync starts → crash → restart
|
|
1707
2661
|
// Expected: Either data is on disk (fully recovered) or not (nothing recovered)
|
|
1708
2662
|
// Never: Partial/corrupted data
|
|
1709
|
-
|
|
2663
|
+
|
|
1710
2664
|
let db_path = std::env::temp_dir().join("pr19_crash_during_sync.log");
|
|
1711
2665
|
let _ = std::fs::remove_file(&db_path);
|
|
1712
|
-
|
|
2666
|
+
|
|
1713
2667
|
// First write: known-good state
|
|
1714
2668
|
let initial_id = {
|
|
1715
2669
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1716
2670
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1717
|
-
|
|
1718
|
-
let rev = store
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
2671
|
+
|
|
2672
|
+
let rev = store
|
|
2673
|
+
.create(
|
|
2674
|
+
"docs:1",
|
|
2675
|
+
r#"{"status":"pre-crash","integrity":"good"}"#.to_string(),
|
|
2676
|
+
"system".to_string(),
|
|
2677
|
+
)
|
|
2678
|
+
.expect("write");
|
|
2679
|
+
|
|
1723
2680
|
rev.id.clone()
|
|
1724
2681
|
};
|
|
1725
|
-
|
|
2682
|
+
|
|
1726
2683
|
// After restart, verify state is either:
|
|
1727
2684
|
// A) Initial state is still there (sync didn't complete), OR
|
|
1728
2685
|
// B) New state is there and fully valid (sync completed), OR
|
|
@@ -1730,12 +2687,17 @@ mod tests {
|
|
|
1730
2687
|
{
|
|
1731
2688
|
let db = crate::FeltDb::open(&db_path).expect("reopen");
|
|
1732
2689
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("recover");
|
|
1733
|
-
|
|
1734
|
-
let current = store
|
|
1735
|
-
|
|
2690
|
+
|
|
2691
|
+
let current = store
|
|
2692
|
+
.get(&initial_id)
|
|
2693
|
+
.expect("the initial state should still exist");
|
|
2694
|
+
|
|
1736
2695
|
// Verify state integrity
|
|
1737
|
-
assert!(
|
|
1738
|
-
|
|
2696
|
+
assert!(
|
|
2697
|
+
current.verify_integrity(),
|
|
2698
|
+
"State should be internally consistent"
|
|
2699
|
+
);
|
|
2700
|
+
|
|
1739
2701
|
// State should either be the initial write or a valid new write
|
|
1740
2702
|
// But never partial or corrupted
|
|
1741
2703
|
assert!(
|
|
@@ -1743,7 +2705,7 @@ mod tests {
|
|
|
1743
2705
|
"Recovered state must be either initial (sync failed) or new+valid (sync succeeded)"
|
|
1744
2706
|
);
|
|
1745
2707
|
}
|
|
1746
|
-
|
|
2708
|
+
|
|
1747
2709
|
let _ = std::fs::remove_file(&db_path);
|
|
1748
2710
|
}
|
|
1749
2711
|
|
|
@@ -1752,30 +2714,31 @@ mod tests {
|
|
|
1752
2714
|
// PROVES: Query execution uses indexes to reduce complexity
|
|
1753
2715
|
// Currently this is unproven - indexes are data structures but
|
|
1754
2716
|
// query execution path is not proven to use them
|
|
1755
|
-
|
|
2717
|
+
|
|
1756
2718
|
let db_path = std::env::temp_dir().join("pr19_query_index.log");
|
|
1757
2719
|
let _ = std::fs::remove_file(&db_path);
|
|
1758
|
-
|
|
2720
|
+
|
|
1759
2721
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1760
2722
|
let store = Arc::new(StateStore::with_feltdb(Arc::new(db)).expect("create store"));
|
|
1761
|
-
|
|
2723
|
+
|
|
1762
2724
|
// Create records with indexed fields
|
|
1763
2725
|
for i in 0..100 {
|
|
1764
2726
|
let content = format!(r#"{{"user_id":"user_{}","active":true}}"#, i % 10);
|
|
1765
|
-
let _ = store
|
|
2727
|
+
let _ = store
|
|
2728
|
+
.create(&format!("record:{i}"), content, format!("creator_{}", i))
|
|
1766
2729
|
.expect("write record");
|
|
1767
2730
|
}
|
|
1768
|
-
|
|
2731
|
+
|
|
1769
2732
|
// Query by indexed field (user_id in this case)
|
|
1770
2733
|
// If indexes are used, this should be O(log n)
|
|
1771
2734
|
// If not, this is O(n)
|
|
1772
|
-
|
|
2735
|
+
|
|
1773
2736
|
// LIMITATION: No way to measure index usage in this test
|
|
1774
2737
|
// This proves index data structures exist, not that they're used
|
|
1775
|
-
|
|
2738
|
+
|
|
1776
2739
|
// TODO: Measure query latency and verify index benefit
|
|
1777
2740
|
// TODO: Add query optimizer tracing to prove index selection
|
|
1778
|
-
|
|
2741
|
+
|
|
1779
2742
|
let _ = std::fs::remove_file(&db_path);
|
|
1780
2743
|
}
|
|
1781
2744
|
|
|
@@ -1784,36 +2747,45 @@ mod tests {
|
|
|
1784
2747
|
// PROVES: Basic multi-tenant isolation at storage layer
|
|
1785
2748
|
// Note: This tests storage isolation, not authorization layer
|
|
1786
2749
|
// Authorization layer tests are in authorization_security_tests.rs
|
|
1787
|
-
|
|
2750
|
+
|
|
1788
2751
|
let db_path = std::env::temp_dir().join("pr19_multi_tenant.log");
|
|
1789
2752
|
let _ = std::fs::remove_file(&db_path);
|
|
1790
|
-
|
|
2753
|
+
|
|
1791
2754
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1792
2755
|
let store = Arc::new(StateStore::with_feltdb(Arc::new(db)).expect("create store"));
|
|
1793
|
-
|
|
2756
|
+
|
|
1794
2757
|
// Create tenant-scoped records
|
|
1795
2758
|
// Tenant A writes a record
|
|
1796
|
-
let tenant_a_record = store
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
2759
|
+
let tenant_a_record = store
|
|
2760
|
+
.create(
|
|
2761
|
+
"tenant_a:1",
|
|
2762
|
+
r#"{"tenant":"tenant_a","data":"secret_a"}"#.to_string(),
|
|
2763
|
+
"tenant_a".to_string(),
|
|
2764
|
+
)
|
|
2765
|
+
.expect("tenant_a write");
|
|
2766
|
+
|
|
1801
2767
|
// Tenant B writes a record
|
|
1802
|
-
let tenant_b_record = store
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
2768
|
+
let tenant_b_record = store
|
|
2769
|
+
.create(
|
|
2770
|
+
"tenant_b:1",
|
|
2771
|
+
r#"{"tenant":"tenant_b","data":"secret_b"}"#.to_string(),
|
|
2772
|
+
"tenant_b".to_string(),
|
|
2773
|
+
)
|
|
2774
|
+
.expect("tenant_b write");
|
|
2775
|
+
|
|
1807
2776
|
// Verify both records are stored (this proves storage works)
|
|
1808
|
-
assert_ne!(
|
|
1809
|
-
|
|
2777
|
+
assert_ne!(
|
|
2778
|
+
tenant_a_record.id, tenant_b_record.id,
|
|
2779
|
+
"Records should have different IDs"
|
|
2780
|
+
);
|
|
2781
|
+
|
|
1810
2782
|
// Current limitation: StateStore doesn't enforce tenant boundaries
|
|
1811
2783
|
// Tenant isolation is enforced at authorization layer, not storage layer
|
|
1812
2784
|
// This test proves that separate StateIds are created for each record
|
|
1813
|
-
|
|
2785
|
+
|
|
1814
2786
|
// TODO: Test authorization layer rejection of cross-tenant access
|
|
1815
2787
|
// See authorization_security_tests.rs
|
|
1816
|
-
|
|
2788
|
+
|
|
1817
2789
|
let _ = std::fs::remove_file(&db_path);
|
|
1818
2790
|
}
|
|
1819
2791
|
|
|
@@ -1821,36 +2793,41 @@ mod tests {
|
|
|
1821
2793
|
fn pr19_proof_atomicity_requires_sync_return() {
|
|
1822
2794
|
// PROVES: Atomicity guarantee only holds if sync() succeeds
|
|
1823
2795
|
// If sync() fails, no durability guarantee
|
|
1824
|
-
|
|
2796
|
+
|
|
1825
2797
|
let db_path = std::env::temp_dir().join("pr19_atomicity_sync.log");
|
|
1826
2798
|
let _ = std::fs::remove_file(&db_path);
|
|
1827
|
-
|
|
2799
|
+
|
|
1828
2800
|
let rev_id = {
|
|
1829
2801
|
let db = crate::FeltDb::open(&db_path).expect("create db");
|
|
1830
2802
|
let store = StateStore::with_feltdb(Arc::new(db)).expect("create store");
|
|
1831
|
-
|
|
2803
|
+
|
|
1832
2804
|
// Write should be durable only if it returns Ok
|
|
1833
2805
|
let result = store.create(
|
|
2806
|
+
"docs:1",
|
|
1834
2807
|
r#"{"atomicity":"requires_sync_success"}"#.to_string(),
|
|
1835
2808
|
"system".to_string(),
|
|
1836
2809
|
);
|
|
1837
|
-
|
|
2810
|
+
|
|
1838
2811
|
// If create() returns Ok, durability is guaranteed
|
|
1839
2812
|
match result {
|
|
1840
2813
|
Ok(rev) => Some(rev.id.clone()),
|
|
1841
2814
|
Err(_) => None,
|
|
1842
2815
|
}
|
|
1843
2816
|
}; // Force drop of db to simulate restart
|
|
1844
|
-
|
|
2817
|
+
|
|
1845
2818
|
if let Some(rev_id) = rev_id {
|
|
1846
2819
|
// Durability guaranteed: restart should see this
|
|
1847
2820
|
let db2 = crate::FeltDb::open(&db_path).expect("reopen");
|
|
1848
2821
|
let store2 = StateStore::with_feltdb(Arc::new(db2)).expect("recover");
|
|
1849
|
-
|
|
1850
|
-
assert_eq!(
|
|
2822
|
+
|
|
2823
|
+
assert_eq!(
|
|
2824
|
+
store2.get(&rev_id).unwrap().id,
|
|
2825
|
+
rev_id,
|
|
2826
|
+
"Durability guaranteed"
|
|
2827
|
+
);
|
|
1851
2828
|
}
|
|
1852
2829
|
// If None, no durability guarantee - sync failed
|
|
1853
|
-
|
|
2830
|
+
|
|
1854
2831
|
let _ = std::fs::remove_file(&db_path);
|
|
1855
2832
|
}
|
|
1856
2833
|
}
|