@lix-js/sdk 0.6.0-preview.4 → 0.6.0-preview.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 (234) hide show
  1. package/README.md +1 -1
  2. package/SKILL.md +65 -64
  3. package/dist/engine-wasm/index.js +4 -4
  4. package/dist/engine-wasm/wasm/lix_engine.d.ts +5 -5
  5. package/dist/engine-wasm/wasm/lix_engine.js +130 -118
  6. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  7. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +9 -8
  8. package/dist/generated/builtin-schemas.d.ts +69 -69
  9. package/dist/generated/builtin-schemas.js +94 -94
  10. package/dist/open-lix.d.ts +33 -26
  11. package/dist/open-lix.js +10 -10
  12. package/dist/sqlite/index.js +86 -30
  13. package/dist-engine-src/README.md +3 -3
  14. package/dist-engine-src/src/backend/capabilities.rs +67 -0
  15. package/dist-engine-src/src/backend/conformance/baseline.rs +1127 -0
  16. package/dist-engine-src/src/backend/conformance/factory.rs +93 -0
  17. package/dist-engine-src/src/backend/conformance/failure_tests.rs +608 -0
  18. package/dist-engine-src/src/backend/conformance/fixtures.rs +26 -0
  19. package/dist-engine-src/src/backend/conformance/mod.rs +75 -0
  20. package/dist-engine-src/src/backend/conformance/model.rs +28 -0
  21. package/dist-engine-src/src/backend/conformance/model_based.rs +257 -0
  22. package/dist-engine-src/src/backend/conformance/persistence.rs +204 -0
  23. package/dist-engine-src/src/backend/conformance/projection.rs +21 -0
  24. package/dist-engine-src/src/backend/conformance/pushdown.rs +24 -0
  25. package/dist-engine-src/src/backend/conformance/runner.rs +90 -0
  26. package/dist-engine-src/src/backend/conformance/scan.rs +24 -0
  27. package/dist-engine-src/src/backend/conformance/write.rs +16 -0
  28. package/dist-engine-src/src/backend/error.rs +94 -0
  29. package/dist-engine-src/src/backend/in_memory.rs +670 -0
  30. package/dist-engine-src/src/backend/mod.rs +36 -9
  31. package/dist-engine-src/src/backend/predicate.rs +80 -0
  32. package/dist-engine-src/src/backend/traits.rs +260 -0
  33. package/dist-engine-src/src/backend/types.rs +224 -81
  34. package/dist-engine-src/src/binary_cas/context.rs +8 -8
  35. package/dist-engine-src/src/binary_cas/kv.rs +234 -259
  36. package/dist-engine-src/src/{version → branch}/context.rs +12 -12
  37. package/dist-engine-src/src/branch/lifecycle.rs +221 -0
  38. package/dist-engine-src/src/branch/mod.rs +13 -0
  39. package/dist-engine-src/src/branch/refs.rs +321 -0
  40. package/dist-engine-src/src/branch/stage_rows.rs +67 -0
  41. package/dist-engine-src/src/branch/types.rs +21 -0
  42. package/dist-engine-src/src/catalog/context.rs +18 -18
  43. package/dist-engine-src/src/catalog/snapshot.rs +8 -8
  44. package/dist-engine-src/src/changelog/bench_support.rs +785 -0
  45. package/dist-engine-src/src/changelog/change.rs +1 -0
  46. package/dist-engine-src/src/changelog/codec.rs +497 -0
  47. package/dist-engine-src/src/changelog/commit.rs +1 -0
  48. package/dist-engine-src/src/changelog/context.rs +1614 -0
  49. package/dist-engine-src/src/changelog/mod.rs +29 -0
  50. package/dist-engine-src/src/changelog/store.rs +163 -0
  51. package/dist-engine-src/src/changelog/test_support.rs +54 -0
  52. package/dist-engine-src/src/changelog/types.rs +213 -0
  53. package/dist-engine-src/src/commit_graph/context.rs +317 -274
  54. package/dist-engine-src/src/commit_graph/mod.rs +2 -4
  55. package/dist-engine-src/src/commit_graph/types.rs +22 -42
  56. package/dist-engine-src/src/commit_graph/walker.rs +133 -103
  57. package/dist-engine-src/src/common/error.rs +52 -18
  58. package/dist-engine-src/src/common/identity.rs +2 -2
  59. package/dist-engine-src/src/common/mod.rs +1 -1
  60. package/dist-engine-src/src/domain.rs +42 -46
  61. package/dist-engine-src/src/engine.rs +74 -96
  62. package/dist-engine-src/src/{entity_identity.rs → entity_pk.rs} +89 -92
  63. package/dist-engine-src/src/functions/context.rs +56 -52
  64. package/dist-engine-src/src/functions/state.rs +51 -52
  65. package/dist-engine-src/src/init.rs +288 -154
  66. package/dist-engine-src/src/json_store/context.rs +15 -266
  67. package/dist-engine-src/src/json_store/mod.rs +26 -0
  68. package/dist-engine-src/src/json_store/store.rs +103 -718
  69. package/dist-engine-src/src/json_store/types.rs +4 -9
  70. package/dist-engine-src/src/lib.rs +49 -19
  71. package/dist-engine-src/src/live_state/context.rs +654 -790
  72. package/dist-engine-src/src/live_state/mod.rs +9 -3
  73. package/dist-engine-src/src/live_state/overlay.rs +4 -4
  74. package/dist-engine-src/src/live_state/types.rs +30 -21
  75. package/dist-engine-src/src/live_state/visibility.rs +514 -71
  76. package/dist-engine-src/src/plugin/install.rs +48 -48
  77. package/dist-engine-src/src/plugin/manifest.rs +7 -7
  78. package/dist-engine-src/src/plugin/materializer.rs +0 -275
  79. package/dist-engine-src/src/plugin/plugin_manifest.json +4 -3
  80. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +2 -2
  81. package/dist-engine-src/src/schema/builtin/lix_branch_descriptor.json +34 -0
  82. package/dist-engine-src/src/schema/builtin/lix_branch_ref.json +48 -0
  83. package/dist-engine-src/src/schema/builtin/lix_change.json +3 -3
  84. package/dist-engine-src/src/schema/builtin/lix_commit.json +1 -1
  85. package/dist-engine-src/src/schema/builtin/lix_label_assignment.json +6 -6
  86. package/dist-engine-src/src/schema/builtin/mod.rs +18 -20
  87. package/dist-engine-src/src/schema/compatibility.rs +11 -11
  88. package/dist-engine-src/src/schema/definition.json +2 -2
  89. package/dist-engine-src/src/schema/definition.rs +5 -5
  90. package/dist-engine-src/src/schema/key.rs +3 -3
  91. package/dist-engine-src/src/schema/mod.rs +1 -1
  92. package/dist-engine-src/src/schema/tests.rs +18 -18
  93. package/dist-engine-src/src/session/context.rs +803 -148
  94. package/dist-engine-src/src/session/create_branch.rs +94 -0
  95. package/dist-engine-src/src/session/execute.rs +223 -83
  96. package/dist-engine-src/src/session/merge/analysis.rs +9 -3
  97. package/dist-engine-src/src/session/merge/{version.rs → branch.rs} +119 -129
  98. package/dist-engine-src/src/session/merge/conflicts.rs +2 -2
  99. package/dist-engine-src/src/session/merge/mod.rs +5 -6
  100. package/dist-engine-src/src/session/merge/stats.rs +7 -11
  101. package/dist-engine-src/src/session/mod.rs +15 -12
  102. package/dist-engine-src/src/session/switch_branch.rs +113 -0
  103. package/dist-engine-src/src/session/transaction.rs +495 -14
  104. package/dist-engine-src/src/sql2/{classify.rs → bind/classify.rs} +3 -75
  105. package/dist-engine-src/src/sql2/bind/error.rs +5 -0
  106. package/dist-engine-src/src/sql2/bind/expr.rs +29 -0
  107. package/dist-engine-src/src/sql2/bind/mod.rs +12 -0
  108. package/dist-engine-src/src/sql2/{udfs/public_call.rs → bind/public_udf.rs} +71 -3
  109. package/dist-engine-src/src/sql2/bind/read.rs +65 -0
  110. package/dist-engine-src/src/sql2/bind/statement.rs +2236 -0
  111. package/dist-engine-src/src/sql2/bind/table.rs +273 -0
  112. package/dist-engine-src/src/sql2/bind/write.rs +86 -0
  113. package/dist-engine-src/src/sql2/branch_scope.rs +436 -0
  114. package/dist-engine-src/src/sql2/catalog/capability.rs +20 -0
  115. package/dist-engine-src/src/sql2/catalog/entity_surface.rs +296 -0
  116. package/dist-engine-src/src/sql2/catalog/mod.rs +15 -0
  117. package/dist-engine-src/src/sql2/catalog/registry.rs +556 -0
  118. package/dist-engine-src/src/sql2/catalog/schema.rs +88 -0
  119. package/dist-engine-src/src/sql2/catalog/surface.rs +41 -0
  120. package/dist-engine-src/src/sql2/change_materialization.rs +122 -0
  121. package/dist-engine-src/src/sql2/context.rs +36 -30
  122. package/dist-engine-src/src/sql2/error.rs +1 -1
  123. package/dist-engine-src/src/sql2/exec/bound_public_write.rs +1593 -0
  124. package/dist-engine-src/src/sql2/exec/datafusion.rs +5266 -0
  125. package/dist-engine-src/src/sql2/exec/fast_write.rs +82 -0
  126. package/dist-engine-src/src/sql2/exec/mod.rs +24 -0
  127. package/dist-engine-src/src/sql2/exec/write.rs +661 -0
  128. package/dist-engine-src/src/sql2/filesystem_planner.rs +72 -77
  129. package/dist-engine-src/src/sql2/filesystem_visibility.rs +21 -21
  130. package/dist-engine-src/src/sql2/history_projection.rs +8 -8
  131. package/dist-engine-src/src/sql2/history_route.rs +35 -31
  132. package/dist-engine-src/src/sql2/mod.rs +28 -23
  133. package/dist-engine-src/src/sql2/optimize/datafusion.rs +1 -0
  134. package/dist-engine-src/src/sql2/optimize/mod.rs +2 -0
  135. package/dist-engine-src/src/sql2/optimize/simple_write.rs +116 -0
  136. package/dist-engine-src/src/sql2/parse/mod.rs +69 -0
  137. package/dist-engine-src/src/sql2/parse/normalize.rs +1 -0
  138. package/dist-engine-src/src/sql2/plan/branch_scope.rs +24 -0
  139. package/dist-engine-src/src/sql2/plan/mod.rs +5 -0
  140. package/dist-engine-src/src/sql2/plan/predicate.rs +22 -0
  141. package/dist-engine-src/src/sql2/plan/write.rs +147 -0
  142. package/dist-engine-src/src/sql2/predicate_typecheck.rs +258 -0
  143. package/dist-engine-src/src/sql2/{version_provider.rs → providers/branch.rs} +218 -214
  144. package/dist-engine-src/src/sql2/{change_provider.rs → providers/change.rs} +156 -42
  145. package/dist-engine-src/src/sql2/{directory_provider.rs → providers/directory.rs} +291 -322
  146. package/dist-engine-src/src/sql2/{directory_history_provider.rs → providers/directory_history.rs} +56 -42
  147. package/dist-engine-src/src/sql2/providers/entity.rs +1484 -0
  148. package/dist-engine-src/src/sql2/{entity_history_provider.rs → providers/entity_history.rs} +43 -31
  149. package/dist-engine-src/src/sql2/{file_provider.rs → providers/file.rs} +323 -316
  150. package/dist-engine-src/src/sql2/{file_history_provider.rs → providers/file_history.rs} +60 -46
  151. package/dist-engine-src/src/sql2/{history_provider.rs → providers/history.rs} +46 -32
  152. package/dist-engine-src/src/sql2/{lix_state_provider.rs → providers/lix_state.rs} +359 -329
  153. package/dist-engine-src/src/sql2/providers/mod.rs +508 -0
  154. package/dist-engine-src/src/sql2/read_only.rs +2 -2
  155. package/dist-engine-src/src/sql2/session.rs +47 -96
  156. package/dist-engine-src/src/sql2/storage/constraints.rs +1 -0
  157. package/dist-engine-src/src/sql2/storage/mod.rs +1 -0
  158. package/dist-engine-src/src/sql2/test_support/differential.rs +712 -0
  159. package/dist-engine-src/src/sql2/test_support/generators.rs +354 -0
  160. package/dist-engine-src/src/sql2/test_support/mod.rs +2 -0
  161. package/dist-engine-src/src/sql2/udfs/{lix_active_version_commit_id.rs → lix_active_branch_commit_id.rs} +7 -7
  162. package/dist-engine-src/src/sql2/udfs/mod.rs +3 -6
  163. package/dist-engine-src/src/sql2/write_normalization.rs +45 -22
  164. package/dist-engine-src/src/storage/conformance.rs +399 -0
  165. package/dist-engine-src/src/storage/context.rs +552 -288
  166. package/dist-engine-src/src/storage/mod.rs +48 -10
  167. package/dist-engine-src/src/storage/point.rs +440 -0
  168. package/dist-engine-src/src/storage/read_scope.rs +43 -64
  169. package/dist-engine-src/src/storage/reader.rs +867 -0
  170. package/dist-engine-src/src/storage/scan.rs +784 -0
  171. package/dist-engine-src/src/storage/spaces.rs +236 -0
  172. package/dist-engine-src/src/storage/stats.rs +80 -0
  173. package/dist-engine-src/src/storage/write_set.rs +962 -0
  174. package/dist-engine-src/src/storage_bench.rs +136 -4828
  175. package/dist-engine-src/src/test_support.rs +360 -138
  176. package/dist-engine-src/src/tracked_state/bench_support.rs +394 -0
  177. package/dist-engine-src/src/tracked_state/codec.rs +155 -1057
  178. package/dist-engine-src/src/tracked_state/commit_root_rebuild.rs +358 -0
  179. package/dist-engine-src/src/tracked_state/context.rs +1927 -993
  180. package/dist-engine-src/src/tracked_state/diff.rs +1715 -261
  181. package/dist-engine-src/src/tracked_state/merge.rs +74 -88
  182. package/dist-engine-src/src/tracked_state/mod.rs +19 -16
  183. package/dist-engine-src/src/tracked_state/{materialization.rs → row_materialization.rs} +50 -178
  184. package/dist-engine-src/src/tracked_state/storage.rs +243 -191
  185. package/dist-engine-src/src/tracked_state/tree.rs +247 -371
  186. package/dist-engine-src/src/tracked_state/types.rs +49 -42
  187. package/dist-engine-src/src/transaction/bench_support.rs +407 -0
  188. package/dist-engine-src/src/transaction/commit.rs +821 -713
  189. package/dist-engine-src/src/transaction/context.rs +705 -600
  190. package/dist-engine-src/src/transaction/mod.rs +13 -2
  191. package/dist-engine-src/src/transaction/normalization.rs +63 -76
  192. package/dist-engine-src/src/transaction/prep.rs +13 -13
  193. package/dist-engine-src/src/transaction/schema_resolver.rs +19 -5
  194. package/dist-engine-src/src/transaction/staging.rs +228 -434
  195. package/dist-engine-src/src/transaction/types.rs +41 -98
  196. package/dist-engine-src/src/transaction/validation.rs +382 -446
  197. package/dist-engine-src/src/untracked_state/codec.rs +337 -29
  198. package/dist-engine-src/src/untracked_state/context.rs +7 -7
  199. package/dist-engine-src/src/untracked_state/materialization.rs +2 -2
  200. package/dist-engine-src/src/untracked_state/mod.rs +1 -1
  201. package/dist-engine-src/src/untracked_state/storage.rs +659 -157
  202. package/dist-engine-src/src/untracked_state/types.rs +21 -21
  203. package/package.json +71 -68
  204. package/dist-engine-src/src/backend/kv.rs +0 -358
  205. package/dist-engine-src/src/backend/testing.rs +0 -658
  206. package/dist-engine-src/src/commit_store/codec.rs +0 -887
  207. package/dist-engine-src/src/commit_store/context.rs +0 -944
  208. package/dist-engine-src/src/commit_store/materialization.rs +0 -84
  209. package/dist-engine-src/src/commit_store/mod.rs +0 -16
  210. package/dist-engine-src/src/commit_store/storage.rs +0 -600
  211. package/dist-engine-src/src/commit_store/types.rs +0 -215
  212. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +0 -34
  213. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +0 -48
  214. package/dist-engine-src/src/session/create_version.rs +0 -88
  215. package/dist-engine-src/src/session/merge/apply.rs +0 -23
  216. package/dist-engine-src/src/session/optimization9_sql2_bench.rs +0 -100
  217. package/dist-engine-src/src/session/switch_version.rs +0 -110
  218. package/dist-engine-src/src/sql2/entity_provider.rs +0 -3211
  219. package/dist-engine-src/src/sql2/execute.rs +0 -3533
  220. package/dist-engine-src/src/sql2/public_bind/assignment.rs +0 -46
  221. package/dist-engine-src/src/sql2/public_bind/capability.rs +0 -41
  222. package/dist-engine-src/src/sql2/public_bind/dml.rs +0 -172
  223. package/dist-engine-src/src/sql2/public_bind/mod.rs +0 -26
  224. package/dist-engine-src/src/sql2/public_bind/table.rs +0 -168
  225. package/dist-engine-src/src/sql2/version_scope.rs +0 -394
  226. package/dist-engine-src/src/storage/types.rs +0 -501
  227. package/dist-engine-src/src/tracked_state/by_file_index.rs +0 -98
  228. package/dist-engine-src/src/tracked_state/materializer.rs +0 -488
  229. package/dist-engine-src/src/transaction/live_state_overlay.rs +0 -35
  230. package/dist-engine-src/src/version/lifecycle.rs +0 -221
  231. package/dist-engine-src/src/version/mod.rs +0 -13
  232. package/dist-engine-src/src/version/refs.rs +0 -330
  233. package/dist-engine-src/src/version/stage_rows.rs +0 -67
  234. package/dist-engine-src/src/version/types.rs +0 -21
@@ -1,8 +1,9 @@
1
- use crate::entity_identity::EntityIdentity;
2
- use crate::tracked_state::types::TrackedStateTreeScanRequest;
3
- use crate::tracked_state::{
4
- MaterializedTrackedStateRow, TrackedStateFilter, TrackedStateStoreReader,
1
+ use crate::entity_pk::EntityPk;
2
+ use crate::json_store::JsonRef;
3
+ use crate::tracked_state::types::{
4
+ TrackedStateIndexValue, TrackedStateKey, TrackedStateTreeScanRequest,
5
5
  };
6
+ use crate::tracked_state::{TrackedStateFilter, TrackedStateStoreReader};
6
7
  use crate::LixError;
7
8
 
8
9
  /// Filter for comparing two tracked-state commit roots.
@@ -26,21 +27,40 @@ pub(crate) struct TrackedStateDiffEntry {
26
27
  ///
27
28
  /// This can be a tombstone. Callers that need user-visible semantics
28
29
  /// should use `visible_before()` instead of inspecting this directly.
29
- pub(crate) before: Option<MaterializedTrackedStateRow>,
30
+ pub(crate) before: Option<TrackedStateDiffRow>,
30
31
  /// Raw row in the right root.
31
32
  ///
32
33
  /// This can be a tombstone. Keeping the raw tombstone is what lets merge
33
34
  /// apply deletes without reloading the source root.
34
- pub(crate) after: Option<MaterializedTrackedStateRow>,
35
+ pub(crate) after: Option<TrackedStateDiffRow>,
36
+ }
37
+
38
+ /// Payload-light tracked-state row carried by diff and merge planning.
39
+ ///
40
+ /// This deliberately stores JSON refs, not JSON payload strings. Diff can
41
+ /// compare and report rows from tracked-state tree values without hydrating
42
+ /// snapshot or metadata bytes.
43
+ #[derive(Debug, Clone, PartialEq, Eq)]
44
+ pub(crate) struct TrackedStateDiffRow {
45
+ pub(crate) entity_pk: EntityPk,
46
+ pub(crate) schema_key: String,
47
+ pub(crate) file_id: Option<String>,
48
+ pub(crate) deleted: bool,
49
+ pub(crate) snapshot_ref: Option<JsonRef>,
50
+ pub(crate) metadata_ref: Option<JsonRef>,
51
+ pub(crate) created_at: String,
52
+ pub(crate) updated_at: String,
53
+ pub(crate) change_id: String,
54
+ pub(crate) commit_id: String,
35
55
  }
36
56
 
37
57
  /// Root-local tracked-state identity.
38
58
  ///
39
- /// Entity identity used by merge/diff logic.
59
+ /// Entity pk used by merge/diff logic.
40
60
  #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
41
61
  pub(crate) struct TrackedStateDiffIdentity {
42
62
  pub(crate) schema_key: String,
43
- pub(crate) entity_id: EntityIdentity,
63
+ pub(crate) entity_pk: EntityPk,
44
64
  pub(crate) file_id: Option<String>,
45
65
  }
46
66
 
@@ -60,69 +80,64 @@ pub(crate) async fn diff_commits<S>(
60
80
  request: &TrackedStateDiffRequest,
61
81
  ) -> Result<TrackedStateDiff, LixError>
62
82
  where
63
- S: crate::storage::StorageReader,
83
+ S: crate::storage::StorageRead + Send + Sync,
84
+ {
85
+ diff_commits_with_validation(reader, left_commit_id, right_commit_id, request, true, true).await
86
+ }
87
+
88
+ pub(crate) async fn diff_commits_with_validation<S>(
89
+ reader: &mut TrackedStateStoreReader<S>,
90
+ left_commit_id: &str,
91
+ right_commit_id: &str,
92
+ request: &TrackedStateDiffRequest,
93
+ validate_left_root: bool,
94
+ validate_right_root: bool,
95
+ ) -> Result<TrackedStateDiff, LixError>
96
+ where
97
+ S: crate::storage::StorageRead + Send + Sync,
64
98
  {
65
99
  let scan_request = scan_request_for_diff(request);
66
100
  let tree_entries = reader
67
101
  .diff_tree_entries_at_commits(left_commit_id, right_commit_id, &scan_request)
68
102
  .await?;
69
- let mut before_entries = Vec::new();
70
- let mut after_entries = Vec::new();
71
- let mut pending_entries = Vec::with_capacity(tree_entries.len());
72
- for tree_entry in tree_entries {
73
- let before_index = tree_entry.before.map(|entry| {
74
- let index = before_entries.len();
75
- before_entries.push(entry);
76
- index
77
- });
78
- let after_index = tree_entry.after.map(|entry| {
79
- let index = after_entries.len();
80
- after_entries.push(entry);
81
- index
82
- });
83
- pending_entries.push(PendingDiffEntry {
84
- before_index,
85
- after_index,
86
- });
87
- }
88
-
89
- let before_rows = reader.materialize_tree_values(before_entries).await?;
90
- let after_rows = reader.materialize_tree_values(after_entries).await?;
91
- let mut entries = Vec::new();
92
- for pending_entry in pending_entries {
93
- let before = materialized_row_at(pending_entry.before_index, &before_rows)?;
94
- let after = materialized_row_at(pending_entry.after_index, &after_rows)?;
103
+ if validate_left_root {
104
+ reader
105
+ .validate_tree_rows_at_commit_against_changelog(left_commit_id, &scan_request)
106
+ .await?;
107
+ }
108
+ if validate_right_root && left_commit_id != right_commit_id {
109
+ reader
110
+ .validate_tree_rows_at_commit_against_changelog(right_commit_id, &scan_request)
111
+ .await?;
112
+ }
113
+ let mut raw_rows = Vec::with_capacity(tree_entries.len());
114
+ for tree_entry in tree_entries.into_iter() {
115
+ let before = tree_entry
116
+ .before
117
+ .map(|(key, value)| TrackedStateDiffRow::from_tree_entry(key, value));
118
+ let after = tree_entry
119
+ .after
120
+ .map(|(key, value)| TrackedStateDiffRow::from_tree_entry(key, value));
121
+ raw_rows.push((before, after));
122
+ }
123
+
124
+ let mut entries = Vec::with_capacity(raw_rows.len());
125
+ for (before, after) in raw_rows {
95
126
  let identity = match before.as_ref().or(after.as_ref()) {
96
- Some(row) => TrackedStateDiffIdentity::from_row(row)?,
127
+ Some(row) => TrackedStateDiffIdentity::from(row),
97
128
  None => continue,
98
129
  };
130
+ if identity.schema_key == "lix_commit" {
131
+ continue;
132
+ }
99
133
  let Some(entry) = classify_diff(identity, before, after) else {
100
134
  continue;
101
135
  };
102
136
  entries.push(entry);
103
137
  }
104
138
 
105
- Ok(TrackedStateDiff { entries })
106
- }
107
-
108
- fn materialized_row_at(
109
- index: Option<usize>,
110
- rows: &[MaterializedTrackedStateRow],
111
- ) -> Result<Option<MaterializedTrackedStateRow>, LixError> {
112
- let Some(index) = index else {
113
- return Ok(None);
114
- };
115
- rows.get(index).cloned().map(Some).ok_or_else(|| {
116
- LixError::new(
117
- LixError::CODE_INTERNAL_ERROR,
118
- "tracked_state diff materialization returned fewer rows than planned",
119
- )
120
- })
121
- }
122
-
123
- struct PendingDiffEntry {
124
- before_index: Option<usize>,
125
- after_index: Option<usize>,
139
+ let diff = TrackedStateDiff { entries };
140
+ Ok(diff)
126
141
  }
127
142
 
128
143
  fn scan_request_for_diff(request: &TrackedStateDiffRequest) -> TrackedStateTreeScanRequest {
@@ -130,7 +145,7 @@ fn scan_request_for_diff(request: &TrackedStateDiffRequest) -> TrackedStateTreeS
130
145
  filter.include_tombstones = true;
131
146
  TrackedStateTreeScanRequest {
132
147
  schema_keys: filter.schema_keys,
133
- entity_ids: filter.entity_ids,
148
+ entity_pks: filter.entity_pks,
134
149
  file_ids: filter.file_ids,
135
150
  include_tombstones: true,
136
151
  limit: None,
@@ -139,8 +154,8 @@ fn scan_request_for_diff(request: &TrackedStateDiffRequest) -> TrackedStateTreeS
139
154
 
140
155
  fn classify_diff(
141
156
  identity: TrackedStateDiffIdentity,
142
- before: Option<MaterializedTrackedStateRow>,
143
- after: Option<MaterializedTrackedStateRow>,
157
+ before: Option<TrackedStateDiffRow>,
158
+ after: Option<TrackedStateDiffRow>,
144
159
  ) -> Option<TrackedStateDiffEntry> {
145
160
  match (is_live_row(before.as_ref()), is_live_row(after.as_ref())) {
146
161
  (None, None) => None,
@@ -166,24 +181,57 @@ fn classify_diff(
166
181
  }
167
182
  }
168
183
 
169
- fn is_live_row(row: Option<&MaterializedTrackedStateRow>) -> Option<&MaterializedTrackedStateRow> {
170
- row.filter(|row| row.snapshot_content.is_some())
184
+ fn is_live_row(row: Option<&TrackedStateDiffRow>) -> Option<&TrackedStateDiffRow> {
185
+ row.filter(|row| !row.deleted)
171
186
  }
172
187
 
173
- fn tracked_row_payload_eq(
174
- left: &MaterializedTrackedStateRow,
175
- right: &MaterializedTrackedStateRow,
176
- ) -> bool {
177
- left.snapshot_content == right.snapshot_content && left.metadata == right.metadata
188
+ fn tracked_row_payload_eq(left: &TrackedStateDiffRow, right: &TrackedStateDiffRow) -> bool {
189
+ left.snapshot_ref == right.snapshot_ref && left.metadata_ref == right.metadata_ref
178
190
  }
179
191
 
180
192
  impl TrackedStateDiffIdentity {
181
- fn from_row(row: &MaterializedTrackedStateRow) -> Result<Self, LixError> {
182
- Ok(Self {
193
+ fn from(row: &TrackedStateDiffRow) -> Self {
194
+ Self {
183
195
  schema_key: row.schema_key.clone(),
184
- entity_id: row.entity_id.clone(),
196
+ entity_pk: row.entity_pk.clone(),
185
197
  file_id: row.file_id.clone(),
186
- })
198
+ }
199
+ }
200
+ }
201
+
202
+ impl TrackedStateDiffRow {
203
+ pub(crate) fn from_tree_entry(key: TrackedStateKey, value: TrackedStateIndexValue) -> Self {
204
+ Self {
205
+ entity_pk: key.entity_pk,
206
+ schema_key: key.schema_key,
207
+ file_id: key.file_id,
208
+ deleted: value.deleted,
209
+ snapshot_ref: value.snapshot_ref,
210
+ metadata_ref: value.metadata_ref,
211
+ created_at: value.created_at,
212
+ updated_at: value.updated_at,
213
+ change_id: value.change_id,
214
+ commit_id: value.commit_id,
215
+ }
216
+ }
217
+
218
+ pub(crate) fn into_index_entry(self) -> (TrackedStateKey, TrackedStateIndexValue) {
219
+ (
220
+ TrackedStateKey {
221
+ schema_key: self.schema_key,
222
+ file_id: self.file_id,
223
+ entity_pk: self.entity_pk,
224
+ },
225
+ TrackedStateIndexValue {
226
+ change_id: self.change_id,
227
+ commit_id: self.commit_id,
228
+ deleted: self.deleted,
229
+ snapshot_ref: self.snapshot_ref,
230
+ metadata_ref: self.metadata_ref,
231
+ created_at: self.created_at,
232
+ updated_at: self.updated_at,
233
+ },
234
+ )
187
235
  }
188
236
  }
189
237
 
@@ -199,35 +247,33 @@ impl TrackedStateDiffEntry {
199
247
  }
200
248
 
201
249
  #[cfg(test)]
202
- pub(crate) fn visible_before(&self) -> Option<&MaterializedTrackedStateRow> {
203
- self.before
204
- .as_ref()
205
- .filter(|row| row.snapshot_content.is_some())
250
+ pub(crate) fn visible_before(&self) -> Option<&TrackedStateDiffRow> {
251
+ self.before.as_ref().filter(|row| !row.deleted)
206
252
  }
207
253
 
208
254
  #[cfg(test)]
209
- pub(crate) fn visible_after(&self) -> Option<&MaterializedTrackedStateRow> {
210
- self.after
211
- .as_ref()
212
- .filter(|row| row.snapshot_content.is_some())
255
+ pub(crate) fn visible_after(&self) -> Option<&TrackedStateDiffRow> {
256
+ self.after.as_ref().filter(|row| !row.deleted)
213
257
  }
214
258
  }
215
259
 
216
260
  #[cfg(test)]
217
261
  mod tests {
218
- use std::sync::Arc;
219
-
220
262
  use super::*;
221
- use crate::backend::testing::UnitTestBackend;
222
- use crate::storage::{StorageContext, StorageWriteTransaction};
223
- use crate::tracked_state::TrackedStateContext;
263
+ use crate::storage::StorageContext;
264
+ use crate::storage::{InMemoryStorageBackend, StorageReadOptions, StorageWriteOptions};
265
+ use crate::tracked_state::types::{
266
+ TrackedStateCommitRoot, TrackedStateCommitRootParent, TrackedStateMutation,
267
+ TrackedStateRootId,
268
+ };
269
+ use crate::tracked_state::{MaterializedTrackedStateRow, TrackedStateContext};
224
270
  use crate::NullableKeyFilter;
225
271
 
226
272
  #[tokio::test]
227
273
  async fn diff_commits_reports_added_rows() {
228
274
  let (storage, tracked_state) = seed_roots(&[], &[row("entity-a", None, "after")]).await;
229
275
 
230
- let diff = diff(storage.clone(), &tracked_state).await;
276
+ let diff = diff(&storage, &tracked_state).await;
231
277
 
232
278
  assert_eq!(
233
279
  kinds(&diff),
@@ -249,7 +295,7 @@ mod tests {
249
295
  async fn diff_commits_reports_removed_rows_when_right_side_is_absent() {
250
296
  let (storage, tracked_state) = seed_roots(&[row("entity-a", None, "before")], &[]).await;
251
297
 
252
- let diff = diff(storage.clone(), &tracked_state).await;
298
+ let diff = diff(&storage, &tracked_state).await;
253
299
 
254
300
  assert_eq!(
255
301
  kinds(&diff),
@@ -275,7 +321,7 @@ mod tests {
275
321
  )
276
322
  .await;
277
323
 
278
- let diff = diff(storage.clone(), &tracked_state).await;
324
+ let diff = diff(&storage, &tracked_state).await;
279
325
 
280
326
  assert_eq!(
281
327
  kinds(&diff),
@@ -287,10 +333,7 @@ mod tests {
287
333
  Some("delete")
288
334
  );
289
335
  assert!(
290
- entry
291
- .after
292
- .as_ref()
293
- .is_some_and(|row| row.snapshot_content.is_none()),
336
+ entry.after.as_ref().is_some_and(|row| row.deleted),
294
337
  "removed diff should preserve the right-side tombstone for merge"
295
338
  );
296
339
  assert!(entry.before_is_live());
@@ -305,7 +348,7 @@ mod tests {
305
348
  )
306
349
  .await;
307
350
 
308
- let diff = diff(storage.clone(), &tracked_state).await;
351
+ let diff = diff(&storage, &tracked_state).await;
309
352
 
310
353
  assert_eq!(
311
354
  kinds(&diff),
@@ -317,10 +360,7 @@ mod tests {
317
360
  Some("delete")
318
361
  );
319
362
  assert!(
320
- entry
321
- .before
322
- .as_ref()
323
- .is_some_and(|row| row.snapshot_content.is_none()),
363
+ entry.before.as_ref().is_some_and(|row| row.deleted),
324
364
  "added diff should preserve the left-side tombstone for merge"
325
365
  );
326
366
  assert!(!entry.before_is_live());
@@ -335,7 +375,7 @@ mod tests {
335
375
  )
336
376
  .await;
337
377
 
338
- let diff = diff(storage.clone(), &tracked_state).await;
378
+ let diff = diff(&storage, &tracked_state).await;
339
379
 
340
380
  assert_eq!(
341
381
  kinds(&diff),
@@ -353,23 +393,27 @@ mod tests {
353
393
  )
354
394
  .await;
355
395
 
356
- let diff = diff(storage.clone(), &tracked_state).await;
396
+ let diff = diff(&storage, &tracked_state).await;
357
397
 
358
398
  assert!(diff.entries.is_empty());
359
399
  }
360
400
 
361
401
  #[tokio::test]
362
402
  async fn diff_commits_distinguishes_same_entity_with_different_file_id() {
363
- let (storage, tracked_state) = seed_roots(
403
+ let (storage, tracked_state) = seed_parent_child_delta(
364
404
  &[row("entity-a", Some("file-a"), "before-a")],
365
- &[
366
- row("entity-a", Some("file-a"), "before-a"),
367
- row("entity-a", Some("file-b"), "after-b"),
368
- ],
405
+ &[row("entity-a", Some("file-b"), "after-b")],
369
406
  )
370
407
  .await;
371
408
 
372
- let diff = diff(storage.clone(), &tracked_state).await;
409
+ let read = storage
410
+ .begin_read(StorageReadOptions::default())
411
+ .expect("read should open");
412
+ let diff = tracked_state
413
+ .reader(read)
414
+ .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
415
+ .await
416
+ .expect("diff should load");
373
417
 
374
418
  assert_eq!(diff.entries.len(), 1);
375
419
  assert_eq!(diff.entries[0].identity.file_id.as_deref(), Some("file-b"));
@@ -386,7 +430,10 @@ mod tests {
386
430
  ],
387
431
  )
388
432
  .await;
389
- let mut reader = tracked_state.reader(storage.clone());
433
+ let read = storage
434
+ .begin_read(StorageReadOptions::default())
435
+ .expect("read should open");
436
+ let mut reader = tracked_state.reader(read);
390
437
  let diff = reader
391
438
  .diff_commits(
392
439
  "left",
@@ -394,9 +441,7 @@ mod tests {
394
441
  &TrackedStateDiffRequest {
395
442
  filter: TrackedStateFilter {
396
443
  schema_keys: vec!["schema-b".to_string()],
397
- entity_ids: vec![crate::entity_identity::EntityIdentity::single(
398
- "entity-b",
399
- )],
444
+ entity_pks: vec![crate::entity_pk::EntityPk::single("entity-b")],
400
445
  file_ids: vec![NullableKeyFilter::Value("file-b".to_string())],
401
446
  ..Default::default()
402
447
  },
@@ -412,200 +457,1492 @@ mod tests {
412
457
  }
413
458
 
414
459
  #[tokio::test]
415
- async fn diff_commits_between_delta_parent_and_child_reports_suffix_rows() {
416
- let backend = Arc::new(UnitTestBackend::new());
417
- let storage = StorageContext::new(backend.clone());
460
+ async fn diff_validation_rejects_row_identity_that_does_not_match_changelog_change() {
461
+ let (storage, tracked_state) = seed_roots(&[], &[row("entity-a", None, "after")]).await;
462
+ let mut diff = diff(&storage, &tracked_state).await;
463
+ diff.entries[0].after.as_mut().expect("after row").entity_pk =
464
+ EntityPk::single("entity-corrupt");
465
+
466
+ let read = storage
467
+ .begin_read(StorageReadOptions::default())
468
+ .expect("read should open");
469
+ let error = tracked_state
470
+ .reader(read)
471
+ .validate_diff_rows_for_commits_against_changelog(&[(
472
+ diff.entries[0].after.as_ref().expect("after row"),
473
+ "right",
474
+ )])
475
+ .await
476
+ .expect_err("identity drift must be rejected");
477
+
478
+ assert!(
479
+ error
480
+ .message
481
+ .contains("does not match changelog change identity")
482
+ || error.message.contains("changelog commit"),
483
+ "unexpected error: {error}"
484
+ );
485
+ }
486
+
487
+ #[tokio::test]
488
+ async fn diff_validation_rejects_missing_changelog_change() {
489
+ let (storage, tracked_state) = seed_roots(&[], &[row("entity-a", None, "after")]).await;
490
+ let mut diff = diff(&storage, &tracked_state).await;
491
+ diff.entries[0].after.as_mut().expect("after row").change_id = "missing-change".to_string();
492
+
493
+ let read = storage
494
+ .begin_read(StorageReadOptions::default())
495
+ .expect("read should open");
496
+ let error = tracked_state
497
+ .reader(read)
498
+ .validate_diff_rows_for_commits_against_changelog(&[(
499
+ diff.entries[0].after.as_ref().expect("after row"),
500
+ "right",
501
+ )])
502
+ .await
503
+ .expect_err("missing change must be rejected");
504
+
505
+ assert!(
506
+ error.message.contains("missing changelog change"),
507
+ "unexpected error: {error}"
508
+ );
509
+ }
510
+
511
+ #[tokio::test]
512
+ async fn diff_validation_rejects_forged_updated_at() {
513
+ let (storage, tracked_state) = seed_roots(&[], &[row("entity-a", None, "after")]).await;
514
+ let mut diff = diff(&storage, &tracked_state).await;
515
+ diff.entries[0]
516
+ .after
517
+ .as_mut()
518
+ .expect("after row")
519
+ .updated_at = "2026-01-02T00:00:00Z".to_string();
520
+
521
+ let read = storage
522
+ .begin_read(StorageReadOptions::default())
523
+ .expect("read should open");
524
+ let error = tracked_state
525
+ .reader(read)
526
+ .validate_diff_rows_for_commits_against_changelog(&[(
527
+ diff.entries[0].after.as_ref().expect("after row"),
528
+ "right",
529
+ )])
530
+ .await
531
+ .expect_err("forged updated_at must be rejected");
532
+
533
+ assert!(
534
+ error.message.contains("updated_at does not match"),
535
+ "unexpected error: {error}"
536
+ );
537
+ }
538
+
539
+ #[tokio::test]
540
+ async fn diff_validation_rejects_forged_created_at() {
541
+ let (storage, tracked_state) = seed_roots(&[], &[row("entity-a", None, "after")]).await;
542
+ let mut diff = diff(&storage, &tracked_state).await;
543
+ diff.entries[0]
544
+ .after
545
+ .as_mut()
546
+ .expect("after row")
547
+ .created_at = "2025-12-31T00:00:00Z".to_string();
548
+
549
+ let read = storage
550
+ .begin_read(StorageReadOptions::default())
551
+ .expect("read should open");
552
+ let error = tracked_state
553
+ .reader(read)
554
+ .validate_diff_rows_for_commits_against_changelog(&[(
555
+ diff.entries[0].after.as_ref().expect("after row"),
556
+ "right",
557
+ )])
558
+ .await
559
+ .expect_err("forged created_at must be rejected");
560
+
561
+ assert!(
562
+ error.message.contains("created_at"),
563
+ "unexpected error: {error}"
564
+ );
565
+ }
566
+
567
+ #[tokio::test]
568
+ async fn diff_commits_rejects_update_with_arbitrary_forged_created_at() {
569
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
418
570
  let tracked_state = TrackedStateContext::new();
419
- let mut tx = storage
420
- .begin_write_transaction()
571
+ write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
421
572
  .await
422
- .expect("transaction should open");
423
- write_root_for_test(
424
- tx.as_mut(),
573
+ .expect("left root should write");
574
+ write_root_committed_for_test(
575
+ &storage,
425
576
  &tracked_state,
426
577
  "parent",
427
578
  None,
428
- &[
429
- row_with_value("entity-a", None, "parent-a", "before"),
430
- row_with_value("entity-b", None, "parent-b", "same"),
431
- ],
579
+ &[row_with_times(
580
+ "entity-a",
581
+ None,
582
+ "parent-change",
583
+ "old",
584
+ "2026-01-01T00:00:00Z",
585
+ "2026-01-01T00:00:00Z",
586
+ )],
432
587
  )
433
588
  .await
434
- .expect("parent should write");
435
- write_root_for_test(
436
- tx.as_mut(),
589
+ .expect("parent root should write");
590
+ write_root_committed_for_test(
591
+ &storage,
437
592
  &tracked_state,
438
593
  "child",
439
594
  Some("parent"),
440
- &[row_with_value("entity-a", None, "child-a", "after")],
595
+ &[row_with_times(
596
+ "entity-a",
597
+ None,
598
+ "child-change",
599
+ "new",
600
+ "2026-01-02T00:00:00Z",
601
+ "2026-01-02T00:00:00Z",
602
+ )],
441
603
  )
442
604
  .await
443
- .expect("child should write");
444
- tx.commit().await.expect("transaction should commit");
605
+ .expect("child root should write");
445
606
 
446
- let diff = tracked_state
447
- .reader(storage)
448
- .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
607
+ let read = storage
608
+ .begin_read(StorageReadOptions::default())
609
+ .expect("read should open");
610
+ let valid_diff = tracked_state
611
+ .reader(read)
612
+ .diff_commits("left", "child", &TrackedStateDiffRequest::default())
449
613
  .await
450
- .expect("diff should load");
614
+ .expect("valid update should load");
615
+ let row = valid_diff
616
+ .entries
617
+ .iter()
618
+ .find_map(|entry| entry.after.clone())
619
+ .expect("child row should appear");
620
+ let (key, mut value) = row.into_index_entry();
621
+ value.created_at = "2026-01-03T00:00:00Z".to_string();
622
+ let parent_commit_row =
623
+ commit_root_row_entry("parent", "parent:commit", "2026-01-01T00:00:00Z");
624
+ let commit_row = commit_root_row_entry("child", "child:commit", "2026-01-02T00:00:00Z");
625
+ stage_corrupt_commit_root(
626
+ &storage,
627
+ "child",
628
+ vec![(key, value), parent_commit_row, commit_row],
629
+ vec![TrackedStateCommitRootParent {
630
+ commit_id: "parent".to_string(),
631
+ root_id: tracked_state_root_id(&storage, "parent").await,
632
+ }],
633
+ )
634
+ .await;
451
635
 
452
- assert_eq!(
453
- kinds(&diff),
454
- vec![("entity-a".to_string(), TrackedStateDiffKind::Modified)]
455
- );
456
- assert_eq!(
457
- diff.entries[0]
458
- .before
459
- .as_ref()
460
- .and_then(|row| row.snapshot_content.as_deref()),
461
- Some("{\"value\":\"before\"}")
462
- );
463
- assert_eq!(
464
- diff.entries[0]
465
- .after
466
- .as_ref()
467
- .and_then(|row| row.snapshot_content.as_deref()),
468
- Some("{\"value\":\"after\"}")
636
+ let read = storage
637
+ .begin_read(StorageReadOptions::default())
638
+ .expect("read should open");
639
+ let error = tracked_state
640
+ .reader(read)
641
+ .diff_commits("left", "child", &TrackedStateDiffRequest::default())
642
+ .await
643
+ .expect_err("arbitrary forged created_at must be rejected");
644
+
645
+ assert!(
646
+ error.message.contains("created_at"),
647
+ "unexpected error: {error}"
469
648
  );
470
649
  }
471
650
 
472
651
  #[tokio::test]
473
- async fn diff_commits_between_delta_child_and_parent_reports_reverse_suffix_rows() {
474
- let (storage, tracked_state) = seed_parent_child_delta(
475
- &[
476
- row_with_value("entity-a", None, "parent-a", "before"),
477
- row_with_value("entity-b", None, "parent-b", "same"),
478
- ],
479
- &[row_with_value("entity-a", None, "child-a", "after")],
652
+ async fn diff_commits_validates_same_payload_rows_before_classification_drops_them() {
653
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
654
+ let tracked_state = TrackedStateContext::new();
655
+ write_root_committed_for_test(
656
+ &storage,
657
+ &tracked_state,
658
+ "left",
659
+ None,
660
+ &[row_with_value("entity-a", None, "left-a", "same")],
480
661
  )
481
- .await;
662
+ .await
663
+ .expect("left root should write");
664
+ write_root_committed_for_test(
665
+ &storage,
666
+ &tracked_state,
667
+ "right-valid",
668
+ None,
669
+ &[row_with_value("entity-b", None, "right-b", "same")],
670
+ )
671
+ .await
672
+ .expect("right changelog should write");
482
673
 
483
- let diff = tracked_state
484
- .reader(storage)
485
- .diff_commits("child", "parent", &TrackedStateDiffRequest::default())
674
+ let read = storage
675
+ .begin_read(StorageReadOptions::default())
676
+ .expect("read should open");
677
+ let valid_diff = tracked_state
678
+ .reader(read)
679
+ .diff_commits("left", "right-valid", &TrackedStateDiffRequest::default())
486
680
  .await
487
- .expect("diff should load");
681
+ .expect("valid diff should load");
682
+ let source_row = valid_diff
683
+ .entries
684
+ .iter()
685
+ .find_map(|entry| entry.after.clone())
686
+ .expect("right row should appear in valid diff");
687
+ let (_source_key, source_value) = source_row.into_index_entry();
688
+ let corrupt_key = TrackedStateKey {
689
+ schema_key: "test_schema".to_string(),
690
+ file_id: None,
691
+ entity_pk: EntityPk::single("entity-a"),
692
+ };
693
+ let result = {
694
+ let mut read = storage
695
+ .begin_read(StorageReadOptions::default())
696
+ .expect("read should open");
697
+ let mut writes = storage.new_write_set();
698
+ let result = crate::tracked_state::tree::TrackedStateTree::new()
699
+ .apply_mutations(
700
+ &mut read,
701
+ &mut writes,
702
+ None,
703
+ vec![TrackedStateMutation::put_encoded(
704
+ crate::tracked_state::codec::encode_key(&corrupt_key),
705
+ crate::tracked_state::codec::encode_value(&source_value),
706
+ )],
707
+ Some("right-corrupt"),
708
+ )
709
+ .await
710
+ .expect("corrupt root should write");
711
+ crate::tracked_state::storage::stage_commit_root(
712
+ &mut writes,
713
+ &TrackedStateCommitRoot {
714
+ commit_id: "right-corrupt".to_string(),
715
+ root_id: result.root_id.clone(),
716
+ parent_roots: Vec::new(),
717
+ changed_key_count: 1,
718
+ row_count_estimate: result.row_count as u64,
719
+ tree_height: result.tree_height as u32,
720
+ primary_chunk_count: result.chunk_count as u64,
721
+ primary_chunk_bytes: result.chunk_bytes as u64,
722
+ },
723
+ )
724
+ .expect("metadata should encode");
725
+ storage
726
+ .commit_write_set(writes, StorageWriteOptions::default())
727
+ .expect("corrupt root should commit");
728
+ result
729
+ };
730
+ assert_eq!(result.row_count, 1);
488
731
 
489
- assert_eq!(
490
- kinds(&diff),
491
- vec![("entity-a".to_string(), TrackedStateDiffKind::Modified)]
492
- );
493
- assert_eq!(
494
- diff.entries[0]
495
- .before
496
- .as_ref()
497
- .and_then(|row| row.snapshot_content.as_deref()),
498
- Some("{\"value\":\"after\"}")
499
- );
500
- assert_eq!(
501
- diff.entries[0]
502
- .after
503
- .as_ref()
504
- .and_then(|row| row.snapshot_content.as_deref()),
505
- Some("{\"value\":\"before\"}")
732
+ let read = storage
733
+ .begin_read(StorageReadOptions::default())
734
+ .expect("read should open");
735
+ let error = tracked_state
736
+ .reader(read)
737
+ .diff_commits("left", "right-corrupt", &TrackedStateDiffRequest::default())
738
+ .await
739
+ .expect_err("raw same-payload corruption must be rejected before classification");
740
+
741
+ assert!(
742
+ error
743
+ .message
744
+ .contains("does not match changelog change identity")
745
+ || error.message.contains("changelog commit"),
746
+ "unexpected error: {error}"
506
747
  );
507
748
  }
508
749
 
509
750
  #[tokio::test]
510
- async fn diff_commits_between_delta_parent_and_child_preserves_suffix_tombstones() {
511
- let (storage, tracked_state) = seed_parent_child_delta(
512
- &[
513
- row_with_value("entity-a", None, "parent-a", "before"),
514
- row_with_value("entity-b", None, "parent-b", "same"),
515
- ],
516
- &[tombstone("entity-a", None, "child-delete")],
751
+ async fn diff_commits_rejects_stale_ancestor_row_that_is_not_root_winner() {
752
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
753
+ let tracked_state = TrackedStateContext::new();
754
+ write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
755
+ .await
756
+ .expect("left root should write");
757
+ write_root_committed_for_test(
758
+ &storage,
759
+ &tracked_state,
760
+ "parent",
761
+ None,
762
+ &[row_with_value("entity-a", None, "parent-change", "old")],
763
+ )
764
+ .await
765
+ .expect("parent root should write");
766
+ write_root_committed_for_test(
767
+ &storage,
768
+ &tracked_state,
769
+ "child",
770
+ Some("parent"),
771
+ &[row_with_value("entity-a", None, "child-change", "new")],
772
+ )
773
+ .await
774
+ .expect("child root should write");
775
+
776
+ let read = storage
777
+ .begin_read(StorageReadOptions::default())
778
+ .expect("read should open");
779
+ let parent_diff = tracked_state
780
+ .reader(read)
781
+ .diff_commits("left", "parent", &TrackedStateDiffRequest::default())
782
+ .await
783
+ .expect("parent diff should load");
784
+ let stale_row = parent_diff
785
+ .entries
786
+ .iter()
787
+ .find_map(|entry| entry.after.clone())
788
+ .expect("parent row should appear");
789
+ let (stale_key, stale_value) = stale_row.into_index_entry();
790
+ stage_corrupt_commit_root(
791
+ &storage,
792
+ "child",
793
+ vec![(stale_key, stale_value)],
794
+ vec![TrackedStateCommitRootParent {
795
+ commit_id: "parent".to_string(),
796
+ root_id: tracked_state_root_id(&storage, "parent").await,
797
+ }],
517
798
  )
518
799
  .await;
519
800
 
520
- let diff = tracked_state
521
- .reader(storage)
522
- .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
801
+ let read = storage
802
+ .begin_read(StorageReadOptions::default())
803
+ .expect("read should open");
804
+ let error = tracked_state
805
+ .reader(read)
806
+ .diff_commits("left", "child", &TrackedStateDiffRequest::default())
523
807
  .await
524
- .expect("diff should load");
808
+ .expect_err("stale ancestor winner must be rejected");
525
809
 
526
- assert_eq!(
527
- kinds(&diff),
528
- vec![("entity-a".to_string(), TrackedStateDiffKind::Removed)]
529
- );
530
- assert!(diff.entries[0].before_is_live());
531
- assert!(!diff.entries[0].after_is_live());
532
- assert_eq!(
533
- diff.entries[0]
534
- .after
535
- .as_ref()
536
- .map(|row| row.change_id.as_str()),
537
- Some("child-delete")
810
+ assert!(
811
+ is_commit_root_validation_error(&error),
812
+ "unexpected error: {error}"
538
813
  );
539
814
  }
540
815
 
541
- async fn diff(
542
- storage: StorageContext,
543
- tracked_state: &TrackedStateContext,
544
- ) -> TrackedStateDiff {
545
- tracked_state
546
- .reader(storage)
547
- .diff_commits("left", "right", &TrackedStateDiffRequest::default())
816
+ #[tokio::test]
817
+ async fn diff_commits_rejects_valid_change_from_unreachable_commit_root() {
818
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
819
+ let tracked_state = TrackedStateContext::new();
820
+ write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
548
821
  .await
549
- .expect("diff should load")
550
- }
822
+ .expect("left root should write");
823
+ write_root_committed_for_test(
824
+ &storage,
825
+ &tracked_state,
826
+ "unrelated",
827
+ None,
828
+ &[row_with_value(
829
+ "entity-a",
830
+ None,
831
+ "unrelated-change",
832
+ "value",
833
+ )],
834
+ )
835
+ .await
836
+ .expect("unrelated changelog should write");
551
837
 
552
- async fn seed_roots(
553
- left_rows: &[MaterializedTrackedStateRow],
554
- right_rows: &[MaterializedTrackedStateRow],
555
- ) -> (StorageContext, TrackedStateContext) {
556
- let backend = Arc::new(UnitTestBackend::new());
557
- let storage = StorageContext::new(backend.clone());
558
- let tracked_state = TrackedStateContext::new();
559
- let mut tx = storage
560
- .begin_write_transaction()
838
+ let read = storage
839
+ .begin_read(StorageReadOptions::default())
840
+ .expect("read should open");
841
+ let unrelated_diff = tracked_state
842
+ .reader(read)
843
+ .diff_commits("left", "unrelated", &TrackedStateDiffRequest::default())
561
844
  .await
562
- .expect("transaction should open");
563
- write_root_for_test(tx.as_mut(), &tracked_state, "left", None, left_rows)
845
+ .expect("valid unrelated diff should load");
846
+ let source_row = unrelated_diff
847
+ .entries
848
+ .iter()
849
+ .find_map(|entry| entry.after.clone())
850
+ .expect("unrelated row should appear in valid diff");
851
+ let (source_key, source_value) = source_row.into_index_entry();
852
+
853
+ let result = {
854
+ let mut read = storage
855
+ .begin_read(StorageReadOptions::default())
856
+ .expect("read should open");
857
+ let mut writes = storage.new_write_set();
858
+ crate::test_support::stage_empty_changelog_commit(
859
+ &mut read,
860
+ &mut writes,
861
+ "right-corrupt",
862
+ None,
863
+ )
564
864
  .await
565
- .expect("left root should write");
566
- write_root_for_test(tx.as_mut(), &tracked_state, "right", None, right_rows)
865
+ .expect("empty right changelog should write");
866
+ let result = crate::tracked_state::tree::TrackedStateTree::new()
867
+ .apply_mutations(
868
+ &mut read,
869
+ &mut writes,
870
+ None,
871
+ vec![TrackedStateMutation::put_encoded(
872
+ crate::tracked_state::codec::encode_key(&source_key),
873
+ crate::tracked_state::codec::encode_value(&source_value),
874
+ )],
875
+ Some("right-corrupt"),
876
+ )
877
+ .await
878
+ .expect("corrupt root should write");
879
+ crate::tracked_state::storage::stage_commit_root(
880
+ &mut writes,
881
+ &TrackedStateCommitRoot {
882
+ commit_id: "right-corrupt".to_string(),
883
+ root_id: result.root_id.clone(),
884
+ parent_roots: Vec::new(),
885
+ changed_key_count: 1,
886
+ row_count_estimate: result.row_count as u64,
887
+ tree_height: result.tree_height as u32,
888
+ primary_chunk_count: result.chunk_count as u64,
889
+ primary_chunk_bytes: result.chunk_bytes as u64,
890
+ },
891
+ )
892
+ .expect("metadata should encode");
893
+ storage
894
+ .commit_write_set(writes, StorageWriteOptions::default())
895
+ .expect("corrupt root should commit");
896
+ result
897
+ };
898
+ assert_eq!(result.row_count, 1);
899
+
900
+ let read = storage
901
+ .begin_read(StorageReadOptions::default())
902
+ .expect("read should open");
903
+ let error = tracked_state
904
+ .reader(read)
905
+ .diff_commits("left", "right-corrupt", &TrackedStateDiffRequest::default())
567
906
  .await
568
- .expect("right root should write");
569
- tx.commit().await.expect("transaction should commit");
570
- (storage, tracked_state)
907
+ .expect_err("unreachable valid change must be rejected");
908
+
909
+ assert!(
910
+ is_commit_root_validation_error(&error),
911
+ "unexpected error: {error}"
912
+ );
571
913
  }
572
914
 
573
- async fn seed_parent_child_delta(
574
- parent_rows: &[MaterializedTrackedStateRow],
575
- child_rows: &[MaterializedTrackedStateRow],
576
- ) -> (StorageContext, TrackedStateContext) {
577
- let backend = Arc::new(UnitTestBackend::new());
578
- let storage = StorageContext::new(backend.clone());
915
+ #[tokio::test]
916
+ async fn diff_commits_rejects_second_parent_row_without_commit_root_proof() {
917
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
579
918
  let tracked_state = TrackedStateContext::new();
580
- let mut tx = storage
581
- .begin_write_transaction()
919
+ write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
582
920
  .await
583
- .expect("transaction should open");
584
- write_root_for_test(tx.as_mut(), &tracked_state, "parent", None, parent_rows)
921
+ .expect("left root should write");
922
+ write_root_committed_for_test(&storage, &tracked_state, "target", None, &[])
585
923
  .await
586
- .expect("parent should write");
587
- write_root_for_test(
588
- tx.as_mut(),
924
+ .expect("target root should write");
925
+ write_root_committed_for_test(
926
+ &storage,
589
927
  &tracked_state,
590
- "child",
591
- Some("parent"),
592
- child_rows,
928
+ "source",
929
+ None,
930
+ &[row_with_value("entity-a", None, "source-change", "value")],
593
931
  )
594
932
  .await
595
- .expect("child should write");
596
- tx.commit().await.expect("transaction should commit");
597
- (storage, tracked_state)
598
- }
933
+ .expect("source root should write");
599
934
 
600
- async fn write_root_for_test(
601
- tx: &mut dyn StorageWriteTransaction,
602
- tracked_state: &TrackedStateContext,
603
- commit_id: &str,
604
- parent_commit_id: Option<&str>,
935
+ let read = storage
936
+ .begin_read(StorageReadOptions::default())
937
+ .expect("read should open");
938
+ let source_diff = tracked_state
939
+ .reader(read)
940
+ .diff_commits("left", "source", &TrackedStateDiffRequest::default())
941
+ .await
942
+ .expect("source diff should load");
943
+ let source_row = source_diff
944
+ .entries
945
+ .iter()
946
+ .find_map(|entry| entry.after.clone())
947
+ .expect("source row should appear");
948
+ let (source_key, source_value) = source_row.into_index_entry();
949
+
950
+ {
951
+ let mut read = storage
952
+ .begin_read(StorageReadOptions::default())
953
+ .expect("read should open");
954
+ let mut writes = storage.new_write_set();
955
+ crate::test_support::stage_empty_changelog_commit_with_parents(
956
+ &mut read,
957
+ &mut writes,
958
+ "merge",
959
+ &["target".to_string(), "source".to_string()],
960
+ )
961
+ .await
962
+ .expect("merge changelog should write");
963
+ storage
964
+ .commit_write_set(writes, StorageWriteOptions::default())
965
+ .expect("merge changelog should commit");
966
+ }
967
+ stage_corrupt_commit_root(
968
+ &storage,
969
+ "merge",
970
+ vec![(source_key, source_value)],
971
+ vec![TrackedStateCommitRootParent {
972
+ commit_id: "target".to_string(),
973
+ root_id: tracked_state_root_id(&storage, "target").await,
974
+ }],
975
+ )
976
+ .await;
977
+
978
+ let read = storage
979
+ .begin_read(StorageReadOptions::default())
980
+ .expect("read should open");
981
+ let error = tracked_state
982
+ .reader(read)
983
+ .diff_commits("left", "merge", &TrackedStateDiffRequest::default())
984
+ .await
985
+ .expect_err("second-parent row without commit-root proof must be rejected");
986
+
987
+ assert!(
988
+ is_commit_root_validation_error(&error),
989
+ "unexpected error: {error}"
990
+ );
991
+ }
992
+
993
+ #[tokio::test]
994
+ async fn diff_commits_rejects_second_parent_row_with_forged_commit_root_parent() {
995
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
996
+ let tracked_state = TrackedStateContext::new();
997
+ write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
998
+ .await
999
+ .expect("left root should write");
1000
+ write_root_committed_for_test(&storage, &tracked_state, "target", None, &[])
1001
+ .await
1002
+ .expect("target root should write");
1003
+ write_root_committed_for_test(
1004
+ &storage,
1005
+ &tracked_state,
1006
+ "source",
1007
+ None,
1008
+ &[row_with_value("entity-a", None, "source-change", "value")],
1009
+ )
1010
+ .await
1011
+ .expect("source root should write");
1012
+
1013
+ let read = storage
1014
+ .begin_read(StorageReadOptions::default())
1015
+ .expect("read should open");
1016
+ let source_diff = tracked_state
1017
+ .reader(read)
1018
+ .diff_commits("left", "source", &TrackedStateDiffRequest::default())
1019
+ .await
1020
+ .expect("source diff should load");
1021
+ let source_row = source_diff
1022
+ .entries
1023
+ .iter()
1024
+ .find_map(|entry| entry.after.clone())
1025
+ .expect("source row should appear");
1026
+ let (source_key, source_value) = source_row.into_index_entry();
1027
+
1028
+ {
1029
+ let mut read = storage
1030
+ .begin_read(StorageReadOptions::default())
1031
+ .expect("read should open");
1032
+ let mut writes = storage.new_write_set();
1033
+ crate::test_support::stage_empty_changelog_commit_with_parents(
1034
+ &mut read,
1035
+ &mut writes,
1036
+ "merge",
1037
+ &["target".to_string(), "source".to_string()],
1038
+ )
1039
+ .await
1040
+ .expect("merge changelog should write");
1041
+ storage
1042
+ .commit_write_set(writes, StorageWriteOptions::default())
1043
+ .expect("merge changelog should commit");
1044
+ }
1045
+ stage_corrupt_commit_root(
1046
+ &storage,
1047
+ "merge",
1048
+ vec![(source_key, source_value)],
1049
+ vec![TrackedStateCommitRootParent {
1050
+ commit_id: "source".to_string(),
1051
+ root_id: tracked_state_root_id(&storage, "source").await,
1052
+ }],
1053
+ )
1054
+ .await;
1055
+
1056
+ let read = storage
1057
+ .begin_read(StorageReadOptions::default())
1058
+ .expect("read should open");
1059
+ let error = tracked_state
1060
+ .reader(read)
1061
+ .diff_commits("left", "merge", &TrackedStateDiffRequest::default())
1062
+ .await
1063
+ .expect_err("forged source parent must be rejected");
1064
+
1065
+ assert!(
1066
+ is_commit_root_validation_error(&error),
1067
+ "unexpected error: {error}"
1068
+ );
1069
+ }
1070
+
1071
+ #[tokio::test]
1072
+ async fn diff_commits_rejects_unrelated_row_with_forged_commit_root_parent() {
1073
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1074
+ let tracked_state = TrackedStateContext::new();
1075
+ write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
1076
+ .await
1077
+ .expect("left root should write");
1078
+ write_root_committed_for_test(
1079
+ &storage,
1080
+ &tracked_state,
1081
+ "source",
1082
+ None,
1083
+ &[row_with_value("entity-a", None, "source-change", "value")],
1084
+ )
1085
+ .await
1086
+ .expect("source root should write");
1087
+
1088
+ let read = storage
1089
+ .begin_read(StorageReadOptions::default())
1090
+ .expect("read should open");
1091
+ let source_diff = tracked_state
1092
+ .reader(read)
1093
+ .diff_commits("left", "source", &TrackedStateDiffRequest::default())
1094
+ .await
1095
+ .expect("source diff should load");
1096
+ let source_row = source_diff
1097
+ .entries
1098
+ .iter()
1099
+ .find_map(|entry| entry.after.clone())
1100
+ .expect("source row should appear");
1101
+ let (source_key, source_value) = source_row.into_index_entry();
1102
+
1103
+ {
1104
+ let mut read = storage
1105
+ .begin_read(StorageReadOptions::default())
1106
+ .expect("read should open");
1107
+ let mut writes = storage.new_write_set();
1108
+ crate::test_support::stage_empty_changelog_commit(
1109
+ &mut read,
1110
+ &mut writes,
1111
+ "right-corrupt",
1112
+ None,
1113
+ )
1114
+ .await
1115
+ .expect("empty right changelog should write");
1116
+ storage
1117
+ .commit_write_set(writes, StorageWriteOptions::default())
1118
+ .expect("right changelog should commit");
1119
+ }
1120
+ stage_corrupt_commit_root(
1121
+ &storage,
1122
+ "right-corrupt",
1123
+ vec![(source_key, source_value)],
1124
+ vec![TrackedStateCommitRootParent {
1125
+ commit_id: "source".to_string(),
1126
+ root_id: tracked_state_root_id(&storage, "source").await,
1127
+ }],
1128
+ )
1129
+ .await;
1130
+
1131
+ let read = storage
1132
+ .begin_read(StorageReadOptions::default())
1133
+ .expect("read should open");
1134
+ let error = tracked_state
1135
+ .reader(read)
1136
+ .diff_commits("left", "right-corrupt", &TrackedStateDiffRequest::default())
1137
+ .await
1138
+ .expect_err("forged unrelated parent must be rejected");
1139
+
1140
+ assert!(
1141
+ is_commit_root_validation_error(&error),
1142
+ "unexpected error: {error}"
1143
+ );
1144
+ }
1145
+
1146
+ #[tokio::test]
1147
+ async fn diff_commits_rejects_forged_parent_metadata_even_for_current_winner_rows() {
1148
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1149
+ let tracked_state = TrackedStateContext::new();
1150
+ write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
1151
+ .await
1152
+ .expect("left root should write");
1153
+ write_root_committed_for_test(&storage, &tracked_state, "target", None, &[])
1154
+ .await
1155
+ .expect("target root should write");
1156
+ write_root_committed_for_test(
1157
+ &storage,
1158
+ &tracked_state,
1159
+ "source",
1160
+ None,
1161
+ &[row_with_value("entity-b", None, "source-b", "source")],
1162
+ )
1163
+ .await
1164
+ .expect("source root should write");
1165
+ write_root_committed_for_test(
1166
+ &storage,
1167
+ &tracked_state,
1168
+ "child",
1169
+ Some("target"),
1170
+ &[row_with_value("entity-a", None, "child-a", "current")],
1171
+ )
1172
+ .await
1173
+ .expect("child root should write");
1174
+
1175
+ let read = storage
1176
+ .begin_read(StorageReadOptions::default())
1177
+ .expect("read should open");
1178
+ let child_diff = tracked_state
1179
+ .reader(read)
1180
+ .diff_commits("left", "child", &TrackedStateDiffRequest::default())
1181
+ .await
1182
+ .expect("child diff should load");
1183
+ let child_row = child_diff
1184
+ .entries
1185
+ .iter()
1186
+ .find_map(|entry| entry.after.clone())
1187
+ .expect("child row should appear");
1188
+ let (child_key, child_value) = child_row.into_index_entry();
1189
+
1190
+ stage_corrupt_commit_root(
1191
+ &storage,
1192
+ "child",
1193
+ vec![(child_key, child_value)],
1194
+ vec![TrackedStateCommitRootParent {
1195
+ commit_id: "source".to_string(),
1196
+ root_id: tracked_state_root_id(&storage, "source").await,
1197
+ }],
1198
+ )
1199
+ .await;
1200
+
1201
+ let read = storage
1202
+ .begin_read(StorageReadOptions::default())
1203
+ .expect("read should open");
1204
+ let error = tracked_state
1205
+ .reader(read)
1206
+ .diff_commits("left", "child", &TrackedStateDiffRequest::default())
1207
+ .await
1208
+ .expect_err("current winner root metadata must still be validated");
1209
+
1210
+ assert!(
1211
+ is_commit_root_validation_error(&error),
1212
+ "unexpected error: {error}"
1213
+ );
1214
+ }
1215
+
1216
+ #[tokio::test]
1217
+ async fn diff_commits_rejects_stale_grandparent_row_with_forged_commit_root_parent() {
1218
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1219
+ let tracked_state = TrackedStateContext::new();
1220
+ write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
1221
+ .await
1222
+ .expect("left root should write");
1223
+ write_root_committed_for_test(
1224
+ &storage,
1225
+ &tracked_state,
1226
+ "grandparent",
1227
+ None,
1228
+ &[row_with_value("entity-a", None, "grandparent-a", "old")],
1229
+ )
1230
+ .await
1231
+ .expect("grandparent root should write");
1232
+ write_root_committed_for_test(
1233
+ &storage,
1234
+ &tracked_state,
1235
+ "parent",
1236
+ Some("grandparent"),
1237
+ &[row_with_value("entity-a", None, "parent-a", "new")],
1238
+ )
1239
+ .await
1240
+ .expect("parent root should write");
1241
+ write_root_committed_for_test(&storage, &tracked_state, "child", Some("parent"), &[])
1242
+ .await
1243
+ .expect("child root should write");
1244
+
1245
+ let read = storage
1246
+ .begin_read(StorageReadOptions::default())
1247
+ .expect("read should open");
1248
+ let stale_diff = tracked_state
1249
+ .reader(read)
1250
+ .diff_commits("left", "grandparent", &TrackedStateDiffRequest::default())
1251
+ .await
1252
+ .expect("grandparent diff should load");
1253
+ let stale_row = stale_diff
1254
+ .entries
1255
+ .iter()
1256
+ .find_map(|entry| entry.after.clone())
1257
+ .expect("grandparent row should appear");
1258
+ let (stale_key, stale_value) = stale_row.into_index_entry();
1259
+
1260
+ stage_corrupt_commit_root(
1261
+ &storage,
1262
+ "child",
1263
+ vec![(stale_key, stale_value)],
1264
+ vec![TrackedStateCommitRootParent {
1265
+ commit_id: "grandparent".to_string(),
1266
+ root_id: tracked_state_root_id(&storage, "grandparent").await,
1267
+ }],
1268
+ )
1269
+ .await;
1270
+
1271
+ let read = storage
1272
+ .begin_read(StorageReadOptions::default())
1273
+ .expect("read should open");
1274
+ let error = tracked_state
1275
+ .reader(read)
1276
+ .diff_commits("left", "child", &TrackedStateDiffRequest::default())
1277
+ .await
1278
+ .expect_err("forged grandparent parent must be rejected");
1279
+
1280
+ assert!(
1281
+ is_commit_root_validation_error(&error),
1282
+ "unexpected error: {error}"
1283
+ );
1284
+ }
1285
+
1286
+ #[tokio::test]
1287
+ async fn diff_commits_allows_rows_reachable_through_parent_commit() {
1288
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1289
+ let tracked_state = TrackedStateContext::new();
1290
+ write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
1291
+ .await
1292
+ .expect("left root should write");
1293
+ write_root_committed_for_test(
1294
+ &storage,
1295
+ &tracked_state,
1296
+ "parent",
1297
+ None,
1298
+ &[row_with_value("entity-a", None, "parent-change", "value")],
1299
+ )
1300
+ .await
1301
+ .expect("parent root should write");
1302
+ write_root_committed_for_test(&storage, &tracked_state, "child", Some("parent"), &[])
1303
+ .await
1304
+ .expect("child root should write");
1305
+
1306
+ let read = storage
1307
+ .begin_read(StorageReadOptions::default())
1308
+ .expect("read should open");
1309
+ let diff = tracked_state
1310
+ .reader(read)
1311
+ .diff_commits("left", "child", &TrackedStateDiffRequest::default())
1312
+ .await
1313
+ .expect("ancestor-reachable row should validate");
1314
+
1315
+ assert_eq!(
1316
+ kinds(&diff),
1317
+ vec![("entity-a".to_string(), TrackedStateDiffKind::Added)]
1318
+ );
1319
+ }
1320
+
1321
+ #[tokio::test]
1322
+ async fn diff_commits_allows_source_update_with_source_created_at() {
1323
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1324
+ let tracked_state = TrackedStateContext::new();
1325
+ write_root_committed_for_test(&storage, &tracked_state, "target", None, &[])
1326
+ .await
1327
+ .expect("target root should write");
1328
+ write_root_committed_for_test(
1329
+ &storage,
1330
+ &tracked_state,
1331
+ "source-add",
1332
+ None,
1333
+ &[row_with_times(
1334
+ "entity-a",
1335
+ None,
1336
+ "source-add-a",
1337
+ "old",
1338
+ "2026-01-01T00:00:00Z",
1339
+ "2026-01-01T00:00:00Z",
1340
+ )],
1341
+ )
1342
+ .await
1343
+ .expect("source add root should write");
1344
+ let source_update = row_with_times(
1345
+ "entity-a",
1346
+ None,
1347
+ "source-update-a",
1348
+ "new",
1349
+ "2026-01-01T00:00:00Z",
1350
+ "2026-01-02T00:00:00Z",
1351
+ );
1352
+ write_root_committed_for_test(
1353
+ &storage,
1354
+ &tracked_state,
1355
+ "source-update",
1356
+ Some("source-add"),
1357
+ std::slice::from_ref(&source_update),
1358
+ )
1359
+ .await
1360
+ .expect("source update root should write");
1361
+ {
1362
+ let mut read = storage
1363
+ .begin_read(StorageReadOptions::default())
1364
+ .expect("read should open");
1365
+ let mut writes = storage.new_write_set();
1366
+ crate::test_support::stage_tracked_root_from_materialized_with_parents(
1367
+ &mut read,
1368
+ &mut writes,
1369
+ &tracked_state,
1370
+ "merge",
1371
+ &["target".to_string(), "source-update".to_string()],
1372
+ Some("target"),
1373
+ std::slice::from_ref(&source_update),
1374
+ )
1375
+ .await
1376
+ .expect("merge root should stage");
1377
+ storage
1378
+ .commit_write_set(writes, StorageWriteOptions::default())
1379
+ .expect("merge root should commit");
1380
+ }
1381
+
1382
+ let read = storage
1383
+ .begin_read(StorageReadOptions::default())
1384
+ .expect("read should open");
1385
+ let diff = tracked_state
1386
+ .reader(read)
1387
+ .diff_commits("target", "merge", &TrackedStateDiffRequest::default())
1388
+ .await
1389
+ .expect("source update should validate");
1390
+
1391
+ assert_eq!(
1392
+ kinds(&diff),
1393
+ vec![("entity-a".to_string(), TrackedStateDiffKind::Added)]
1394
+ );
1395
+ let row = diff.entries[0].after.as_ref().expect("after row");
1396
+ assert_eq!(row.created_at, "2026-01-01T00:00:00Z");
1397
+ assert_eq!(row.updated_at, "2026-01-02T00:00:00Z");
1398
+ assert_eq!(row.change_id, "source-update-a");
1399
+ }
1400
+
1401
+ #[tokio::test]
1402
+ async fn diff_commits_rejects_omitted_inherited_row_even_when_diff_is_non_empty() {
1403
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1404
+ let tracked_state = TrackedStateContext::new();
1405
+ write_root_committed_for_test(
1406
+ &storage,
1407
+ &tracked_state,
1408
+ "parent",
1409
+ None,
1410
+ &[row_with_value("entity-a", None, "parent-a", "inherited")],
1411
+ )
1412
+ .await
1413
+ .expect("parent root should write");
1414
+ write_root_committed_for_test(
1415
+ &storage,
1416
+ &tracked_state,
1417
+ "child",
1418
+ Some("parent"),
1419
+ &[row_with_value("entity-b", None, "child-b", "unrelated")],
1420
+ )
1421
+ .await
1422
+ .expect("child root should write");
1423
+
1424
+ let read = storage
1425
+ .begin_read(StorageReadOptions::default())
1426
+ .expect("read should open");
1427
+ let valid_diff = tracked_state
1428
+ .reader(read)
1429
+ .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
1430
+ .await
1431
+ .expect("valid child diff should load");
1432
+ let unrelated_row = valid_diff
1433
+ .entries
1434
+ .iter()
1435
+ .find_map(|entry| {
1436
+ entry
1437
+ .after
1438
+ .as_ref()
1439
+ .filter(|row| row.change_id == "child-b")
1440
+ .cloned()
1441
+ })
1442
+ .expect("unrelated child row should appear");
1443
+ let (unrelated_key, unrelated_value) = unrelated_row.into_index_entry();
1444
+ stage_corrupt_commit_root(
1445
+ &storage,
1446
+ "child",
1447
+ vec![(unrelated_key, unrelated_value)],
1448
+ vec![TrackedStateCommitRootParent {
1449
+ commit_id: "parent".to_string(),
1450
+ root_id: tracked_state_root_id(&storage, "parent").await,
1451
+ }],
1452
+ )
1453
+ .await;
1454
+
1455
+ let read = storage
1456
+ .begin_read(StorageReadOptions::default())
1457
+ .expect("read should open");
1458
+ let error = tracked_state
1459
+ .reader(read)
1460
+ .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
1461
+ .await
1462
+ .expect_err("omitted inherited row must be rejected");
1463
+
1464
+ assert!(
1465
+ is_commit_root_validation_error(&error),
1466
+ "unexpected error: {error}"
1467
+ );
1468
+ }
1469
+
1470
+ #[tokio::test]
1471
+ async fn diff_commits_rejects_omitted_updated_row_even_when_diff_is_non_empty() {
1472
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1473
+ let tracked_state = TrackedStateContext::new();
1474
+ write_root_committed_for_test(
1475
+ &storage,
1476
+ &tracked_state,
1477
+ "parent",
1478
+ None,
1479
+ &[row_with_value("entity-a", None, "parent-a", "old")],
1480
+ )
1481
+ .await
1482
+ .expect("parent root should write");
1483
+ write_root_committed_for_test(
1484
+ &storage,
1485
+ &tracked_state,
1486
+ "child",
1487
+ Some("parent"),
1488
+ &[
1489
+ row_with_value("entity-a", None, "child-a", "new"),
1490
+ row_with_value("entity-b", None, "child-b", "unrelated"),
1491
+ ],
1492
+ )
1493
+ .await
1494
+ .expect("child root should write");
1495
+
1496
+ let read = storage
1497
+ .begin_read(StorageReadOptions::default())
1498
+ .expect("read should open");
1499
+ let valid_diff = tracked_state
1500
+ .reader(read)
1501
+ .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
1502
+ .await
1503
+ .expect("valid child diff should load");
1504
+ let unrelated_row = valid_diff
1505
+ .entries
1506
+ .iter()
1507
+ .find_map(|entry| {
1508
+ entry
1509
+ .after
1510
+ .as_ref()
1511
+ .filter(|row| row.change_id == "child-b")
1512
+ .cloned()
1513
+ })
1514
+ .expect("unrelated child row should appear");
1515
+ let (unrelated_key, unrelated_value) = unrelated_row.into_index_entry();
1516
+ stage_corrupt_commit_root(
1517
+ &storage,
1518
+ "child",
1519
+ vec![(unrelated_key, unrelated_value)],
1520
+ vec![TrackedStateCommitRootParent {
1521
+ commit_id: "parent".to_string(),
1522
+ root_id: tracked_state_root_id(&storage, "parent").await,
1523
+ }],
1524
+ )
1525
+ .await;
1526
+
1527
+ let read = storage
1528
+ .begin_read(StorageReadOptions::default())
1529
+ .expect("read should open");
1530
+ let error = tracked_state
1531
+ .reader(read)
1532
+ .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
1533
+ .await
1534
+ .expect_err("omitted updated row must be rejected");
1535
+
1536
+ assert!(
1537
+ is_commit_root_validation_error(&error),
1538
+ "unexpected error: {error}"
1539
+ );
1540
+ }
1541
+
1542
+ #[tokio::test]
1543
+ async fn diff_commits_rejects_shared_omitted_row_even_when_diff_is_non_empty() {
1544
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1545
+ let tracked_state = TrackedStateContext::new();
1546
+ write_root_committed_for_test(
1547
+ &storage,
1548
+ &tracked_state,
1549
+ "parent",
1550
+ None,
1551
+ &[row_with_value("entity-a", None, "parent-a", "shared")],
1552
+ )
1553
+ .await
1554
+ .expect("parent root should write");
1555
+ write_root_committed_for_test(
1556
+ &storage,
1557
+ &tracked_state,
1558
+ "left",
1559
+ Some("parent"),
1560
+ &[row_with_value("entity-b", None, "left-b", "left")],
1561
+ )
1562
+ .await
1563
+ .expect("left root should write");
1564
+ write_root_committed_for_test(
1565
+ &storage,
1566
+ &tracked_state,
1567
+ "right",
1568
+ Some("parent"),
1569
+ &[row_with_value("entity-c", None, "right-c", "right")],
1570
+ )
1571
+ .await
1572
+ .expect("right root should write");
1573
+
1574
+ let read = storage
1575
+ .begin_read(StorageReadOptions::default())
1576
+ .expect("read should open");
1577
+ let left_diff = tracked_state
1578
+ .reader(read)
1579
+ .diff_commits("parent", "left", &TrackedStateDiffRequest::default())
1580
+ .await
1581
+ .expect("left diff should load");
1582
+ let left_row = left_diff
1583
+ .entries
1584
+ .iter()
1585
+ .find_map(|entry| {
1586
+ entry
1587
+ .after
1588
+ .as_ref()
1589
+ .filter(|row| row.change_id == "left-b")
1590
+ .cloned()
1591
+ })
1592
+ .expect("left row should appear");
1593
+ let (left_key, left_value) = left_row.into_index_entry();
1594
+ let read = storage
1595
+ .begin_read(StorageReadOptions::default())
1596
+ .expect("read should open");
1597
+ let right_diff = tracked_state
1598
+ .reader(read)
1599
+ .diff_commits("parent", "right", &TrackedStateDiffRequest::default())
1600
+ .await
1601
+ .expect("right diff should load");
1602
+ let right_row = right_diff
1603
+ .entries
1604
+ .iter()
1605
+ .find_map(|entry| {
1606
+ entry
1607
+ .after
1608
+ .as_ref()
1609
+ .filter(|row| row.change_id == "right-c")
1610
+ .cloned()
1611
+ })
1612
+ .expect("right row should appear");
1613
+ let (right_key, right_value) = right_row.into_index_entry();
1614
+ stage_corrupt_commit_root(
1615
+ &storage,
1616
+ "left",
1617
+ vec![(left_key, left_value)],
1618
+ vec![TrackedStateCommitRootParent {
1619
+ commit_id: "parent".to_string(),
1620
+ root_id: tracked_state_root_id(&storage, "parent").await,
1621
+ }],
1622
+ )
1623
+ .await;
1624
+ stage_corrupt_commit_root(
1625
+ &storage,
1626
+ "right",
1627
+ vec![(right_key, right_value)],
1628
+ vec![TrackedStateCommitRootParent {
1629
+ commit_id: "parent".to_string(),
1630
+ root_id: tracked_state_root_id(&storage, "parent").await,
1631
+ }],
1632
+ )
1633
+ .await;
1634
+
1635
+ let read = storage
1636
+ .begin_read(StorageReadOptions::default())
1637
+ .expect("read should open");
1638
+ let error = tracked_state
1639
+ .reader(read)
1640
+ .diff_commits("left", "right", &TrackedStateDiffRequest::default())
1641
+ .await
1642
+ .expect_err("shared hidden omission must be rejected");
1643
+
1644
+ assert!(
1645
+ is_commit_root_validation_error(&error),
1646
+ "unexpected error: {error}"
1647
+ );
1648
+ }
1649
+
1650
+ #[tokio::test]
1651
+ async fn diff_commits_validates_roots_even_when_tree_diff_is_empty() {
1652
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1653
+ let tracked_state = TrackedStateContext::new();
1654
+ write_root_committed_for_test(
1655
+ &storage,
1656
+ &tracked_state,
1657
+ "source",
1658
+ None,
1659
+ &[row_with_value("entity-a", None, "source-change", "value")],
1660
+ )
1661
+ .await
1662
+ .expect("source root should write");
1663
+ write_root_committed_for_test(&storage, &tracked_state, "left-corrupt", None, &[])
1664
+ .await
1665
+ .expect("left changelog should write");
1666
+ write_root_committed_for_test(&storage, &tracked_state, "right-corrupt", None, &[])
1667
+ .await
1668
+ .expect("right changelog should write");
1669
+
1670
+ let read = storage
1671
+ .begin_read(StorageReadOptions::default())
1672
+ .expect("read should open");
1673
+ let source_diff = tracked_state
1674
+ .reader(read)
1675
+ .diff_commits(
1676
+ "left-corrupt",
1677
+ "source",
1678
+ &TrackedStateDiffRequest::default(),
1679
+ )
1680
+ .await
1681
+ .expect("source diff should load");
1682
+ let source_row = source_diff
1683
+ .entries
1684
+ .iter()
1685
+ .find_map(|entry| entry.after.clone())
1686
+ .expect("source row should appear");
1687
+ let (source_key, source_value) = source_row.into_index_entry();
1688
+
1689
+ stage_corrupt_commit_root(
1690
+ &storage,
1691
+ "left-corrupt",
1692
+ vec![(source_key.clone(), source_value.clone())],
1693
+ Vec::new(),
1694
+ )
1695
+ .await;
1696
+ stage_corrupt_commit_root(
1697
+ &storage,
1698
+ "right-corrupt",
1699
+ vec![(source_key, source_value)],
1700
+ Vec::new(),
1701
+ )
1702
+ .await;
1703
+
1704
+ let read = storage
1705
+ .begin_read(StorageReadOptions::default())
1706
+ .expect("read should open");
1707
+ let error = tracked_state
1708
+ .reader(read)
1709
+ .diff_commits(
1710
+ "left-corrupt",
1711
+ "right-corrupt",
1712
+ &TrackedStateDiffRequest::default(),
1713
+ )
1714
+ .await
1715
+ .expect_err("identical corrupt roots must still be validated");
1716
+
1717
+ assert!(
1718
+ is_commit_root_validation_error(&error),
1719
+ "unexpected error: {error}"
1720
+ );
1721
+ }
1722
+
1723
+ #[tokio::test]
1724
+ async fn diff_commits_between_delta_parent_and_child_reports_suffix_rows() {
1725
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1726
+ let tracked_state = TrackedStateContext::new();
1727
+ let mut read = storage
1728
+ .begin_read(StorageReadOptions::default())
1729
+ .expect("read should open");
1730
+ let mut writes = storage.new_write_set();
1731
+ write_root_for_test(
1732
+ &mut read,
1733
+ &mut writes,
1734
+ &tracked_state,
1735
+ "parent",
1736
+ None,
1737
+ &[
1738
+ row_with_value("entity-a", None, "parent-a", "before"),
1739
+ row_with_value("entity-b", None, "parent-b", "same"),
1740
+ ],
1741
+ )
1742
+ .await
1743
+ .expect("parent should write");
1744
+ storage
1745
+ .commit_write_set(writes, StorageWriteOptions::default())
1746
+ .expect("parent writes should commit");
1747
+ let mut read = storage
1748
+ .begin_read(StorageReadOptions::default())
1749
+ .expect("child read should open");
1750
+ let mut writes = storage.new_write_set();
1751
+ write_root_for_test(
1752
+ &mut read,
1753
+ &mut writes,
1754
+ &tracked_state,
1755
+ "child",
1756
+ Some("parent"),
1757
+ &[row_with_value("entity-a", None, "child-a", "after")],
1758
+ )
1759
+ .await
1760
+ .expect("child should write");
1761
+ storage
1762
+ .commit_write_set(writes, StorageWriteOptions::default())
1763
+ .expect("writes should commit");
1764
+
1765
+ let read = storage
1766
+ .begin_read(StorageReadOptions::default())
1767
+ .expect("read should open");
1768
+ let diff = tracked_state
1769
+ .reader(read)
1770
+ .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
1771
+ .await
1772
+ .expect("diff should load");
1773
+
1774
+ assert_eq!(
1775
+ kinds(&diff),
1776
+ vec![("entity-a".to_string(), TrackedStateDiffKind::Modified)]
1777
+ );
1778
+ assert_ne!(
1779
+ diff.entries[0]
1780
+ .before
1781
+ .as_ref()
1782
+ .and_then(|row| row.snapshot_ref.as_ref()),
1783
+ diff.entries[0]
1784
+ .after
1785
+ .as_ref()
1786
+ .and_then(|row| row.snapshot_ref.as_ref())
1787
+ );
1788
+ }
1789
+
1790
+ #[tokio::test]
1791
+ async fn diff_commits_between_delta_child_and_parent_reports_reverse_suffix_rows() {
1792
+ let (storage, tracked_state) = seed_parent_child_delta(
1793
+ &[
1794
+ row_with_value("entity-a", None, "parent-a", "before"),
1795
+ row_with_value("entity-b", None, "parent-b", "same"),
1796
+ ],
1797
+ &[row_with_value("entity-a", None, "child-a", "after")],
1798
+ )
1799
+ .await;
1800
+
1801
+ let read = storage
1802
+ .begin_read(StorageReadOptions::default())
1803
+ .expect("read should open");
1804
+ let diff = tracked_state
1805
+ .reader(read)
1806
+ .diff_commits("child", "parent", &TrackedStateDiffRequest::default())
1807
+ .await
1808
+ .expect("diff should load");
1809
+
1810
+ assert_eq!(
1811
+ kinds(&diff),
1812
+ vec![("entity-a".to_string(), TrackedStateDiffKind::Modified)]
1813
+ );
1814
+ assert_ne!(
1815
+ diff.entries[0]
1816
+ .before
1817
+ .as_ref()
1818
+ .and_then(|row| row.snapshot_ref.as_ref()),
1819
+ diff.entries[0]
1820
+ .after
1821
+ .as_ref()
1822
+ .and_then(|row| row.snapshot_ref.as_ref())
1823
+ );
1824
+ }
1825
+
1826
+ #[tokio::test]
1827
+ async fn diff_commits_between_delta_parent_and_child_preserves_suffix_tombstones() {
1828
+ let (storage, tracked_state) = seed_parent_child_delta(
1829
+ &[
1830
+ row_with_value("entity-a", None, "parent-a", "before"),
1831
+ row_with_value("entity-b", None, "parent-b", "same"),
1832
+ ],
1833
+ &[tombstone("entity-a", None, "child-delete")],
1834
+ )
1835
+ .await;
1836
+
1837
+ let read = storage
1838
+ .begin_read(StorageReadOptions::default())
1839
+ .expect("read should open");
1840
+ let diff = tracked_state
1841
+ .reader(read)
1842
+ .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
1843
+ .await
1844
+ .expect("diff should load");
1845
+
1846
+ assert_eq!(
1847
+ kinds(&diff),
1848
+ vec![("entity-a".to_string(), TrackedStateDiffKind::Removed)]
1849
+ );
1850
+ assert!(diff.entries[0].before_is_live());
1851
+ assert!(!diff.entries[0].after_is_live());
1852
+ assert_eq!(
1853
+ diff.entries[0]
1854
+ .after
1855
+ .as_ref()
1856
+ .map(|row| row.change_id.as_str()),
1857
+ Some("child-delete")
1858
+ );
1859
+ }
1860
+
1861
+ async fn diff(
1862
+ storage: &StorageContext,
1863
+ tracked_state: &TrackedStateContext,
1864
+ ) -> TrackedStateDiff {
1865
+ let read = storage
1866
+ .begin_read(StorageReadOptions::default())
1867
+ .expect("read should open");
1868
+ tracked_state
1869
+ .reader(read)
1870
+ .diff_commits("left", "right", &TrackedStateDiffRequest::default())
1871
+ .await
1872
+ .expect("diff should load")
1873
+ }
1874
+
1875
+ async fn seed_roots(
1876
+ left_rows: &[MaterializedTrackedStateRow],
1877
+ right_rows: &[MaterializedTrackedStateRow],
1878
+ ) -> (StorageContext, TrackedStateContext) {
1879
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1880
+ let tracked_state = TrackedStateContext::new();
1881
+ write_root_committed_for_test(&storage, &tracked_state, "left", None, left_rows)
1882
+ .await
1883
+ .expect("left root should write");
1884
+ write_root_committed_for_test(&storage, &tracked_state, "right", None, right_rows)
1885
+ .await
1886
+ .expect("right root should write");
1887
+ (storage, tracked_state)
1888
+ }
1889
+
1890
+ async fn seed_parent_child_delta(
1891
+ parent_rows: &[MaterializedTrackedStateRow],
1892
+ child_rows: &[MaterializedTrackedStateRow],
1893
+ ) -> (StorageContext, TrackedStateContext) {
1894
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1895
+ let tracked_state = TrackedStateContext::new();
1896
+ write_root_committed_for_test(&storage, &tracked_state, "parent", None, parent_rows)
1897
+ .await
1898
+ .expect("parent should write");
1899
+ write_root_committed_for_test(
1900
+ &storage,
1901
+ &tracked_state,
1902
+ "child",
1903
+ Some("parent"),
1904
+ child_rows,
1905
+ )
1906
+ .await
1907
+ .expect("child should write");
1908
+ (storage, tracked_state)
1909
+ }
1910
+
1911
+ async fn write_root_committed_for_test(
1912
+ storage: &StorageContext,
1913
+ tracked_state: &TrackedStateContext,
1914
+ commit_id: &str,
1915
+ parent_commit_id: Option<&str>,
1916
+ rows: &[MaterializedTrackedStateRow],
1917
+ ) -> Result<(), LixError> {
1918
+ let mut read = storage
1919
+ .begin_read(StorageReadOptions::default())
1920
+ .expect("read should open");
1921
+ let mut writes = storage.new_write_set();
1922
+ write_root_for_test(
1923
+ &mut read,
1924
+ &mut writes,
1925
+ tracked_state,
1926
+ commit_id,
1927
+ parent_commit_id,
1928
+ rows,
1929
+ )
1930
+ .await?;
1931
+ storage.commit_write_set(writes, StorageWriteOptions::default())?;
1932
+ Ok(())
1933
+ }
1934
+
1935
+ async fn write_root_for_test(
1936
+ read: &mut (impl crate::storage::StorageRead + Send + Sync + ?Sized),
1937
+ writes: &mut crate::storage::StorageWriteSet,
1938
+ tracked_state: &TrackedStateContext,
1939
+ commit_id: &str,
1940
+ parent_commit_id: Option<&str>,
605
1941
  rows: &[MaterializedTrackedStateRow],
606
1942
  ) -> Result<(), LixError> {
607
1943
  crate::test_support::stage_tracked_root_from_materialized(
608
- tx,
1944
+ read,
1945
+ writes,
609
1946
  tracked_state,
610
1947
  commit_id,
611
1948
  parent_commit_id,
@@ -614,6 +1951,62 @@ mod tests {
614
1951
  .await
615
1952
  }
616
1953
 
1954
+ async fn tracked_state_root_id(
1955
+ storage: &StorageContext,
1956
+ commit_id: &str,
1957
+ ) -> TrackedStateRootId {
1958
+ let read = storage
1959
+ .begin_read(StorageReadOptions::default())
1960
+ .expect("read should open");
1961
+ crate::tracked_state::storage::load_root(&read, commit_id)
1962
+ .await
1963
+ .expect("root should load")
1964
+ .expect("root should exist")
1965
+ }
1966
+
1967
+ async fn stage_corrupt_commit_root(
1968
+ storage: &StorageContext,
1969
+ commit_id: &str,
1970
+ entries: Vec<(TrackedStateKey, TrackedStateIndexValue)>,
1971
+ parent_roots: Vec<TrackedStateCommitRootParent>,
1972
+ ) {
1973
+ let read = storage
1974
+ .begin_read(StorageReadOptions::default())
1975
+ .expect("read should open");
1976
+ let mut writes = storage.new_write_set();
1977
+ let mutations = entries
1978
+ .into_iter()
1979
+ .map(|(key, value)| {
1980
+ TrackedStateMutation::put_encoded(
1981
+ crate::tracked_state::codec::encode_key(&key),
1982
+ crate::tracked_state::codec::encode_value(&value),
1983
+ )
1984
+ })
1985
+ .collect::<Vec<_>>();
1986
+ let changed_key_count = mutations.len() as u64;
1987
+ let result = crate::tracked_state::tree::TrackedStateTree::new()
1988
+ .apply_mutations(&read, &mut writes, None, mutations, Some(commit_id))
1989
+ .await
1990
+ .expect("corrupt root should write");
1991
+ crate::tracked_state::storage::stage_commit_root(
1992
+ &mut writes,
1993
+ &TrackedStateCommitRoot {
1994
+ commit_id: commit_id.to_string(),
1995
+ root_id: result.root_id,
1996
+ parent_roots,
1997
+ changed_key_count,
1998
+ row_count_estimate: result.row_count as u64,
1999
+ tree_height: result.tree_height as u32,
2000
+ primary_chunk_count: result.chunk_count as u64,
2001
+ primary_chunk_bytes: result.chunk_bytes as u64,
2002
+ },
2003
+ )
2004
+ .expect("metadata should encode");
2005
+ storage
2006
+ .commit_write_set(writes, StorageWriteOptions::default())
2007
+ .expect("corrupt root should commit");
2008
+ }
2009
+
617
2010
  fn kinds(diff: &TrackedStateDiff) -> Vec<(String, TrackedStateDiffKind)> {
618
2011
  diff.entries
619
2012
  .iter()
@@ -621,7 +2014,7 @@ mod tests {
621
2014
  (
622
2015
  entry
623
2016
  .identity
624
- .entity_id
2017
+ .entity_pk
625
2018
  .as_single_string_owned()
626
2019
  .expect("identity"),
627
2020
  entry.kind,
@@ -630,48 +2023,109 @@ mod tests {
630
2023
  .collect()
631
2024
  }
632
2025
 
2026
+ fn is_commit_root_validation_error(error: &LixError) -> bool {
2027
+ error.message.contains("not the first-parent winner")
2028
+ || error.message.contains("does not match parent root")
2029
+ || error
2030
+ .message
2031
+ .contains("does not match changelog first-parent winners")
2032
+ || error.message.contains("contains non-winner identity")
2033
+ || error.message.contains("but changelog first parent is")
2034
+ || error
2035
+ .message
2036
+ .contains("nearest available first-parent root")
2037
+ || error.message.contains("references unexpected parent")
2038
+ || error.message.contains("missing changelog winner")
2039
+ || error.message.contains("has change")
2040
+ || error.message.contains("omits current changelog change")
2041
+ || error.message.contains("omits inherited identity")
2042
+ || error
2043
+ .message
2044
+ .contains("does not preserve inherited identity")
2045
+ || error.message.contains("but changelog winner is")
2046
+ }
2047
+
2048
+ fn commit_root_row_entry(
2049
+ commit_id: &str,
2050
+ change_id: &str,
2051
+ created_at: &str,
2052
+ ) -> (TrackedStateKey, TrackedStateIndexValue) {
2053
+ (
2054
+ TrackedStateKey {
2055
+ schema_key: "lix_commit".to_string(),
2056
+ file_id: None,
2057
+ entity_pk: EntityPk::single(commit_id),
2058
+ },
2059
+ TrackedStateIndexValue {
2060
+ change_id: change_id.to_string(),
2061
+ commit_id: commit_id.to_string(),
2062
+ deleted: false,
2063
+ snapshot_ref: Some(JsonRef::for_content(
2064
+ format!("{{\"id\":\"{commit_id}\"}}").as_bytes(),
2065
+ )),
2066
+ metadata_ref: None,
2067
+ created_at: created_at.to_string(),
2068
+ updated_at: created_at.to_string(),
2069
+ },
2070
+ )
2071
+ }
2072
+
633
2073
  fn tombstone(
634
- entity_id: &str,
2074
+ entity_pk: &str,
635
2075
  file_id: Option<&str>,
636
2076
  change_id: &str,
637
2077
  ) -> MaterializedTrackedStateRow {
638
- let mut row = row(entity_id, file_id, change_id);
2078
+ let mut row = row(entity_pk, file_id, change_id);
639
2079
  row.snapshot_content = None;
640
2080
  row.deleted = true;
641
2081
  row
642
2082
  }
643
2083
 
644
- fn row(entity_id: &str, file_id: Option<&str>, change_id: &str) -> MaterializedTrackedStateRow {
645
- row_with_schema(entity_id, file_id, "test_schema", change_id)
2084
+ fn row(entity_pk: &str, file_id: Option<&str>, change_id: &str) -> MaterializedTrackedStateRow {
2085
+ row_with_schema(entity_pk, file_id, "test_schema", change_id)
646
2086
  }
647
2087
 
648
2088
  fn row_with_schema(
649
- entity_id: &str,
2089
+ entity_pk: &str,
650
2090
  file_id: Option<&str>,
651
2091
  schema_key: &str,
652
2092
  change_id: &str,
653
2093
  ) -> MaterializedTrackedStateRow {
654
- row_with_schema_and_value(entity_id, file_id, schema_key, change_id, "value")
2094
+ row_with_schema_and_value(entity_pk, file_id, schema_key, change_id, "value")
655
2095
  }
656
2096
 
657
2097
  fn row_with_value(
658
- entity_id: &str,
2098
+ entity_pk: &str,
659
2099
  file_id: Option<&str>,
660
2100
  change_id: &str,
661
2101
  value: &str,
662
2102
  ) -> MaterializedTrackedStateRow {
663
- row_with_schema_and_value(entity_id, file_id, "test_schema", change_id, value)
2103
+ row_with_schema_and_value(entity_pk, file_id, "test_schema", change_id, value)
2104
+ }
2105
+
2106
+ fn row_with_times(
2107
+ entity_pk: &str,
2108
+ file_id: Option<&str>,
2109
+ change_id: &str,
2110
+ value: &str,
2111
+ created_at: &str,
2112
+ updated_at: &str,
2113
+ ) -> MaterializedTrackedStateRow {
2114
+ let mut row = row_with_value(entity_pk, file_id, change_id, value);
2115
+ row.created_at = created_at.to_string();
2116
+ row.updated_at = updated_at.to_string();
2117
+ row
664
2118
  }
665
2119
 
666
2120
  fn row_with_schema_and_value(
667
- entity_id: &str,
2121
+ entity_pk: &str,
668
2122
  file_id: Option<&str>,
669
2123
  schema_key: &str,
670
2124
  change_id: &str,
671
2125
  value: &str,
672
2126
  ) -> MaterializedTrackedStateRow {
673
2127
  MaterializedTrackedStateRow {
674
- entity_id: EntityIdentity::single(entity_id),
2128
+ entity_pk: EntityPk::single(entity_pk),
675
2129
  schema_key: schema_key.to_string(),
676
2130
  file_id: file_id.map(str::to_string),
677
2131
  snapshot_content: Some(format!("{{\"value\":\"{value}\"}}")),