@lix-js/sdk 0.6.0-preview.3 → 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 (235) hide show
  1. package/README.md +1 -1
  2. package/SKILL.md +105 -65
  3. package/dist/engine-wasm/index.js +4 -4
  4. package/dist/engine-wasm/wasm/lix_engine.d.ts +30 -6
  5. package/dist/engine-wasm/wasm/lix_engine.js +187 -117
  6. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  7. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +14 -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 +42 -28
  11. package/dist/open-lix.js +49 -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 +819 -124
  94. package/dist-engine-src/src/session/create_branch.rs +94 -0
  95. package/dist-engine-src/src/session/execute.rs +260 -57
  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 +19 -16
  102. package/dist-engine-src/src/session/switch_branch.rs +113 -0
  103. package/dist-engine-src/src/session/transaction.rs +557 -0
  104. package/dist-engine-src/src/sql2/bind/classify.rs +102 -0
  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} +98 -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 +4 -5
  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 +30 -24
  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 -109
  218. package/dist-engine-src/src/sql2/classify.rs +0 -182
  219. package/dist-engine-src/src/sql2/entity_provider.rs +0 -3211
  220. package/dist-engine-src/src/sql2/execute.rs +0 -3440
  221. package/dist-engine-src/src/sql2/public_bind/assignment.rs +0 -46
  222. package/dist-engine-src/src/sql2/public_bind/capability.rs +0 -41
  223. package/dist-engine-src/src/sql2/public_bind/dml.rs +0 -166
  224. package/dist-engine-src/src/sql2/public_bind/mod.rs +0 -25
  225. package/dist-engine-src/src/sql2/public_bind/table.rs +0 -168
  226. package/dist-engine-src/src/sql2/version_scope.rs +0 -394
  227. package/dist-engine-src/src/storage/types.rs +0 -501
  228. package/dist-engine-src/src/tracked_state/by_file_index.rs +0 -98
  229. package/dist-engine-src/src/tracked_state/materializer.rs +0 -488
  230. package/dist-engine-src/src/transaction/live_state_overlay.rs +0 -35
  231. package/dist-engine-src/src/version/lifecycle.rs +0 -221
  232. package/dist-engine-src/src/version/mod.rs +0 -13
  233. package/dist-engine-src/src/version/refs.rs +0 -330
  234. package/dist-engine-src/src/version/stage_rows.rs +0 -67
  235. package/dist-engine-src/src/version/types.rs +0 -21
@@ -1,90 +1,96 @@
1
- use std::collections::{BTreeMap, BTreeSet};
1
+ use std::collections::{BTreeMap, HashMap, HashSet};
2
2
 
3
- use crate::commit_store::CommitStoreContext;
4
- use crate::storage::{StorageReader, StorageWriteSet};
5
- use crate::tracked_state::by_file_index::ByFileIndex;
3
+ use crate::changelog::{
4
+ ChangeLoadRequest, ChangeRecord, ChangelogContext, ChangelogReader, CommitLoadEntry,
5
+ CommitLoadRequest, CommitProjection, CommitRecord,
6
+ };
7
+ use crate::entity_pk::EntityPk;
8
+ use crate::storage::{StorageRead, StorageWriteSet};
6
9
  use crate::tracked_state::codec::{encode_key_ref, encode_value_ref};
7
- use crate::tracked_state::diff::{diff_commits, TrackedStateDiff, TrackedStateDiffRequest};
8
- use crate::tracked_state::materialize_index_entries;
10
+ use crate::tracked_state::diff::{
11
+ diff_commits, diff_commits_with_validation, TrackedStateDiff, TrackedStateDiffRequest,
12
+ TrackedStateDiffRow,
13
+ };
14
+ use crate::tracked_state::materialize_rows_from_index_entries;
15
+ #[cfg(test)]
9
16
  use crate::tracked_state::merge::{self, TrackedStateMergePlan};
10
17
  use crate::tracked_state::storage;
11
- use crate::tracked_state::storage::DeltaJsonPackIndexesRef;
12
18
  use crate::tracked_state::tree::TrackedStateTree;
13
19
  use crate::tracked_state::types::{
14
- TrackedStateIndexValue, TrackedStateKey, TrackedStateKeyRef, TrackedStateMutation,
15
- TrackedStateTreeDiffEntry, TrackedStateTreeScanRequest,
20
+ TrackedStateCommitRoot, TrackedStateCommitRootParent, TrackedStateIndexValue, TrackedStateKey,
21
+ TrackedStateKeyRef, TrackedStateMutation, TrackedStateRootId, TrackedStateTreeScanRequest,
16
22
  };
17
23
  use crate::tracked_state::{
18
- MaterializedTrackedStateRow, TrackedStateDeltaRef, TrackedStateRowRequest,
19
- TrackedStateScanRequest,
24
+ MaterializedTrackedStateRow, TrackedStateDeltaRef, TrackedStateScanRequest,
20
25
  };
21
- use crate::LixError;
26
+ use crate::{LixError, NullableKeyFilter};
27
+
28
+ #[derive(Debug, Clone, PartialEq, Eq, Hash)]
29
+ struct TrackedStateIdentity {
30
+ schema_key: String,
31
+ file_id: Option<String>,
32
+ entity_pk: EntityPk,
33
+ }
22
34
 
23
- /// Factory for tracked-state readers, delta writers, and projection-root materializers.
35
+ /// Factory for tracked-state readers, root writers, and commit-root rebuilders.
24
36
  ///
25
- /// Tracked state is stored as content-addressed roots. Version refs
37
+ /// Tracked state is stored as content-addressed roots. Branch refs
26
38
  /// choose which commit/root to read; this context only owns root operations.
27
39
  #[derive(Clone)]
28
40
  pub(crate) struct TrackedStateContext {
29
41
  tree: TrackedStateTree,
30
- commit_store: CommitStoreContext,
31
42
  }
32
43
 
33
44
  impl TrackedStateContext {
34
45
  pub(crate) fn new() -> Self {
35
46
  Self {
36
47
  tree: TrackedStateTree::new(),
37
- commit_store: CommitStoreContext::new(),
38
48
  }
39
49
  }
40
50
 
41
51
  /// Creates a commit-id-addressed tracked-state reader.
42
52
  pub(crate) fn reader<S>(&self, store: S) -> TrackedStateStoreReader<S>
43
53
  where
44
- S: StorageReader,
54
+ S: StorageRead + Send + Sync,
45
55
  {
46
56
  TrackedStateStoreReader {
47
57
  store,
48
58
  tree: self.tree.clone(),
49
- commit_store: self.commit_store,
50
59
  }
51
60
  }
52
61
 
53
62
  /// Creates a tracked-state writer over a caller-owned transaction and write set.
54
63
  pub(crate) fn writer<'a, S>(
55
64
  &'a self,
56
- store: &'a mut S,
65
+ store: &'a S,
57
66
  writes: &'a mut StorageWriteSet,
58
67
  ) -> TrackedStateWriter<'a, S>
59
68
  where
60
- S: StorageReader + ?Sized,
69
+ S: StorageRead + Send + Sync + ?Sized,
61
70
  {
62
71
  TrackedStateWriter {
72
+ chunk_overlay: storage::TrackedStateChunkOverlay::new(),
73
+ staged_roots: BTreeMap::new(),
63
74
  tree: self.tree.clone(),
64
75
  store,
65
76
  writes,
66
77
  }
67
78
  }
68
79
 
69
- /// Creates an explicit tracked-state projection-root materializer.
80
+ /// Creates an explicit tracked-state commit-root rebuilder.
70
81
  ///
71
- /// Normal commits should use `writer(...).stage_delta(...)`. Materializing a
72
- /// projection root is a caller-chosen maintenance/read-acceleration step.
73
- pub(crate) fn materializer<'a, S>(
82
+ /// Normal commits stage commit roots directly. This rebuilder reconstructs
83
+ /// a missing root from changelog facts as an explicit maintenance path.
84
+ pub(crate) fn root_rebuilder<'a, S>(
74
85
  &'a self,
75
- store: &'a mut S,
86
+ store: &'a S,
76
87
  writes: &'a mut StorageWriteSet,
77
- commit_store: &'a CommitStoreContext,
78
- ) -> TrackedStateMaterializer<'a, S>
88
+ ) -> TrackedStateRootRebuilder<'a, S>
79
89
  where
80
- S: StorageReader + ?Sized,
90
+ S: StorageRead + Send + Sync + ?Sized,
81
91
  {
82
- TrackedStateMaterializer {
83
- tracked_state: self,
84
- store,
85
- writes,
86
- commit_store,
87
- }
92
+ let _ = self;
93
+ TrackedStateRootRebuilder { store, writes }
88
94
  }
89
95
  }
90
96
 
@@ -92,52 +98,53 @@ impl TrackedStateContext {
92
98
  pub(crate) struct TrackedStateStoreReader<S> {
93
99
  store: S,
94
100
  tree: TrackedStateTree,
95
- commit_store: CommitStoreContext,
101
+ }
102
+
103
+ struct DiffCommitRootValidationCache {
104
+ commit_ref_winners: HashMap<String, HashMap<TrackedStateIdentity, String>>,
105
+ commit_root_metadata: HashMap<String, TrackedStateCommitRoot>,
106
+ commit_roots: HashMap<String, TrackedStateRootId>,
107
+ tree_values: HashMap<(TrackedStateRootId, TrackedStateKey), Option<TrackedStateIndexValue>>,
108
+ changelog_first_parents: HashMap<String, Option<String>>,
109
+ }
110
+
111
+ impl DiffCommitRootValidationCache {
112
+ fn new() -> Self {
113
+ Self {
114
+ commit_ref_winners: HashMap::new(),
115
+ commit_root_metadata: HashMap::new(),
116
+ commit_roots: HashMap::new(),
117
+ tree_values: HashMap::new(),
118
+ changelog_first_parents: HashMap::new(),
119
+ }
120
+ }
96
121
  }
97
122
 
98
123
  impl<S> TrackedStateStoreReader<S>
99
124
  where
100
- S: StorageReader,
125
+ S: StorageRead + Send + Sync,
101
126
  {
102
127
  pub(crate) async fn scan_rows_at_commit(
103
128
  &mut self,
104
129
  commit_id: &str,
105
130
  request: &TrackedStateScanRequest,
106
131
  ) -> Result<Vec<MaterializedTrackedStateRow>, LixError> {
107
- let root_id = self.tree.load_root(&mut self.store, commit_id).await?;
108
- let rows = if let Some(root_id) = root_id {
109
- if ByFileIndex::should_use(request) {
110
- if let Some(by_file_root_id) =
111
- storage::load_by_file_root(&mut self.store, commit_id).await?
112
- {
113
- self.scan_rows_at_commit_by_file_index(&root_id, &by_file_root_id, request)
114
- .await?
115
- } else {
116
- self.tree
117
- .scan(
118
- &mut self.store,
119
- &root_id,
120
- &tree_scan_request_from_tracked(request),
121
- )
122
- .await?
123
- }
124
- } else {
125
- self.tree
126
- .scan(
127
- &mut self.store,
128
- &root_id,
129
- &tree_scan_request_from_tracked(request),
130
- )
131
- .await?
132
- }
133
- } else {
134
- self.projection_entries_at_commit(commit_id, &tree_scan_request_from_tracked(request))
135
- .await?
132
+ let Some(root_id) = self.tree.load_root(&mut self.store, commit_id).await? else {
133
+ return Err(missing_commit_root_error(commit_id));
136
134
  };
137
- let projection = crate::tracked_state::TrackedMaterializationProjection::from_columns(
138
- &request.projection.columns,
135
+ let rows = self
136
+ .tree
137
+ .scan(
138
+ &mut self.store,
139
+ &root_id,
140
+ &tree_scan_request_from_tracked(request),
141
+ )
142
+ .await?;
143
+ let materialization = crate::tracked_state::TrackedRowMaterialization::from_columns(
144
+ &request.read_columns.columns,
139
145
  );
140
- let mut rows = materialize_index_entries(&mut self.store, rows, &projection).await?;
146
+ let mut rows =
147
+ materialize_rows_from_index_entries(&mut self.store, rows, &materialization).await?;
141
148
  if !request.filter.include_tombstones {
142
149
  rows.retain(|row| !row.deleted);
143
150
  }
@@ -147,36 +154,31 @@ where
147
154
  Ok(rows)
148
155
  }
149
156
 
157
+ #[cfg(any(test, feature = "storage-benches"))]
150
158
  pub(crate) async fn load_rows_at_commit(
151
159
  &mut self,
152
160
  commit_id: &str,
153
- requests: &[TrackedStateRowRequest],
161
+ keys: &[TrackedStateKey],
154
162
  ) -> Result<Vec<Option<MaterializedTrackedStateRow>>, LixError> {
155
- if requests.is_empty() {
163
+ if keys.is_empty() {
156
164
  return Ok(Vec::new());
157
165
  }
158
- let keys = requests
159
- .iter()
160
- .map(tracked_key_from_request)
161
- .collect::<Result<Vec<_>, _>>()?;
162
- let values = self
163
- .projection_values_at_commit_for_keys(commit_id, &keys)
164
- .await?;
166
+ let values = self.commit_root_values_for_keys(commit_id, &keys).await?;
165
167
  let mut entry_indices = Vec::new();
166
168
  let mut entries = Vec::new();
167
- for (index, (key, value)) in keys.into_iter().zip(values).enumerate() {
169
+ for (index, (key, value)) in keys.iter().cloned().zip(values).enumerate() {
168
170
  if let Some(value) = value {
169
171
  entry_indices.push(index);
170
172
  entries.push((key, value));
171
173
  }
172
174
  }
173
- let materialized = materialize_index_entries(
175
+ let materialized = materialize_rows_from_index_entries(
174
176
  &mut self.store,
175
177
  entries,
176
- &crate::tracked_state::TrackedMaterializationProjection::full(),
178
+ &crate::tracked_state::TrackedRowMaterialization::full(),
177
179
  )
178
180
  .await?;
179
- let mut rows = vec![None; requests.len()];
181
+ let mut rows = vec![None; keys.len()];
180
182
  for (index, row) in entry_indices.into_iter().zip(materialized) {
181
183
  rows[index] = Some(row);
182
184
  }
@@ -192,500 +194,623 @@ where
192
194
  diff_commits(self, left_commit_id, right_commit_id, request).await
193
195
  }
194
196
 
195
- pub(crate) async fn diff_tree_entries_at_commits(
197
+ pub(crate) async fn diff_commits_with_validation(
196
198
  &mut self,
197
199
  left_commit_id: &str,
198
200
  right_commit_id: &str,
199
- request: &TrackedStateTreeScanRequest,
200
- ) -> Result<Vec<crate::tracked_state::types::TrackedStateTreeDiffEntry>, LixError> {
201
- if !self.projection_has_pending_deltas(left_commit_id).await?
202
- && !self.projection_has_pending_deltas(right_commit_id).await?
203
- && self.projection_root_exists(left_commit_id).await?
204
- && self.projection_root_exists(right_commit_id).await?
205
- {
206
- let left_root = self.tree.load_root(&mut self.store, left_commit_id).await?;
207
- let right_root = self
208
- .tree
209
- .load_root(&mut self.store, right_commit_id)
210
- .await?;
211
- let entries = self
212
- .tree
213
- .diff(
214
- &mut self.store,
215
- left_root.as_ref(),
216
- right_root.as_ref(),
217
- request,
218
- )
219
- .await?;
220
- return Ok(entries);
221
- }
222
-
223
- if let Some(entries) = self
224
- .diff_pending_delta_suffix(left_commit_id, right_commit_id, request)
225
- .await?
226
- {
227
- return Ok(entries);
228
- }
229
-
230
- let left = self
231
- .projection_entries_at_commit(left_commit_id, request)
232
- .await?
233
- .into_iter()
234
- .collect::<BTreeMap<_, _>>();
235
- let right = self
236
- .projection_entries_at_commit(right_commit_id, request)
237
- .await?
238
- .into_iter()
239
- .collect::<BTreeMap<_, _>>();
240
- let keys = left
241
- .keys()
242
- .chain(right.keys())
243
- .cloned()
244
- .collect::<BTreeSet<_>>();
245
- let entries = keys
246
- .into_iter()
247
- .filter_map(|key| {
248
- let before = left.get(&key).cloned().map(|value| (key.clone(), value));
249
- let after = right.get(&key).cloned().map(|value| (key, value));
250
- if before == after {
251
- None
252
- } else {
253
- Some(TrackedStateTreeDiffEntry { before, after })
254
- }
255
- })
256
- .collect();
257
- Ok(entries)
201
+ request: &TrackedStateDiffRequest,
202
+ validate_left_root: bool,
203
+ validate_right_root: bool,
204
+ ) -> Result<TrackedStateDiff, LixError> {
205
+ diff_commits_with_validation(
206
+ self,
207
+ left_commit_id,
208
+ right_commit_id,
209
+ request,
210
+ validate_left_root,
211
+ validate_right_root,
212
+ )
213
+ .await
258
214
  }
259
215
 
260
- async fn diff_pending_delta_suffix(
216
+ pub(crate) async fn validate_diff_rows_for_commits_against_changelog(
261
217
  &mut self,
262
- left_commit_id: &str,
263
- right_commit_id: &str,
264
- request: &TrackedStateTreeScanRequest,
265
- ) -> Result<Option<Vec<TrackedStateTreeDiffEntry>>, LixError> {
266
- let left_delta_ids = self
267
- .delta_commit_ids_since_projection_root(left_commit_id)
268
- .await?;
269
- let right_delta_ids = self
270
- .delta_commit_ids_since_projection_root(right_commit_id)
271
- .await?;
272
- let left_base_commit_id = self
273
- .projection_base_commit_id(left_commit_id, &left_delta_ids)
274
- .await?;
275
- let right_base_commit_id = self
276
- .projection_base_commit_id(right_commit_id, &right_delta_ids)
277
- .await?;
278
- if left_base_commit_id != right_base_commit_id {
279
- return Ok(None);
280
- }
281
-
282
- if right_delta_ids.starts_with(&left_delta_ids) {
283
- let suffix = &right_delta_ids[left_delta_ids.len()..];
284
- return self
285
- .diff_pending_delta_suffix_from_base(left_commit_id, suffix, request, true)
286
- .await
287
- .map(Some);
288
- }
289
-
290
- if left_delta_ids.starts_with(&right_delta_ids) {
291
- let suffix = &left_delta_ids[right_delta_ids.len()..];
292
- return self
293
- .diff_pending_delta_suffix_from_base(right_commit_id, suffix, request, false)
294
- .await
295
- .map(Some);
218
+ rows: &[(&TrackedStateDiffRow, &str)],
219
+ ) -> Result<(), LixError> {
220
+ if rows.is_empty() {
221
+ return Ok(());
296
222
  }
297
223
 
298
- Ok(None)
299
- }
300
-
301
- async fn diff_pending_delta_suffix_from_base(
302
- &mut self,
303
- base_commit_id: &str,
304
- suffix_commit_ids: &[String],
305
- request: &TrackedStateTreeScanRequest,
306
- suffix_is_after: bool,
307
- ) -> Result<Vec<TrackedStateTreeDiffEntry>, LixError> {
308
- if suffix_commit_ids.is_empty() {
309
- return Ok(Vec::new());
310
- }
224
+ let mut change_ids = rows
225
+ .iter()
226
+ .filter(|(row, _)| row.schema_key != "lix_commit")
227
+ .map(|(row, _)| row.change_id.clone())
228
+ .collect::<Vec<_>>();
229
+ change_ids.sort();
230
+ change_ids.dedup();
311
231
 
312
- let mut changed = BTreeMap::<TrackedStateKey, TrackedStateIndexValue>::new();
313
- for commit_id in suffix_commit_ids {
314
- let Some(delta_entries) = storage::load_delta_pack(&mut self.store, commit_id).await?
315
- else {
316
- continue;
232
+ let mut changelog_reader = ChangelogContext::new().reader(&mut self.store);
233
+ let loaded_changes = changelog_reader
234
+ .load_changes(ChangeLoadRequest {
235
+ change_ids: &change_ids,
236
+ })
237
+ .await?;
238
+ let mut changes = HashMap::new();
239
+ for (change_id, loaded) in change_ids.into_iter().zip(loaded_changes.entries) {
240
+ let Some(change) = loaded else {
241
+ return Err(LixError::unknown(format!(
242
+ "tracked-state diff row references missing changelog change '{change_id}'"
243
+ )));
317
244
  };
318
- for delta in delta_entries {
319
- if request.matches_key(&delta.key) {
320
- changed.insert(delta.key, delta.value);
321
- }
322
- }
245
+ changes.insert(change_id, change);
323
246
  }
324
-
325
- if changed.is_empty() {
326
- return Ok(Vec::new());
247
+ let commit_ids = rows
248
+ .iter()
249
+ .filter(|(row, _)| row.schema_key == "lix_commit")
250
+ .map(|(row, _)| row.commit_id.clone())
251
+ .collect::<Vec<_>>();
252
+ if !commit_ids.is_empty() {
253
+ let batch = changelog_reader
254
+ .load_commits(CommitLoadRequest {
255
+ commit_ids: &commit_ids,
256
+ projection: CommitProjection::Record,
257
+ })
258
+ .await?;
259
+ for (commit_id, entry) in commit_ids.into_iter().zip(batch.entries) {
260
+ let Some(CommitLoadEntry::Record(commit)) = entry else {
261
+ return Err(LixError::unknown(format!(
262
+ "tracked-state diff row references missing changelog commit '{commit_id}'"
263
+ )));
264
+ };
265
+ changes.insert(
266
+ commit.change_id.clone(),
267
+ change_record_from_commit_record(&commit)?,
268
+ );
269
+ }
327
270
  }
328
271
 
329
- let keys = changed.keys().cloned().collect::<Vec<_>>();
330
- let base_values = self
331
- .projection_values_at_commit_for_keys(base_commit_id, &keys)
272
+ let mut validation_cache = DiffCommitRootValidationCache::new();
273
+ for (row, expected_commit_id) in rows {
274
+ validate_diff_row_against_changelog(row, &changes)?;
275
+ let change_created_at = changes
276
+ .get(&row.change_id)
277
+ .map(|change| change.created_at.as_str())
278
+ .ok_or_else(|| {
279
+ LixError::unknown(format!(
280
+ "tracked-state diff row references missing changelog change '{}'",
281
+ row.change_id
282
+ ))
283
+ })?;
284
+ self.validate_diff_row_commit_root_membership(
285
+ row,
286
+ expected_commit_id,
287
+ change_created_at,
288
+ &mut validation_cache,
289
+ )
332
290
  .await?;
333
- let entries = keys
334
- .into_iter()
335
- .zip(base_values)
336
- .filter_map(|(key, base_value)| {
337
- let changed_value = changed.get(&key).cloned();
338
- let (before_value, after_value) = if suffix_is_after {
339
- (base_value, changed_value)
340
- } else {
341
- (changed_value, base_value)
342
- };
343
- if before_value == after_value {
344
- return None;
345
- }
346
- Some(TrackedStateTreeDiffEntry {
347
- before: before_value.map(|value| (key.clone(), value)),
348
- after: after_value.map(|value| (key, value)),
349
- })
350
- })
351
- .collect();
352
- Ok(entries)
353
- }
354
-
355
- pub(crate) async fn materialize_tree_values(
356
- &mut self,
357
- entries: Vec<(TrackedStateKey, TrackedStateIndexValue)>,
358
- ) -> Result<Vec<MaterializedTrackedStateRow>, LixError> {
359
- materialize_index_entries(
360
- &mut self.store,
361
- entries,
362
- &crate::tracked_state::TrackedMaterializationProjection::full(),
363
- )
364
- .await
291
+ }
292
+ Ok(())
365
293
  }
366
294
 
367
- async fn scan_rows_at_commit_by_file_index(
295
+ async fn validate_diff_row_commit_root_membership(
368
296
  &mut self,
369
- primary_root_id: &crate::tracked_state::types::TrackedStateRootId,
370
- by_file_root_id: &crate::tracked_state::types::TrackedStateRootId,
371
- request: &TrackedStateScanRequest,
372
- ) -> Result<Vec<(TrackedStateKey, TrackedStateIndexValue)>, LixError> {
373
- let by_file_request = ByFileIndex::scan_request_from_tracked(request);
374
- let index_match_count = self
375
- .tree
376
- .count_matching_keys(&mut self.store, by_file_root_id, &by_file_request)
377
- .await?;
378
- let primary_row_count = self
379
- .tree
380
- .row_count(&mut self.store, primary_root_id)
297
+ row: &TrackedStateDiffRow,
298
+ root_commit_id: &str,
299
+ change_created_at: &str,
300
+ cache: &mut DiffCommitRootValidationCache,
301
+ ) -> Result<(), LixError> {
302
+ let identity = tracked_state_identity_from_diff_row(row)?;
303
+ let key = TrackedStateKey {
304
+ schema_key: row.schema_key.clone(),
305
+ file_id: row.file_id.clone(),
306
+ entity_pk: row.entity_pk.clone(),
307
+ };
308
+ let root_metadata = self
309
+ .load_cached_commit_root_metadata(root_commit_id, cache)
381
310
  .await?;
382
- if index_match_count * 20 > primary_row_count {
383
- let rows = self
384
- .tree
385
- .scan(
386
- &mut self.store,
387
- primary_root_id,
388
- &tree_scan_request_from_tracked(request),
389
- )
390
- .await?;
391
- return Ok(rows);
392
- }
393
- let index_rows = self
394
- .tree
395
- .scan(&mut self.store, by_file_root_id, &by_file_request)
311
+ self.validate_commit_root_parent_matches_changelog(root_commit_id, &root_metadata, cache)
396
312
  .await?;
397
- let mut rows = Vec::new();
398
- let tree_request = tree_scan_request_from_tracked(request);
399
- let needs_payloads = scan_needs_json_payloads(request);
400
- if needs_payloads {
401
- let mut primary_keys = Vec::with_capacity(index_rows.len());
402
- for (index_key, _) in index_rows {
403
- if let Some(primary_key) = ByFileIndex::primary_key_from_index_key(index_key) {
404
- primary_keys.push(primary_key);
405
- }
313
+ let (_, row_value) = row.clone().into_index_entry();
314
+ let mut current_commit_id = root_commit_id.to_string();
315
+ let mut seen = HashSet::new();
316
+ loop {
317
+ if !seen.insert(current_commit_id.clone()) {
318
+ return Err(LixError::unknown(format!(
319
+ "tracked-state commit-root parent chain contains cycle at commit '{current_commit_id}'"
320
+ )));
406
321
  }
407
- let primary_values = self
408
- .tree
409
- .get_many(&mut self.store, primary_root_id, &primary_keys)
322
+
323
+ let winners = self
324
+ .load_cached_commit_ref_winners(&current_commit_id, cache)
410
325
  .await?;
411
- for (primary_key, value) in primary_keys.into_iter().zip(primary_values) {
412
- let Some(value) = value else {
413
- continue;
414
- };
415
- if !tree_request.matches(&primary_key, &value) {
416
- continue;
326
+ if let Some(winner_change_id) = winners.get(&identity) {
327
+ if winner_change_id != &row.change_id {
328
+ return Err(LixError::unknown(format!(
329
+ "tracked-state diff row references changelog change '{}' that is not the first-parent winner for commit '{}' and identity {:?}",
330
+ row.change_id, root_commit_id, identity
331
+ )));
417
332
  }
418
- rows.push((primary_key, value));
333
+ self.validate_diff_row_created_at(row, &key, &current_commit_id, change_created_at)
334
+ .await?;
335
+ return Ok(());
419
336
  }
420
- return Ok(rows);
421
- }
422
337
 
423
- for (index_key, index_value) in index_rows {
424
- let Some(primary_key) = ByFileIndex::primary_key_from_index_key(index_key) else {
425
- continue;
338
+ let metadata = self
339
+ .load_cached_commit_root_metadata(&current_commit_id, cache)
340
+ .await?;
341
+ self.validate_commit_root_parent_matches_changelog(
342
+ &current_commit_id,
343
+ &metadata,
344
+ cache,
345
+ )
346
+ .await?;
347
+ let Some(parent) = metadata.parent_roots.first() else {
348
+ return Err(LixError::unknown(format!(
349
+ "tracked-state diff row references changelog change '{}' that is not the first-parent winner for commit '{}' and identity {:?}",
350
+ row.change_id, root_commit_id, identity
351
+ )));
426
352
  };
427
- let value = index_value;
428
- if tree_request.matches(&primary_key, &value) {
429
- rows.push((primary_key, value));
353
+ let parent_value = self
354
+ .load_cached_tree_value(&parent.root_id, &key, cache)
355
+ .await?;
356
+ if parent_value.as_ref() != Some(&row_value) {
357
+ return Err(LixError::unknown(format!(
358
+ "tracked-state commit-root row for commit '{}' does not match parent root '{}' for inherited identity {:?}",
359
+ root_commit_id, parent.commit_id, identity
360
+ )));
430
361
  }
362
+ current_commit_id = parent.commit_id.clone();
431
363
  }
432
- Ok(rows)
433
364
  }
434
365
 
435
- async fn projection_root_exists(&mut self, commit_id: &str) -> Result<bool, LixError> {
436
- Ok(self
437
- .tree
438
- .load_root(&mut self.store, commit_id)
439
- .await?
440
- .is_some())
366
+ async fn validate_commit_root_parent_matches_changelog(
367
+ &mut self,
368
+ commit_id: &str,
369
+ metadata: &TrackedStateCommitRoot,
370
+ cache: &mut DiffCommitRootValidationCache,
371
+ ) -> Result<(), LixError> {
372
+ let changelog_first_parent = self
373
+ .load_cached_changelog_first_parent(commit_id, cache)
374
+ .await?;
375
+ let expected_parent = match changelog_first_parent.as_deref() {
376
+ Some(first_parent_id) => {
377
+ self.nearest_available_commit_root_parent(first_parent_id, cache)
378
+ .await?
379
+ }
380
+ None => None,
381
+ };
382
+ match (expected_parent, metadata.parent_roots.first()) {
383
+ (None, None) => Ok(()),
384
+ (Some((expected_parent_id, expected_root)), Some(parent))
385
+ if parent.commit_id == expected_parent_id && parent.root_id == expected_root =>
386
+ {
387
+ Ok(())
388
+ }
389
+ (Some((expected_parent_id, expected_root)), Some(parent))
390
+ if parent.commit_id == expected_parent_id =>
391
+ {
392
+ let _ = expected_root;
393
+ Err(LixError::unknown(format!(
394
+ "tracked-state commit-root metadata for commit '{}' references stale root for commit-root parent '{}'",
395
+ commit_id, expected_parent_id
396
+ )))
397
+ }
398
+ (Some((expected_parent_id, _)), Some(parent)) => Err(LixError::unknown(format!(
399
+ "tracked-state commit-root metadata for commit '{}' references parent '{}' but nearest available first-parent root is '{}'",
400
+ commit_id, parent.commit_id, expected_parent_id
401
+ ))),
402
+ (Some((expected_parent_id, _)), None) => Err(LixError::unknown(format!(
403
+ "tracked-state commit-root metadata for commit '{}' is missing commit-root parent '{}'",
404
+ commit_id, expected_parent_id
405
+ ))),
406
+ (None, Some(parent)) => Err(LixError::unknown(format!(
407
+ "tracked-state commit-root metadata for root commit '{}' references unexpected parent '{}'",
408
+ commit_id, parent.commit_id
409
+ ))),
410
+ }
441
411
  }
442
412
 
443
- async fn projection_has_pending_deltas(&mut self, commit_id: &str) -> Result<bool, LixError> {
444
- Ok(!self
445
- .delta_commit_ids_since_projection_root(commit_id)
446
- .await?
447
- .is_empty())
413
+ async fn nearest_available_commit_root_parent(
414
+ &mut self,
415
+ start_commit_id: &str,
416
+ cache: &mut DiffCommitRootValidationCache,
417
+ ) -> Result<Option<(String, TrackedStateRootId)>, LixError> {
418
+ let mut current = Some(start_commit_id.to_string());
419
+ let mut seen = HashSet::new();
420
+ while let Some(commit_id) = current {
421
+ if !seen.insert(commit_id.clone()) {
422
+ return Err(LixError::unknown(format!(
423
+ "tracked-state commit-root parent chain contains cycle at commit '{commit_id}'"
424
+ )));
425
+ }
426
+ if let Some(root_id) = self
427
+ .load_cached_commit_root_optional(&commit_id, cache)
428
+ .await?
429
+ {
430
+ return Ok(Some((commit_id, root_id)));
431
+ }
432
+ current = self
433
+ .load_cached_changelog_first_parent(&commit_id, cache)
434
+ .await?;
435
+ }
436
+ Ok(None)
448
437
  }
449
438
 
450
- async fn projection_entries_at_commit(
439
+ async fn load_cached_commit_ref_winners(
451
440
  &mut self,
452
441
  commit_id: &str,
453
- request: &TrackedStateTreeScanRequest,
454
- ) -> Result<Vec<(TrackedStateKey, TrackedStateIndexValue)>, LixError> {
455
- let delta_commit_ids = self
456
- .delta_commit_ids_since_projection_root(commit_id)
457
- .await?;
458
- let base_commit_id = self
459
- .projection_base_commit_id(commit_id, &delta_commit_ids)
460
- .await?;
461
- if base_commit_id.is_none() && delta_commit_ids.len() == 1 {
462
- return self
463
- .single_delta_pack_entries(&delta_commit_ids[0], request)
464
- .await;
442
+ cache: &mut DiffCommitRootValidationCache,
443
+ ) -> Result<HashMap<TrackedStateIdentity, String>, LixError> {
444
+ if let Some(winners) = cache.commit_ref_winners.get(commit_id) {
445
+ return Ok(winners.clone());
465
446
  }
466
- let mut entries = if let Some(base_commit_id) = base_commit_id {
467
- let root_id = self
468
- .tree
469
- .load_root(&mut self.store, &base_commit_id)
470
- .await?
471
- .ok_or_else(|| {
472
- LixError::new(
473
- LixError::CODE_INTERNAL_ERROR,
474
- format!(
475
- "tracked_state projection base root '{base_commit_id}' disappeared"
476
- ),
477
- )
478
- })?;
479
- self.tree
480
- .scan(&mut self.store, &root_id, request)
481
- .await?
482
- .into_iter()
483
- .collect::<BTreeMap<_, _>>()
484
- } else {
485
- BTreeMap::new()
486
- };
487
- self.apply_delta_packs_to_entries(&delta_commit_ids, Some(request), &mut entries)
447
+ let commit_ids = [commit_id.to_string()];
448
+ let mut changelog_reader = ChangelogContext::new().reader(&mut self.store);
449
+ let batch = changelog_reader
450
+ .load_commits(CommitLoadRequest {
451
+ commit_ids: &commit_ids,
452
+ projection: CommitProjection::Full,
453
+ })
488
454
  .await?;
489
- Ok(entries.into_iter().collect())
455
+ let Some(entry) = batch.entries.into_iter().next().flatten() else {
456
+ return Err(LixError::unknown(format!(
457
+ "changelog commit '{commit_id}' is missing while validating tracked-state commit-root rows"
458
+ )));
459
+ };
460
+ let CommitLoadEntry::Full {
461
+ record,
462
+ change_ref_chunks: chunks,
463
+ } = entry
464
+ else {
465
+ return Err(LixError::unknown(format!(
466
+ "changelog commit '{commit_id}' did not return full commit"
467
+ )));
468
+ };
469
+ let mut winners = HashMap::new();
470
+ winners.insert(
471
+ TrackedStateIdentity {
472
+ schema_key: "lix_commit".to_string(),
473
+ file_id: None,
474
+ entity_pk: EntityPk::single(&record.commit_id),
475
+ },
476
+ record.change_id,
477
+ );
478
+ for change_ref in chunks.into_iter().flat_map(|chunk| chunk.entries) {
479
+ winners.insert(
480
+ TrackedStateIdentity {
481
+ schema_key: change_ref.schema_key,
482
+ file_id: change_ref.file_id,
483
+ entity_pk: change_ref.entity_pk,
484
+ },
485
+ change_ref.change_id,
486
+ );
487
+ }
488
+ cache
489
+ .commit_ref_winners
490
+ .insert(commit_id.to_string(), winners.clone());
491
+ Ok(winners)
490
492
  }
491
493
 
492
- async fn single_delta_pack_entries(
494
+ async fn load_cached_commit_root_metadata(
493
495
  &mut self,
494
496
  commit_id: &str,
495
- request: &TrackedStateTreeScanRequest,
496
- ) -> Result<Vec<(TrackedStateKey, TrackedStateIndexValue)>, LixError> {
497
- let Some(delta_entries) = storage::load_delta_pack(&mut self.store, commit_id).await?
498
- else {
499
- return Ok(Vec::new());
500
- };
501
- let mut rows = delta_entries
502
- .into_iter()
503
- .enumerate()
504
- .filter_map(|(ordinal, delta)| {
505
- request
506
- .matches_key(&delta.key)
507
- .then_some((ordinal, delta.key, delta.value))
508
- })
509
- .collect::<Vec<_>>();
510
- rows.sort_by(|left, right| left.1.cmp(&right.1).then(left.0.cmp(&right.0)));
511
-
512
- let mut out = Vec::new();
513
- let mut rows = rows.into_iter().peekable();
514
- while let Some((_, key, mut value)) = rows.next() {
515
- while rows.peek().is_some_and(|(_, next_key, _)| next_key == &key) {
516
- let (_, _, next_value) = rows
517
- .next()
518
- .expect("peek confirmed duplicate delta entry exists");
519
- value = next_value;
520
- }
521
- if !request.include_tombstones && value.deleted {
522
- continue;
523
- }
524
- out.push((key, value));
497
+ cache: &mut DiffCommitRootValidationCache,
498
+ ) -> Result<TrackedStateCommitRoot, LixError> {
499
+ if let Some(metadata) = cache.commit_root_metadata.get(commit_id) {
500
+ return Ok(metadata.clone());
525
501
  }
526
- Ok(out)
502
+ let metadata = storage::load_commit_root(&mut self.store, commit_id)
503
+ .await?
504
+ .ok_or_else(|| missing_commit_root_error(commit_id))?;
505
+ cache
506
+ .commit_root_metadata
507
+ .insert(commit_id.to_string(), metadata.clone());
508
+ Ok(metadata)
527
509
  }
528
510
 
529
- async fn projection_values_at_commit_for_keys(
511
+ async fn load_cached_commit_root_optional(
530
512
  &mut self,
531
513
  commit_id: &str,
532
- keys: &[TrackedStateKey],
533
- ) -> Result<Vec<Option<TrackedStateIndexValue>>, LixError> {
534
- let delta_commit_ids = self
535
- .delta_commit_ids_since_projection_root(commit_id)
536
- .await?;
537
- let base_commit_id = self
538
- .projection_base_commit_id(commit_id, &delta_commit_ids)
539
- .await?;
540
- let mut entries = if let Some(base_commit_id) = base_commit_id {
541
- let root_id = self
542
- .tree
543
- .load_root(&mut self.store, &base_commit_id)
544
- .await?
545
- .ok_or_else(|| {
546
- LixError::new(
547
- LixError::CODE_INTERNAL_ERROR,
548
- format!(
549
- "tracked_state projection base root '{base_commit_id}' disappeared"
550
- ),
551
- )
552
- })?;
553
- let values = self.tree.get_many(&mut self.store, &root_id, keys).await?;
554
- keys.iter()
555
- .cloned()
556
- .zip(values)
557
- .filter_map(|(key, value)| value.map(|value| (key, value)))
558
- .collect::<BTreeMap<_, _>>()
559
- } else {
560
- BTreeMap::new()
561
- };
562
- let key_filter = keys.iter().cloned().collect::<BTreeSet<_>>();
563
- self.apply_delta_packs_to_entries_for_keys(&delta_commit_ids, &key_filter, &mut entries)
564
- .await?;
565
- Ok(keys.iter().map(|key| entries.get(key).cloned()).collect())
514
+ cache: &mut DiffCommitRootValidationCache,
515
+ ) -> Result<Option<TrackedStateRootId>, LixError> {
516
+ if let Some(root_id) = cache.commit_roots.get(commit_id) {
517
+ return Ok(Some(root_id.clone()));
518
+ }
519
+ let root_id = storage::load_root(&self.store, commit_id).await?;
520
+ if let Some(root_id) = &root_id {
521
+ cache
522
+ .commit_roots
523
+ .insert(commit_id.to_string(), root_id.clone());
524
+ }
525
+ Ok(root_id)
526
+ }
527
+
528
+ async fn load_cached_tree_value(
529
+ &mut self,
530
+ root_id: &TrackedStateRootId,
531
+ key: &TrackedStateKey,
532
+ cache: &mut DiffCommitRootValidationCache,
533
+ ) -> Result<Option<TrackedStateIndexValue>, LixError> {
534
+ let cache_key = (root_id.clone(), key.clone());
535
+ if let Some(value) = cache.tree_values.get(&cache_key) {
536
+ return Ok(value.clone());
537
+ }
538
+ let value = self
539
+ .tree
540
+ .get_many(&mut self.store, root_id, std::slice::from_ref(key))
541
+ .await?
542
+ .into_iter()
543
+ .next()
544
+ .flatten();
545
+ cache.tree_values.insert(cache_key, value.clone());
546
+ Ok(value)
566
547
  }
567
548
 
568
- async fn projection_base_commit_id(
549
+ async fn load_cached_changelog_first_parent(
569
550
  &mut self,
570
551
  commit_id: &str,
571
- delta_commit_ids: &[String],
552
+ cache: &mut DiffCommitRootValidationCache,
572
553
  ) -> Result<Option<String>, LixError> {
573
- if delta_commit_ids.is_empty() {
574
- return Ok(if self.projection_root_exists(commit_id).await? {
575
- Some(commit_id.to_string())
576
- } else {
577
- None
578
- });
554
+ if let Some(parent_id) = cache.changelog_first_parents.get(commit_id) {
555
+ return Ok(parent_id.clone());
579
556
  }
580
- let Some(first_delta_commit_id) = delta_commit_ids.first() else {
581
- return Ok(None);
557
+ let commit_ids = [commit_id.to_string()];
558
+ let mut changelog_reader = ChangelogContext::new().reader(&mut self.store);
559
+ let batch = changelog_reader
560
+ .load_commits(CommitLoadRequest {
561
+ commit_ids: &commit_ids,
562
+ projection: CommitProjection::Record,
563
+ })
564
+ .await?;
565
+ let Some(entry) = batch.entries.into_iter().next().flatten() else {
566
+ return Err(LixError::unknown(format!(
567
+ "changelog commit '{commit_id}' is missing while validating tracked-state commit-root metadata"
568
+ )));
582
569
  };
583
- let commit = self
584
- .commit_store
585
- .load_commit_from(&mut self.store, first_delta_commit_id)
586
- .await?
587
- .ok_or_else(|| missing_commit_error(first_delta_commit_id))?;
588
- let Some(parent_id) = commit.parent_ids.first() else {
589
- return Ok(None);
570
+ let CommitLoadEntry::Record(record) = entry else {
571
+ return Err(LixError::unknown(format!(
572
+ "changelog commit '{commit_id}' did not return a commit record"
573
+ )));
590
574
  };
591
- Ok(if self.projection_root_exists(parent_id).await? {
592
- Some(parent_id.clone())
593
- } else {
594
- None
595
- })
575
+ let parent_id = record.parent_commit_ids.first().cloned();
576
+ cache
577
+ .changelog_first_parents
578
+ .insert(commit_id.to_string(), parent_id.clone());
579
+ Ok(parent_id)
596
580
  }
597
581
 
598
- async fn delta_commit_ids_since_projection_root(
582
+ async fn validate_diff_row_created_at(
599
583
  &mut self,
584
+ row: &TrackedStateDiffRow,
585
+ key: &TrackedStateKey,
600
586
  commit_id: &str,
601
- ) -> Result<Vec<String>, LixError> {
602
- let mut out = Vec::new();
603
- let mut seen = BTreeSet::new();
604
- let mut current = Some(commit_id.to_string());
605
- while let Some(current_id) = current {
606
- if !seen.insert(current_id.clone()) {
607
- return Err(LixError::new(
608
- LixError::CODE_INTERNAL_ERROR,
609
- format!("tracked_state projection found first-parent cycle at '{current_id}'"),
610
- ));
611
- }
612
- if self
587
+ change_created_at: &str,
588
+ ) -> Result<(), LixError> {
589
+ let mut expected_created_at = change_created_at.to_string();
590
+ let Some(metadata) = storage::load_commit_root(&mut self.store, commit_id).await? else {
591
+ return Err(missing_commit_root_error(commit_id));
592
+ };
593
+ if let Some(parent) = metadata.parent_roots.first() {
594
+ let parent_value = self
613
595
  .tree
614
- .load_root(&mut self.store, &current_id)
596
+ .get_many(&mut self.store, &parent.root_id, std::slice::from_ref(key))
615
597
  .await?
616
- .is_some()
617
- {
618
- break;
619
- }
620
- if storage::delta_pack_exists(&mut self.store, &current_id).await? {
621
- out.push(current_id.clone());
598
+ .into_iter()
599
+ .next()
600
+ .flatten();
601
+ if let Some(parent_value) = parent_value {
602
+ expected_created_at = parent_value.created_at;
622
603
  }
623
- let commit = self
624
- .commit_store
625
- .load_commit_from(&mut self.store, &current_id)
604
+ }
605
+ if expected_created_at == change_created_at {
606
+ if let Some(merge_parent_created_at) = self
607
+ .load_merge_parent_created_at_for_row(commit_id, row, key)
626
608
  .await?
627
- .ok_or_else(|| missing_commit_error(&current_id))?;
628
- current = commit.parent_ids.first().cloned();
609
+ {
610
+ expected_created_at = merge_parent_created_at;
611
+ }
629
612
  }
630
- out.reverse();
631
- Ok(out)
632
- }
633
-
634
- async fn apply_delta_packs_to_entries(
635
- &mut self,
636
- commit_ids: &[String],
637
- request: Option<&TrackedStateTreeScanRequest>,
638
- entries: &mut BTreeMap<TrackedStateKey, TrackedStateIndexValue>,
639
- ) -> Result<(), LixError> {
640
- for commit_id in commit_ids {
641
- let Some(delta_entries) = storage::load_delta_pack(&mut self.store, commit_id).await?
642
- else {
643
- continue;
644
- };
645
- for delta in delta_entries {
646
- if let Some(request) = request {
647
- if !request.matches_key(&delta.key) {
648
- continue;
649
- }
650
- if !request.include_tombstones && delta.value.deleted {
651
- entries.remove(&delta.key);
652
- continue;
653
- }
654
- entries.insert(delta.key, delta.value);
655
- } else {
656
- entries.insert(delta.key, delta.value);
657
- }
613
+ if expected_created_at == change_created_at && row.commit_id != commit_id {
614
+ if let Some(source_created_at) =
615
+ self.load_parent_created_at_for_row_commit(row, key).await?
616
+ {
617
+ expected_created_at = source_created_at;
658
618
  }
659
619
  }
660
- Ok(())
620
+ if row.created_at == expected_created_at {
621
+ return Ok(());
622
+ }
623
+ Err(LixError::unknown(format!(
624
+ "tracked-state diff row for change '{}' created_at '{}' does not match first ancestry timestamp '{}'",
625
+ row.change_id, row.created_at, expected_created_at
626
+ )))
661
627
  }
662
628
 
663
- async fn apply_delta_packs_to_entries_for_keys(
629
+ async fn load_merge_parent_created_at_for_row(
664
630
  &mut self,
665
- commit_ids: &[String],
666
- keys: &BTreeSet<TrackedStateKey>,
667
- entries: &mut BTreeMap<TrackedStateKey, TrackedStateIndexValue>,
668
- ) -> Result<(), LixError> {
669
- for commit_id in commit_ids {
670
- let Some(delta_entries) = storage::load_delta_pack(&mut self.store, commit_id).await?
671
- else {
631
+ commit_id: &str,
632
+ row: &TrackedStateDiffRow,
633
+ key: &TrackedStateKey,
634
+ ) -> Result<Option<String>, LixError> {
635
+ let commit_ids = [commit_id.to_string()];
636
+ let mut changelog_reader = ChangelogContext::new().reader(&mut self.store);
637
+ let batch = changelog_reader
638
+ .load_commits(CommitLoadRequest {
639
+ commit_ids: &commit_ids,
640
+ projection: CommitProjection::Record,
641
+ })
642
+ .await?;
643
+ let Some(CommitLoadEntry::Record(commit)) = batch.entries.into_iter().next().flatten()
644
+ else {
645
+ return Ok(None);
646
+ };
647
+ for parent_id in commit.parent_commit_ids.iter().skip(1) {
648
+ let Some(parent_root) = storage::load_root(&self.store, parent_id).await? else {
672
649
  continue;
673
650
  };
674
- for delta in delta_entries {
675
- if keys.contains(&delta.key) {
676
- entries.insert(delta.key, delta.value);
651
+ let parent_value = self
652
+ .tree
653
+ .get_many(&mut self.store, &parent_root, std::slice::from_ref(key))
654
+ .await?
655
+ .into_iter()
656
+ .next()
657
+ .flatten();
658
+ if let Some(parent_value) = parent_value {
659
+ if parent_value.change_id == row.change_id {
660
+ return Ok(Some(parent_value.created_at));
677
661
  }
678
662
  }
679
663
  }
680
- Ok(())
664
+ Ok(None)
681
665
  }
682
666
 
683
- /// Plans a three-way merge by diffing both heads against the same base.
684
- ///
685
- /// `target_commit_id` is the destination root that should keep its own
686
- /// changes. `source_commit_id` is the incoming root whose non-conflicting
667
+ async fn load_parent_created_at_for_row_commit(
668
+ &mut self,
669
+ row: &TrackedStateDiffRow,
670
+ key: &TrackedStateKey,
671
+ ) -> Result<Option<String>, LixError> {
672
+ let Some(metadata) = storage::load_commit_root(&mut self.store, &row.commit_id).await?
673
+ else {
674
+ return Ok(None);
675
+ };
676
+ let Some(parent) = metadata.parent_roots.first() else {
677
+ return Ok(None);
678
+ };
679
+ let parent_value = self
680
+ .tree
681
+ .get_many(&mut self.store, &parent.root_id, std::slice::from_ref(key))
682
+ .await?
683
+ .into_iter()
684
+ .next()
685
+ .flatten();
686
+ Ok(parent_value.map(|value| value.created_at))
687
+ }
688
+
689
+ pub(crate) async fn validate_tree_rows_at_commit_against_changelog(
690
+ &mut self,
691
+ commit_id: &str,
692
+ request: &TrackedStateTreeScanRequest,
693
+ ) -> Result<(), LixError> {
694
+ let root = self.load_ensured_root(commit_id).await?;
695
+ let rows = self.tree.scan(&mut self.store, &root, request).await?;
696
+ self.validate_commit_root_coverage(commit_id, request, &rows)
697
+ .await?;
698
+ let rows = rows
699
+ .into_iter()
700
+ .map(|(key, value)| TrackedStateDiffRow::from_tree_entry(key, value))
701
+ .collect::<Vec<_>>();
702
+ let row_refs = rows.iter().map(|row| (row, commit_id)).collect::<Vec<_>>();
703
+ self.validate_diff_rows_for_commits_against_changelog(&row_refs)
704
+ .await
705
+ }
706
+
707
+ async fn validate_commit_root_coverage(
708
+ &mut self,
709
+ commit_id: &str,
710
+ request: &TrackedStateTreeScanRequest,
711
+ rows: &[(TrackedStateKey, TrackedStateIndexValue)],
712
+ ) -> Result<(), LixError> {
713
+ let row_map = rows
714
+ .iter()
715
+ .map(|(key, value)| (tracked_state_identity_from_key(key), value))
716
+ .collect::<HashMap<_, _>>();
717
+ let mut cache = DiffCommitRootValidationCache::new();
718
+ let winners = self
719
+ .load_cached_commit_ref_winners(commit_id, &mut cache)
720
+ .await?;
721
+ for (identity, change_id) in &winners {
722
+ if !tracked_state_identity_matches_tree_request(identity, request) {
723
+ continue;
724
+ }
725
+ let Some(value) = row_map.get(identity) else {
726
+ return Err(LixError::unknown(format!(
727
+ "tracked-state commit-root for commit '{commit_id}' omits current changelog change '{change_id}' for identity {:?}",
728
+ identity
729
+ )));
730
+ };
731
+ if &value.change_id != change_id {
732
+ return Err(LixError::unknown(format!(
733
+ "tracked-state commit-root for commit '{commit_id}' stores change '{}' but changelog winner is '{}' for identity {:?}",
734
+ value.change_id, change_id, identity
735
+ )));
736
+ }
737
+ }
738
+
739
+ let metadata = self
740
+ .load_cached_commit_root_metadata(commit_id, &mut cache)
741
+ .await?;
742
+ let Some(parent) = metadata.parent_roots.first() else {
743
+ return Ok(());
744
+ };
745
+ let parent_rows = self
746
+ .tree
747
+ .scan(&mut self.store, &parent.root_id, request)
748
+ .await?;
749
+ for (parent_key, parent_value) in parent_rows {
750
+ let identity = tracked_state_identity_from_key(&parent_key);
751
+ if winners.contains_key(&identity) {
752
+ continue;
753
+ }
754
+ let Some(value) = row_map.get(&identity) else {
755
+ return Err(LixError::unknown(format!(
756
+ "tracked-state commit-root for commit '{commit_id}' omits inherited identity {:?} from parent '{}'",
757
+ identity, parent.commit_id
758
+ )));
759
+ };
760
+ if *value != &parent_value {
761
+ return Err(LixError::unknown(format!(
762
+ "tracked-state commit-root for commit '{commit_id}' does not preserve inherited identity {:?} from parent '{}'",
763
+ identity, parent.commit_id
764
+ )));
765
+ }
766
+ }
767
+ Ok(())
768
+ }
769
+
770
+ pub(crate) async fn diff_tree_entries_at_commits(
771
+ &mut self,
772
+ left_commit_id: &str,
773
+ right_commit_id: &str,
774
+ request: &TrackedStateTreeScanRequest,
775
+ ) -> Result<Vec<crate::tracked_state::types::TrackedStateTreeDiffEntry>, LixError> {
776
+ let left_root = self.load_ensured_root(left_commit_id).await?;
777
+ let right_root = self.load_ensured_root(right_commit_id).await?;
778
+ self.tree
779
+ .diff(
780
+ &mut self.store,
781
+ Some(&left_root),
782
+ Some(&right_root),
783
+ request,
784
+ )
785
+ .await
786
+ }
787
+
788
+ async fn load_ensured_root(
789
+ &mut self,
790
+ commit_id: &str,
791
+ ) -> Result<crate::tracked_state::types::TrackedStateRootId, LixError> {
792
+ self.tree
793
+ .load_root(&mut self.store, commit_id)
794
+ .await?
795
+ .ok_or_else(|| missing_commit_root_error(commit_id))
796
+ }
797
+
798
+ #[cfg(any(test, feature = "storage-benches"))]
799
+ async fn commit_root_values_for_keys(
800
+ &mut self,
801
+ commit_id: &str,
802
+ keys: &[TrackedStateKey],
803
+ ) -> Result<Vec<Option<TrackedStateIndexValue>>, LixError> {
804
+ let root_id = self.load_ensured_root(commit_id).await?;
805
+ self.tree.get_many(&mut self.store, &root_id, keys).await
806
+ }
807
+
808
+ /// Plans a three-way merge by diffing both heads against the same base.
809
+ ///
810
+ /// `target_commit_id` is the destination root that should keep its own
811
+ /// changes. `source_commit_id` is the incoming root whose non-conflicting
687
812
  /// changes should be applied.
688
- #[allow(dead_code)]
813
+ #[cfg(test)]
689
814
  pub(crate) async fn plan_merge(
690
815
  &mut self,
691
816
  base_commit_id: &str,
@@ -703,75 +828,38 @@ where
703
828
  }
704
829
  }
705
830
 
706
- /// Writer for commit-store-backed tracked-state projection roots.
831
+ /// Writer for changelog-backed tracked-state commit roots.
707
832
  pub(crate) struct TrackedStateWriter<'a, S: ?Sized> {
833
+ chunk_overlay: storage::TrackedStateChunkOverlay,
834
+ staged_roots: BTreeMap<String, crate::tracked_state::types::TrackedStateRootId>,
708
835
  tree: TrackedStateTree,
709
- store: &'a mut S,
836
+ store: &'a S,
710
837
  writes: &'a mut StorageWriteSet,
711
838
  }
712
839
 
713
- /// Explicit projection-root materializer created by `TrackedStateContext`.
714
- pub(crate) struct TrackedStateMaterializer<'a, S: ?Sized> {
715
- pub(super) tracked_state: &'a TrackedStateContext,
716
- pub(super) store: &'a mut S,
840
+ /// Explicit commit-root rebuilder created by `TrackedStateContext`.
841
+ pub(crate) struct TrackedStateRootRebuilder<'a, S: ?Sized> {
842
+ pub(super) store: &'a S,
717
843
  pub(super) writes: &'a mut StorageWriteSet,
718
- pub(super) commit_store: &'a CommitStoreContext,
719
844
  }
720
845
 
721
- impl<S> TrackedStateMaterializer<'_, S>
846
+ impl<S> TrackedStateRootRebuilder<'_, S>
722
847
  where
723
- S: StorageReader + ?Sized,
848
+ S: StorageRead + Send + Sync + ?Sized,
724
849
  {
725
- pub(crate) async fn materialize_root_at(
850
+ pub(crate) async fn rebuild_commit_root_at(
726
851
  &mut self,
727
852
  commit_id: &str,
728
853
  ) -> Result<TrackedStateWriteReport, LixError> {
729
- crate::tracked_state::materializer::materialize_root_at(self, commit_id).await
854
+ crate::tracked_state::commit_root_rebuild::rebuild_commit_root_at(self, commit_id).await
730
855
  }
731
856
  }
732
857
 
733
858
  impl<S> TrackedStateWriter<'_, S>
734
859
  where
735
- S: StorageReader + ?Sized,
860
+ S: StorageRead + Send + Sync + ?Sized,
736
861
  {
737
- /// Stages one tracked-state projection delta for `commit_id`.
738
- pub(crate) async fn stage_delta(
739
- &mut self,
740
- commit_id: &str,
741
- _parent_commit_id: Option<&str>,
742
- deltas: &[TrackedStateDeltaRef<'_>],
743
- ) -> Result<TrackedStateWriteReport, LixError> {
744
- storage::stage_delta_pack_refs(self.writes, commit_id, deltas)?;
745
- Ok(TrackedStateWriteReport {
746
- commit_id: commit_id.to_string(),
747
- changed_rows: deltas.len(),
748
- primary_chunk_puts: 0,
749
- by_file_chunk_puts: 0,
750
- })
751
- }
752
-
753
- pub(crate) async fn stage_delta_with_json_pack_indexes(
754
- &mut self,
755
- commit_id: &str,
756
- _parent_commit_id: Option<&str>,
757
- deltas: &[TrackedStateDeltaRef<'_>],
758
- json_pack_indexes: DeltaJsonPackIndexesRef<'_>,
759
- ) -> Result<TrackedStateWriteReport, LixError> {
760
- storage::stage_delta_pack_refs_with_json_pack_indexes(
761
- self.writes,
762
- commit_id,
763
- deltas,
764
- json_pack_indexes,
765
- )?;
766
- Ok(TrackedStateWriteReport {
767
- commit_id: commit_id.to_string(),
768
- changed_rows: deltas.len(),
769
- primary_chunk_puts: 0,
770
- by_file_chunk_puts: 0,
771
- })
772
- }
773
-
774
- pub(crate) async fn stage_projection_root<'a, I>(
862
+ pub(crate) async fn stage_commit_root<'a, I>(
775
863
  &mut self,
776
864
  commit_id: &str,
777
865
  parent_commit_id: Option<&str>,
@@ -783,7 +871,11 @@ where
783
871
  let deltas = deltas.into_iter().collect::<Vec<_>>();
784
872
  let base_root = match parent_commit_id {
785
873
  Some(parent_commit_id) => {
786
- let Some(root) = self.tree.load_root(self.store, parent_commit_id).await? else {
874
+ let root = match self.staged_roots.get(parent_commit_id) {
875
+ Some(root) => Some(root.clone()),
876
+ None => self.tree.load_root(self.store, parent_commit_id).await?,
877
+ };
878
+ let Some(root) = root else {
787
879
  return Err(LixError::new(
788
880
  "LIX_ERROR_UNKNOWN",
789
881
  format!(
@@ -795,19 +887,37 @@ where
795
887
  }
796
888
  None => None,
797
889
  };
890
+ let parent_values = if let Some(base_root) = base_root.as_ref() {
891
+ let keys = deltas
892
+ .iter()
893
+ .map(|delta| TrackedStateKey {
894
+ schema_key: delta.schema_key.to_string(),
895
+ file_id: delta.file_id.map(str::to_string),
896
+ entity_pk: delta.entity_pk.clone(),
897
+ })
898
+ .collect::<Vec<_>>();
899
+ self.tree.get_many(self.store, base_root, &keys).await?
900
+ } else {
901
+ vec![None; deltas.len()]
902
+ };
798
903
  let mut mutations = Vec::with_capacity(deltas.len());
799
- for delta in &deltas {
904
+ for (delta, parent_value) in deltas.iter().zip(parent_values.iter()) {
905
+ let created_at = parent_value
906
+ .as_ref()
907
+ .map(|value| value.created_at.as_str())
908
+ .unwrap_or(delta.created_at);
800
909
  let key = TrackedStateKeyRef {
801
- schema_key: delta.change.schema_key,
802
- file_id: delta.change.file_id,
803
- entity_id: delta.change.entity_id,
910
+ schema_key: delta.schema_key,
911
+ file_id: delta.file_id,
912
+ entity_pk: delta.entity_pk,
804
913
  };
805
914
  let value = crate::tracked_state::types::TrackedStateIndexValueRef {
806
- change_locator: delta.locator,
807
- deleted: delta.change.snapshot_ref.is_none(),
808
- snapshot_ref: delta.change.snapshot_ref,
809
- metadata_ref: delta.change.metadata_ref,
810
- created_at: delta.created_at,
915
+ change_id: delta.change_id,
916
+ commit_id: delta.commit_id,
917
+ deleted: delta.deleted,
918
+ snapshot_ref: delta.snapshot_ref,
919
+ metadata_ref: delta.metadata_ref,
920
+ created_at,
811
921
  updated_at: delta.updated_at,
812
922
  };
813
923
  mutations.push(TrackedStateMutation::put_encoded(
@@ -817,69 +927,69 @@ where
817
927
  }
818
928
  let result = self
819
929
  .tree
820
- .apply_mutations(
930
+ .apply_mutations_with_overlay(
821
931
  self.store,
822
932
  self.writes,
933
+ &mut self.chunk_overlay,
823
934
  base_root.as_ref(),
824
935
  mutations,
825
936
  Some(commit_id),
826
937
  )
827
938
  .await?;
939
+ self.staged_roots
940
+ .insert(commit_id.to_string(), result.root_id.clone());
941
+ storage::stage_commit_root(
942
+ self.writes,
943
+ &TrackedStateCommitRoot {
944
+ commit_id: commit_id.to_string(),
945
+ root_id: result.root_id.clone(),
946
+ parent_roots: parent_commit_id
947
+ .zip(base_root.as_ref())
948
+ .map(|(parent_commit_id, root_id)| {
949
+ vec![TrackedStateCommitRootParent {
950
+ commit_id: parent_commit_id.to_string(),
951
+ root_id: root_id.clone(),
952
+ }]
953
+ })
954
+ .unwrap_or_default(),
955
+ changed_key_count: u64::try_from(deltas.len()).map_err(|_| {
956
+ LixError::new(
957
+ LixError::CODE_INTERNAL_ERROR,
958
+ "tracked_state commit_root changed key count exceeds u64",
959
+ )
960
+ })?,
961
+ row_count_estimate: u64::try_from(result.row_count).map_err(|_| {
962
+ LixError::new(
963
+ LixError::CODE_INTERNAL_ERROR,
964
+ "tracked_state commit_root row count exceeds u64",
965
+ )
966
+ })?,
967
+ tree_height: u32::try_from(result.tree_height).map_err(|_| {
968
+ LixError::new(
969
+ LixError::CODE_INTERNAL_ERROR,
970
+ "tracked_state commit_root tree height exceeds u32",
971
+ )
972
+ })?,
973
+ primary_chunk_count: u64::try_from(result.chunk_count).map_err(|_| {
974
+ LixError::new(
975
+ LixError::CODE_INTERNAL_ERROR,
976
+ "tracked_state commit_root chunk count exceeds u64",
977
+ )
978
+ })?,
979
+ primary_chunk_bytes: u64::try_from(result.chunk_bytes).map_err(|_| {
980
+ LixError::new(
981
+ LixError::CODE_INTERNAL_ERROR,
982
+ "tracked_state commit_root chunk bytes exceeds u64",
983
+ )
984
+ })?,
985
+ },
986
+ )?;
828
987
 
829
- let by_file_base_root = match parent_commit_id {
830
- Some(parent_commit_id) => {
831
- storage::load_by_file_root(self.store, parent_commit_id).await?
832
- }
833
- None => None,
834
- };
835
- let concrete_file_deltas = deltas
836
- .iter()
837
- .filter(|delta| delta.change.file_id.is_some())
838
- .collect::<Vec<_>>();
839
- let by_file_chunk_puts = if concrete_file_deltas.is_empty() {
840
- if let Some(by_file_base_root) = by_file_base_root.as_ref() {
841
- storage::stage_by_file_root(self.writes, commit_id, by_file_base_root);
842
- }
843
- 0
844
- } else {
845
- let mut by_file_mutations = Vec::with_capacity(concrete_file_deltas.len());
846
- for delta in concrete_file_deltas {
847
- let key = TrackedStateKeyRef {
848
- schema_key: delta.change.schema_key,
849
- file_id: delta.change.file_id,
850
- entity_id: delta.change.entity_id,
851
- };
852
- let header_value = crate::tracked_state::types::TrackedStateIndexValueRef {
853
- change_locator: delta.locator,
854
- deleted: delta.change.snapshot_ref.is_none(),
855
- snapshot_ref: None,
856
- metadata_ref: None,
857
- created_at: delta.created_at,
858
- updated_at: delta.updated_at,
859
- };
860
- by_file_mutations.push(TrackedStateMutation::put_encoded(
861
- ByFileIndex::encode_key_ref(key),
862
- ByFileIndex::encode_header_value_ref(header_value),
863
- ));
864
- }
865
- let by_file_result = self
866
- .tree
867
- .apply_mutations(
868
- self.store,
869
- self.writes,
870
- by_file_base_root.as_ref(),
871
- by_file_mutations,
872
- None,
873
- )
874
- .await?;
875
- storage::stage_by_file_root(self.writes, commit_id, &by_file_result.root_id);
876
- by_file_result.chunk_count
877
- };
878
988
  Ok(TrackedStateWriteReport {
879
989
  commit_id: commit_id.to_string(),
990
+ root_id: result.root_id,
880
991
  changed_rows: deltas.len(),
881
992
  primary_chunk_puts: result.chunk_count,
882
- by_file_chunk_puts,
883
993
  })
884
994
  }
885
995
  }
@@ -887,15 +997,17 @@ where
887
997
  #[derive(Debug, Clone, PartialEq, Eq)]
888
998
  pub(crate) struct TrackedStateWriteReport {
889
999
  pub(crate) commit_id: String,
1000
+ pub(crate) root_id: TrackedStateRootId,
890
1001
  pub(crate) changed_rows: usize,
891
1002
  pub(crate) primary_chunk_puts: usize,
892
- pub(crate) by_file_chunk_puts: usize,
893
1003
  }
894
1004
 
895
- fn missing_commit_error(commit_id: &str) -> LixError {
1005
+ fn missing_commit_root_error(commit_id: &str) -> LixError {
896
1006
  LixError::new(
897
- LixError::CODE_INTERNAL_ERROR,
898
- format!("tracked_state projection references missing commit '{commit_id}'"),
1007
+ LixError::CODE_INTERNAL_ERROR,
1008
+ format!(
1009
+ "tracked_state commit_root is missing for commit '{commit_id}'; run explicit commit_root rebuild before structural diff"
1010
+ ),
899
1011
  )
900
1012
  }
901
1013
 
@@ -904,7 +1016,7 @@ fn tree_scan_request_from_tracked(
904
1016
  ) -> TrackedStateTreeScanRequest {
905
1017
  TrackedStateTreeScanRequest {
906
1018
  schema_keys: request.filter.schema_keys.clone(),
907
- entity_ids: request.filter.entity_ids.clone(),
1019
+ entity_pks: request.filter.entity_pks.clone(),
908
1020
  file_ids: request.filter.file_ids.clone(),
909
1021
  include_tombstones: request.filter.include_tombstones,
910
1022
  // User limits belong above delta overlay and tombstone visibility.
@@ -914,63 +1026,207 @@ fn tree_scan_request_from_tracked(
914
1026
  }
915
1027
  }
916
1028
 
917
- fn scan_needs_json_payloads(request: &TrackedStateScanRequest) -> bool {
918
- if request.projection.columns.is_empty() {
919
- return true;
1029
+ fn validate_diff_row_against_changelog(
1030
+ row: &TrackedStateDiffRow,
1031
+ changes: &HashMap<String, ChangeRecord>,
1032
+ ) -> Result<(), LixError> {
1033
+ let Some(change) = changes.get(&row.change_id) else {
1034
+ return Err(LixError::unknown(format!(
1035
+ "tracked-state diff row references missing changelog change '{}'",
1036
+ row.change_id
1037
+ )));
1038
+ };
1039
+ if change.schema_key != row.schema_key
1040
+ || change.file_id != row.file_id
1041
+ || change.entity_pk != row.entity_pk
1042
+ {
1043
+ return Err(LixError::unknown(format!(
1044
+ "tracked-state diff row for change '{}' does not match changelog change identity",
1045
+ row.change_id
1046
+ )));
1047
+ }
1048
+ if row.deleted != change.snapshot_ref.is_none() {
1049
+ return Err(LixError::unknown(format!(
1050
+ "tracked-state diff row for change '{}' deleted flag does not match changelog snapshot",
1051
+ row.change_id
1052
+ )));
1053
+ }
1054
+ if row.snapshot_ref != change.snapshot_ref || row.metadata_ref != change.metadata_ref {
1055
+ return Err(LixError::unknown(format!(
1056
+ "tracked-state diff row for change '{}' payload refs do not match changelog change",
1057
+ row.change_id
1058
+ )));
1059
+ }
1060
+ if row.updated_at != change.created_at {
1061
+ return Err(LixError::unknown(format!(
1062
+ "tracked-state diff row for change '{}' updated_at does not match changelog change timestamp",
1063
+ row.change_id
1064
+ )));
920
1065
  }
921
- request
922
- .projection
923
- .columns
924
- .iter()
925
- .any(|column| column == "snapshot_content" || column == "metadata")
1066
+ Ok(())
926
1067
  }
927
1068
 
928
- fn tracked_key_from_request(request: &TrackedStateRowRequest) -> Result<TrackedStateKey, LixError> {
929
- let file_id = match &request.file_id {
930
- crate::NullableKeyFilter::Null => None,
931
- crate::NullableKeyFilter::Value(value) => Some(value.clone()),
932
- crate::NullableKeyFilter::Any => {
933
- return Err(LixError::new(
934
- "LIX_ERROR_UNKNOWN",
935
- "tracked-state tree exact lookup requires a concrete file_id filter",
936
- ))
937
- }
938
- };
939
- Ok(TrackedStateKey {
940
- schema_key: request.schema_key.clone(),
941
- file_id,
942
- entity_id: request.entity_id.clone(),
1069
+ fn change_record_from_commit_record(commit: &CommitRecord) -> Result<ChangeRecord, LixError> {
1070
+ let snapshot_content = commit_row_snapshot_content(&commit.commit_id)?;
1071
+ Ok(ChangeRecord {
1072
+ format_version: 1,
1073
+ change_id: commit.change_id.clone(),
1074
+ schema_key: "lix_commit".to_string(),
1075
+ entity_pk: EntityPk::single(&commit.commit_id),
1076
+ file_id: None,
1077
+ snapshot_ref: Some(crate::json_store::JsonRef::for_content(
1078
+ snapshot_content.as_bytes(),
1079
+ )),
1080
+ metadata_ref: None,
1081
+ created_at: commit.created_at.clone(),
943
1082
  })
944
1083
  }
945
1084
 
1085
+ fn commit_row_snapshot_content(commit_id: &str) -> Result<String, LixError> {
1086
+ serde_json::to_string(&serde_json::json!({
1087
+ "id": commit_id,
1088
+ }))
1089
+ .map_err(|error| {
1090
+ LixError::new(
1091
+ LixError::CODE_INTERNAL_ERROR,
1092
+ format!("failed to encode lix_commit snapshot: {error}"),
1093
+ )
1094
+ })
1095
+ }
1096
+
1097
+ fn tracked_state_identity_from_diff_row(
1098
+ row: &TrackedStateDiffRow,
1099
+ ) -> Result<TrackedStateIdentity, LixError> {
1100
+ Ok(TrackedStateIdentity {
1101
+ schema_key: row.schema_key.clone(),
1102
+ file_id: row.file_id.clone(),
1103
+ entity_pk: row.entity_pk.clone(),
1104
+ })
1105
+ }
1106
+
1107
+ fn tracked_state_identity_from_key(key: &TrackedStateKey) -> TrackedStateIdentity {
1108
+ TrackedStateIdentity {
1109
+ schema_key: key.schema_key.clone(),
1110
+ file_id: key.file_id.clone(),
1111
+ entity_pk: key.entity_pk.clone(),
1112
+ }
1113
+ }
1114
+
1115
+ fn tracked_state_identity_matches_tree_request(
1116
+ identity: &TrackedStateIdentity,
1117
+ request: &TrackedStateTreeScanRequest,
1118
+ ) -> bool {
1119
+ if !request.schema_keys.is_empty() && !request.schema_keys.contains(&identity.schema_key) {
1120
+ return false;
1121
+ }
1122
+ if !request.entity_pks.is_empty() && !request.entity_pks.contains(&identity.entity_pk) {
1123
+ return false;
1124
+ }
1125
+ nullable_key_filter_allows(&request.file_ids, identity.file_id.as_deref())
1126
+ }
1127
+
1128
+ fn nullable_key_filter_allows(filters: &[NullableKeyFilter<String>], value: Option<&str>) -> bool {
1129
+ filters.is_empty()
1130
+ || filters.iter().any(|filter| match (filter, value) {
1131
+ (NullableKeyFilter::Any, _) => true,
1132
+ (NullableKeyFilter::Null, None) => true,
1133
+ (NullableKeyFilter::Value(expected), Some(value)) => expected == value,
1134
+ _ => false,
1135
+ })
1136
+ }
1137
+
946
1138
  #[cfg(test)]
947
1139
  mod tests {
948
- use std::sync::Arc;
949
-
950
1140
  use super::*;
951
- use crate::backend::{testing::UnitTestBackend, Backend};
952
- use crate::storage::{StorageContext, StorageWriteTransaction};
1141
+ use crate::storage::StorageContext;
1142
+ use crate::storage::{InMemoryStorageBackend, StorageReadOptions, StorageWriteOptions};
953
1143
  use crate::NullableKeyFilter;
954
1144
 
955
1145
  #[tokio::test]
956
- async fn stage_delta_does_not_require_parent_projection_root() {
957
- let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
958
- let storage = StorageContext::new(Arc::clone(&backend));
1146
+ async fn stage_commit_root_requires_parent_commit_root() {
1147
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
959
1148
  let tracked_state = TrackedStateContext::new();
960
- let mut transaction = storage
961
- .begin_write_transaction()
1149
+ {
1150
+ let mut read = storage
1151
+ .begin_read(StorageReadOptions::default())
1152
+ .expect("parent read should open");
1153
+ let mut writes = storage.new_write_set();
1154
+ crate::test_support::stage_empty_changelog_commit(
1155
+ &mut read,
1156
+ &mut writes,
1157
+ "missing-parent",
1158
+ None,
1159
+ )
962
1160
  .await
963
- .expect("transaction should open");
1161
+ .expect("parent changelog commit should stage");
1162
+ storage
1163
+ .commit_write_set(writes, StorageWriteOptions::default())
1164
+ .expect("parent changelog commit should commit");
1165
+ }
964
1166
 
965
1167
  write_root_for_test(
966
- transaction.as_mut(),
1168
+ &storage,
967
1169
  &tracked_state,
968
1170
  "commit-child",
969
1171
  Some("missing-parent"),
970
1172
  &[row("entity-child", "change-child", "commit-child")],
971
1173
  )
972
1174
  .await
973
- .expect("delta pack staging should not require a parent projection root");
1175
+ .expect_err("root staging should require a parent commit root");
1176
+ }
1177
+
1178
+ #[tokio::test]
1179
+ async fn stage_commit_root_writes_commit_root_metadata() {
1180
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1181
+ let tracked_state = TrackedStateContext::new();
1182
+ write_root_for_test(
1183
+ &storage,
1184
+ &tracked_state,
1185
+ "parent",
1186
+ None,
1187
+ &[row("entity-a", "change-parent", "parent")],
1188
+ )
1189
+ .await
1190
+ .expect("parent root should write");
1191
+ write_root_for_test(
1192
+ &storage,
1193
+ &tracked_state,
1194
+ "child",
1195
+ Some("parent"),
1196
+ &[
1197
+ row("entity-a", "change-child-a", "child"),
1198
+ row("entity-b", "change-child-b", "child"),
1199
+ ],
1200
+ )
1201
+ .await
1202
+ .expect("child root should write");
1203
+
1204
+ let read = storage
1205
+ .begin_read(StorageReadOptions::default())
1206
+ .expect("read should open");
1207
+ let parent_root = storage::load_root(&read, "parent")
1208
+ .await
1209
+ .expect("parent root should load")
1210
+ .expect("parent root should exist");
1211
+ let child_root = storage::load_root(&read, "child")
1212
+ .await
1213
+ .expect("child root should load")
1214
+ .expect("child root should exist");
1215
+ let metadata = storage::load_commit_root(&read, "child")
1216
+ .await
1217
+ .expect("metadata should load")
1218
+ .expect("metadata should exist");
1219
+
1220
+ assert_eq!(metadata.commit_id, "child");
1221
+ assert_eq!(metadata.root_id, child_root);
1222
+ assert_eq!(metadata.parent_roots.len(), 1);
1223
+ assert_eq!(metadata.parent_roots[0].commit_id, "parent");
1224
+ assert_eq!(metadata.parent_roots[0].root_id, parent_root);
1225
+ assert_eq!(metadata.changed_key_count, 3);
1226
+ assert_eq!(metadata.row_count_estimate, 4);
1227
+ assert!(metadata.tree_height >= 1);
1228
+ assert!(metadata.primary_chunk_count >= 1);
1229
+ assert!(metadata.primary_chunk_bytes > 0);
974
1230
  }
975
1231
 
976
1232
  #[tokio::test]
@@ -988,7 +1244,11 @@ mod tests {
988
1244
  .await;
989
1245
 
990
1246
  let plan = tracked_state
991
- .reader(storage.clone())
1247
+ .reader(
1248
+ storage
1249
+ .begin_read(StorageReadOptions::default())
1250
+ .expect("read should open"),
1251
+ )
992
1252
  .plan_merge(
993
1253
  "base",
994
1254
  "target",
@@ -998,7 +1258,7 @@ mod tests {
998
1258
  .await
999
1259
  .expect("merge should plan");
1000
1260
 
1001
- assert_eq!(merge_patch_ids(&plan), vec!["entity-a"]);
1261
+ assert_eq!(merge_pick_ids(&plan), vec!["entity-a"]);
1002
1262
  assert!(plan.conflicts.is_empty());
1003
1263
  }
1004
1264
 
@@ -1009,98 +1269,661 @@ mod tests {
1009
1269
  &[row("entity-a", "change-target", "target")],
1010
1270
  &[row("entity-a", "change-base", "base")],
1011
1271
  )
1012
- .await;
1272
+ .await;
1273
+
1274
+ let plan = tracked_state
1275
+ .reader(
1276
+ storage
1277
+ .begin_read(StorageReadOptions::default())
1278
+ .expect("read should open"),
1279
+ )
1280
+ .plan_merge(
1281
+ "base",
1282
+ "target",
1283
+ "source",
1284
+ &TrackedStateDiffRequest::default(),
1285
+ )
1286
+ .await
1287
+ .expect("merge should plan");
1288
+
1289
+ assert!(plan.picks.is_empty());
1290
+ assert!(plan.conflicts.is_empty());
1291
+ }
1292
+
1293
+ #[tokio::test]
1294
+ async fn plan_merge_from_roots_reports_divergent_modification_conflict() {
1295
+ let (storage, tracked_state) = seed_merge_roots(
1296
+ &[row_with_value("entity-a", "change-base", "base", "base")],
1297
+ &[row_with_value(
1298
+ "entity-a",
1299
+ "change-target",
1300
+ "target",
1301
+ "target",
1302
+ )],
1303
+ &[row_with_value(
1304
+ "entity-a",
1305
+ "change-source",
1306
+ "source",
1307
+ "source",
1308
+ )],
1309
+ )
1310
+ .await;
1311
+
1312
+ let plan = tracked_state
1313
+ .reader(
1314
+ storage
1315
+ .begin_read(StorageReadOptions::default())
1316
+ .expect("read should open"),
1317
+ )
1318
+ .plan_merge(
1319
+ "base",
1320
+ "target",
1321
+ "source",
1322
+ &TrackedStateDiffRequest::default(),
1323
+ )
1324
+ .await
1325
+ .expect("merge should plan");
1326
+
1327
+ assert!(plan.picks.is_empty());
1328
+ assert_eq!(merge_conflict_ids(&plan), vec!["entity-a"]);
1329
+ }
1330
+
1331
+ #[tokio::test]
1332
+ async fn plan_merge_from_roots_applies_source_tombstone() {
1333
+ let (storage, tracked_state) = seed_merge_roots(
1334
+ &[row("entity-a", "change-base", "base")],
1335
+ &[row("entity-a", "change-base", "base")],
1336
+ &[tombstone("entity-a", "change-source-delete", "source")],
1337
+ )
1338
+ .await;
1339
+
1340
+ let plan = tracked_state
1341
+ .reader(
1342
+ storage
1343
+ .begin_read(StorageReadOptions::default())
1344
+ .expect("read should open"),
1345
+ )
1346
+ .plan_merge(
1347
+ "base",
1348
+ "target",
1349
+ "source",
1350
+ &TrackedStateDiffRequest::default(),
1351
+ )
1352
+ .await
1353
+ .expect("merge should plan");
1354
+
1355
+ assert_eq!(merge_pick_ids(&plan), vec!["entity-a"]);
1356
+ assert!(plan.picks[0].source_row().deleted);
1357
+ assert_eq!(plan.picks[0].source_change_id(), "change-source-delete");
1358
+ }
1359
+
1360
+ #[tokio::test]
1361
+ async fn explicit_rebuild_repairs_missing_child_root_from_nearest_parent() {
1362
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1363
+ let tracked_state = TrackedStateContext::new();
1364
+ write_root_for_test(
1365
+ &storage,
1366
+ &tracked_state,
1367
+ "base",
1368
+ None,
1369
+ &[row_with_value("entity-a", "change-base", "base", "base")],
1370
+ )
1371
+ .await
1372
+ .expect("base root should write");
1373
+ write_root_for_test(
1374
+ &storage,
1375
+ &tracked_state,
1376
+ "child",
1377
+ Some("base"),
1378
+ &[row_with_value("entity-a", "change-child", "child", "child")],
1379
+ )
1380
+ .await
1381
+ .expect("child root should write");
1382
+ {
1383
+ let mut writes = storage.new_write_set();
1384
+ writes.delete(
1385
+ storage::TRACKED_STATE_COMMIT_ROOT_SPACE,
1386
+ crate::storage::StorageKey(bytes::Bytes::copy_from_slice(b"child")),
1387
+ );
1388
+ storage
1389
+ .commit_write_set(writes, StorageWriteOptions::default())
1390
+ .expect("child commit_root delete should commit");
1391
+ }
1392
+
1393
+ tracked_state
1394
+ .reader(
1395
+ storage
1396
+ .begin_read(StorageReadOptions::default())
1397
+ .expect("read should open"),
1398
+ )
1399
+ .diff_commits("base", "child", &test_schema_diff_request())
1400
+ .await
1401
+ .expect_err("diff should require durable roots before repair");
1402
+
1403
+ let mut read = storage
1404
+ .begin_read(StorageReadOptions::default())
1405
+ .expect("read should open");
1406
+ let mut writes = storage.new_write_set();
1407
+ tracked_state
1408
+ .root_rebuilder(&mut read, &mut writes)
1409
+ .rebuild_commit_root_at("child")
1410
+ .await
1411
+ .expect("child root should repair");
1412
+ storage
1413
+ .commit_write_set(writes, StorageWriteOptions::default())
1414
+ .expect("repaired root should commit");
1415
+
1416
+ let diff = tracked_state
1417
+ .reader(
1418
+ storage
1419
+ .begin_read(StorageReadOptions::default())
1420
+ .expect("read should open"),
1421
+ )
1422
+ .diff_commits("base", "child", &test_schema_diff_request())
1423
+ .await
1424
+ .expect("diff should use repaired root");
1425
+
1426
+ assert_eq!(diff.entries.len(), 1);
1427
+ assert_eq!(
1428
+ diff.entries[0].kind,
1429
+ crate::tracked_state::TrackedStateDiffKind::Modified
1430
+ );
1431
+ assert_eq!(
1432
+ diff.entries[0]
1433
+ .after
1434
+ .as_ref()
1435
+ .map(|row| row.change_id.as_str()),
1436
+ Some("change-child")
1437
+ );
1438
+ }
1439
+
1440
+ #[tokio::test]
1441
+ async fn diff_allows_repaired_root_with_rebuilt_ancestor_chain() {
1442
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1443
+ let tracked_state = TrackedStateContext::new();
1444
+ write_root_for_test(
1445
+ &storage,
1446
+ &tracked_state,
1447
+ "base",
1448
+ None,
1449
+ &[row_with_value("entity-a", "change-base", "base", "base")],
1450
+ )
1451
+ .await
1452
+ .expect("base root should write");
1453
+ write_root_for_test(
1454
+ &storage,
1455
+ &tracked_state,
1456
+ "middle",
1457
+ Some("base"),
1458
+ &[row_with_value(
1459
+ "entity-a",
1460
+ "change-middle",
1461
+ "middle",
1462
+ "middle",
1463
+ )],
1464
+ )
1465
+ .await
1466
+ .expect("middle root should write");
1467
+ write_root_for_test(
1468
+ &storage,
1469
+ &tracked_state,
1470
+ "child",
1471
+ Some("middle"),
1472
+ &[row_with_value("entity-a", "change-child", "child", "child")],
1473
+ )
1474
+ .await
1475
+ .expect("child root should write");
1476
+ {
1477
+ let mut writes = storage.new_write_set();
1478
+ for commit_id in ["middle", "child"] {
1479
+ writes.delete(
1480
+ storage::TRACKED_STATE_COMMIT_ROOT_SPACE,
1481
+ crate::storage::StorageKey(bytes::Bytes::copy_from_slice(commit_id.as_bytes())),
1482
+ );
1483
+ }
1484
+ storage
1485
+ .commit_write_set(writes, StorageWriteOptions::default())
1486
+ .expect("commit_root deletes should commit");
1487
+ }
1488
+
1489
+ let mut read = storage
1490
+ .begin_read(StorageReadOptions::default())
1491
+ .expect("read should open");
1492
+ let mut writes = storage.new_write_set();
1493
+ tracked_state
1494
+ .root_rebuilder(&mut read, &mut writes)
1495
+ .rebuild_commit_root_at("child")
1496
+ .await
1497
+ .expect("child root should repair");
1498
+ storage
1499
+ .commit_write_set(writes, StorageWriteOptions::default())
1500
+ .expect("repaired root should commit");
1501
+
1502
+ let diff = tracked_state
1503
+ .reader(
1504
+ storage
1505
+ .begin_read(StorageReadOptions::default())
1506
+ .expect("read should open"),
1507
+ )
1508
+ .diff_commits("base", "child", &test_schema_diff_request())
1509
+ .await
1510
+ .expect("diff should accept repaired nearest-ancestor parent metadata");
1511
+
1512
+ assert_eq!(diff.entries.len(), 1);
1513
+ assert_eq!(
1514
+ diff.entries[0]
1515
+ .after
1516
+ .as_ref()
1517
+ .map(|row| row.change_id.as_str()),
1518
+ Some("change-child")
1519
+ );
1520
+ }
1521
+
1522
+ #[tokio::test]
1523
+ async fn explicit_rebuild_repairs_missing_ancestor_chain() {
1524
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1525
+ let tracked_state = TrackedStateContext::new();
1526
+ write_root_for_test(
1527
+ &storage,
1528
+ &tracked_state,
1529
+ "base",
1530
+ None,
1531
+ &[row_with_value("entity-a", "change-base", "base", "base")],
1532
+ )
1533
+ .await
1534
+ .expect("base root should write");
1535
+ write_root_for_test(
1536
+ &storage,
1537
+ &tracked_state,
1538
+ "middle",
1539
+ Some("base"),
1540
+ &[row_with_value(
1541
+ "entity-a",
1542
+ "change-middle",
1543
+ "middle",
1544
+ "middle",
1545
+ )],
1546
+ )
1547
+ .await
1548
+ .expect("middle root should write");
1549
+ write_root_for_test(
1550
+ &storage,
1551
+ &tracked_state,
1552
+ "child",
1553
+ Some("middle"),
1554
+ &[row_with_value("entity-a", "change-child", "child", "child")],
1555
+ )
1556
+ .await
1557
+ .expect("child root should write");
1558
+ {
1559
+ let mut writes = storage.new_write_set();
1560
+ for commit_id in ["middle", "child"] {
1561
+ writes.delete(
1562
+ storage::TRACKED_STATE_COMMIT_ROOT_SPACE,
1563
+ crate::storage::StorageKey(bytes::Bytes::copy_from_slice(commit_id.as_bytes())),
1564
+ );
1565
+ }
1566
+ storage
1567
+ .commit_write_set(writes, StorageWriteOptions::default())
1568
+ .expect("commit_root deletes should commit");
1569
+ }
1570
+
1571
+ let read = storage
1572
+ .begin_read(StorageReadOptions::default())
1573
+ .expect("read should open");
1574
+ let mut writes = storage.new_write_set();
1575
+ tracked_state
1576
+ .root_rebuilder(&read, &mut writes)
1577
+ .rebuild_commit_root_at("child")
1578
+ .await
1579
+ .expect("explicit rebuild should repair missing ancestor chain");
1580
+ storage
1581
+ .commit_write_set(writes, StorageWriteOptions::default())
1582
+ .expect("repaired roots should commit");
1583
+
1584
+ let diff = tracked_state
1585
+ .reader(
1586
+ storage
1587
+ .begin_read(StorageReadOptions::default())
1588
+ .expect("read should open"),
1589
+ )
1590
+ .diff_commits("base", "child", &test_schema_diff_request())
1591
+ .await
1592
+ .expect("diff should accept explicitly rebuilt chain");
1593
+
1594
+ assert_eq!(diff.entries.len(), 1);
1595
+ assert_eq!(
1596
+ diff.entries[0]
1597
+ .after
1598
+ .as_ref()
1599
+ .map(|row| row.change_id.as_str()),
1600
+ Some("change-child")
1601
+ );
1602
+ }
1603
+
1604
+ #[tokio::test]
1605
+ async fn explicit_rebuild_errors_on_first_parent_cycle() {
1606
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1607
+ let tracked_state = TrackedStateContext::new();
1608
+ {
1609
+ let mut read = storage
1610
+ .begin_read(StorageReadOptions::default())
1611
+ .expect("read should open");
1612
+ let mut writes = storage.new_write_set();
1613
+ crate::test_support::stage_empty_changelog_commit(
1614
+ &mut read,
1615
+ &mut writes,
1616
+ "commit-a",
1617
+ None,
1618
+ )
1619
+ .await
1620
+ .expect("commit-a should stage");
1621
+ storage
1622
+ .commit_write_set(writes, StorageWriteOptions::default())
1623
+ .expect("commit-a should commit");
1624
+ }
1625
+ {
1626
+ let mut read = storage
1627
+ .begin_read(StorageReadOptions::default())
1628
+ .expect("read should open");
1629
+ let mut writes = storage.new_write_set();
1630
+ crate::test_support::stage_empty_changelog_commit_with_parents(
1631
+ &mut read,
1632
+ &mut writes,
1633
+ "commit-b",
1634
+ &["commit-a".to_string()],
1635
+ )
1636
+ .await
1637
+ .expect("commit-b should stage");
1638
+ storage
1639
+ .commit_write_set(writes, StorageWriteOptions::default())
1640
+ .expect("commit-b should commit");
1641
+ }
1642
+ {
1643
+ let mut writes = storage.new_write_set();
1644
+ writes.put(
1645
+ crate::changelog::COMMIT_SPACE,
1646
+ crate::storage::StorageKey(bytes::Bytes::copy_from_slice(b"commit-a")),
1647
+ crate::changelog::encode_commit_record(&crate::changelog::CommitRecord {
1648
+ format_version: 1,
1649
+ commit_id: "commit-a".to_string(),
1650
+ parent_commit_ids: vec!["commit-b".to_string()],
1651
+ change_id: "commit-a:commit".to_string(),
1652
+ author_account_ids: Vec::new(),
1653
+ created_at: "1970-01-01T00:00:00.000Z".to_string(),
1654
+ })
1655
+ .expect("corrupt cycle commit should encode"),
1656
+ );
1657
+ storage
1658
+ .commit_write_set(writes, StorageWriteOptions::default())
1659
+ .expect("cycle corruption should commit");
1660
+ }
1661
+
1662
+ let read = storage
1663
+ .begin_read(StorageReadOptions::default())
1664
+ .expect("read should open");
1665
+ let mut writes = storage.new_write_set();
1666
+ let error = tracked_state
1667
+ .root_rebuilder(&read, &mut writes)
1668
+ .rebuild_commit_root_at("commit-a")
1669
+ .await
1670
+ .expect_err("first-parent cycle should not rebuild forever");
1671
+
1672
+ assert_eq!(error.code, LixError::CODE_INTERNAL_ERROR);
1673
+ assert!(
1674
+ error.message.contains("first-parent cycle"),
1675
+ "unexpected error message: {}",
1676
+ error.message
1677
+ );
1678
+ }
1679
+
1680
+ #[tokio::test]
1681
+ async fn explicit_rebuild_repairs_missing_head_root_chunk() {
1682
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1683
+ let tracked_state = TrackedStateContext::new();
1684
+ write_root_for_test(
1685
+ &storage,
1686
+ &tracked_state,
1687
+ "base",
1688
+ None,
1689
+ &[row_with_value("entity-a", "change-base", "base", "base")],
1690
+ )
1691
+ .await
1692
+ .expect("base root should write");
1693
+ write_root_for_test(
1694
+ &storage,
1695
+ &tracked_state,
1696
+ "child",
1697
+ Some("base"),
1698
+ &[row_with_value("entity-a", "change-child", "child", "child")],
1699
+ )
1700
+ .await
1701
+ .expect("child root should write");
1702
+ delete_root_chunk_for_test(&storage, "child").await;
1703
+
1704
+ tracked_state
1705
+ .reader(
1706
+ storage
1707
+ .begin_read(StorageReadOptions::default())
1708
+ .expect("read should open"),
1709
+ )
1710
+ .diff_commits("base", "child", &test_schema_diff_request())
1711
+ .await
1712
+ .expect_err("diff should fail before missing root chunk repair");
1713
+
1714
+ let read = storage
1715
+ .begin_read(StorageReadOptions::default())
1716
+ .expect("read should open");
1717
+ let mut writes = storage.new_write_set();
1718
+ tracked_state
1719
+ .root_rebuilder(&read, &mut writes)
1720
+ .rebuild_commit_root_at("child")
1721
+ .await
1722
+ .expect("child root chunk should repair");
1723
+ storage
1724
+ .commit_write_set(writes, StorageWriteOptions::default())
1725
+ .expect("repaired root should commit");
1726
+
1727
+ let diff = tracked_state
1728
+ .reader(
1729
+ storage
1730
+ .begin_read(StorageReadOptions::default())
1731
+ .expect("read should open"),
1732
+ )
1733
+ .diff_commits("base", "child", &test_schema_diff_request())
1734
+ .await
1735
+ .expect("diff should use repaired root chunk");
1736
+
1737
+ assert_eq!(diff.entries.len(), 1);
1738
+ assert_eq!(
1739
+ diff.entries[0]
1740
+ .after
1741
+ .as_ref()
1742
+ .map(|row| row.change_id.as_str()),
1743
+ Some("change-child")
1744
+ );
1745
+ }
1746
+
1747
+ #[tokio::test]
1748
+ async fn explicit_rebuild_repairs_corrupt_head_root_chunk() {
1749
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1750
+ let tracked_state = TrackedStateContext::new();
1751
+ write_root_for_test(
1752
+ &storage,
1753
+ &tracked_state,
1754
+ "base",
1755
+ None,
1756
+ &[row_with_value("entity-a", "change-base", "base", "base")],
1757
+ )
1758
+ .await
1759
+ .expect("base root should write");
1760
+ write_root_for_test(
1761
+ &storage,
1762
+ &tracked_state,
1763
+ "child",
1764
+ Some("base"),
1765
+ &[row_with_value("entity-a", "change-child", "child", "child")],
1766
+ )
1767
+ .await
1768
+ .expect("child root should write");
1769
+ corrupt_root_chunk_for_test(&storage, "child").await;
1013
1770
 
1014
- let plan = tracked_state
1015
- .reader(storage.clone())
1016
- .plan_merge(
1017
- "base",
1018
- "target",
1019
- "source",
1020
- &TrackedStateDiffRequest::default(),
1771
+ let read = storage
1772
+ .begin_read(StorageReadOptions::default())
1773
+ .expect("read should open");
1774
+ let mut writes = storage.new_write_set();
1775
+ tracked_state
1776
+ .root_rebuilder(&read, &mut writes)
1777
+ .rebuild_commit_root_at("child")
1778
+ .await
1779
+ .expect("corrupt child root chunk should repair");
1780
+ storage
1781
+ .commit_write_set(writes, StorageWriteOptions::default())
1782
+ .expect("repaired root should commit");
1783
+
1784
+ let diff = tracked_state
1785
+ .reader(
1786
+ storage
1787
+ .begin_read(StorageReadOptions::default())
1788
+ .expect("read should open"),
1021
1789
  )
1790
+ .diff_commits("base", "child", &test_schema_diff_request())
1022
1791
  .await
1023
- .expect("merge should plan");
1792
+ .expect("diff should use repaired root chunk");
1024
1793
 
1025
- assert!(plan.patches.is_empty());
1026
- assert!(plan.conflicts.is_empty());
1794
+ assert_eq!(diff.entries.len(), 1);
1795
+ assert_eq!(
1796
+ diff.entries[0]
1797
+ .after
1798
+ .as_ref()
1799
+ .map(|row| row.change_id.as_str()),
1800
+ Some("change-child")
1801
+ );
1027
1802
  }
1028
1803
 
1029
1804
  #[tokio::test]
1030
- async fn plan_merge_from_roots_reports_divergent_modification_conflict() {
1031
- let (storage, tracked_state) = seed_merge_roots(
1032
- &[row_with_value("entity-a", "change-base", "base", "base")],
1033
- &[row_with_value(
1034
- "entity-a",
1035
- "change-target",
1036
- "target",
1037
- "target",
1038
- )],
1039
- &[row_with_value(
1040
- "entity-a",
1041
- "change-source",
1042
- "source",
1043
- "source",
1044
- )],
1805
+ async fn explicit_rebuild_repairs_stale_root_missing_commit_row() {
1806
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1807
+ let tracked_state = TrackedStateContext::new();
1808
+ let row = row_with_value("entity-a", "change-a", "commit-1", "value");
1809
+ write_root_for_test(
1810
+ &storage,
1811
+ &tracked_state,
1812
+ "commit-1",
1813
+ None,
1814
+ std::slice::from_ref(&row),
1815
+ )
1816
+ .await
1817
+ .expect("root should write");
1818
+ overwrite_root_without_commit_row_for_test(
1819
+ &storage,
1820
+ "commit-1",
1821
+ std::slice::from_ref(&row),
1045
1822
  )
1046
1823
  .await;
1047
1824
 
1048
- let plan = tracked_state
1049
- .reader(storage.clone())
1050
- .plan_merge(
1051
- "base",
1052
- "target",
1053
- "source",
1054
- &TrackedStateDiffRequest::default(),
1055
- )
1825
+ let read = storage
1826
+ .begin_read(StorageReadOptions::default())
1827
+ .expect("read should open");
1828
+ let mut writes = storage.new_write_set();
1829
+ tracked_state
1830
+ .root_rebuilder(&read, &mut writes)
1831
+ .rebuild_commit_root_at("commit-1")
1056
1832
  .await
1057
- .expect("merge should plan");
1833
+ .expect("stale root should repair");
1834
+ storage
1835
+ .commit_write_set(writes, StorageWriteOptions::default())
1836
+ .expect("repaired root should commit");
1058
1837
 
1059
- assert!(plan.patches.is_empty());
1060
- assert_eq!(merge_conflict_ids(&plan), vec!["entity-a"]);
1838
+ let rows = tracked_state
1839
+ .reader(
1840
+ storage
1841
+ .begin_read(StorageReadOptions::default())
1842
+ .expect("read should open"),
1843
+ )
1844
+ .scan_rows_at_commit(
1845
+ "commit-1",
1846
+ &TrackedStateScanRequest {
1847
+ filter: crate::tracked_state::TrackedStateFilter {
1848
+ schema_keys: vec!["lix_commit".to_string()],
1849
+ ..Default::default()
1850
+ },
1851
+ ..Default::default()
1852
+ },
1853
+ )
1854
+ .await
1855
+ .expect("repaired root should scan");
1856
+ assert_eq!(rows.len(), 1);
1857
+ assert_eq!(rows[0].schema_key, "lix_commit");
1061
1858
  }
1062
1859
 
1063
1860
  #[tokio::test]
1064
- async fn plan_merge_from_roots_applies_source_tombstone() {
1065
- let (storage, tracked_state) = seed_merge_roots(
1066
- &[row("entity-a", "change-base", "base")],
1067
- &[row("entity-a", "change-base", "base")],
1068
- &[tombstone("entity-a", "change-source-delete", "source")],
1861
+ async fn explicit_rebuild_repairs_stale_root_missing_inherited_row() {
1862
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1863
+ let tracked_state = TrackedStateContext::new();
1864
+ let inherited = row_with_value("entity-a", "change-base", "base", "base");
1865
+ let child = row_with_value("entity-b", "change-child", "child", "child");
1866
+ write_root_for_test(
1867
+ &storage,
1868
+ &tracked_state,
1869
+ "base",
1870
+ None,
1871
+ std::slice::from_ref(&inherited),
1069
1872
  )
1070
- .await;
1873
+ .await
1874
+ .expect("base root should write");
1875
+ write_root_for_test(
1876
+ &storage,
1877
+ &tracked_state,
1878
+ "child",
1879
+ Some("base"),
1880
+ std::slice::from_ref(&child),
1881
+ )
1882
+ .await
1883
+ .expect("child root should write");
1884
+ overwrite_root_without_commit_row_for_test(&storage, "child", std::slice::from_ref(&child))
1885
+ .await;
1071
1886
 
1072
- let plan = tracked_state
1073
- .reader(storage.clone())
1074
- .plan_merge(
1075
- "base",
1076
- "target",
1077
- "source",
1078
- &TrackedStateDiffRequest::default(),
1079
- )
1887
+ let read = storage
1888
+ .begin_read(StorageReadOptions::default())
1889
+ .expect("read should open");
1890
+ let mut writes = storage.new_write_set();
1891
+ tracked_state
1892
+ .root_rebuilder(&read, &mut writes)
1893
+ .rebuild_commit_root_at("child")
1080
1894
  .await
1081
- .expect("merge should plan");
1895
+ .expect("stale child root should repair");
1896
+ storage
1897
+ .commit_write_set(writes, StorageWriteOptions::default())
1898
+ .expect("repaired root should commit");
1082
1899
 
1083
- assert_eq!(merge_patch_ids(&plan), vec!["entity-a"]);
1084
- assert_eq!(plan.patches[0].projected_row().snapshot_content, None);
1085
- assert_eq!(plan.patches[0].change_id(), "change-source-delete");
1900
+ let rows = tracked_state
1901
+ .reader(
1902
+ storage
1903
+ .begin_read(StorageReadOptions::default())
1904
+ .expect("read should open"),
1905
+ )
1906
+ .scan_rows_at_commit("child", &test_schema_scan_request())
1907
+ .await
1908
+ .expect("repaired child root should scan");
1909
+ assert_eq!(
1910
+ rows.iter()
1911
+ .map(|row| row.change_id.as_str())
1912
+ .collect::<Vec<_>>(),
1913
+ vec!["change-base", "change-child"]
1914
+ );
1086
1915
  }
1087
1916
 
1088
1917
  #[tokio::test]
1089
- async fn scan_rows_by_file_uses_file_index_shape() {
1090
- let backend = Arc::new(UnitTestBackend::new());
1091
- let storage = StorageContext::new(backend.clone());
1918
+ async fn scan_rows_filters_by_file() {
1919
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1092
1920
  let tracked_state = TrackedStateContext::new();
1093
1921
  let mut file_a = row("entity-a", "change-a", "commit-1");
1094
1922
  file_a.file_id = Some("file-a.json".to_string());
1095
1923
  let mut file_b = row("entity-b", "change-b", "commit-1");
1096
1924
  file_b.file_id = Some("file-b.json".to_string());
1097
-
1098
- let mut transaction = storage
1099
- .begin_write_transaction()
1100
- .await
1101
- .expect("transaction should open");
1102
1925
  write_root_for_test(
1103
- transaction.as_mut(),
1926
+ &storage,
1104
1927
  &tracked_state,
1105
1928
  "commit-1",
1106
1929
  None,
@@ -1108,13 +1931,13 @@ mod tests {
1108
1931
  )
1109
1932
  .await
1110
1933
  .expect("root should write");
1111
- transaction
1112
- .commit()
1113
- .await
1114
- .expect("transaction should commit");
1115
1934
 
1116
1935
  let rows = tracked_state
1117
- .reader(storage.clone())
1936
+ .reader(
1937
+ storage
1938
+ .begin_read(StorageReadOptions::default())
1939
+ .expect("read should open"),
1940
+ )
1118
1941
  .scan_rows_at_commit(
1119
1942
  "commit-1",
1120
1943
  &TrackedStateScanRequest {
@@ -1126,34 +1949,28 @@ mod tests {
1126
1949
  },
1127
1950
  )
1128
1951
  .await
1129
- .expect("file scan should read through index");
1952
+ .expect("file scan should use primary root");
1130
1953
 
1131
1954
  assert_eq!(rows.len(), 1);
1132
1955
  assert_eq!(
1133
1956
  rows[0]
1134
- .entity_id
1957
+ .entity_pk
1135
1958
  .as_single_string_owned()
1136
- .expect("entity id"),
1959
+ .expect("entity pk"),
1137
1960
  "entity-a"
1138
1961
  );
1139
1962
  assert_eq!(rows[0].file_id.as_deref(), Some("file-a.json"));
1140
1963
  }
1141
1964
 
1142
1965
  #[tokio::test]
1143
- async fn by_file_header_index_fetches_primary_payload_only_when_requested() {
1144
- let backend = Arc::new(UnitTestBackend::new());
1145
- let storage = StorageContext::new(backend.clone());
1966
+ async fn file_filtered_header_scan_fetches_primary_payload_only_when_requested() {
1967
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1146
1968
  let tracked_state = TrackedStateContext::new();
1147
1969
  let mut row = row("entity-a", "change-a", "commit-1");
1148
1970
  row.file_id = Some("file-a.json".to_string());
1149
1971
  let expected_snapshot = row.snapshot_content.clone();
1150
-
1151
- let mut transaction = storage
1152
- .begin_write_transaction()
1153
- .await
1154
- .expect("transaction should open");
1155
1972
  write_root_for_test(
1156
- transaction.as_mut(),
1973
+ &storage,
1157
1974
  &tracked_state,
1158
1975
  "commit-1",
1159
1976
  None,
@@ -1161,12 +1978,12 @@ mod tests {
1161
1978
  )
1162
1979
  .await
1163
1980
  .expect("root should write");
1164
- transaction
1165
- .commit()
1166
- .await
1167
- .expect("transaction should commit");
1168
1981
 
1169
- let mut reader = tracked_state.reader(storage.clone());
1982
+ let mut reader = tracked_state.reader(
1983
+ storage
1984
+ .begin_read(StorageReadOptions::default())
1985
+ .expect("read should open"),
1986
+ );
1170
1987
  let header_rows = reader
1171
1988
  .scan_rows_at_commit(
1172
1989
  "commit-1",
@@ -1175,14 +1992,14 @@ mod tests {
1175
1992
  file_ids: vec![NullableKeyFilter::Value("file-a.json".to_string())],
1176
1993
  ..Default::default()
1177
1994
  },
1178
- projection: crate::tracked_state::TrackedStateProjection {
1179
- columns: vec!["entity_id".to_string()],
1995
+ read_columns: crate::tracked_state::TrackedStateReadColumns {
1996
+ columns: vec!["entity_pk".to_string()],
1180
1997
  },
1181
1998
  ..Default::default()
1182
1999
  },
1183
2000
  )
1184
2001
  .await
1185
- .expect("header scan should read through by-file index");
2002
+ .expect("header scan should use primary root");
1186
2003
  let full_rows = reader
1187
2004
  .scan_rows_at_commit(
1188
2005
  "commit-1",
@@ -1202,18 +2019,12 @@ mod tests {
1202
2019
  }
1203
2020
 
1204
2021
  #[tokio::test]
1205
- async fn null_file_rows_do_not_stage_by_file_index() {
1206
- let backend = Arc::new(UnitTestBackend::new());
1207
- let storage = StorageContext::new(backend.clone());
2022
+ async fn null_file_rows_match_null_file_filter() {
2023
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1208
2024
  let tracked_state = TrackedStateContext::new();
1209
2025
  let row = row("entity-a", "change-a", "commit-1");
1210
-
1211
- let mut transaction = storage
1212
- .begin_write_transaction()
1213
- .await
1214
- .expect("transaction should open");
1215
2026
  write_root_for_test(
1216
- transaction.as_mut(),
2027
+ &storage,
1217
2028
  &tracked_state,
1218
2029
  "commit-1",
1219
2030
  None,
@@ -1221,22 +2032,18 @@ mod tests {
1221
2032
  )
1222
2033
  .await
1223
2034
  .expect("root should write");
1224
- transaction
1225
- .commit()
1226
- .await
1227
- .expect("transaction should commit");
1228
-
1229
- let by_file_root = storage::load_by_file_root(&mut storage.clone(), "commit-1")
1230
- .await
1231
- .expect("by-file root lookup should load");
1232
- assert!(by_file_root.is_none());
1233
2035
 
1234
2036
  let rows = tracked_state
1235
- .reader(storage.clone())
2037
+ .reader(
2038
+ storage
2039
+ .begin_read(StorageReadOptions::default())
2040
+ .expect("read should open"),
2041
+ )
1236
2042
  .scan_rows_at_commit(
1237
2043
  "commit-1",
1238
2044
  &TrackedStateScanRequest {
1239
2045
  filter: crate::tracked_state::TrackedStateFilter {
2046
+ schema_keys: vec!["test_schema".to_string()],
1240
2047
  file_ids: vec![NullableKeyFilter::Null],
1241
2048
  ..Default::default()
1242
2049
  },
@@ -1244,33 +2051,27 @@ mod tests {
1244
2051
  },
1245
2052
  )
1246
2053
  .await
1247
- .expect("null file scan should fall back to primary tree");
2054
+ .expect("null file scan should use primary tree");
1248
2055
 
1249
2056
  assert_eq!(rows.len(), 1);
1250
2057
  assert_eq!(
1251
2058
  rows[0]
1252
- .entity_id
2059
+ .entity_pk
1253
2060
  .as_single_string_owned()
1254
- .expect("entity id"),
2061
+ .expect("entity pk"),
1255
2062
  "entity-a"
1256
2063
  );
1257
2064
  }
1258
2065
 
1259
2066
  #[tokio::test]
1260
2067
  async fn mixed_null_and_concrete_file_scan_uses_primary_tree() {
1261
- let backend = Arc::new(UnitTestBackend::new());
1262
- let storage = StorageContext::new(backend.clone());
2068
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1263
2069
  let tracked_state = TrackedStateContext::new();
1264
2070
  let null_row = row("entity-null", "change-null", "commit-1");
1265
2071
  let mut file_row = row("entity-file", "change-file", "commit-2");
1266
2072
  file_row.file_id = Some("file-a.json".to_string());
1267
-
1268
- let mut transaction = storage
1269
- .begin_write_transaction()
1270
- .await
1271
- .expect("transaction should open");
1272
2073
  write_root_for_test(
1273
- transaction.as_mut(),
2074
+ &storage,
1274
2075
  &tracked_state,
1275
2076
  "commit-1",
1276
2077
  None,
@@ -1279,7 +2080,7 @@ mod tests {
1279
2080
  .await
1280
2081
  .expect("parent root should write");
1281
2082
  write_root_for_test(
1282
- transaction.as_mut(),
2083
+ &storage,
1283
2084
  &tracked_state,
1284
2085
  "commit-2",
1285
2086
  Some("commit-1"),
@@ -1287,17 +2088,18 @@ mod tests {
1287
2088
  )
1288
2089
  .await
1289
2090
  .expect("child root should write");
1290
- transaction
1291
- .commit()
1292
- .await
1293
- .expect("transaction should commit");
1294
2091
 
1295
2092
  let rows = tracked_state
1296
- .reader(storage.clone())
2093
+ .reader(
2094
+ storage
2095
+ .begin_read(StorageReadOptions::default())
2096
+ .expect("read should open"),
2097
+ )
1297
2098
  .scan_rows_at_commit(
1298
2099
  "commit-2",
1299
2100
  &TrackedStateScanRequest {
1300
2101
  filter: crate::tracked_state::TrackedStateFilter {
2102
+ schema_keys: vec!["test_schema".to_string()],
1301
2103
  file_ids: vec![
1302
2104
  NullableKeyFilter::Null,
1303
2105
  NullableKeyFilter::Value("file-a.json".to_string()),
@@ -1310,44 +2112,32 @@ mod tests {
1310
2112
  .await
1311
2113
  .expect("mixed scan should use primary tree");
1312
2114
 
1313
- let mut entity_ids = rows
2115
+ let mut entity_pks = rows
1314
2116
  .iter()
1315
- .map(|row| row.entity_id.as_single_string_owned().expect("entity id"))
2117
+ .map(|row| row.entity_pk.as_single_string_owned().expect("entity pk"))
1316
2118
  .collect::<Vec<_>>();
1317
- entity_ids.sort();
1318
- assert_eq!(entity_ids, vec!["entity-file", "entity-null"]);
2119
+ entity_pks.sort();
2120
+ assert_eq!(entity_pks, vec!["entity-file", "entity-null"]);
1319
2121
  }
1320
2122
 
1321
2123
  #[tokio::test]
1322
- async fn by_file_header_index_filters_tombstones_without_payload_sentinel() {
1323
- let backend = Arc::new(UnitTestBackend::new());
1324
- let storage = StorageContext::new(backend.clone());
2124
+ async fn file_filtered_header_scan_filters_tombstones_without_payload_sentinel() {
2125
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1325
2126
  let tracked_state = TrackedStateContext::new();
1326
2127
  let mut live = row("entity-live", "change-live", "commit-1");
1327
2128
  live.file_id = Some("file-a.json".to_string());
1328
2129
  let mut deleted = tombstone("entity-deleted", "change-delete", "commit-1");
1329
2130
  deleted.file_id = Some("file-a.json".to_string());
1330
-
1331
- let mut transaction = storage
1332
- .begin_write_transaction()
1333
- .await
1334
- .expect("transaction should open");
1335
- write_root_for_test(
1336
- transaction.as_mut(),
1337
- &tracked_state,
1338
- "commit-1",
1339
- None,
1340
- &[live, deleted],
1341
- )
1342
- .await
1343
- .expect("root should write");
1344
- transaction
1345
- .commit()
2131
+ write_root_for_test(&storage, &tracked_state, "commit-1", None, &[live, deleted])
1346
2132
  .await
1347
- .expect("transaction should commit");
2133
+ .expect("root should write");
1348
2134
 
1349
2135
  let rows = tracked_state
1350
- .reader(storage.clone())
2136
+ .reader(
2137
+ storage
2138
+ .begin_read(StorageReadOptions::default())
2139
+ .expect("read should open"),
2140
+ )
1351
2141
  .scan_rows_at_commit(
1352
2142
  "commit-1",
1353
2143
  &TrackedStateScanRequest {
@@ -1355,107 +2145,81 @@ mod tests {
1355
2145
  file_ids: vec![NullableKeyFilter::Value("file-a.json".to_string())],
1356
2146
  ..Default::default()
1357
2147
  },
1358
- projection: crate::tracked_state::TrackedStateProjection {
1359
- columns: vec!["entity_id".to_string()],
2148
+ read_columns: crate::tracked_state::TrackedStateReadColumns {
2149
+ columns: vec!["entity_pk".to_string()],
1360
2150
  },
1361
2151
  ..Default::default()
1362
2152
  },
1363
2153
  )
1364
2154
  .await
1365
- .expect("file scan should read through index");
2155
+ .expect("file scan should use primary root");
1366
2156
 
1367
2157
  assert_eq!(rows.len(), 1);
1368
2158
  assert_eq!(
1369
2159
  rows[0]
1370
- .entity_id
2160
+ .entity_pk
1371
2161
  .as_single_string_owned()
1372
- .expect("entity id"),
2162
+ .expect("entity pk"),
1373
2163
  "entity-live"
1374
2164
  );
1375
2165
  }
1376
2166
 
1377
2167
  #[tokio::test]
1378
- async fn pending_tombstone_delta_hides_materialized_base_row() {
1379
- let backend = Arc::new(UnitTestBackend::new());
1380
- let storage = StorageContext::new(backend.clone());
2168
+ async fn child_root_tombstone_hides_materialized_base_row() {
2169
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1381
2170
  let tracked_state = TrackedStateContext::new();
1382
2171
  let base = row("entity-a", "change-base", "base");
1383
2172
  let delete = tombstone("entity-a", "change-delete", "child");
1384
-
1385
- let mut transaction = storage
1386
- .begin_write_transaction()
1387
- .await
1388
- .expect("base transaction should open");
1389
2173
  write_root_for_test(
1390
- transaction.as_mut(),
2174
+ &storage,
1391
2175
  &tracked_state,
1392
2176
  "base",
1393
2177
  None,
1394
2178
  std::slice::from_ref(&base),
1395
2179
  )
1396
2180
  .await
1397
- .expect("base delta should write");
1398
- transaction.commit().await.expect("base should commit");
1399
-
1400
- let mut transaction = storage
1401
- .begin_write_transaction()
1402
- .await
1403
- .expect("materialize transaction should open");
1404
- let mut writes = StorageWriteSet::new();
2181
+ .expect("base root should write");
2182
+ let read = storage
2183
+ .begin_read(StorageReadOptions::default())
2184
+ .expect("read should open");
2185
+ let mut writes = storage.new_write_set();
1405
2186
  tracked_state
1406
- .materializer(
1407
- transaction.as_mut(),
1408
- &mut writes,
1409
- &CommitStoreContext::new(),
1410
- )
1411
- .materialize_root_at("base")
1412
- .await
1413
- .expect("base projection root should materialize");
1414
- writes
1415
- .apply(transaction.as_mut())
1416
- .await
1417
- .expect("base root writes should apply");
1418
- transaction
1419
- .commit()
2187
+ .root_rebuilder(&read, &mut writes)
2188
+ .rebuild_commit_root_at("base")
1420
2189
  .await
2190
+ .expect("base commit root should materialize");
2191
+ storage
2192
+ .commit_write_set(writes, StorageWriteOptions::default())
1421
2193
  .expect("materialized base should commit");
1422
-
1423
- let mut transaction = storage
1424
- .begin_write_transaction()
1425
- .await
1426
- .expect("child transaction should open");
1427
2194
  write_root_for_test(
1428
- transaction.as_mut(),
2195
+ &storage,
1429
2196
  &tracked_state,
1430
2197
  "child",
1431
2198
  Some("base"),
1432
2199
  std::slice::from_ref(&delete),
1433
2200
  )
1434
2201
  .await
1435
- .expect("child tombstone delta should write");
1436
- transaction.commit().await.expect("child should commit");
2202
+ .expect("child tombstone root should write");
1437
2203
 
1438
2204
  let rows = tracked_state
1439
- .reader(storage.clone())
1440
- .scan_rows_at_commit("child", &TrackedStateScanRequest::default())
2205
+ .reader(
2206
+ storage
2207
+ .begin_read(StorageReadOptions::default())
2208
+ .expect("read should open"),
2209
+ )
2210
+ .scan_rows_at_commit("child", &test_schema_scan_request())
1441
2211
  .await
1442
- .expect("child scan should apply pending tombstone over base root");
2212
+ .expect("child scan should apply tombstone over base root");
1443
2213
 
1444
2214
  assert!(rows.is_empty(), "pending tombstone must hide base row");
1445
2215
  }
1446
2216
 
1447
2217
  #[tokio::test]
1448
- async fn single_delta_pack_scan_keeps_last_delta_for_duplicate_key() {
1449
- let backend = Arc::new(UnitTestBackend::new());
1450
- let storage = StorageContext::new(backend.clone());
2218
+ async fn root_scan_keeps_last_mutation_for_duplicate_key() {
2219
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1451
2220
  let tracked_state = TrackedStateContext::new();
1452
-
1453
- let mut transaction = storage
1454
- .begin_write_transaction()
1455
- .await
1456
- .expect("transaction should open");
1457
2221
  write_root_for_test(
1458
- transaction.as_mut(),
2222
+ &storage,
1459
2223
  &tracked_state,
1460
2224
  "commit-1",
1461
2225
  None,
@@ -1467,23 +2231,23 @@ mod tests {
1467
2231
  ],
1468
2232
  )
1469
2233
  .await
1470
- .expect("delta pack should write");
1471
- transaction
1472
- .commit()
1473
- .await
1474
- .expect("transaction should commit");
2234
+ .expect("root should write");
1475
2235
 
1476
2236
  let rows = tracked_state
1477
- .reader(storage.clone())
1478
- .scan_rows_at_commit("commit-1", &TrackedStateScanRequest::default())
2237
+ .reader(
2238
+ storage
2239
+ .begin_read(StorageReadOptions::default())
2240
+ .expect("read should open"),
2241
+ )
2242
+ .scan_rows_at_commit("commit-1", &test_schema_scan_request())
1479
2243
  .await
1480
- .expect("single delta pack should scan");
2244
+ .expect("root should scan");
1481
2245
 
1482
2246
  assert_eq!(rows.len(), 2);
1483
2247
  assert_eq!(
1484
2248
  rows.iter()
1485
2249
  .map(|row| (
1486
- row.entity_id.as_single_string_owned().expect("entity id"),
2250
+ row.entity_pk.as_single_string_owned().expect("entity pk"),
1487
2251
  row.snapshot_content.clone()
1488
2252
  ))
1489
2253
  .collect::<Vec<_>>(),
@@ -1502,16 +2266,10 @@ mod tests {
1502
2266
 
1503
2267
  #[tokio::test]
1504
2268
  async fn scan_limit_applies_after_tombstone_visibility() {
1505
- let backend = Arc::new(UnitTestBackend::new());
1506
- let storage = StorageContext::new(backend.clone());
2269
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1507
2270
  let tracked_state = TrackedStateContext::new();
1508
-
1509
- let mut transaction = storage
1510
- .begin_write_transaction()
1511
- .await
1512
- .expect("transaction should open");
1513
2271
  write_root_for_test(
1514
- transaction.as_mut(),
2272
+ &storage,
1515
2273
  &tracked_state,
1516
2274
  "commit-1",
1517
2275
  None,
@@ -1522,16 +2280,20 @@ mod tests {
1522
2280
  )
1523
2281
  .await
1524
2282
  .expect("root should write");
1525
- transaction
1526
- .commit()
1527
- .await
1528
- .expect("transaction should commit");
1529
2283
 
1530
2284
  let rows = tracked_state
1531
- .reader(storage.clone())
2285
+ .reader(
2286
+ storage
2287
+ .begin_read(StorageReadOptions::default())
2288
+ .expect("read should open"),
2289
+ )
1532
2290
  .scan_rows_at_commit(
1533
2291
  "commit-1",
1534
2292
  &TrackedStateScanRequest {
2293
+ filter: crate::tracked_state::TrackedStateFilter {
2294
+ schema_keys: vec!["test_schema".to_string()],
2295
+ ..Default::default()
2296
+ },
1535
2297
  limit: Some(1),
1536
2298
  ..Default::default()
1537
2299
  },
@@ -1542,43 +2304,31 @@ mod tests {
1542
2304
  assert_eq!(rows.len(), 1);
1543
2305
  assert_eq!(
1544
2306
  rows[0]
1545
- .entity_id
2307
+ .entity_pk
1546
2308
  .as_single_string_owned()
1547
- .expect("entity id"),
2309
+ .expect("entity pk"),
1548
2310
  "entity-b"
1549
2311
  );
1550
2312
  }
1551
2313
 
1552
2314
  #[tokio::test]
1553
- async fn by_file_scan_limit_applies_after_tombstone_visibility() {
1554
- let backend = Arc::new(UnitTestBackend::new());
1555
- let storage = StorageContext::new(backend.clone());
2315
+ async fn file_filtered_scan_limit_applies_after_tombstone_visibility() {
2316
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1556
2317
  let tracked_state = TrackedStateContext::new();
1557
2318
  let mut deleted = tombstone("entity-a", "change-delete", "commit-1");
1558
2319
  deleted.file_id = Some("file-a.json".to_string());
1559
2320
  let mut live = row("entity-b", "change-live", "commit-1");
1560
2321
  live.file_id = Some("file-a.json".to_string());
1561
-
1562
- let mut transaction = storage
1563
- .begin_write_transaction()
1564
- .await
1565
- .expect("transaction should open");
1566
- write_root_for_test(
1567
- transaction.as_mut(),
1568
- &tracked_state,
1569
- "commit-1",
1570
- None,
1571
- &[deleted, live],
1572
- )
1573
- .await
1574
- .expect("root should write");
1575
- transaction
1576
- .commit()
2322
+ write_root_for_test(&storage, &tracked_state, "commit-1", None, &[deleted, live])
1577
2323
  .await
1578
- .expect("transaction should commit");
2324
+ .expect("root should write");
1579
2325
 
1580
2326
  let rows = tracked_state
1581
- .reader(storage.clone())
2327
+ .reader(
2328
+ storage
2329
+ .begin_read(StorageReadOptions::default())
2330
+ .expect("read should open"),
2331
+ )
1582
2332
  .scan_rows_at_commit(
1583
2333
  "commit-1",
1584
2334
  &TrackedStateScanRequest {
@@ -1586,39 +2336,33 @@ mod tests {
1586
2336
  file_ids: vec![NullableKeyFilter::Value("file-a.json".to_string())],
1587
2337
  ..Default::default()
1588
2338
  },
1589
- projection: crate::tracked_state::TrackedStateProjection {
1590
- columns: vec!["entity_id".to_string()],
2339
+ read_columns: crate::tracked_state::TrackedStateReadColumns {
2340
+ columns: vec!["entity_pk".to_string()],
1591
2341
  },
1592
2342
  limit: Some(1),
1593
2343
  },
1594
2344
  )
1595
2345
  .await
1596
- .expect("limited by-file scan should apply visibility before limit");
2346
+ .expect("limited file scan should apply visibility before limit");
1597
2347
 
1598
2348
  assert_eq!(rows.len(), 1);
1599
2349
  assert_eq!(
1600
2350
  rows[0]
1601
- .entity_id
2351
+ .entity_pk
1602
2352
  .as_single_string_owned()
1603
- .expect("entity id"),
2353
+ .expect("entity pk"),
1604
2354
  "entity-b"
1605
2355
  );
1606
2356
  }
1607
2357
 
1608
2358
  #[tokio::test]
1609
2359
  async fn reads_resolve_json_snapshot_refs() {
1610
- let backend = Arc::new(UnitTestBackend::new());
1611
- let storage = StorageContext::new(backend.clone());
2360
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1612
2361
  let tracked_state = TrackedStateContext::new();
1613
2362
  let large_value = "x".repeat(1536);
1614
2363
  let row = row_with_value("entity-a", "change-a", "commit-1", &large_value);
1615
-
1616
- let mut transaction = storage
1617
- .begin_write_transaction()
1618
- .await
1619
- .expect("transaction should open");
1620
2364
  write_root_for_test(
1621
- transaction.as_mut(),
2365
+ &storage,
1622
2366
  &tracked_state,
1623
2367
  "commit-1",
1624
2368
  None,
@@ -1626,19 +2370,19 @@ mod tests {
1626
2370
  )
1627
2371
  .await
1628
2372
  .expect("root should write");
1629
- transaction
1630
- .commit()
1631
- .await
1632
- .expect("transaction should commit");
1633
2373
 
1634
- let mut reader = tracked_state.reader(storage.clone());
2374
+ let mut reader = tracked_state.reader(
2375
+ storage
2376
+ .begin_read(StorageReadOptions::default())
2377
+ .expect("read should open"),
2378
+ );
1635
2379
  let loaded = reader
1636
2380
  .load_rows_at_commit(
1637
2381
  "commit-1",
1638
- &[TrackedStateRowRequest {
2382
+ &[TrackedStateKey {
1639
2383
  schema_key: row.schema_key.clone(),
1640
- entity_id: row.entity_id.clone(),
1641
- file_id: NullableKeyFilter::Null,
2384
+ entity_pk: row.entity_pk.clone(),
2385
+ file_id: None,
1642
2386
  }],
1643
2387
  )
1644
2388
  .await
@@ -1647,7 +2391,7 @@ mod tests {
1647
2391
  .flatten()
1648
2392
  .expect("row should exist");
1649
2393
  let scanned = reader
1650
- .scan_rows_at_commit("commit-1", &TrackedStateScanRequest::default())
2394
+ .scan_rows_at_commit("commit-1", &test_schema_scan_request())
1651
2395
  .await
1652
2396
  .expect("rows should scan");
1653
2397
 
@@ -1656,20 +2400,14 @@ mod tests {
1656
2400
  }
1657
2401
 
1658
2402
  #[tokio::test]
1659
- async fn projection_cache_uses_seen_updated_at_not_change_created_at() {
1660
- let backend = Arc::new(UnitTestBackend::new());
1661
- let storage = StorageContext::new(backend.clone());
2403
+ async fn commit_root_cache_uses_seen_updated_at_not_change_created_at() {
2404
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1662
2405
  let tracked_state = TrackedStateContext::new();
1663
2406
  let mut row = row("entity-a", "change-a", "commit-1");
1664
2407
  row.created_at = "2026-01-01T00:00:00Z".to_string();
1665
2408
  row.updated_at = "2026-01-02T00:00:00Z".to_string();
1666
-
1667
- let mut transaction = storage
1668
- .begin_write_transaction()
1669
- .await
1670
- .expect("transaction should open");
1671
2409
  write_root_for_test(
1672
- transaction.as_mut(),
2410
+ &storage,
1673
2411
  &tracked_state,
1674
2412
  "commit-1",
1675
2413
  None,
@@ -1677,19 +2415,19 @@ mod tests {
1677
2415
  )
1678
2416
  .await
1679
2417
  .expect("root should write");
1680
- transaction
1681
- .commit()
1682
- .await
1683
- .expect("transaction should commit");
1684
2418
 
1685
2419
  let loaded = tracked_state
1686
- .reader(storage.clone())
2420
+ .reader(
2421
+ storage
2422
+ .begin_read(StorageReadOptions::default())
2423
+ .expect("read should open"),
2424
+ )
1687
2425
  .load_rows_at_commit(
1688
2426
  "commit-1",
1689
- &[TrackedStateRowRequest {
2427
+ &[TrackedStateKey {
1690
2428
  schema_key: row.schema_key.clone(),
1691
- entity_id: row.entity_id.clone(),
1692
- file_id: NullableKeyFilter::Null,
2429
+ entity_pk: row.entity_pk.clone(),
2430
+ file_id: None,
1693
2431
  }],
1694
2432
  )
1695
2433
  .await
@@ -1703,19 +2441,104 @@ mod tests {
1703
2441
  }
1704
2442
 
1705
2443
  #[tokio::test]
1706
- async fn projected_scans_do_not_materialize_snapshot_when_snapshot_content_is_omitted() {
1707
- let backend = Arc::new(UnitTestBackend::new());
1708
- let storage = StorageContext::new(backend.clone());
2444
+ async fn updates_preserve_first_visible_created_at_across_rebuild() {
2445
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1709
2446
  let tracked_state = TrackedStateContext::new();
1710
- let large_value = "x".repeat(1536);
1711
- let row = row_with_value("entity-a", "change-a", "commit-1", &large_value);
2447
+ let mut parent = row("entity-a", "change-parent", "parent");
2448
+ parent.created_at = "2026-01-01T00:00:00Z".to_string();
2449
+ parent.updated_at = "2026-01-01T00:00:00Z".to_string();
2450
+ write_root_for_test(
2451
+ &storage,
2452
+ &tracked_state,
2453
+ "parent",
2454
+ None,
2455
+ std::slice::from_ref(&parent),
2456
+ )
2457
+ .await
2458
+ .expect("parent root should write");
2459
+
2460
+ let mut child = row("entity-a", "change-child", "child");
2461
+ child.created_at = "2026-01-02T00:00:00Z".to_string();
2462
+ child.updated_at = "2026-01-03T00:00:00Z".to_string();
2463
+ write_root_for_test(
2464
+ &storage,
2465
+ &tracked_state,
2466
+ "child",
2467
+ Some("parent"),
2468
+ std::slice::from_ref(&child),
2469
+ )
2470
+ .await
2471
+ .expect("child root should write");
2472
+
2473
+ let key = TrackedStateKey {
2474
+ schema_key: child.schema_key.clone(),
2475
+ file_id: child.file_id.clone(),
2476
+ entity_pk: child.entity_pk.clone(),
2477
+ };
2478
+ let loaded = tracked_state
2479
+ .reader(
2480
+ storage
2481
+ .begin_read(StorageReadOptions::default())
2482
+ .expect("read should open"),
2483
+ )
2484
+ .load_rows_at_commit("child", std::slice::from_ref(&key))
2485
+ .await
2486
+ .expect("child row should load")
2487
+ .pop()
2488
+ .flatten()
2489
+ .expect("child row should exist");
2490
+ assert_eq!(loaded.created_at, "2026-01-01T00:00:00Z");
2491
+ assert_eq!(loaded.updated_at, "2026-01-03T00:00:00Z");
2492
+
2493
+ {
2494
+ let mut writes = storage.new_write_set();
2495
+ writes.delete(
2496
+ storage::TRACKED_STATE_COMMIT_ROOT_SPACE,
2497
+ crate::storage::StorageKey(bytes::Bytes::copy_from_slice(b"child")),
2498
+ );
2499
+ storage
2500
+ .commit_write_set(writes, StorageWriteOptions::default())
2501
+ .expect("child root delete should commit");
2502
+ }
2503
+ {
2504
+ let read = storage
2505
+ .begin_read(StorageReadOptions::default())
2506
+ .expect("read should open");
2507
+ let mut writes = storage.new_write_set();
2508
+ tracked_state
2509
+ .root_rebuilder(&read, &mut writes)
2510
+ .rebuild_commit_root_at("child")
2511
+ .await
2512
+ .expect("child root should rebuild");
2513
+ storage
2514
+ .commit_write_set(writes, StorageWriteOptions::default())
2515
+ .expect("rebuilt child root should commit");
2516
+ }
1712
2517
 
1713
- let mut transaction = storage
1714
- .begin_write_transaction()
2518
+ let rebuilt = tracked_state
2519
+ .reader(
2520
+ storage
2521
+ .begin_read(StorageReadOptions::default())
2522
+ .expect("read should open"),
2523
+ )
2524
+ .load_rows_at_commit("child", &[key])
1715
2525
  .await
1716
- .expect("transaction should open");
2526
+ .expect("rebuilt child row should load")
2527
+ .pop()
2528
+ .flatten()
2529
+ .expect("rebuilt child row should exist");
2530
+ assert_eq!(rebuilt.created_at, "2026-01-01T00:00:00Z");
2531
+ assert_eq!(rebuilt.updated_at, "2026-01-03T00:00:00Z");
2532
+ }
2533
+
2534
+ #[tokio::test]
2535
+ async fn selected_column_scans_do_not_materialize_snapshot_when_snapshot_content_is_omitted() {
2536
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
2537
+ let tracked_state = TrackedStateContext::new();
2538
+ let large_value = "x".repeat(1536);
2539
+ let row = row_with_value("entity-a", "change-a", "commit-1", &large_value);
1717
2540
  write_root_for_test(
1718
- transaction.as_mut(),
2541
+ &storage,
1719
2542
  &tracked_state,
1720
2543
  "commit-1",
1721
2544
  None,
@@ -1723,18 +2546,22 @@ mod tests {
1723
2546
  )
1724
2547
  .await
1725
2548
  .expect("root should write");
1726
- transaction
1727
- .commit()
1728
- .await
1729
- .expect("transaction should commit");
1730
2549
 
1731
2550
  let rows = tracked_state
1732
- .reader(storage.clone())
2551
+ .reader(
2552
+ storage
2553
+ .begin_read(StorageReadOptions::default())
2554
+ .expect("read should open"),
2555
+ )
1733
2556
  .scan_rows_at_commit(
1734
2557
  "commit-1",
1735
2558
  &TrackedStateScanRequest {
1736
- projection: crate::tracked_state::TrackedStateProjection {
1737
- columns: vec!["entity_id".to_string()],
2559
+ filter: crate::tracked_state::TrackedStateFilter {
2560
+ schema_keys: vec!["test_schema".to_string()],
2561
+ ..Default::default()
2562
+ },
2563
+ read_columns: crate::tracked_state::TrackedStateReadColumns {
2564
+ columns: vec!["entity_pk".to_string()],
1738
2565
  },
1739
2566
  ..Default::default()
1740
2567
  },
@@ -1751,54 +2578,39 @@ mod tests {
1751
2578
  target_rows: &[MaterializedTrackedStateRow],
1752
2579
  source_rows: &[MaterializedTrackedStateRow],
1753
2580
  ) -> (StorageContext, TrackedStateContext) {
1754
- let backend = Arc::new(UnitTestBackend::new());
1755
- let storage = StorageContext::new(backend.clone());
2581
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
1756
2582
  let tracked_state = TrackedStateContext::new();
1757
- let mut transaction = storage
1758
- .begin_write_transaction()
2583
+ write_root_for_test(&storage, &tracked_state, "base", None, base_rows)
1759
2584
  .await
1760
- .expect("transaction should open");
1761
- write_root_for_test(
1762
- transaction.as_mut(),
1763
- &tracked_state,
1764
- "base",
1765
- None,
1766
- base_rows,
1767
- )
1768
- .await
1769
- .expect("base root should write");
2585
+ .expect("base root should write");
1770
2586
  write_root_for_test(
1771
- transaction.as_mut(),
2587
+ &storage,
1772
2588
  &tracked_state,
1773
2589
  "target",
1774
- None,
2590
+ Some("base"),
1775
2591
  target_rows,
1776
2592
  )
1777
2593
  .await
1778
2594
  .expect("target root should write");
1779
2595
  write_root_for_test(
1780
- transaction.as_mut(),
2596
+ &storage,
1781
2597
  &tracked_state,
1782
2598
  "source",
1783
- None,
2599
+ Some("base"),
1784
2600
  source_rows,
1785
2601
  )
1786
2602
  .await
1787
2603
  .expect("source root should write");
1788
- transaction
1789
- .commit()
1790
- .await
1791
- .expect("transaction should commit");
1792
2604
  (storage, tracked_state)
1793
2605
  }
1794
2606
 
1795
- fn merge_patch_ids(plan: &TrackedStateMergePlan) -> Vec<String> {
1796
- plan.patches
2607
+ fn merge_pick_ids(plan: &TrackedStateMergePlan) -> Vec<String> {
2608
+ plan.picks
1797
2609
  .iter()
1798
2610
  .map(|entry| {
1799
2611
  entry
1800
2612
  .identity()
1801
- .entity_id
2613
+ .entity_pk
1802
2614
  .as_single_string_owned()
1803
2615
  .expect("identity")
1804
2616
  })
@@ -1811,7 +2623,7 @@ mod tests {
1811
2623
  .map(|entry| {
1812
2624
  entry
1813
2625
  .identity
1814
- .entity_id
2626
+ .entity_pk
1815
2627
  .as_single_string_owned()
1816
2628
  .expect("identity")
1817
2629
  })
@@ -1819,40 +2631,162 @@ mod tests {
1819
2631
  }
1820
2632
 
1821
2633
  async fn write_root_for_test(
1822
- transaction: &mut dyn StorageWriteTransaction,
2634
+ storage: &StorageContext,
1823
2635
  tracked_state: &TrackedStateContext,
1824
2636
  commit_id: &str,
1825
2637
  parent_commit_id: Option<&str>,
1826
2638
  rows: &[MaterializedTrackedStateRow],
1827
2639
  ) -> Result<(), LixError> {
2640
+ let mut read = storage
2641
+ .begin_read(StorageReadOptions::default())
2642
+ .expect("read should open");
2643
+ let mut writes = storage.new_write_set();
1828
2644
  crate::test_support::stage_tracked_root_from_materialized(
1829
- transaction,
2645
+ &mut read,
2646
+ &mut writes,
1830
2647
  tracked_state,
1831
2648
  commit_id,
1832
2649
  parent_commit_id,
1833
2650
  rows,
1834
2651
  )
1835
- .await
2652
+ .await?;
2653
+ storage.commit_write_set(writes, StorageWriteOptions::default())?;
2654
+ Ok(())
2655
+ }
2656
+
2657
+ async fn delete_root_chunk_for_test(storage: &StorageContext, commit_id: &str) {
2658
+ let read = storage
2659
+ .begin_read(StorageReadOptions::default())
2660
+ .expect("read should open");
2661
+ let root_id = storage::load_root(&read, commit_id)
2662
+ .await
2663
+ .expect("root metadata should load")
2664
+ .expect("root metadata should exist");
2665
+ let mut writes = storage.new_write_set();
2666
+ writes.delete(
2667
+ storage::TRACKED_STATE_TREE_CHUNK_SPACE,
2668
+ crate::storage::StorageKey(bytes::Bytes::copy_from_slice(root_id.as_bytes())),
2669
+ );
2670
+ storage
2671
+ .commit_write_set(writes, StorageWriteOptions::default())
2672
+ .expect("root chunk delete should commit");
2673
+ }
2674
+
2675
+ async fn corrupt_root_chunk_for_test(storage: &StorageContext, commit_id: &str) {
2676
+ let read = storage
2677
+ .begin_read(StorageReadOptions::default())
2678
+ .expect("read should open");
2679
+ let root_id = storage::load_root(&read, commit_id)
2680
+ .await
2681
+ .expect("root metadata should load")
2682
+ .expect("root metadata should exist");
2683
+ let mut writes = storage.new_write_set();
2684
+ writes.put(
2685
+ storage::TRACKED_STATE_TREE_CHUNK_SPACE,
2686
+ crate::storage::StorageKey(bytes::Bytes::copy_from_slice(root_id.as_bytes())),
2687
+ b"corrupt tracked-state root chunk".as_slice(),
2688
+ );
2689
+ storage
2690
+ .commit_write_set(writes, StorageWriteOptions::default())
2691
+ .expect("root chunk corruption should commit");
2692
+ }
2693
+
2694
+ async fn overwrite_root_without_commit_row_for_test(
2695
+ storage: &StorageContext,
2696
+ commit_id: &str,
2697
+ rows: &[MaterializedTrackedStateRow],
2698
+ ) {
2699
+ let read = storage
2700
+ .begin_read(StorageReadOptions::default())
2701
+ .expect("read should open");
2702
+ let mut writes = storage.new_write_set();
2703
+ let mutations =
2704
+ rows.iter()
2705
+ .map(|row| {
2706
+ let key = TrackedStateKey {
2707
+ schema_key: row.schema_key.clone(),
2708
+ file_id: row.file_id.clone(),
2709
+ entity_pk: row.entity_pk.clone(),
2710
+ };
2711
+ let value = TrackedStateIndexValue {
2712
+ change_id: row.change_id.clone(),
2713
+ commit_id: row.commit_id.clone(),
2714
+ deleted: row.deleted,
2715
+ snapshot_ref: row.snapshot_content.as_ref().map(|content| {
2716
+ crate::json_store::JsonRef::for_content(content.as_bytes())
2717
+ }),
2718
+ metadata_ref: row.metadata.as_ref().map(|metadata| {
2719
+ crate::json_store::JsonRef::for_content(metadata.as_bytes())
2720
+ }),
2721
+ created_at: row.created_at.clone(),
2722
+ updated_at: row.updated_at.clone(),
2723
+ };
2724
+ TrackedStateMutation::put_encoded(
2725
+ crate::tracked_state::codec::encode_key(&key),
2726
+ crate::tracked_state::codec::encode_value(&value),
2727
+ )
2728
+ })
2729
+ .collect::<Vec<_>>();
2730
+ let result = TrackedStateTree::new()
2731
+ .apply_mutations(&read, &mut writes, None, mutations, Some(commit_id))
2732
+ .await
2733
+ .expect("stale root should write");
2734
+ storage::stage_commit_root(
2735
+ &mut writes,
2736
+ &TrackedStateCommitRoot {
2737
+ commit_id: commit_id.to_string(),
2738
+ root_id: result.root_id,
2739
+ parent_roots: Vec::new(),
2740
+ changed_key_count: rows.len() as u64,
2741
+ row_count_estimate: result.row_count as u64,
2742
+ tree_height: result.tree_height as u32,
2743
+ primary_chunk_count: result.chunk_count as u64,
2744
+ primary_chunk_bytes: result.chunk_bytes as u64,
2745
+ },
2746
+ )
2747
+ .expect("stale metadata should encode");
2748
+ storage
2749
+ .commit_write_set(writes, StorageWriteOptions::default())
2750
+ .expect("stale root overwrite should commit");
2751
+ }
2752
+
2753
+ fn test_schema_scan_request() -> TrackedStateScanRequest {
2754
+ TrackedStateScanRequest {
2755
+ filter: crate::tracked_state::TrackedStateFilter {
2756
+ schema_keys: vec!["test_schema".to_string()],
2757
+ ..Default::default()
2758
+ },
2759
+ ..Default::default()
2760
+ }
2761
+ }
2762
+
2763
+ fn test_schema_diff_request() -> TrackedStateDiffRequest {
2764
+ TrackedStateDiffRequest {
2765
+ filter: crate::tracked_state::TrackedStateFilter {
2766
+ schema_keys: vec!["test_schema".to_string()],
2767
+ ..Default::default()
2768
+ },
2769
+ }
1836
2770
  }
1837
2771
 
1838
- fn tombstone(entity_id: &str, change_id: &str, commit_id: &str) -> MaterializedTrackedStateRow {
1839
- let mut row = row(entity_id, change_id, commit_id);
2772
+ fn tombstone(entity_pk: &str, change_id: &str, commit_id: &str) -> MaterializedTrackedStateRow {
2773
+ let mut row = row(entity_pk, change_id, commit_id);
1840
2774
  row.snapshot_content = None;
1841
2775
  row
1842
2776
  }
1843
2777
 
1844
- fn row(entity_id: &str, change_id: &str, commit_id: &str) -> MaterializedTrackedStateRow {
1845
- row_with_value(entity_id, change_id, commit_id, "value")
2778
+ fn row(entity_pk: &str, change_id: &str, commit_id: &str) -> MaterializedTrackedStateRow {
2779
+ row_with_value(entity_pk, change_id, commit_id, "value")
1846
2780
  }
1847
2781
 
1848
2782
  fn row_with_value(
1849
- entity_id: &str,
2783
+ entity_pk: &str,
1850
2784
  change_id: &str,
1851
2785
  commit_id: &str,
1852
2786
  value: &str,
1853
2787
  ) -> MaterializedTrackedStateRow {
1854
2788
  MaterializedTrackedStateRow {
1855
- entity_id: crate::entity_identity::EntityIdentity::single(entity_id),
2789
+ entity_pk: crate::entity_pk::EntityPk::single(entity_pk),
1856
2790
  schema_key: "test_schema".to_string(),
1857
2791
  file_id: None,
1858
2792
  snapshot_content: Some(format!("{{\"value\":\"{value}\"}}")),