@feltdb/core 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +165 -0
- package/dist/create/server-source/Cargo.toml +9 -0
- package/dist/create/server-source/crates/feltdb/Cargo.toml +3 -0
- package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +44 -44
- package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +12 -24
- package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +7 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +13 -10
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +12 -9
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +78 -25
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +16 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +13 -5
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +41 -22
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +33 -15
- package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +100 -33
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +56 -20
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +116 -41
- package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +186 -55
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +64 -26
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +46 -15
- package/dist/create/server-source/crates/feltdb/src/admission.rs +8 -15
- package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +43 -13
- package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +15 -42
- package/dist/create/server-source/crates/feltdb/src/analytics.rs +65 -19
- package/dist/create/server-source/crates/feltdb/src/application.rs +113 -30
- package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +475 -140
- package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +17 -15
- package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +266 -114
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +25 -7
- package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +10 -15
- package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +3 -11
- package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +14 -5
- package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +21 -25
- package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +14 -11
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +103 -22
- package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +18 -15
- package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +12 -8
- package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +1 -5
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +87 -23
- package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +3 -7
- package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +10 -9
- package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +1 -6
- package/dist/create/server-source/crates/feltdb/src/indexing.rs +35 -38
- package/dist/create/server-source/crates/feltdb/src/lib.rs +1648 -46
- package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +4 -1
- package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +1 -2
- package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +107 -30
- package/dist/create/server-source/crates/feltdb/src/observability.rs +19 -6
- package/dist/create/server-source/crates/feltdb/src/operation.rs +39 -0
- package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +12 -11
- package/dist/create/server-source/crates/feltdb/src/operation_log.rs +9 -4
- package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +65 -18
- package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +193 -57
- package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +37 -27
- package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +38 -10
- package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +20 -14
- package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +394 -229
- package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +8 -6
- package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +11 -13
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +79 -70
- package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +3 -3
- package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +33 -11
- package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +6 -6
- package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +14 -56
- package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +29 -27
- package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +35 -17
- package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +8 -2
- package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +70 -17
- package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +23 -6
- package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +12 -3
- package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +41 -11
- package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +56 -25
- package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +701 -245
- package/dist/create/server-source/crates/feltdb/src/production_api.rs +31 -13
- package/dist/create/server-source/crates/feltdb/src/query_performance.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +5 -5
- package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +48 -18
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +30 -11
- package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +4 -3
- package/dist/create/server-source/crates/feltdb/src/sharding.rs +36 -10
- package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +516 -0
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +13 -4
- package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +222 -0
- package/dist/create/server-source/crates/feltdb/src/state_facade.rs +82 -54
- package/dist/create/server-source/crates/feltdb/src/state_hash.rs +2 -2
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +1565 -536
- package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +672 -0
- package/dist/create/server-source/crates/feltdb/src/storage.rs +9 -3
- package/dist/create/server-source/crates/feltdb/src/submission.rs +5 -11
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +24 -35
- package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +24 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +248 -59
- package/dist/create/server-source/crates/feltdb/src/transactions.rs +17 -20
- package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +749 -0
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
- package/dist/create/server-source/crates/feltdb/src/workload.rs +512 -4
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +3 -4
- package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +132 -0
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +272 -0
- package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +467 -0
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +309 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +445 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +518 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +392 -0
- package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +436 -220
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +1916 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +1878 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +1862 -0
- package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +278 -0
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +2 -1
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +80 -25
- package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +5 -8
- package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +52 -44
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +33 -16
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +22 -7
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +41 -22
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +25 -8
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +9 -6
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +120 -69
- package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +21 -10
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +1365 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +868 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +221 -0
- package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +417 -0
- package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +338 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +1039 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +739 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +427 -0
- package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +3 -3
- package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +25 -22
- package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +1799 -0
- package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +1316 -0
- package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +53 -61
- package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +156 -61
- package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +255 -0
- package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +249 -0
- package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +994 -0
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +442 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +16 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +695 -47
- package/dist/create/server-source/crates/feltdb-server/src/metrics.rs +21 -0
- package/dist/studio-app/assets/{feltdb_wasm-CVQWgXO-.js → feltdb_wasm-C1VhI-U5.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-C8HXbAXb.wasm +0 -0
- package/dist/studio-app/assets/{index-DwgNAIIX.js → index-Bbos1m2U.js} +1 -1
- package/dist/studio-app/index.html +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workload.d.ts +2 -1
- package/dist/workload.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
//! Applying an explicit `ReconciliationPlan` to materialize a state.
|
|
2
|
+
//!
|
|
3
|
+
//! ```text
|
|
4
|
+
//! Classification — which changes can coexist?
|
|
5
|
+
//! Plan — what happens to the ones that cannot?
|
|
6
|
+
//! Application — produce the resulting state from that decision.
|
|
7
|
+
//! ```
|
|
8
|
+
//!
|
|
9
|
+
//! These tests establish the application semantics, that inputs are never
|
|
10
|
+
//! mutated, that invalid plans are rejected rather than producing arbitrary
|
|
11
|
+
//! state, and the whole causal path from three revisions to a materialized one.
|
|
12
|
+
//!
|
|
13
|
+
//! Nothing here advances a branch head, merges automatically, or reconciles
|
|
14
|
+
//! anything FeltDB was not explicitly told to.
|
|
15
|
+
|
|
16
|
+
use feltdb::state_model::{
|
|
17
|
+
apply_reconciliation_plan, reconcile, ConflictClass, ConflictClassification, PathComponent,
|
|
18
|
+
ReconciliationPlan, SemanticDiff, StateId, StateRevision, StateStore,
|
|
19
|
+
RECONCILE_CONTENT_UNPARSEABLE, RECONCILE_OVERRIDE_PATHS_OVERLAP,
|
|
20
|
+
RECONCILE_OVERRIDE_UNADDRESSABLE, RECONCILE_PLAN_INVALID, RECONCILE_REVISION_MISMATCH,
|
|
21
|
+
};
|
|
22
|
+
use serde_json::{json, Value};
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/// A revision with fixed metadata, so tests compare states rather than clocks.
|
|
29
|
+
fn revision(content: &str) -> StateRevision {
|
|
30
|
+
StateRevision {
|
|
31
|
+
id: StateId::compute(content),
|
|
32
|
+
resource: "docs:1".into(),
|
|
33
|
+
content: content.to_string(),
|
|
34
|
+
content_id: StateId::compute(content),
|
|
35
|
+
parent_id: None,
|
|
36
|
+
sequence: 0,
|
|
37
|
+
authority: "test".into(),
|
|
38
|
+
timestamp_ms: 0,
|
|
39
|
+
metadata: Default::default(),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fn key(name: &str) -> PathComponent {
|
|
44
|
+
PathComponent::Key(name.to_string())
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const LEFT: bool = true;
|
|
48
|
+
const RIGHT: bool = false;
|
|
49
|
+
|
|
50
|
+
/// The three revisions a scenario compares, and a plan builder over them.
|
|
51
|
+
struct Scenario {
|
|
52
|
+
base: StateRevision,
|
|
53
|
+
left: StateRevision,
|
|
54
|
+
right: StateRevision,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
impl Scenario {
|
|
58
|
+
fn new(base: &str, left: &str, right: &str) -> Self {
|
|
59
|
+
Self {
|
|
60
|
+
base: revision(base),
|
|
61
|
+
left: revision(left),
|
|
62
|
+
right: revision(right),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fn plan(&self, parent_choice: bool) -> ReconciliationPlan {
|
|
67
|
+
ReconciliationPlan::new(
|
|
68
|
+
self.left.id.clone(),
|
|
69
|
+
self.right.id.clone(),
|
|
70
|
+
self.base.id.clone(),
|
|
71
|
+
parent_choice,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fn apply(&self, plan: &ReconciliationPlan) -> Result<String, String> {
|
|
76
|
+
apply_reconciliation_plan(&self.base, &self.left, &self.right, plan)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// The applied result as a value, for comparing states rather than text.
|
|
80
|
+
fn applied(&self, plan: &ReconciliationPlan) -> Value {
|
|
81
|
+
serde_json::from_str(&self.apply(plan).expect("the plan applies")).unwrap()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fn classification(&self) -> ConflictClassification {
|
|
85
|
+
ConflictClassification::classify(&self.base, &self.left, &self.right)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// The error code an application failed with.
|
|
90
|
+
fn code(result: Result<String, String>) -> String {
|
|
91
|
+
let message = result.expect_err("the plan must be rejected");
|
|
92
|
+
message
|
|
93
|
+
.split_once(':')
|
|
94
|
+
.map(|(code, _)| code.to_string())
|
|
95
|
+
.unwrap_or(message)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Parent choice
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/// `parent_choice` selects the state to start from, and has exactly two values.
|
|
103
|
+
///
|
|
104
|
+
/// The field is a `bool`, so there is no third choice and no "unknown parent
|
|
105
|
+
/// choice" for application to reject — the type forecloses it. The ancestor is
|
|
106
|
+
/// not selectable.
|
|
107
|
+
#[test]
|
|
108
|
+
fn parent_choice_selects_the_starting_state() {
|
|
109
|
+
let scenario = Scenario::new(r#"{"a":1,"b":1}"#, r#"{"a":2,"b":1}"#, r#"{"a":1,"b":2}"#);
|
|
110
|
+
|
|
111
|
+
assert_eq!(scenario.applied(&scenario.plan(LEFT)), json!({"a":2,"b":1}));
|
|
112
|
+
assert_eq!(
|
|
113
|
+
scenario.applied(&scenario.plan(RIGHT)),
|
|
114
|
+
json!({"a":1,"b":2})
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// With no overrides the result is exactly the selected branch's state, not
|
|
118
|
+
// a merge of the two. Independent changes on the branch not selected are
|
|
119
|
+
// not carried over: the plan did not ask for them.
|
|
120
|
+
assert_ne!(
|
|
121
|
+
scenario.applied(&scenario.plan(LEFT)),
|
|
122
|
+
json!({"a":2,"b":2}),
|
|
123
|
+
"application never merges on its own"
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Override semantics
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
#[test]
|
|
132
|
+
fn an_override_replaces_a_value() {
|
|
133
|
+
let scenario = Scenario::new(r#"{"a":1}"#, r#"{"a":2}"#, r#"{"a":3}"#);
|
|
134
|
+
let plan = scenario.plan(LEFT).with_override(vec![key("a")], json!(9));
|
|
135
|
+
assert_eq!(scenario.applied(&plan), json!({"a":9}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#[test]
|
|
139
|
+
fn an_override_may_add_a_member_to_an_existing_object() {
|
|
140
|
+
// Assigning a new member creates no structure, so it is addressable. This
|
|
141
|
+
// is how a plan takes a member the other branch added.
|
|
142
|
+
let scenario = Scenario::new(r#"{"o":{}}"#, r#"{"o":{}}"#, r#"{"o":{"added":1}}"#);
|
|
143
|
+
let plan = scenario
|
|
144
|
+
.plan(LEFT)
|
|
145
|
+
.with_override(vec![key("o"), key("added")], json!(1));
|
|
146
|
+
assert_eq!(scenario.applied(&plan), json!({"o":{"added":1}}));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#[test]
|
|
150
|
+
fn an_override_can_set_null_but_cannot_express_removal() {
|
|
151
|
+
let scenario = Scenario::new(r#"{"a":1,"b":2}"#, r#"{"a":1,"b":2}"#, r#"{"a":1}"#);
|
|
152
|
+
|
|
153
|
+
// Null is a value, and setting it is an ordinary override.
|
|
154
|
+
let nulled = scenario
|
|
155
|
+
.plan(LEFT)
|
|
156
|
+
.with_override(vec![key("b")], Value::Null);
|
|
157
|
+
assert_eq!(scenario.applied(&nulled), json!({"a":1,"b":null}));
|
|
158
|
+
|
|
159
|
+
// `path_overrides` maps a path to a `Value`, and a `Value` cannot be
|
|
160
|
+
// absent. Removing a member means overriding the container that holds it.
|
|
161
|
+
let removed = scenario.plan(LEFT).with_override(vec![], json!({"a":1}));
|
|
162
|
+
assert_eq!(scenario.applied(&removed), json!({"a":1}));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#[test]
|
|
166
|
+
fn overrides_reach_nested_paths() {
|
|
167
|
+
let scenario = Scenario::new(
|
|
168
|
+
r#"{"o":{"m":{"deep":1}}}"#,
|
|
169
|
+
r#"{"o":{"m":{"deep":2}}}"#,
|
|
170
|
+
r#"{"o":{"m":{"deep":3}}}"#,
|
|
171
|
+
);
|
|
172
|
+
let plan = scenario
|
|
173
|
+
.plan(LEFT)
|
|
174
|
+
.with_override(vec![key("o"), key("m"), key("deep")], json!(9));
|
|
175
|
+
assert_eq!(scenario.applied(&plan), json!({"o":{"m":{"deep":9}}}));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#[test]
|
|
179
|
+
fn override_paths_are_structural_not_textual() {
|
|
180
|
+
// `a.b` and `a.bc` share a string prefix and are different locations.
|
|
181
|
+
let scenario = Scenario::new(
|
|
182
|
+
r#"{"a":{"b":1,"bc":1}}"#,
|
|
183
|
+
r#"{"a":{"b":2,"bc":1}}"#,
|
|
184
|
+
r#"{"a":{"b":1,"bc":2}}"#,
|
|
185
|
+
);
|
|
186
|
+
let plan = scenario
|
|
187
|
+
.plan(LEFT)
|
|
188
|
+
.with_override(vec![key("a"), key("bc")], json!(9));
|
|
189
|
+
assert_eq!(scenario.applied(&plan), json!({"a":{"b":2,"bc":9}}));
|
|
190
|
+
|
|
191
|
+
// And a key named "0" is not the first element of an array.
|
|
192
|
+
let mixed = Scenario::new(r#"{"x":{"0":1}}"#, r#"{"x":{"0":2}}"#, r#"{"x":{"0":3}}"#);
|
|
193
|
+
let by_key = mixed
|
|
194
|
+
.plan(LEFT)
|
|
195
|
+
.with_override(vec![key("x"), key("0")], json!(9));
|
|
196
|
+
assert_eq!(mixed.applied(&by_key), json!({"x":{"0":9}}));
|
|
197
|
+
let by_index = mixed
|
|
198
|
+
.plan(LEFT)
|
|
199
|
+
.with_override(vec![key("x"), PathComponent::Index(0)], json!(9));
|
|
200
|
+
assert_eq!(
|
|
201
|
+
code(mixed.apply(&by_index)),
|
|
202
|
+
RECONCILE_OVERRIDE_UNADDRESSABLE
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/// A repeated override path cannot exist: `path_overrides` is a map.
|
|
207
|
+
#[test]
|
|
208
|
+
fn a_repeated_override_path_is_impossible_by_construction() {
|
|
209
|
+
let scenario = Scenario::new(r#"{"a":1}"#, r#"{"a":2}"#, r#"{"a":3}"#);
|
|
210
|
+
let plan = scenario
|
|
211
|
+
.plan(LEFT)
|
|
212
|
+
.with_override(vec![key("a")], json!(8))
|
|
213
|
+
.with_override(vec![key("a")], json!(9));
|
|
214
|
+
assert_eq!(plan.path_overrides.len(), 1, "the map holds one entry");
|
|
215
|
+
assert_eq!(scenario.applied(&plan), json!({"a":9}), "the later one");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Root path
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
#[test]
|
|
223
|
+
fn the_root_path_replaces_the_whole_state() {
|
|
224
|
+
let scenario = Scenario::new(r#"{"o":{"a":1}}"#, r#"{"o":7}"#, r#"{"o":{"a":2}}"#);
|
|
225
|
+
for replacement in [
|
|
226
|
+
json!({"whole":"object"}),
|
|
227
|
+
json!([1, 2, 3]),
|
|
228
|
+
json!("scalar"),
|
|
229
|
+
json!(42),
|
|
230
|
+
Value::Null,
|
|
231
|
+
] {
|
|
232
|
+
let plan = scenario
|
|
233
|
+
.plan(LEFT)
|
|
234
|
+
.with_override(vec![], replacement.clone());
|
|
235
|
+
assert_eq!(
|
|
236
|
+
scenario.applied(&plan),
|
|
237
|
+
replacement,
|
|
238
|
+
"the empty path is the state itself"
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// A root override and any other override overlap, so the plan is ambiguous.
|
|
244
|
+
#[test]
|
|
245
|
+
fn a_root_override_excludes_every_other_override() {
|
|
246
|
+
let scenario = Scenario::new(r#"{"a":1}"#, r#"{"a":2}"#, r#"{"a":3}"#);
|
|
247
|
+
let plan = scenario
|
|
248
|
+
.plan(LEFT)
|
|
249
|
+
.with_override(vec![], json!({"a":8}))
|
|
250
|
+
.with_override(vec![key("a")], json!(9));
|
|
251
|
+
assert_eq!(
|
|
252
|
+
code(scenario.apply(&plan)),
|
|
253
|
+
RECONCILE_OVERRIDE_PATHS_OVERLAP
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Parent/child overrides
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/// Overlapping override paths are rejected rather than given an invented
|
|
262
|
+
/// precedence.
|
|
263
|
+
///
|
|
264
|
+
/// The plan is a map from path to value with no stated ordering, so a plan
|
|
265
|
+
/// holding both `o` and `o.a` does not say whether the inner override refines
|
|
266
|
+
/// the outer value or the outer one was meant to stand. Rather than pick one,
|
|
267
|
+
/// application refuses. Overlap is the same structural relation conflict
|
|
268
|
+
/// classification uses.
|
|
269
|
+
#[test]
|
|
270
|
+
fn overlapping_override_paths_are_rejected() {
|
|
271
|
+
let scenario = Scenario::new(r#"{"o":{"a":1}}"#, r#"{"o":{"a":2}}"#, r#"{"o":{"a":3}}"#);
|
|
272
|
+
for (one, two) in [
|
|
273
|
+
(vec![key("o")], vec![key("o"), key("a")]),
|
|
274
|
+
(vec![key("o"), key("a")], vec![key("o")]),
|
|
275
|
+
(vec![], vec![key("o")]),
|
|
276
|
+
] {
|
|
277
|
+
let plan = scenario
|
|
278
|
+
.plan(LEFT)
|
|
279
|
+
.with_override(one, json!({"a": 8}))
|
|
280
|
+
.with_override(two, json!(9));
|
|
281
|
+
assert_eq!(
|
|
282
|
+
code(scenario.apply(&plan)),
|
|
283
|
+
RECONCILE_OVERRIDE_PATHS_OVERLAP
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Disjoint paths under a shared, un-overridden parent are fine.
|
|
288
|
+
let siblings = Scenario::new(
|
|
289
|
+
r#"{"o":{"a":1,"b":1}}"#,
|
|
290
|
+
r#"{"o":{"a":2,"b":1}}"#,
|
|
291
|
+
r#"{"o":{"a":1,"b":2}}"#,
|
|
292
|
+
);
|
|
293
|
+
let plan = siblings
|
|
294
|
+
.plan(LEFT)
|
|
295
|
+
.with_override(vec![key("o"), key("a")], json!(8))
|
|
296
|
+
.with_override(vec![key("o"), key("b")], json!(9));
|
|
297
|
+
assert_eq!(siblings.applied(&plan), json!({"o":{"a":8,"b":9}}));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// Arrays
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
#[test]
|
|
305
|
+
fn array_overrides_are_positional() {
|
|
306
|
+
let scenario = Scenario::new(
|
|
307
|
+
r#"{"xs":[1,2,3]}"#,
|
|
308
|
+
r#"{"xs":[9,2,3]}"#,
|
|
309
|
+
r#"{"xs":[1,8,3]}"#,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// Replacing one element leaves the others alone.
|
|
313
|
+
let replaced = scenario
|
|
314
|
+
.plan(LEFT)
|
|
315
|
+
.with_override(vec![key("xs"), PathComponent::Index(1)], json!(8));
|
|
316
|
+
assert_eq!(scenario.applied(&replaced), json!({"xs":[9,8,3]}));
|
|
317
|
+
|
|
318
|
+
// Two different indexes are disjoint paths, so both survive.
|
|
319
|
+
let both = scenario
|
|
320
|
+
.plan(LEFT)
|
|
321
|
+
.with_override(vec![key("xs"), PathComponent::Index(0)], json!(7))
|
|
322
|
+
.with_override(vec![key("xs"), PathComponent::Index(2)], json!(6));
|
|
323
|
+
assert_eq!(scenario.applied(&both), json!({"xs":[7,2,6]}));
|
|
324
|
+
|
|
325
|
+
// Length changes go through the array itself. There is no separate array
|
|
326
|
+
// merge algorithm: the plan is authoritative.
|
|
327
|
+
for (label, value) in [
|
|
328
|
+
("append", json!([1, 2, 3, 4])),
|
|
329
|
+
("remove", json!([1, 2])),
|
|
330
|
+
("prepend", json!([0, 1, 2, 3])),
|
|
331
|
+
("empty", json!([])),
|
|
332
|
+
("to an object", json!({"0":1})),
|
|
333
|
+
("to a scalar", json!("flat")),
|
|
334
|
+
] {
|
|
335
|
+
let plan = scenario
|
|
336
|
+
.plan(LEFT)
|
|
337
|
+
.with_override(vec![key("xs")], value.clone());
|
|
338
|
+
assert_eq!(
|
|
339
|
+
scenario.applied(&plan),
|
|
340
|
+
json!({ "xs": value }),
|
|
341
|
+
"{label} is expressed by overriding the array"
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#[test]
|
|
347
|
+
fn an_index_past_the_end_is_rejected() {
|
|
348
|
+
let scenario = Scenario::new(r#"{"xs":[1,2]}"#, r#"{"xs":[9,2]}"#, r#"{"xs":[1,8]}"#);
|
|
349
|
+
for index in [2usize, 3, 99] {
|
|
350
|
+
let plan = scenario
|
|
351
|
+
.plan(LEFT)
|
|
352
|
+
.with_override(vec![key("xs"), PathComponent::Index(index)], json!(0));
|
|
353
|
+
assert_eq!(
|
|
354
|
+
code(scenario.apply(&plan)),
|
|
355
|
+
RECONCILE_OVERRIDE_UNADDRESSABLE,
|
|
356
|
+
"index {index} would require inventing the elements before it"
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#[test]
|
|
362
|
+
fn overrides_reach_inside_array_elements() {
|
|
363
|
+
let scenario = Scenario::new(
|
|
364
|
+
r#"{"xs":[{"a":1},{"b":[1]}]}"#,
|
|
365
|
+
r#"{"xs":[{"a":2},{"b":[1]}]}"#,
|
|
366
|
+
r#"{"xs":[{"a":1},{"b":[2]}]}"#,
|
|
367
|
+
);
|
|
368
|
+
let plan = scenario.plan(LEFT).with_override(
|
|
369
|
+
vec![
|
|
370
|
+
key("xs"),
|
|
371
|
+
PathComponent::Index(1),
|
|
372
|
+
key("b"),
|
|
373
|
+
PathComponent::Index(0),
|
|
374
|
+
],
|
|
375
|
+
json!(9),
|
|
376
|
+
);
|
|
377
|
+
assert_eq!(scenario.applied(&plan), json!({"xs":[{"a":2},{"b":[9]}]}));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Canonical output
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
/// The result is canonical, whatever the selected parent's content looked like.
|
|
385
|
+
///
|
|
386
|
+
/// Content identity is the hash of the content string, so two revisions holding
|
|
387
|
+
/// the same state with differently ordered members are different revisions.
|
|
388
|
+
/// Applying an equivalent plan to either produces the *same* canonical content,
|
|
389
|
+
/// and therefore the same identity.
|
|
390
|
+
#[test]
|
|
391
|
+
fn the_result_is_canonical_regardless_of_the_parents_representation() {
|
|
392
|
+
let sorted = Scenario::new(r#"{"a":1,"b":1}"#, r#"{"a":2,"b":1}"#, r#"{"a":1,"b":2}"#);
|
|
393
|
+
let unsorted = Scenario::new(r#"{"b":1,"a":1}"#, r#"{"b":1,"a":2}"#, r#"{"a":1,"b":2}"#);
|
|
394
|
+
|
|
395
|
+
assert_ne!(
|
|
396
|
+
sorted.left.id, unsorted.left.id,
|
|
397
|
+
"differently ordered content is a different revision"
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
let one = sorted.apply(&sorted.plan(LEFT)).unwrap();
|
|
401
|
+
let two = unsorted.apply(&unsorted.plan(LEFT)).unwrap();
|
|
402
|
+
assert_eq!(one, two, "both applications produce canonical content");
|
|
403
|
+
assert_eq!(one, r#"{"a":2,"b":1}"#);
|
|
404
|
+
assert_eq!(
|
|
405
|
+
StateId::compute(&one),
|
|
406
|
+
StateId::compute(&two),
|
|
407
|
+
"so they have the same content identity"
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/// Repeated application produces the same bytes, and the result is a fixed
|
|
412
|
+
/// point of the diff.
|
|
413
|
+
#[test]
|
|
414
|
+
fn application_is_repeatable_and_the_result_is_a_fixed_point() {
|
|
415
|
+
let scenario = Scenario::new(
|
|
416
|
+
r#"{"o":{"a":1},"xs":[1,2]}"#,
|
|
417
|
+
r#"{"o":{"a":2},"xs":[9,2]}"#,
|
|
418
|
+
r#"{"o":{"a":3},"xs":[1,8]}"#,
|
|
419
|
+
);
|
|
420
|
+
let plan = scenario
|
|
421
|
+
.plan(LEFT)
|
|
422
|
+
.with_override(vec![key("o"), key("a")], json!(5))
|
|
423
|
+
.with_override(vec![key("xs"), PathComponent::Index(1)], json!(8));
|
|
424
|
+
|
|
425
|
+
let first = scenario.apply(&plan).expect("applies");
|
|
426
|
+
for _ in 0..8 {
|
|
427
|
+
assert_eq!(scenario.apply(&plan).expect("applies"), first);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let result: Value = serde_json::from_str(&first).unwrap();
|
|
431
|
+
assert!(
|
|
432
|
+
SemanticDiff::compute(&result, &result).changes.is_empty(),
|
|
433
|
+
"the result does not differ from itself"
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// Immutability
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
/// Applying a plan mutates nothing it was given.
|
|
442
|
+
#[test]
|
|
443
|
+
fn application_mutates_none_of_its_inputs() {
|
|
444
|
+
let scenario = Scenario::new(r#"{"o":{"a":1}}"#, r#"{"o":{"a":2}}"#, r#"{"o":{"a":3}}"#);
|
|
445
|
+
let plan = scenario
|
|
446
|
+
.plan(LEFT)
|
|
447
|
+
.with_override(vec![key("o"), key("a")], json!(9));
|
|
448
|
+
|
|
449
|
+
let before = (
|
|
450
|
+
serde_json::to_string(&scenario.base).unwrap(),
|
|
451
|
+
serde_json::to_string(&scenario.left).unwrap(),
|
|
452
|
+
serde_json::to_string(&scenario.right).unwrap(),
|
|
453
|
+
format!("{plan:?}"),
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
let result = reconcile(
|
|
457
|
+
&scenario.base,
|
|
458
|
+
&scenario.left,
|
|
459
|
+
&scenario.right,
|
|
460
|
+
&plan,
|
|
461
|
+
"reconciler".into(),
|
|
462
|
+
)
|
|
463
|
+
.expect("applies");
|
|
464
|
+
|
|
465
|
+
assert_eq!(serde_json::to_string(&scenario.base).unwrap(), before.0);
|
|
466
|
+
assert_eq!(serde_json::to_string(&scenario.left).unwrap(), before.1);
|
|
467
|
+
assert_eq!(serde_json::to_string(&scenario.right).unwrap(), before.2);
|
|
468
|
+
assert_eq!(format!("{plan:?}"), before.3);
|
|
469
|
+
assert_eq!(
|
|
470
|
+
result.materialized_state.content, r#"{"o":{"a":9}}"#,
|
|
471
|
+
"the result is newly materialized"
|
|
472
|
+
);
|
|
473
|
+
// And the result is a different revision from all three inputs.
|
|
474
|
+
for existing in [&scenario.base, &scenario.left, &scenario.right] {
|
|
475
|
+
assert_ne!(result.materialized_state.id, existing.id);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
// Materialization
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
/// The materialized revision uses the existing identity and provenance model.
|
|
484
|
+
#[test]
|
|
485
|
+
fn the_materialized_revision_uses_the_existing_model() {
|
|
486
|
+
let scenario = Scenario::new(r#"{"a":1}"#, r#"{"a":2}"#, r#"{"a":3}"#);
|
|
487
|
+
|
|
488
|
+
for (choice, expected_parent) in [(LEFT, &scenario.left), (RIGHT, &scenario.right)] {
|
|
489
|
+
let plan = scenario.plan(choice);
|
|
490
|
+
let result = reconcile(
|
|
491
|
+
&scenario.base,
|
|
492
|
+
&scenario.left,
|
|
493
|
+
&scenario.right,
|
|
494
|
+
&plan,
|
|
495
|
+
"reconciler".into(),
|
|
496
|
+
)
|
|
497
|
+
.expect("applies");
|
|
498
|
+
let revision = &result.materialized_state;
|
|
499
|
+
|
|
500
|
+
assert!(
|
|
501
|
+
revision.verify_integrity(),
|
|
502
|
+
"both identities match what they are computed from"
|
|
503
|
+
);
|
|
504
|
+
assert_eq!(
|
|
505
|
+
revision.content_id,
|
|
506
|
+
StateId::compute(&revision.content),
|
|
507
|
+
"no new content-identity algorithm"
|
|
508
|
+
);
|
|
509
|
+
assert_ne!(
|
|
510
|
+
revision.id, revision.content_id,
|
|
511
|
+
"the revision identity names the occurrence, not the state"
|
|
512
|
+
);
|
|
513
|
+
assert_eq!(
|
|
514
|
+
revision.parent_id.as_ref(),
|
|
515
|
+
Some(&expected_parent.id),
|
|
516
|
+
"the selected branch is the parent"
|
|
517
|
+
);
|
|
518
|
+
assert_eq!(revision.authority, "reconciler");
|
|
519
|
+
// The plan travels with the result, so the decision stays attached to
|
|
520
|
+
// the state it produced.
|
|
521
|
+
assert_eq!(result.plan.parent_choice, choice);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/// A materialized revision can be committed through the existing store, and
|
|
526
|
+
/// nothing about branches moves.
|
|
527
|
+
#[test]
|
|
528
|
+
fn a_materialized_revision_commits_through_the_existing_store() {
|
|
529
|
+
let store = StateStore::new_volatile();
|
|
530
|
+
let base = store
|
|
531
|
+
.create("docs:1", r#"{"a":1,"b":1}"#.to_string(), "auth".into())
|
|
532
|
+
.expect("create");
|
|
533
|
+
let left = store
|
|
534
|
+
.commit(r#"{"a":2,"b":1}"#.to_string(), &base, "auth".into())
|
|
535
|
+
.expect("left");
|
|
536
|
+
let right = store
|
|
537
|
+
.commit(r#"{"a":1,"b":2}"#.to_string(), &base, "auth".into())
|
|
538
|
+
.expect("right");
|
|
539
|
+
|
|
540
|
+
let plan = ReconciliationPlan::new(left.id.clone(), right.id.clone(), base.id.clone(), LEFT)
|
|
541
|
+
.with_override(vec![key("b")], json!(2));
|
|
542
|
+
|
|
543
|
+
let result = reconcile(&base, &left, &right, &plan, "reconciler".into()).expect("applies");
|
|
544
|
+
let committed = store
|
|
545
|
+
.commit(
|
|
546
|
+
result.materialized_state.content.clone(),
|
|
547
|
+
&left,
|
|
548
|
+
"reconciler".into(),
|
|
549
|
+
)
|
|
550
|
+
.expect("commit");
|
|
551
|
+
|
|
552
|
+
assert_eq!(committed.content, r#"{"a":2,"b":2}"#);
|
|
553
|
+
assert_eq!(committed.id, result.materialized_state.id);
|
|
554
|
+
|
|
555
|
+
// Applying a plan disturbed nothing it started from: both source revisions
|
|
556
|
+
// are exactly as retrievable as before. There is no branch head to move,
|
|
557
|
+
// because the store keeps none.
|
|
558
|
+
assert_eq!(store.get(&left.id).unwrap().content, left.content);
|
|
559
|
+
assert_eq!(store.get(&right.id).unwrap().content, right.content);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// Rejection
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
#[test]
|
|
567
|
+
fn an_invalid_plan_is_rejected_rather_than_producing_state() {
|
|
568
|
+
let scenario = Scenario::new(r#"{"a":1}"#, r#"{"a":2}"#, r#"{"a":3}"#);
|
|
569
|
+
|
|
570
|
+
// A plan whose ids are empty does not reference three revisions.
|
|
571
|
+
let mut empty = scenario.plan(LEFT);
|
|
572
|
+
empty.base_id = StateId::from_hex(String::new());
|
|
573
|
+
assert_eq!(code(scenario.apply(&empty)), RECONCILE_PLAN_INVALID);
|
|
574
|
+
|
|
575
|
+
// A plan that names revisions other than the ones supplied.
|
|
576
|
+
let other = revision(r#"{"a":99}"#);
|
|
577
|
+
for (base, left, right) in [
|
|
578
|
+
(&other, &scenario.left, &scenario.right),
|
|
579
|
+
(&scenario.base, &other, &scenario.right),
|
|
580
|
+
(&scenario.base, &scenario.left, &other),
|
|
581
|
+
] {
|
|
582
|
+
assert_eq!(
|
|
583
|
+
code(apply_reconciliation_plan(
|
|
584
|
+
base,
|
|
585
|
+
left,
|
|
586
|
+
right,
|
|
587
|
+
&scenario.plan(LEFT)
|
|
588
|
+
)),
|
|
589
|
+
RECONCILE_REVISION_MISMATCH
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Content that is not JSON. Classification reads such content as JSON null
|
|
594
|
+
// because it only describes; materializing a state from it would be
|
|
595
|
+
// producing arbitrary state, so this refuses.
|
|
596
|
+
let broken = Scenario::new("{}", "not json", "{}");
|
|
597
|
+
assert_eq!(
|
|
598
|
+
code(broken.apply(&broken.plan(LEFT))),
|
|
599
|
+
RECONCILE_CONTENT_UNPARSEABLE
|
|
600
|
+
);
|
|
601
|
+
// The branch that is not selected is not parsed, so it cannot fail the plan.
|
|
602
|
+
assert_eq!(broken.applied(&broken.plan(RIGHT)), json!({}));
|
|
603
|
+
|
|
604
|
+
// Override paths that name nothing assignable.
|
|
605
|
+
let scenario = Scenario::new(r#"{"o":{"a":1}}"#, r#"{"o":7}"#, r#"{"o":{"a":2}}"#);
|
|
606
|
+
for path in [
|
|
607
|
+
vec![key("o"), key("a")], // `o` is a scalar in the left branch
|
|
608
|
+
vec![key("missing"), key("deep")], // the prefix does not resolve
|
|
609
|
+
vec![key("o"), PathComponent::Index(0)], // a scalar is not an array
|
|
610
|
+
] {
|
|
611
|
+
let plan = scenario.plan(LEFT).with_override(path, json!(1));
|
|
612
|
+
assert_eq!(
|
|
613
|
+
code(scenario.apply(&plan)),
|
|
614
|
+
RECONCILE_OVERRIDE_UNADDRESSABLE
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/// Application does not require every conflict to have an override, and says so.
|
|
620
|
+
///
|
|
621
|
+
/// `unresolved_conflicts` reports which conflicting paths a plan does not
|
|
622
|
+
/// address, so a caller that wants each one decided individually can require it
|
|
623
|
+
/// to be empty. Application itself does not: leaving a conflict unaddressed
|
|
624
|
+
/// resolves it to the selected parent's value, and selecting that parent is
|
|
625
|
+
/// itself an explicit decision.
|
|
626
|
+
#[test]
|
|
627
|
+
fn unresolved_conflicts_are_reported_but_not_required() {
|
|
628
|
+
let scenario = Scenario::new(r#"{"a":1,"b":1}"#, r#"{"a":2,"b":2}"#, r#"{"a":3,"b":3}"#);
|
|
629
|
+
let classification = scenario.classification();
|
|
630
|
+
assert_eq!(classification.overall, ConflictClass::Conflict);
|
|
631
|
+
|
|
632
|
+
let bare = scenario.plan(LEFT);
|
|
633
|
+
let unresolved = bare.unresolved_conflicts(&classification);
|
|
634
|
+
assert_eq!(unresolved.len(), 2, "neither conflict is addressed");
|
|
635
|
+
|
|
636
|
+
// Applying anyway is allowed: the conflicts resolve to the chosen branch.
|
|
637
|
+
assert_eq!(scenario.applied(&bare), json!({"a":2,"b":2}));
|
|
638
|
+
|
|
639
|
+
let partly = scenario.plan(LEFT).with_override(vec![key("a")], json!(9));
|
|
640
|
+
assert_eq!(partly.unresolved_conflicts(&classification).len(), 1);
|
|
641
|
+
|
|
642
|
+
let fully = scenario
|
|
643
|
+
.plan(LEFT)
|
|
644
|
+
.with_override(vec![key("a")], json!(9))
|
|
645
|
+
.with_override(vec![key("b")], json!(8));
|
|
646
|
+
assert!(fully.unresolved_conflicts(&classification).is_empty());
|
|
647
|
+
assert_eq!(scenario.applied(&fully), json!({"a":9,"b":8}));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
// The overlap regression from the corrected classifier
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
/// The ancestor/descendant case the corrected classifier now flags.
|
|
655
|
+
///
|
|
656
|
+
/// ```text
|
|
657
|
+
/// base: {"o":{"a":1}}
|
|
658
|
+
/// left: {"o":7}
|
|
659
|
+
/// right: {"o":{"a":2}}
|
|
660
|
+
/// ```
|
|
661
|
+
///
|
|
662
|
+
/// The two changes cannot both be applied. The classifier says so, and
|
|
663
|
+
/// application does not quietly merge them into something that keeps both: the
|
|
664
|
+
/// result is exactly the branch the plan chose, or exactly the override it
|
|
665
|
+
/// supplied.
|
|
666
|
+
#[test]
|
|
667
|
+
fn an_overlapping_conflict_is_resolved_only_as_the_plan_says() {
|
|
668
|
+
let scenario = Scenario::new(r#"{"o":{"a":1}}"#, r#"{"o":7}"#, r#"{"o":{"a":2}}"#);
|
|
669
|
+
|
|
670
|
+
let classification = scenario.classification();
|
|
671
|
+
assert_eq!(
|
|
672
|
+
classification.overall,
|
|
673
|
+
ConflictClass::Conflict,
|
|
674
|
+
"the corrected classifier identifies the overlap"
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
// Choosing left takes the scalar, whole.
|
|
678
|
+
assert_eq!(scenario.applied(&scenario.plan(LEFT)), json!({"o":7}));
|
|
679
|
+
// Choosing right takes the object, whole.
|
|
680
|
+
assert_eq!(
|
|
681
|
+
scenario.applied(&scenario.plan(RIGHT)),
|
|
682
|
+
json!({"o":{"a":2}})
|
|
683
|
+
);
|
|
684
|
+
// An explicit override takes exactly what it says.
|
|
685
|
+
let explicit = scenario
|
|
686
|
+
.plan(LEFT)
|
|
687
|
+
.with_override(vec![key("o")], json!({"a":3}));
|
|
688
|
+
assert_eq!(scenario.applied(&explicit), json!({"o":{"a":3}}));
|
|
689
|
+
|
|
690
|
+
// Nothing produces a state carrying both the scalar and the member: these
|
|
691
|
+
// structures cannot be combined, and application does not try.
|
|
692
|
+
for plan in [scenario.plan(LEFT), scenario.plan(RIGHT), explicit] {
|
|
693
|
+
let result = scenario.applied(&plan);
|
|
694
|
+
let o = &result["o"];
|
|
695
|
+
assert!(
|
|
696
|
+
o.is_number() ^ o.is_object(),
|
|
697
|
+
"the result is one shape or the other, never an invented combination"
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// End to end
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
/// The whole causal path, with independent changes and one real conflict.
|
|
707
|
+
///
|
|
708
|
+
/// ```text
|
|
709
|
+
/// base → left, right → SemanticDiff → ConflictClassification
|
|
710
|
+
/// → explicit ReconciliationPlan → apply → result revision
|
|
711
|
+
/// ```
|
|
712
|
+
#[test]
|
|
713
|
+
fn the_state_model_reconciles_an_explicit_decision_end_to_end() {
|
|
714
|
+
let store = StateStore::new_volatile();
|
|
715
|
+
|
|
716
|
+
// Two branches: each edits a different member independently, and both edit
|
|
717
|
+
// `shared` differently, which is the conflict.
|
|
718
|
+
let base = store
|
|
719
|
+
.create(
|
|
720
|
+
"docs:1",
|
|
721
|
+
r#"{"only_left":1,"only_right":1,"shared":1,"untouched":"keep"}"#.to_string(),
|
|
722
|
+
"auth".into(),
|
|
723
|
+
)
|
|
724
|
+
.expect("create");
|
|
725
|
+
let left = store
|
|
726
|
+
.commit(
|
|
727
|
+
r#"{"only_left":2,"only_right":1,"shared":10,"untouched":"keep"}"#.to_string(),
|
|
728
|
+
&base,
|
|
729
|
+
"alice".into(),
|
|
730
|
+
)
|
|
731
|
+
.expect("left");
|
|
732
|
+
let right = store
|
|
733
|
+
.commit(
|
|
734
|
+
r#"{"only_left":1,"only_right":2,"shared":20,"untouched":"keep"}"#.to_string(),
|
|
735
|
+
&base,
|
|
736
|
+
"bob".into(),
|
|
737
|
+
)
|
|
738
|
+
.expect("right");
|
|
739
|
+
|
|
740
|
+
// The diffs are what classification is defined over.
|
|
741
|
+
let base_value: Value = serde_json::from_str(&base.content).unwrap();
|
|
742
|
+
let left_value: Value = serde_json::from_str(&left.content).unwrap();
|
|
743
|
+
let right_value: Value = serde_json::from_str(&right.content).unwrap();
|
|
744
|
+
assert_eq!(
|
|
745
|
+
SemanticDiff::compute(&base_value, &left_value)
|
|
746
|
+
.changes
|
|
747
|
+
.len(),
|
|
748
|
+
2
|
|
749
|
+
);
|
|
750
|
+
assert_eq!(
|
|
751
|
+
SemanticDiff::compute(&base_value, &right_value)
|
|
752
|
+
.changes
|
|
753
|
+
.len(),
|
|
754
|
+
2
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
// Classification separates what can coexist from what cannot.
|
|
758
|
+
let classification = ConflictClassification::classify(&base, &left, &right);
|
|
759
|
+
assert_eq!(classification.overall, ConflictClass::Conflict);
|
|
760
|
+
let conflicting: Vec<_> = classification
|
|
761
|
+
.path_conflicts
|
|
762
|
+
.iter()
|
|
763
|
+
.filter(|entry| entry.classification == ConflictClass::Conflict)
|
|
764
|
+
.collect();
|
|
765
|
+
assert_eq!(conflicting.len(), 1, "exactly one path conflicts");
|
|
766
|
+
assert_eq!(conflicting[0].path, vec![key("shared")]);
|
|
767
|
+
assert_eq!(conflicting[0].base_value, Some(json!(1)));
|
|
768
|
+
assert_eq!(conflicting[0].left_value, Some(json!(10)));
|
|
769
|
+
assert_eq!(conflicting[0].right_value, Some(json!(20)));
|
|
770
|
+
|
|
771
|
+
// The caller decides: take the left branch, keep the right branch's
|
|
772
|
+
// independent change, and resolve the conflict to a third value neither
|
|
773
|
+
// branch proposed.
|
|
774
|
+
let plan = ReconciliationPlan::new(left.id.clone(), right.id.clone(), base.id.clone(), LEFT)
|
|
775
|
+
.with_override(vec![key("only_right")], json!(2))
|
|
776
|
+
.with_override(vec![key("shared")], json!(99));
|
|
777
|
+
assert!(
|
|
778
|
+
plan.unresolved_conflicts(&classification).is_empty(),
|
|
779
|
+
"every conflict is explicitly addressed"
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
let before = (
|
|
783
|
+
base.content.clone(),
|
|
784
|
+
left.content.clone(),
|
|
785
|
+
right.content.clone(),
|
|
786
|
+
);
|
|
787
|
+
let result = reconcile(&base, &left, &right, &plan, "reconciler".into()).expect("applies");
|
|
788
|
+
let reconciled: Value = serde_json::from_str(&result.materialized_state.content).unwrap();
|
|
789
|
+
|
|
790
|
+
// 1. the independent change on the selected branch survives
|
|
791
|
+
assert_eq!(reconciled["only_left"], json!(2));
|
|
792
|
+
// 2. the independent change on the other branch survives, because the plan
|
|
793
|
+
// asked for it
|
|
794
|
+
assert_eq!(reconciled["only_right"], json!(2));
|
|
795
|
+
// 3. the chosen resolution survives, and neither branch's value does
|
|
796
|
+
assert_eq!(reconciled["shared"], json!(99));
|
|
797
|
+
assert_ne!(reconciled["shared"], json!(10));
|
|
798
|
+
assert_ne!(reconciled["shared"], json!(20));
|
|
799
|
+
// 4. unrelated state is preserved
|
|
800
|
+
assert_eq!(reconciled["untouched"], json!("keep"));
|
|
801
|
+
// 5. the result is canonical
|
|
802
|
+
assert_eq!(
|
|
803
|
+
result.materialized_state.content,
|
|
804
|
+
serde_json::to_string(&reconciled).unwrap()
|
|
805
|
+
);
|
|
806
|
+
assert!(result.materialized_state.verify_integrity());
|
|
807
|
+
// 6. the inputs are unchanged
|
|
808
|
+
assert_eq!(
|
|
809
|
+
(
|
|
810
|
+
base.content.clone(),
|
|
811
|
+
left.content.clone(),
|
|
812
|
+
right.content.clone()
|
|
813
|
+
),
|
|
814
|
+
before
|
|
815
|
+
);
|
|
816
|
+
|
|
817
|
+
// The result is a revision like any other, committable through the store.
|
|
818
|
+
let committed = store
|
|
819
|
+
.commit(
|
|
820
|
+
result.materialized_state.content.clone(),
|
|
821
|
+
&left,
|
|
822
|
+
"reconciler".into(),
|
|
823
|
+
)
|
|
824
|
+
.expect("commit");
|
|
825
|
+
assert_eq!(committed.id, result.materialized_state.id);
|
|
826
|
+
assert_eq!(committed.parent_id.as_ref(), Some(&left.id));
|
|
827
|
+
|
|
828
|
+
// And the reconciled state has no remaining difference from itself.
|
|
829
|
+
let final_value: Value = serde_json::from_str(&committed.content).unwrap();
|
|
830
|
+
assert!(SemanticDiff::compute(&final_value, &reconciled)
|
|
831
|
+
.changes
|
|
832
|
+
.is_empty());
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ---------------------------------------------------------------------------
|
|
836
|
+
// A recorded limitation of the plan type
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
/// A plan carrying overrides cannot be serialized to JSON.
|
|
840
|
+
///
|
|
841
|
+
/// `path_overrides` is keyed by `Vec<PathComponent>`, and JSON object keys must
|
|
842
|
+
/// be strings, so `serde_json` refuses the map the moment it holds an entry.
|
|
843
|
+
/// The type derives `Serialize`, which makes this look supported; it is not.
|
|
844
|
+
///
|
|
845
|
+
/// This is recorded, not fixed. A plan is presently constructed and applied
|
|
846
|
+
/// in-process, which this PR is what establishes; nothing transmits one. Giving
|
|
847
|
+
/// the path a string encoding would be inventing a second path syntax — exactly
|
|
848
|
+
/// the flattened rendering the conflict work went out of its way to avoid —
|
|
849
|
+
/// and it is not needed by any caller that exists. The test exists so that the
|
|
850
|
+
/// day a caller does need to transmit a plan, the limitation is already stated
|
|
851
|
+
/// rather than discovered.
|
|
852
|
+
#[test]
|
|
853
|
+
fn a_plan_with_overrides_is_not_json_serializable() {
|
|
854
|
+
let scenario = Scenario::new(r#"{"a":1}"#, r#"{"a":2}"#, r#"{"a":3}"#);
|
|
855
|
+
|
|
856
|
+
// Without overrides the plan serializes.
|
|
857
|
+
let plain = scenario.plan(LEFT);
|
|
858
|
+
assert!(serde_json::to_string(&plain).is_ok());
|
|
859
|
+
|
|
860
|
+
// With one, it does not, and application is unaffected either way.
|
|
861
|
+
let with_override = plain.clone().with_override(vec![key("a")], json!(9));
|
|
862
|
+
let error = serde_json::to_string(&with_override).expect_err("non-string key");
|
|
863
|
+
assert!(
|
|
864
|
+
error.to_string().contains("key must be a string"),
|
|
865
|
+
"unexpected error: {error}"
|
|
866
|
+
);
|
|
867
|
+
assert_eq!(scenario.apply(&with_override).unwrap(), r#"{"a":9}"#);
|
|
868
|
+
}
|