@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.
Files changed (193) hide show
  1. package/dist/cli/commands.js +4 -1
  2. package/dist/cli/provisioning-neutrality.js +79 -0
  3. package/dist/collection.d.ts +43 -1
  4. package/dist/collection.d.ts.map +1 -1
  5. package/dist/collection.js +192 -22
  6. package/dist/create/create.js +25 -21
  7. package/dist/create/managed-account.js +11 -0
  8. package/dist/create/package-versions.js +1 -1
  9. package/dist/create/server-source/Cargo.lock +165 -0
  10. package/dist/create/server-source/Cargo.toml +9 -0
  11. package/dist/create/server-source/crates/feltdb/Cargo.toml +3 -0
  12. package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +44 -44
  13. package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +12 -24
  14. package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +7 -13
  15. package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +13 -10
  16. package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +12 -9
  17. package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +78 -25
  18. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +16 -13
  19. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +13 -5
  20. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +41 -22
  21. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +33 -15
  22. package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +100 -33
  23. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +56 -20
  24. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +116 -41
  25. package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +186 -55
  26. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +64 -26
  27. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +46 -15
  28. package/dist/create/server-source/crates/feltdb/src/admission.rs +8 -15
  29. package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +43 -13
  30. package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +15 -42
  31. package/dist/create/server-source/crates/feltdb/src/analytics.rs +65 -19
  32. package/dist/create/server-source/crates/feltdb/src/application.rs +113 -30
  33. package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +475 -140
  34. package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +17 -15
  35. package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -1
  36. package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +59 -15
  37. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +266 -114
  38. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +25 -7
  39. package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +10 -15
  40. package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +3 -11
  41. package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +14 -5
  42. package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +21 -25
  43. package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +14 -11
  44. package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +103 -22
  45. package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +18 -15
  46. package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +12 -8
  47. package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +1 -5
  48. package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +87 -23
  49. package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +3 -7
  50. package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +10 -9
  51. package/dist/create/server-source/crates/feltdb/src/equality_index.rs +595 -0
  52. package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +1 -6
  53. package/dist/create/server-source/crates/feltdb/src/indexing.rs +35 -38
  54. package/dist/create/server-source/crates/feltdb/src/lib.rs +1050 -117
  55. package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +4 -1
  56. package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -1
  57. package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +1 -2
  58. package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +107 -30
  59. package/dist/create/server-source/crates/feltdb/src/observability.rs +19 -6
  60. package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +12 -11
  61. package/dist/create/server-source/crates/feltdb/src/operation_log.rs +9 -4
  62. package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +65 -18
  63. package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +193 -57
  64. package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +37 -27
  65. package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +38 -10
  66. package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +20 -14
  67. package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +394 -229
  68. package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +8 -6
  69. package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +11 -13
  70. package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +90 -72
  71. package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +3 -3
  72. package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +33 -11
  73. package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +6 -6
  74. package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +14 -56
  75. package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +29 -27
  76. package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +35 -17
  77. package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +8 -2
  78. package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +59 -15
  79. package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +70 -17
  80. package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +23 -6
  81. package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +12 -3
  82. package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +41 -11
  83. package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +56 -25
  84. package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +701 -245
  85. package/dist/create/server-source/crates/feltdb/src/production_api.rs +31 -13
  86. package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +126 -0
  87. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +6 -8
  88. package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +5 -5
  89. package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +48 -18
  90. package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +30 -11
  91. package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +6 -3
  92. package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +4 -3
  93. package/dist/create/server-source/crates/feltdb/src/sharding.rs +36 -10
  94. package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +516 -0
  95. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +305 -6
  96. package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +222 -0
  97. package/dist/create/server-source/crates/feltdb/src/state_facade.rs +82 -54
  98. package/dist/create/server-source/crates/feltdb/src/state_hash.rs +2 -2
  99. package/dist/create/server-source/crates/feltdb/src/state_model.rs +1514 -537
  100. package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +6 -3
  101. package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +672 -0
  102. package/dist/create/server-source/crates/feltdb/src/submission.rs +5 -11
  103. package/dist/create/server-source/crates/feltdb/src/sync.rs +12 -0
  104. package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +6 -8
  105. package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +24 -35
  106. package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +24 -8
  107. package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +248 -59
  108. package/dist/create/server-source/crates/feltdb/src/transactions.rs +17 -20
  109. package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +749 -0
  110. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
  111. package/dist/create/server-source/crates/feltdb/src/workload.rs +512 -4
  112. package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +442 -0
  113. package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
  114. package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +288 -0
  115. package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +392 -0
  116. package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +436 -220
  117. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +1916 -0
  118. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +1878 -0
  119. package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +1862 -0
  120. package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +234 -0
  121. package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +947 -0
  122. package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +5 -8
  123. package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +52 -44
  124. package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +33 -16
  125. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +22 -7
  126. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +41 -22
  127. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +25 -8
  128. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +9 -6
  129. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +120 -69
  130. package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +21 -10
  131. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +1032 -0
  132. package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +868 -0
  133. package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +221 -0
  134. package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +338 -0
  135. package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +1039 -0
  136. package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +739 -0
  137. package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +427 -0
  138. package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +3 -3
  139. package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +25 -22
  140. package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +1799 -0
  141. package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +1316 -0
  142. package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +53 -61
  143. package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +156 -61
  144. package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +299 -0
  145. package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +255 -0
  146. package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +249 -0
  147. package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +994 -0
  148. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +2 -1
  149. package/dist/create/server-source/crates/feltdb-server/src/audit.rs +1137 -29
  150. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
  151. package/dist/create/server-source/crates/feltdb-server/src/main.rs +800 -34
  152. package/dist/db.d.ts +33 -34
  153. package/dist/db.d.ts.map +1 -1
  154. package/dist/db.js +74 -20
  155. package/dist/deployment.d.ts +30 -0
  156. package/dist/deployment.d.ts.map +1 -0
  157. package/dist/deployment.js +130 -0
  158. package/dist/embedded-transaction.d.ts +22 -4
  159. package/dist/embedded-transaction.d.ts.map +1 -1
  160. package/dist/embedded-transaction.js +51 -5
  161. package/dist/feltdb.d.ts +14 -2
  162. package/dist/feltdb.d.ts.map +1 -1
  163. package/dist/file-db.js +1 -1
  164. package/dist/http-client.d.ts +14 -0
  165. package/dist/http-client.d.ts.map +1 -1
  166. package/dist/http-client.js +23 -5
  167. package/dist/http-db.d.ts +119 -1
  168. package/dist/http-db.d.ts.map +1 -1
  169. package/dist/http-db.js +346 -31
  170. package/dist/index-core.d.ts +2 -0
  171. package/dist/index-core.d.ts.map +1 -1
  172. package/dist/index-core.js +2 -0
  173. package/dist/index.d.ts.map +1 -1
  174. package/dist/index.js +9 -0
  175. package/dist/indexeddb-db.d.ts.map +1 -1
  176. package/dist/indexeddb-db.js +35 -21
  177. package/dist/managed-recovery.d.ts +192 -0
  178. package/dist/managed-recovery.d.ts.map +1 -0
  179. package/dist/managed-recovery.js +242 -0
  180. package/dist/memory-db.js +1 -1
  181. package/dist/studio-app/assets/{feltdb_wasm-DB8cX151.js → feltdb_wasm-DaNwCLRX.js} +1 -1
  182. package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
  183. package/dist/studio-app/assets/index-j8IlhNqJ.js +29 -0
  184. package/dist/studio-app/index.html +1 -1
  185. package/dist/transaction.d.ts +30 -0
  186. package/dist/transaction.d.ts.map +1 -1
  187. package/dist/transaction.js +41 -0
  188. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  189. package/dist/workload.d.ts +2 -1
  190. package/dist/workload.d.ts.map +1 -1
  191. package/package.json +1 -1
  192. package/dist/studio-app/assets/feltdb_wasm_bg-ClhDHp0S.wasm +0 -0
  193. package/dist/studio-app/assets/index-B0k4UAlI.js +0 -29
@@ -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
+ }