@lix-js/sdk 0.6.0-preview.1 → 0.6.0-preview.3

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 (205) hide show
  1. package/SKILL.md +304 -320
  2. package/dist/engine-wasm/wasm/lix_engine.d.ts +5 -0
  3. package/dist/engine-wasm/wasm/lix_engine.js +9 -13
  4. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  5. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +1 -0
  6. package/dist/generated/builtin-schemas.d.ts +87 -162
  7. package/dist/generated/builtin-schemas.js +139 -236
  8. package/dist/open-lix.d.ts +103 -14
  9. package/dist/open-lix.js +3 -0
  10. package/dist/sqlite/index.js +99 -22
  11. package/dist-engine-src/README.md +18 -0
  12. package/dist-engine-src/src/backend/kv.rs +358 -0
  13. package/dist-engine-src/src/backend/mod.rs +12 -0
  14. package/dist-engine-src/src/backend/testing.rs +658 -0
  15. package/dist-engine-src/src/backend/types.rs +96 -0
  16. package/dist-engine-src/src/binary_cas/chunking.rs +31 -0
  17. package/dist-engine-src/src/binary_cas/codec.rs +346 -0
  18. package/dist-engine-src/src/binary_cas/context.rs +139 -0
  19. package/dist-engine-src/src/binary_cas/kv.rs +1063 -0
  20. package/dist-engine-src/src/binary_cas/mod.rs +11 -0
  21. package/dist-engine-src/src/binary_cas/types.rs +121 -0
  22. package/dist-engine-src/src/catalog/context.rs +412 -0
  23. package/dist-engine-src/src/catalog/mod.rs +10 -0
  24. package/dist-engine-src/src/catalog/schema.rs +4 -0
  25. package/dist-engine-src/src/catalog/snapshot.rs +1114 -0
  26. package/dist-engine-src/src/cel/context.rs +86 -0
  27. package/dist-engine-src/src/cel/error.rs +19 -0
  28. package/dist-engine-src/src/cel/mod.rs +8 -0
  29. package/dist-engine-src/src/cel/provider.rs +9 -0
  30. package/dist-engine-src/src/cel/runtime.rs +167 -0
  31. package/dist-engine-src/src/cel/value.rs +50 -0
  32. package/dist-engine-src/src/commit_graph/context.rs +901 -0
  33. package/dist-engine-src/src/commit_graph/mod.rs +11 -0
  34. package/dist-engine-src/src/commit_graph/types.rs +109 -0
  35. package/dist-engine-src/src/commit_graph/walker.rs +756 -0
  36. package/dist-engine-src/src/commit_store/codec.rs +887 -0
  37. package/dist-engine-src/src/commit_store/context.rs +944 -0
  38. package/dist-engine-src/src/commit_store/materialization.rs +84 -0
  39. package/dist-engine-src/src/commit_store/mod.rs +16 -0
  40. package/dist-engine-src/src/commit_store/storage.rs +600 -0
  41. package/dist-engine-src/src/commit_store/types.rs +215 -0
  42. package/dist-engine-src/src/common/error.rs +313 -0
  43. package/dist-engine-src/src/common/fingerprint.rs +3 -0
  44. package/dist-engine-src/src/common/fs_path.rs +1336 -0
  45. package/dist-engine-src/src/common/identity.rs +145 -0
  46. package/dist-engine-src/src/common/json_pointer.rs +67 -0
  47. package/dist-engine-src/src/common/metadata.rs +40 -0
  48. package/dist-engine-src/src/common/mod.rs +23 -0
  49. package/dist-engine-src/src/common/types.rs +105 -0
  50. package/dist-engine-src/src/common/wire.rs +222 -0
  51. package/dist-engine-src/src/domain.rs +324 -0
  52. package/dist-engine-src/src/engine.rs +225 -0
  53. package/dist-engine-src/src/entity_identity.rs +405 -0
  54. package/dist-engine-src/src/functions/context.rs +292 -0
  55. package/dist-engine-src/src/functions/deterministic.rs +113 -0
  56. package/dist-engine-src/src/functions/mod.rs +18 -0
  57. package/dist-engine-src/src/functions/provider.rs +130 -0
  58. package/dist-engine-src/src/functions/state.rs +336 -0
  59. package/dist-engine-src/src/functions/types.rs +37 -0
  60. package/dist-engine-src/src/init.rs +558 -0
  61. package/dist-engine-src/src/json_store/compression.rs +77 -0
  62. package/dist-engine-src/src/json_store/context.rs +423 -0
  63. package/dist-engine-src/src/json_store/encoded.rs +15 -0
  64. package/dist-engine-src/src/json_store/mod.rs +12 -0
  65. package/dist-engine-src/src/json_store/store.rs +1109 -0
  66. package/dist-engine-src/src/json_store/types.rs +217 -0
  67. package/dist-engine-src/src/lib.rs +62 -0
  68. package/dist-engine-src/src/live_state/context.rs +2019 -0
  69. package/dist-engine-src/src/live_state/mod.rs +15 -0
  70. package/dist-engine-src/src/live_state/overlay.rs +75 -0
  71. package/dist-engine-src/src/live_state/reader.rs +23 -0
  72. package/dist-engine-src/src/live_state/types.rs +222 -0
  73. package/dist-engine-src/src/live_state/visibility.rs +223 -0
  74. package/dist-engine-src/src/plugin/archive.rs +438 -0
  75. package/dist-engine-src/src/plugin/component.rs +183 -0
  76. package/dist-engine-src/src/plugin/install.rs +619 -0
  77. package/dist-engine-src/src/plugin/manifest.rs +516 -0
  78. package/dist-engine-src/src/plugin/materializer.rs +477 -0
  79. package/dist-engine-src/src/plugin/mod.rs +33 -0
  80. package/dist-engine-src/src/plugin/plugin_manifest.json +118 -0
  81. package/dist-engine-src/src/plugin/storage.rs +74 -0
  82. package/dist-engine-src/src/schema/annotations/defaults.rs +275 -0
  83. package/dist-engine-src/src/schema/annotations/mod.rs +1 -0
  84. package/dist-engine-src/src/schema/builtin/lix_account.json +21 -0
  85. package/dist-engine-src/src/schema/builtin/lix_active_account.json +29 -0
  86. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +29 -0
  87. package/dist-engine-src/src/schema/builtin/lix_change.json +63 -0
  88. package/dist-engine-src/src/schema/builtin/lix_change_author.json +45 -0
  89. package/dist-engine-src/src/schema/builtin/lix_commit.json +24 -0
  90. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +53 -0
  91. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +52 -0
  92. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +52 -0
  93. package/dist-engine-src/src/schema/builtin/lix_key_value.json +40 -0
  94. package/dist-engine-src/src/schema/builtin/lix_label.json +29 -0
  95. package/dist-engine-src/src/schema/builtin/lix_label_assignment.json +74 -0
  96. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +25 -0
  97. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +34 -0
  98. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +48 -0
  99. package/dist-engine-src/src/schema/builtin/mod.rs +222 -0
  100. package/dist-engine-src/src/schema/compatibility.rs +787 -0
  101. package/dist-engine-src/src/schema/definition.json +187 -0
  102. package/dist-engine-src/src/schema/definition.rs +742 -0
  103. package/dist-engine-src/src/schema/key.rs +138 -0
  104. package/dist-engine-src/src/schema/mod.rs +20 -0
  105. package/dist-engine-src/src/schema/seed.rs +14 -0
  106. package/dist-engine-src/src/schema/tests.rs +780 -0
  107. package/dist-engine-src/src/session/context.rs +364 -0
  108. package/dist-engine-src/src/session/create_version.rs +88 -0
  109. package/dist-engine-src/src/session/execute.rs +478 -0
  110. package/dist-engine-src/src/session/merge/analysis.rs +102 -0
  111. package/dist-engine-src/src/session/merge/apply.rs +23 -0
  112. package/dist-engine-src/src/session/merge/conflicts.rs +63 -0
  113. package/dist-engine-src/src/session/merge/mod.rs +11 -0
  114. package/dist-engine-src/src/session/merge/stats.rs +65 -0
  115. package/dist-engine-src/src/session/merge/version.rs +427 -0
  116. package/dist-engine-src/src/session/mod.rs +27 -0
  117. package/dist-engine-src/src/session/optimization9_sql2_bench.rs +100 -0
  118. package/dist-engine-src/src/session/switch_version.rs +109 -0
  119. package/dist-engine-src/src/sql2/change_provider.rs +331 -0
  120. package/dist-engine-src/src/sql2/classify.rs +182 -0
  121. package/dist-engine-src/src/sql2/context.rs +311 -0
  122. package/dist-engine-src/src/sql2/directory_history_provider.rs +631 -0
  123. package/dist-engine-src/src/sql2/directory_provider.rs +2453 -0
  124. package/dist-engine-src/src/sql2/dml.rs +148 -0
  125. package/dist-engine-src/src/sql2/entity_history_provider.rs +440 -0
  126. package/dist-engine-src/src/sql2/entity_provider.rs +3211 -0
  127. package/dist-engine-src/src/sql2/error.rs +216 -0
  128. package/dist-engine-src/src/sql2/execute.rs +3440 -0
  129. package/dist-engine-src/src/sql2/file_history_provider.rs +910 -0
  130. package/dist-engine-src/src/sql2/file_provider.rs +3679 -0
  131. package/dist-engine-src/src/sql2/filesystem_planner.rs +1490 -0
  132. package/dist-engine-src/src/sql2/filesystem_predicates.rs +159 -0
  133. package/dist-engine-src/src/sql2/filesystem_visibility.rs +383 -0
  134. package/dist-engine-src/src/sql2/history_projection.rs +56 -0
  135. package/dist-engine-src/src/sql2/history_provider.rs +412 -0
  136. package/dist-engine-src/src/sql2/history_route.rs +657 -0
  137. package/dist-engine-src/src/sql2/lix_state_provider.rs +2512 -0
  138. package/dist-engine-src/src/sql2/mod.rs +46 -0
  139. package/dist-engine-src/src/sql2/predicate_typecheck.rs +246 -0
  140. package/dist-engine-src/src/sql2/public_bind/assignment.rs +46 -0
  141. package/dist-engine-src/src/sql2/public_bind/capability.rs +41 -0
  142. package/dist-engine-src/src/sql2/public_bind/dml.rs +166 -0
  143. package/dist-engine-src/src/sql2/public_bind/mod.rs +25 -0
  144. package/dist-engine-src/src/sql2/public_bind/table.rs +168 -0
  145. package/dist-engine-src/src/sql2/read_only.rs +63 -0
  146. package/dist-engine-src/src/sql2/record_batch.rs +17 -0
  147. package/dist-engine-src/src/sql2/result_metadata.rs +29 -0
  148. package/dist-engine-src/src/sql2/runtime.rs +60 -0
  149. package/dist-engine-src/src/sql2/session.rs +132 -0
  150. package/dist-engine-src/src/sql2/udfs/common.rs +295 -0
  151. package/dist-engine-src/src/sql2/udfs/lix_active_version_commit_id.rs +53 -0
  152. package/dist-engine-src/src/sql2/udfs/lix_empty_blob.rs +47 -0
  153. package/dist-engine-src/src/sql2/udfs/lix_json.rs +100 -0
  154. package/dist-engine-src/src/sql2/udfs/lix_json_get.rs +99 -0
  155. package/dist-engine-src/src/sql2/udfs/lix_json_get_text.rs +99 -0
  156. package/dist-engine-src/src/sql2/udfs/lix_text_decode.rs +82 -0
  157. package/dist-engine-src/src/sql2/udfs/lix_text_encode.rs +85 -0
  158. package/dist-engine-src/src/sql2/udfs/lix_timestamp.rs +76 -0
  159. package/dist-engine-src/src/sql2/udfs/lix_uuid_v7.rs +76 -0
  160. package/dist-engine-src/src/sql2/udfs/mod.rs +89 -0
  161. package/dist-engine-src/src/sql2/udfs/public_call.rs +211 -0
  162. package/dist-engine-src/src/sql2/version_provider.rs +1202 -0
  163. package/dist-engine-src/src/sql2/version_scope.rs +394 -0
  164. package/dist-engine-src/src/sql2/write_normalization.rs +345 -0
  165. package/dist-engine-src/src/storage/context.rs +356 -0
  166. package/dist-engine-src/src/storage/mod.rs +14 -0
  167. package/dist-engine-src/src/storage/read_scope.rs +88 -0
  168. package/dist-engine-src/src/storage/types.rs +501 -0
  169. package/dist-engine-src/src/storage_bench.rs +4863 -0
  170. package/dist-engine-src/src/test_support.rs +228 -0
  171. package/dist-engine-src/src/tracked_state/by_file_index.rs +98 -0
  172. package/dist-engine-src/src/tracked_state/codec.rs +2085 -0
  173. package/dist-engine-src/src/tracked_state/context.rs +1867 -0
  174. package/dist-engine-src/src/tracked_state/diff.rs +686 -0
  175. package/dist-engine-src/src/tracked_state/materialization.rs +403 -0
  176. package/dist-engine-src/src/tracked_state/materializer.rs +488 -0
  177. package/dist-engine-src/src/tracked_state/merge.rs +492 -0
  178. package/dist-engine-src/src/tracked_state/mod.rs +32 -0
  179. package/dist-engine-src/src/tracked_state/storage.rs +375 -0
  180. package/dist-engine-src/src/tracked_state/tree.rs +3187 -0
  181. package/dist-engine-src/src/tracked_state/types.rs +231 -0
  182. package/dist-engine-src/src/transaction/commit.rs +1484 -0
  183. package/dist-engine-src/src/transaction/context.rs +1548 -0
  184. package/dist-engine-src/src/transaction/live_state_overlay.rs +35 -0
  185. package/dist-engine-src/src/transaction/mod.rs +13 -0
  186. package/dist-engine-src/src/transaction/normalization.rs +890 -0
  187. package/dist-engine-src/src/transaction/prep.rs +37 -0
  188. package/dist-engine-src/src/transaction/schema_resolver.rs +149 -0
  189. package/dist-engine-src/src/transaction/staging.rs +1731 -0
  190. package/dist-engine-src/src/transaction/types.rs +460 -0
  191. package/dist-engine-src/src/transaction/validation.rs +5830 -0
  192. package/dist-engine-src/src/untracked_state/codec.rs +307 -0
  193. package/dist-engine-src/src/untracked_state/context.rs +98 -0
  194. package/dist-engine-src/src/untracked_state/materialization.rs +63 -0
  195. package/dist-engine-src/src/untracked_state/mod.rs +15 -0
  196. package/dist-engine-src/src/untracked_state/storage.rs +396 -0
  197. package/dist-engine-src/src/untracked_state/types.rs +146 -0
  198. package/dist-engine-src/src/version/context.rs +40 -0
  199. package/dist-engine-src/src/version/lifecycle.rs +221 -0
  200. package/dist-engine-src/src/version/mod.rs +13 -0
  201. package/dist-engine-src/src/version/refs.rs +330 -0
  202. package/dist-engine-src/src/version/stage_rows.rs +67 -0
  203. package/dist-engine-src/src/version/types.rs +21 -0
  204. package/dist-engine-src/src/wasm/mod.rs +60 -0
  205. package/package.json +68 -64
@@ -0,0 +1,1867 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+
3
+ use crate::commit_store::CommitStoreContext;
4
+ use crate::storage::{StorageReader, StorageWriteSet};
5
+ use crate::tracked_state::by_file_index::ByFileIndex;
6
+ 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;
9
+ use crate::tracked_state::merge::{self, TrackedStateMergePlan};
10
+ use crate::tracked_state::storage;
11
+ use crate::tracked_state::storage::DeltaJsonPackIndexesRef;
12
+ use crate::tracked_state::tree::TrackedStateTree;
13
+ use crate::tracked_state::types::{
14
+ TrackedStateIndexValue, TrackedStateKey, TrackedStateKeyRef, TrackedStateMutation,
15
+ TrackedStateTreeDiffEntry, TrackedStateTreeScanRequest,
16
+ };
17
+ use crate::tracked_state::{
18
+ MaterializedTrackedStateRow, TrackedStateDeltaRef, TrackedStateRowRequest,
19
+ TrackedStateScanRequest,
20
+ };
21
+ use crate::LixError;
22
+
23
+ /// Factory for tracked-state readers, delta writers, and projection-root materializers.
24
+ ///
25
+ /// Tracked state is stored as content-addressed roots. Version refs
26
+ /// choose which commit/root to read; this context only owns root operations.
27
+ #[derive(Clone)]
28
+ pub(crate) struct TrackedStateContext {
29
+ tree: TrackedStateTree,
30
+ commit_store: CommitStoreContext,
31
+ }
32
+
33
+ impl TrackedStateContext {
34
+ pub(crate) fn new() -> Self {
35
+ Self {
36
+ tree: TrackedStateTree::new(),
37
+ commit_store: CommitStoreContext::new(),
38
+ }
39
+ }
40
+
41
+ /// Creates a commit-id-addressed tracked-state reader.
42
+ pub(crate) fn reader<S>(&self, store: S) -> TrackedStateStoreReader<S>
43
+ where
44
+ S: StorageReader,
45
+ {
46
+ TrackedStateStoreReader {
47
+ store,
48
+ tree: self.tree.clone(),
49
+ commit_store: self.commit_store,
50
+ }
51
+ }
52
+
53
+ /// Creates a tracked-state writer over a caller-owned transaction and write set.
54
+ pub(crate) fn writer<'a, S>(
55
+ &'a self,
56
+ store: &'a mut S,
57
+ writes: &'a mut StorageWriteSet,
58
+ ) -> TrackedStateWriter<'a, S>
59
+ where
60
+ S: StorageReader + ?Sized,
61
+ {
62
+ TrackedStateWriter {
63
+ tree: self.tree.clone(),
64
+ store,
65
+ writes,
66
+ }
67
+ }
68
+
69
+ /// Creates an explicit tracked-state projection-root materializer.
70
+ ///
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>(
74
+ &'a self,
75
+ store: &'a mut S,
76
+ writes: &'a mut StorageWriteSet,
77
+ commit_store: &'a CommitStoreContext,
78
+ ) -> TrackedStateMaterializer<'a, S>
79
+ where
80
+ S: StorageReader + ?Sized,
81
+ {
82
+ TrackedStateMaterializer {
83
+ tracked_state: self,
84
+ store,
85
+ writes,
86
+ commit_store,
87
+ }
88
+ }
89
+ }
90
+
91
+ /// Store-backed tracked-state reader created by `TrackedStateContext`.
92
+ pub(crate) struct TrackedStateStoreReader<S> {
93
+ store: S,
94
+ tree: TrackedStateTree,
95
+ commit_store: CommitStoreContext,
96
+ }
97
+
98
+ impl<S> TrackedStateStoreReader<S>
99
+ where
100
+ S: StorageReader,
101
+ {
102
+ pub(crate) async fn scan_rows_at_commit(
103
+ &mut self,
104
+ commit_id: &str,
105
+ request: &TrackedStateScanRequest,
106
+ ) -> 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?
136
+ };
137
+ let projection = crate::tracked_state::TrackedMaterializationProjection::from_columns(
138
+ &request.projection.columns,
139
+ );
140
+ let mut rows = materialize_index_entries(&mut self.store, rows, &projection).await?;
141
+ if !request.filter.include_tombstones {
142
+ rows.retain(|row| !row.deleted);
143
+ }
144
+ if let Some(limit) = request.limit {
145
+ rows.truncate(limit);
146
+ }
147
+ Ok(rows)
148
+ }
149
+
150
+ pub(crate) async fn load_rows_at_commit(
151
+ &mut self,
152
+ commit_id: &str,
153
+ requests: &[TrackedStateRowRequest],
154
+ ) -> Result<Vec<Option<MaterializedTrackedStateRow>>, LixError> {
155
+ if requests.is_empty() {
156
+ return Ok(Vec::new());
157
+ }
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?;
165
+ let mut entry_indices = Vec::new();
166
+ let mut entries = Vec::new();
167
+ for (index, (key, value)) in keys.into_iter().zip(values).enumerate() {
168
+ if let Some(value) = value {
169
+ entry_indices.push(index);
170
+ entries.push((key, value));
171
+ }
172
+ }
173
+ let materialized = materialize_index_entries(
174
+ &mut self.store,
175
+ entries,
176
+ &crate::tracked_state::TrackedMaterializationProjection::full(),
177
+ )
178
+ .await?;
179
+ let mut rows = vec![None; requests.len()];
180
+ for (index, row) in entry_indices.into_iter().zip(materialized) {
181
+ rows[index] = Some(row);
182
+ }
183
+ Ok(rows)
184
+ }
185
+
186
+ pub(crate) async fn diff_commits(
187
+ &mut self,
188
+ left_commit_id: &str,
189
+ right_commit_id: &str,
190
+ request: &TrackedStateDiffRequest,
191
+ ) -> Result<TrackedStateDiff, LixError> {
192
+ diff_commits(self, left_commit_id, right_commit_id, request).await
193
+ }
194
+
195
+ pub(crate) async fn diff_tree_entries_at_commits(
196
+ &mut self,
197
+ left_commit_id: &str,
198
+ 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)
258
+ }
259
+
260
+ async fn diff_pending_delta_suffix(
261
+ &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);
296
+ }
297
+
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
+ }
311
+
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;
317
+ };
318
+ for delta in delta_entries {
319
+ if request.matches_key(&delta.key) {
320
+ changed.insert(delta.key, delta.value);
321
+ }
322
+ }
323
+ }
324
+
325
+ if changed.is_empty() {
326
+ return Ok(Vec::new());
327
+ }
328
+
329
+ let keys = changed.keys().cloned().collect::<Vec<_>>();
330
+ let base_values = self
331
+ .projection_values_at_commit_for_keys(base_commit_id, &keys)
332
+ .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
365
+ }
366
+
367
+ async fn scan_rows_at_commit_by_file_index(
368
+ &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)
381
+ .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)
396
+ .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
+ }
406
+ }
407
+ let primary_values = self
408
+ .tree
409
+ .get_many(&mut self.store, primary_root_id, &primary_keys)
410
+ .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;
417
+ }
418
+ rows.push((primary_key, value));
419
+ }
420
+ return Ok(rows);
421
+ }
422
+
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;
426
+ };
427
+ let value = index_value;
428
+ if tree_request.matches(&primary_key, &value) {
429
+ rows.push((primary_key, value));
430
+ }
431
+ }
432
+ Ok(rows)
433
+ }
434
+
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())
441
+ }
442
+
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())
448
+ }
449
+
450
+ async fn projection_entries_at_commit(
451
+ &mut self,
452
+ 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;
465
+ }
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)
488
+ .await?;
489
+ Ok(entries.into_iter().collect())
490
+ }
491
+
492
+ async fn single_delta_pack_entries(
493
+ &mut self,
494
+ 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));
525
+ }
526
+ Ok(out)
527
+ }
528
+
529
+ async fn projection_values_at_commit_for_keys(
530
+ &mut self,
531
+ 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())
566
+ }
567
+
568
+ async fn projection_base_commit_id(
569
+ &mut self,
570
+ commit_id: &str,
571
+ delta_commit_ids: &[String],
572
+ ) -> 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
+ });
579
+ }
580
+ let Some(first_delta_commit_id) = delta_commit_ids.first() else {
581
+ return Ok(None);
582
+ };
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);
590
+ };
591
+ Ok(if self.projection_root_exists(parent_id).await? {
592
+ Some(parent_id.clone())
593
+ } else {
594
+ None
595
+ })
596
+ }
597
+
598
+ async fn delta_commit_ids_since_projection_root(
599
+ &mut self,
600
+ 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
613
+ .tree
614
+ .load_root(&mut self.store, &current_id)
615
+ .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());
622
+ }
623
+ let commit = self
624
+ .commit_store
625
+ .load_commit_from(&mut self.store, &current_id)
626
+ .await?
627
+ .ok_or_else(|| missing_commit_error(&current_id))?;
628
+ current = commit.parent_ids.first().cloned();
629
+ }
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
+ }
658
+ }
659
+ }
660
+ Ok(())
661
+ }
662
+
663
+ async fn apply_delta_packs_to_entries_for_keys(
664
+ &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 {
672
+ continue;
673
+ };
674
+ for delta in delta_entries {
675
+ if keys.contains(&delta.key) {
676
+ entries.insert(delta.key, delta.value);
677
+ }
678
+ }
679
+ }
680
+ Ok(())
681
+ }
682
+
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
687
+ /// changes should be applied.
688
+ #[allow(dead_code)]
689
+ pub(crate) async fn plan_merge(
690
+ &mut self,
691
+ base_commit_id: &str,
692
+ target_commit_id: &str,
693
+ source_commit_id: &str,
694
+ request: &TrackedStateDiffRequest,
695
+ ) -> Result<TrackedStateMergePlan, LixError> {
696
+ let target_diff = self
697
+ .diff_commits(base_commit_id, target_commit_id, request)
698
+ .await?;
699
+ let source_diff = self
700
+ .diff_commits(base_commit_id, source_commit_id, request)
701
+ .await?;
702
+ merge::plan_merge(&target_diff, &source_diff)
703
+ }
704
+ }
705
+
706
+ /// Writer for commit-store-backed tracked-state projection roots.
707
+ pub(crate) struct TrackedStateWriter<'a, S: ?Sized> {
708
+ tree: TrackedStateTree,
709
+ store: &'a mut S,
710
+ writes: &'a mut StorageWriteSet,
711
+ }
712
+
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,
717
+ pub(super) writes: &'a mut StorageWriteSet,
718
+ pub(super) commit_store: &'a CommitStoreContext,
719
+ }
720
+
721
+ impl<S> TrackedStateMaterializer<'_, S>
722
+ where
723
+ S: StorageReader + ?Sized,
724
+ {
725
+ pub(crate) async fn materialize_root_at(
726
+ &mut self,
727
+ commit_id: &str,
728
+ ) -> Result<TrackedStateWriteReport, LixError> {
729
+ crate::tracked_state::materializer::materialize_root_at(self, commit_id).await
730
+ }
731
+ }
732
+
733
+ impl<S> TrackedStateWriter<'_, S>
734
+ where
735
+ S: StorageReader + ?Sized,
736
+ {
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>(
775
+ &mut self,
776
+ commit_id: &str,
777
+ parent_commit_id: Option<&str>,
778
+ deltas: I,
779
+ ) -> Result<TrackedStateWriteReport, LixError>
780
+ where
781
+ I: IntoIterator<Item = TrackedStateDeltaRef<'a>>,
782
+ {
783
+ let deltas = deltas.into_iter().collect::<Vec<_>>();
784
+ let base_root = match parent_commit_id {
785
+ Some(parent_commit_id) => {
786
+ let Some(root) = self.tree.load_root(self.store, parent_commit_id).await? else {
787
+ return Err(LixError::new(
788
+ "LIX_ERROR_UNKNOWN",
789
+ format!(
790
+ "tracked-state parent root for commit '{parent_commit_id}' is missing"
791
+ ),
792
+ ));
793
+ };
794
+ Some(root)
795
+ }
796
+ None => None,
797
+ };
798
+ let mut mutations = Vec::with_capacity(deltas.len());
799
+ for delta in &deltas {
800
+ let key = TrackedStateKeyRef {
801
+ schema_key: delta.change.schema_key,
802
+ file_id: delta.change.file_id,
803
+ entity_id: delta.change.entity_id,
804
+ };
805
+ 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,
811
+ updated_at: delta.updated_at,
812
+ };
813
+ mutations.push(TrackedStateMutation::put_encoded(
814
+ encode_key_ref(key),
815
+ encode_value_ref(value),
816
+ ));
817
+ }
818
+ let result = self
819
+ .tree
820
+ .apply_mutations(
821
+ self.store,
822
+ self.writes,
823
+ base_root.as_ref(),
824
+ mutations,
825
+ Some(commit_id),
826
+ )
827
+ .await?;
828
+
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
+ Ok(TrackedStateWriteReport {
879
+ commit_id: commit_id.to_string(),
880
+ changed_rows: deltas.len(),
881
+ primary_chunk_puts: result.chunk_count,
882
+ by_file_chunk_puts,
883
+ })
884
+ }
885
+ }
886
+
887
+ #[derive(Debug, Clone, PartialEq, Eq)]
888
+ pub(crate) struct TrackedStateWriteReport {
889
+ pub(crate) commit_id: String,
890
+ pub(crate) changed_rows: usize,
891
+ pub(crate) primary_chunk_puts: usize,
892
+ pub(crate) by_file_chunk_puts: usize,
893
+ }
894
+
895
+ fn missing_commit_error(commit_id: &str) -> LixError {
896
+ LixError::new(
897
+ LixError::CODE_INTERNAL_ERROR,
898
+ format!("tracked_state projection references missing commit '{commit_id}'"),
899
+ )
900
+ }
901
+
902
+ fn tree_scan_request_from_tracked(
903
+ request: &TrackedStateScanRequest,
904
+ ) -> TrackedStateTreeScanRequest {
905
+ TrackedStateTreeScanRequest {
906
+ schema_keys: request.filter.schema_keys.clone(),
907
+ entity_ids: request.filter.entity_ids.clone(),
908
+ file_ids: request.filter.file_ids.clone(),
909
+ include_tombstones: request.filter.include_tombstones,
910
+ // User limits belong above delta overlay and tombstone visibility.
911
+ // Pushing them into the physical tree can stop on rows that are later
912
+ // hidden, returning too few live rows.
913
+ limit: None,
914
+ }
915
+ }
916
+
917
+ fn scan_needs_json_payloads(request: &TrackedStateScanRequest) -> bool {
918
+ if request.projection.columns.is_empty() {
919
+ return true;
920
+ }
921
+ request
922
+ .projection
923
+ .columns
924
+ .iter()
925
+ .any(|column| column == "snapshot_content" || column == "metadata")
926
+ }
927
+
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(),
943
+ })
944
+ }
945
+
946
+ #[cfg(test)]
947
+ mod tests {
948
+ use std::sync::Arc;
949
+
950
+ use super::*;
951
+ use crate::backend::{testing::UnitTestBackend, Backend};
952
+ use crate::storage::{StorageContext, StorageWriteTransaction};
953
+ use crate::NullableKeyFilter;
954
+
955
+ #[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));
959
+ let tracked_state = TrackedStateContext::new();
960
+ let mut transaction = storage
961
+ .begin_write_transaction()
962
+ .await
963
+ .expect("transaction should open");
964
+
965
+ write_root_for_test(
966
+ transaction.as_mut(),
967
+ &tracked_state,
968
+ "commit-child",
969
+ Some("missing-parent"),
970
+ &[row("entity-child", "change-child", "commit-child")],
971
+ )
972
+ .await
973
+ .expect("delta pack staging should not require a parent projection root");
974
+ }
975
+
976
+ #[tokio::test]
977
+ async fn plan_merge_from_roots_applies_source_only_change() {
978
+ let (storage, tracked_state) = seed_merge_roots(
979
+ &[row_with_value("entity-a", "change-base", "base", "base")],
980
+ &[row_with_value("entity-a", "change-base", "base", "base")],
981
+ &[row_with_value(
982
+ "entity-a",
983
+ "change-source",
984
+ "source",
985
+ "source",
986
+ )],
987
+ )
988
+ .await;
989
+
990
+ let plan = tracked_state
991
+ .reader(storage.clone())
992
+ .plan_merge(
993
+ "base",
994
+ "target",
995
+ "source",
996
+ &TrackedStateDiffRequest::default(),
997
+ )
998
+ .await
999
+ .expect("merge should plan");
1000
+
1001
+ assert_eq!(merge_patch_ids(&plan), vec!["entity-a"]);
1002
+ assert!(plan.conflicts.is_empty());
1003
+ }
1004
+
1005
+ #[tokio::test]
1006
+ async fn plan_merge_from_roots_keeps_target_only_change() {
1007
+ let (storage, tracked_state) = seed_merge_roots(
1008
+ &[row("entity-a", "change-base", "base")],
1009
+ &[row("entity-a", "change-target", "target")],
1010
+ &[row("entity-a", "change-base", "base")],
1011
+ )
1012
+ .await;
1013
+
1014
+ let plan = tracked_state
1015
+ .reader(storage.clone())
1016
+ .plan_merge(
1017
+ "base",
1018
+ "target",
1019
+ "source",
1020
+ &TrackedStateDiffRequest::default(),
1021
+ )
1022
+ .await
1023
+ .expect("merge should plan");
1024
+
1025
+ assert!(plan.patches.is_empty());
1026
+ assert!(plan.conflicts.is_empty());
1027
+ }
1028
+
1029
+ #[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
+ )],
1045
+ )
1046
+ .await;
1047
+
1048
+ let plan = tracked_state
1049
+ .reader(storage.clone())
1050
+ .plan_merge(
1051
+ "base",
1052
+ "target",
1053
+ "source",
1054
+ &TrackedStateDiffRequest::default(),
1055
+ )
1056
+ .await
1057
+ .expect("merge should plan");
1058
+
1059
+ assert!(plan.patches.is_empty());
1060
+ assert_eq!(merge_conflict_ids(&plan), vec!["entity-a"]);
1061
+ }
1062
+
1063
+ #[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")],
1069
+ )
1070
+ .await;
1071
+
1072
+ let plan = tracked_state
1073
+ .reader(storage.clone())
1074
+ .plan_merge(
1075
+ "base",
1076
+ "target",
1077
+ "source",
1078
+ &TrackedStateDiffRequest::default(),
1079
+ )
1080
+ .await
1081
+ .expect("merge should plan");
1082
+
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");
1086
+ }
1087
+
1088
+ #[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());
1092
+ let tracked_state = TrackedStateContext::new();
1093
+ let mut file_a = row("entity-a", "change-a", "commit-1");
1094
+ file_a.file_id = Some("file-a.json".to_string());
1095
+ let mut file_b = row("entity-b", "change-b", "commit-1");
1096
+ 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
+ write_root_for_test(
1103
+ transaction.as_mut(),
1104
+ &tracked_state,
1105
+ "commit-1",
1106
+ None,
1107
+ &[file_a, file_b],
1108
+ )
1109
+ .await
1110
+ .expect("root should write");
1111
+ transaction
1112
+ .commit()
1113
+ .await
1114
+ .expect("transaction should commit");
1115
+
1116
+ let rows = tracked_state
1117
+ .reader(storage.clone())
1118
+ .scan_rows_at_commit(
1119
+ "commit-1",
1120
+ &TrackedStateScanRequest {
1121
+ filter: crate::tracked_state::TrackedStateFilter {
1122
+ file_ids: vec![NullableKeyFilter::Value("file-a.json".to_string())],
1123
+ ..Default::default()
1124
+ },
1125
+ ..Default::default()
1126
+ },
1127
+ )
1128
+ .await
1129
+ .expect("file scan should read through index");
1130
+
1131
+ assert_eq!(rows.len(), 1);
1132
+ assert_eq!(
1133
+ rows[0]
1134
+ .entity_id
1135
+ .as_single_string_owned()
1136
+ .expect("entity id"),
1137
+ "entity-a"
1138
+ );
1139
+ assert_eq!(rows[0].file_id.as_deref(), Some("file-a.json"));
1140
+ }
1141
+
1142
+ #[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());
1146
+ let tracked_state = TrackedStateContext::new();
1147
+ let mut row = row("entity-a", "change-a", "commit-1");
1148
+ row.file_id = Some("file-a.json".to_string());
1149
+ 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
+ write_root_for_test(
1156
+ transaction.as_mut(),
1157
+ &tracked_state,
1158
+ "commit-1",
1159
+ None,
1160
+ std::slice::from_ref(&row),
1161
+ )
1162
+ .await
1163
+ .expect("root should write");
1164
+ transaction
1165
+ .commit()
1166
+ .await
1167
+ .expect("transaction should commit");
1168
+
1169
+ let mut reader = tracked_state.reader(storage.clone());
1170
+ let header_rows = reader
1171
+ .scan_rows_at_commit(
1172
+ "commit-1",
1173
+ &TrackedStateScanRequest {
1174
+ filter: crate::tracked_state::TrackedStateFilter {
1175
+ file_ids: vec![NullableKeyFilter::Value("file-a.json".to_string())],
1176
+ ..Default::default()
1177
+ },
1178
+ projection: crate::tracked_state::TrackedStateProjection {
1179
+ columns: vec!["entity_id".to_string()],
1180
+ },
1181
+ ..Default::default()
1182
+ },
1183
+ )
1184
+ .await
1185
+ .expect("header scan should read through by-file index");
1186
+ let full_rows = reader
1187
+ .scan_rows_at_commit(
1188
+ "commit-1",
1189
+ &TrackedStateScanRequest {
1190
+ filter: crate::tracked_state::TrackedStateFilter {
1191
+ file_ids: vec![NullableKeyFilter::Value("file-a.json".to_string())],
1192
+ ..Default::default()
1193
+ },
1194
+ ..Default::default()
1195
+ },
1196
+ )
1197
+ .await
1198
+ .expect("full scan should fetch primary payload");
1199
+
1200
+ assert_eq!(header_rows[0].snapshot_content, None);
1201
+ assert_eq!(full_rows[0].snapshot_content, expected_snapshot);
1202
+ }
1203
+
1204
+ #[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());
1208
+ let tracked_state = TrackedStateContext::new();
1209
+ 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
+ write_root_for_test(
1216
+ transaction.as_mut(),
1217
+ &tracked_state,
1218
+ "commit-1",
1219
+ None,
1220
+ std::slice::from_ref(&row),
1221
+ )
1222
+ .await
1223
+ .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
+
1234
+ let rows = tracked_state
1235
+ .reader(storage.clone())
1236
+ .scan_rows_at_commit(
1237
+ "commit-1",
1238
+ &TrackedStateScanRequest {
1239
+ filter: crate::tracked_state::TrackedStateFilter {
1240
+ file_ids: vec![NullableKeyFilter::Null],
1241
+ ..Default::default()
1242
+ },
1243
+ ..Default::default()
1244
+ },
1245
+ )
1246
+ .await
1247
+ .expect("null file scan should fall back to primary tree");
1248
+
1249
+ assert_eq!(rows.len(), 1);
1250
+ assert_eq!(
1251
+ rows[0]
1252
+ .entity_id
1253
+ .as_single_string_owned()
1254
+ .expect("entity id"),
1255
+ "entity-a"
1256
+ );
1257
+ }
1258
+
1259
+ #[tokio::test]
1260
+ 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());
1263
+ let tracked_state = TrackedStateContext::new();
1264
+ let null_row = row("entity-null", "change-null", "commit-1");
1265
+ let mut file_row = row("entity-file", "change-file", "commit-2");
1266
+ 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
+ write_root_for_test(
1273
+ transaction.as_mut(),
1274
+ &tracked_state,
1275
+ "commit-1",
1276
+ None,
1277
+ std::slice::from_ref(&null_row),
1278
+ )
1279
+ .await
1280
+ .expect("parent root should write");
1281
+ write_root_for_test(
1282
+ transaction.as_mut(),
1283
+ &tracked_state,
1284
+ "commit-2",
1285
+ Some("commit-1"),
1286
+ std::slice::from_ref(&file_row),
1287
+ )
1288
+ .await
1289
+ .expect("child root should write");
1290
+ transaction
1291
+ .commit()
1292
+ .await
1293
+ .expect("transaction should commit");
1294
+
1295
+ let rows = tracked_state
1296
+ .reader(storage.clone())
1297
+ .scan_rows_at_commit(
1298
+ "commit-2",
1299
+ &TrackedStateScanRequest {
1300
+ filter: crate::tracked_state::TrackedStateFilter {
1301
+ file_ids: vec![
1302
+ NullableKeyFilter::Null,
1303
+ NullableKeyFilter::Value("file-a.json".to_string()),
1304
+ ],
1305
+ ..Default::default()
1306
+ },
1307
+ ..Default::default()
1308
+ },
1309
+ )
1310
+ .await
1311
+ .expect("mixed scan should use primary tree");
1312
+
1313
+ let mut entity_ids = rows
1314
+ .iter()
1315
+ .map(|row| row.entity_id.as_single_string_owned().expect("entity id"))
1316
+ .collect::<Vec<_>>();
1317
+ entity_ids.sort();
1318
+ assert_eq!(entity_ids, vec!["entity-file", "entity-null"]);
1319
+ }
1320
+
1321
+ #[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());
1325
+ let tracked_state = TrackedStateContext::new();
1326
+ let mut live = row("entity-live", "change-live", "commit-1");
1327
+ live.file_id = Some("file-a.json".to_string());
1328
+ let mut deleted = tombstone("entity-deleted", "change-delete", "commit-1");
1329
+ 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()
1346
+ .await
1347
+ .expect("transaction should commit");
1348
+
1349
+ let rows = tracked_state
1350
+ .reader(storage.clone())
1351
+ .scan_rows_at_commit(
1352
+ "commit-1",
1353
+ &TrackedStateScanRequest {
1354
+ filter: crate::tracked_state::TrackedStateFilter {
1355
+ file_ids: vec![NullableKeyFilter::Value("file-a.json".to_string())],
1356
+ ..Default::default()
1357
+ },
1358
+ projection: crate::tracked_state::TrackedStateProjection {
1359
+ columns: vec!["entity_id".to_string()],
1360
+ },
1361
+ ..Default::default()
1362
+ },
1363
+ )
1364
+ .await
1365
+ .expect("file scan should read through index");
1366
+
1367
+ assert_eq!(rows.len(), 1);
1368
+ assert_eq!(
1369
+ rows[0]
1370
+ .entity_id
1371
+ .as_single_string_owned()
1372
+ .expect("entity id"),
1373
+ "entity-live"
1374
+ );
1375
+ }
1376
+
1377
+ #[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());
1381
+ let tracked_state = TrackedStateContext::new();
1382
+ let base = row("entity-a", "change-base", "base");
1383
+ 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
+ write_root_for_test(
1390
+ transaction.as_mut(),
1391
+ &tracked_state,
1392
+ "base",
1393
+ None,
1394
+ std::slice::from_ref(&base),
1395
+ )
1396
+ .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();
1405
+ 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()
1420
+ .await
1421
+ .expect("materialized base should commit");
1422
+
1423
+ let mut transaction = storage
1424
+ .begin_write_transaction()
1425
+ .await
1426
+ .expect("child transaction should open");
1427
+ write_root_for_test(
1428
+ transaction.as_mut(),
1429
+ &tracked_state,
1430
+ "child",
1431
+ Some("base"),
1432
+ std::slice::from_ref(&delete),
1433
+ )
1434
+ .await
1435
+ .expect("child tombstone delta should write");
1436
+ transaction.commit().await.expect("child should commit");
1437
+
1438
+ let rows = tracked_state
1439
+ .reader(storage.clone())
1440
+ .scan_rows_at_commit("child", &TrackedStateScanRequest::default())
1441
+ .await
1442
+ .expect("child scan should apply pending tombstone over base root");
1443
+
1444
+ assert!(rows.is_empty(), "pending tombstone must hide base row");
1445
+ }
1446
+
1447
+ #[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());
1451
+ let tracked_state = TrackedStateContext::new();
1452
+
1453
+ let mut transaction = storage
1454
+ .begin_write_transaction()
1455
+ .await
1456
+ .expect("transaction should open");
1457
+ write_root_for_test(
1458
+ transaction.as_mut(),
1459
+ &tracked_state,
1460
+ "commit-1",
1461
+ None,
1462
+ &[
1463
+ row_with_value("entity-a", "change-a1", "commit-1", "first"),
1464
+ row_with_value("entity-b", "change-b", "commit-1", "middle"),
1465
+ row_with_value("entity-a", "change-a2", "commit-1", "second"),
1466
+ tombstone("entity-c", "change-c1", "commit-1"),
1467
+ ],
1468
+ )
1469
+ .await
1470
+ .expect("delta pack should write");
1471
+ transaction
1472
+ .commit()
1473
+ .await
1474
+ .expect("transaction should commit");
1475
+
1476
+ let rows = tracked_state
1477
+ .reader(storage.clone())
1478
+ .scan_rows_at_commit("commit-1", &TrackedStateScanRequest::default())
1479
+ .await
1480
+ .expect("single delta pack should scan");
1481
+
1482
+ assert_eq!(rows.len(), 2);
1483
+ assert_eq!(
1484
+ rows.iter()
1485
+ .map(|row| (
1486
+ row.entity_id.as_single_string_owned().expect("entity id"),
1487
+ row.snapshot_content.clone()
1488
+ ))
1489
+ .collect::<Vec<_>>(),
1490
+ vec![
1491
+ (
1492
+ "entity-a".to_string(),
1493
+ Some("{\"value\":\"second\"}".to_string())
1494
+ ),
1495
+ (
1496
+ "entity-b".to_string(),
1497
+ Some("{\"value\":\"middle\"}".to_string())
1498
+ ),
1499
+ ]
1500
+ );
1501
+ }
1502
+
1503
+ #[tokio::test]
1504
+ async fn scan_limit_applies_after_tombstone_visibility() {
1505
+ let backend = Arc::new(UnitTestBackend::new());
1506
+ let storage = StorageContext::new(backend.clone());
1507
+ let tracked_state = TrackedStateContext::new();
1508
+
1509
+ let mut transaction = storage
1510
+ .begin_write_transaction()
1511
+ .await
1512
+ .expect("transaction should open");
1513
+ write_root_for_test(
1514
+ transaction.as_mut(),
1515
+ &tracked_state,
1516
+ "commit-1",
1517
+ None,
1518
+ &[
1519
+ tombstone("entity-a", "change-delete", "commit-1"),
1520
+ row("entity-b", "change-live", "commit-1"),
1521
+ ],
1522
+ )
1523
+ .await
1524
+ .expect("root should write");
1525
+ transaction
1526
+ .commit()
1527
+ .await
1528
+ .expect("transaction should commit");
1529
+
1530
+ let rows = tracked_state
1531
+ .reader(storage.clone())
1532
+ .scan_rows_at_commit(
1533
+ "commit-1",
1534
+ &TrackedStateScanRequest {
1535
+ limit: Some(1),
1536
+ ..Default::default()
1537
+ },
1538
+ )
1539
+ .await
1540
+ .expect("limited scan should apply visibility before limit");
1541
+
1542
+ assert_eq!(rows.len(), 1);
1543
+ assert_eq!(
1544
+ rows[0]
1545
+ .entity_id
1546
+ .as_single_string_owned()
1547
+ .expect("entity id"),
1548
+ "entity-b"
1549
+ );
1550
+ }
1551
+
1552
+ #[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());
1556
+ let tracked_state = TrackedStateContext::new();
1557
+ let mut deleted = tombstone("entity-a", "change-delete", "commit-1");
1558
+ deleted.file_id = Some("file-a.json".to_string());
1559
+ let mut live = row("entity-b", "change-live", "commit-1");
1560
+ 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()
1577
+ .await
1578
+ .expect("transaction should commit");
1579
+
1580
+ let rows = tracked_state
1581
+ .reader(storage.clone())
1582
+ .scan_rows_at_commit(
1583
+ "commit-1",
1584
+ &TrackedStateScanRequest {
1585
+ filter: crate::tracked_state::TrackedStateFilter {
1586
+ file_ids: vec![NullableKeyFilter::Value("file-a.json".to_string())],
1587
+ ..Default::default()
1588
+ },
1589
+ projection: crate::tracked_state::TrackedStateProjection {
1590
+ columns: vec!["entity_id".to_string()],
1591
+ },
1592
+ limit: Some(1),
1593
+ },
1594
+ )
1595
+ .await
1596
+ .expect("limited by-file scan should apply visibility before limit");
1597
+
1598
+ assert_eq!(rows.len(), 1);
1599
+ assert_eq!(
1600
+ rows[0]
1601
+ .entity_id
1602
+ .as_single_string_owned()
1603
+ .expect("entity id"),
1604
+ "entity-b"
1605
+ );
1606
+ }
1607
+
1608
+ #[tokio::test]
1609
+ async fn reads_resolve_json_snapshot_refs() {
1610
+ let backend = Arc::new(UnitTestBackend::new());
1611
+ let storage = StorageContext::new(backend.clone());
1612
+ let tracked_state = TrackedStateContext::new();
1613
+ let large_value = "x".repeat(1536);
1614
+ 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
+ write_root_for_test(
1621
+ transaction.as_mut(),
1622
+ &tracked_state,
1623
+ "commit-1",
1624
+ None,
1625
+ std::slice::from_ref(&row),
1626
+ )
1627
+ .await
1628
+ .expect("root should write");
1629
+ transaction
1630
+ .commit()
1631
+ .await
1632
+ .expect("transaction should commit");
1633
+
1634
+ let mut reader = tracked_state.reader(storage.clone());
1635
+ let loaded = reader
1636
+ .load_rows_at_commit(
1637
+ "commit-1",
1638
+ &[TrackedStateRowRequest {
1639
+ schema_key: row.schema_key.clone(),
1640
+ entity_id: row.entity_id.clone(),
1641
+ file_id: NullableKeyFilter::Null,
1642
+ }],
1643
+ )
1644
+ .await
1645
+ .expect("row should load")
1646
+ .pop()
1647
+ .flatten()
1648
+ .expect("row should exist");
1649
+ let scanned = reader
1650
+ .scan_rows_at_commit("commit-1", &TrackedStateScanRequest::default())
1651
+ .await
1652
+ .expect("rows should scan");
1653
+
1654
+ assert_eq!(loaded.snapshot_content, row.snapshot_content);
1655
+ assert_eq!(scanned[0].snapshot_content, row.snapshot_content);
1656
+ }
1657
+
1658
+ #[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());
1662
+ let tracked_state = TrackedStateContext::new();
1663
+ let mut row = row("entity-a", "change-a", "commit-1");
1664
+ row.created_at = "2026-01-01T00:00:00Z".to_string();
1665
+ 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
+ write_root_for_test(
1672
+ transaction.as_mut(),
1673
+ &tracked_state,
1674
+ "commit-1",
1675
+ None,
1676
+ std::slice::from_ref(&row),
1677
+ )
1678
+ .await
1679
+ .expect("root should write");
1680
+ transaction
1681
+ .commit()
1682
+ .await
1683
+ .expect("transaction should commit");
1684
+
1685
+ let loaded = tracked_state
1686
+ .reader(storage.clone())
1687
+ .load_rows_at_commit(
1688
+ "commit-1",
1689
+ &[TrackedStateRowRequest {
1690
+ schema_key: row.schema_key.clone(),
1691
+ entity_id: row.entity_id.clone(),
1692
+ file_id: NullableKeyFilter::Null,
1693
+ }],
1694
+ )
1695
+ .await
1696
+ .expect("row should load")
1697
+ .pop()
1698
+ .flatten()
1699
+ .expect("row should exist");
1700
+
1701
+ assert_eq!(loaded.created_at, "2026-01-01T00:00:00Z");
1702
+ assert_eq!(loaded.updated_at, "2026-01-02T00:00:00Z");
1703
+ }
1704
+
1705
+ #[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());
1709
+ 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);
1712
+
1713
+ let mut transaction = storage
1714
+ .begin_write_transaction()
1715
+ .await
1716
+ .expect("transaction should open");
1717
+ write_root_for_test(
1718
+ transaction.as_mut(),
1719
+ &tracked_state,
1720
+ "commit-1",
1721
+ None,
1722
+ std::slice::from_ref(&row),
1723
+ )
1724
+ .await
1725
+ .expect("root should write");
1726
+ transaction
1727
+ .commit()
1728
+ .await
1729
+ .expect("transaction should commit");
1730
+
1731
+ let rows = tracked_state
1732
+ .reader(storage.clone())
1733
+ .scan_rows_at_commit(
1734
+ "commit-1",
1735
+ &TrackedStateScanRequest {
1736
+ projection: crate::tracked_state::TrackedStateProjection {
1737
+ columns: vec!["entity_id".to_string()],
1738
+ },
1739
+ ..Default::default()
1740
+ },
1741
+ )
1742
+ .await
1743
+ .expect("rows should scan");
1744
+
1745
+ assert_eq!(rows.len(), 1);
1746
+ assert_eq!(rows[0].snapshot_content, None);
1747
+ }
1748
+
1749
+ async fn seed_merge_roots(
1750
+ base_rows: &[MaterializedTrackedStateRow],
1751
+ target_rows: &[MaterializedTrackedStateRow],
1752
+ source_rows: &[MaterializedTrackedStateRow],
1753
+ ) -> (StorageContext, TrackedStateContext) {
1754
+ let backend = Arc::new(UnitTestBackend::new());
1755
+ let storage = StorageContext::new(backend.clone());
1756
+ let tracked_state = TrackedStateContext::new();
1757
+ let mut transaction = storage
1758
+ .begin_write_transaction()
1759
+ .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");
1770
+ write_root_for_test(
1771
+ transaction.as_mut(),
1772
+ &tracked_state,
1773
+ "target",
1774
+ None,
1775
+ target_rows,
1776
+ )
1777
+ .await
1778
+ .expect("target root should write");
1779
+ write_root_for_test(
1780
+ transaction.as_mut(),
1781
+ &tracked_state,
1782
+ "source",
1783
+ None,
1784
+ source_rows,
1785
+ )
1786
+ .await
1787
+ .expect("source root should write");
1788
+ transaction
1789
+ .commit()
1790
+ .await
1791
+ .expect("transaction should commit");
1792
+ (storage, tracked_state)
1793
+ }
1794
+
1795
+ fn merge_patch_ids(plan: &TrackedStateMergePlan) -> Vec<String> {
1796
+ plan.patches
1797
+ .iter()
1798
+ .map(|entry| {
1799
+ entry
1800
+ .identity()
1801
+ .entity_id
1802
+ .as_single_string_owned()
1803
+ .expect("identity")
1804
+ })
1805
+ .collect()
1806
+ }
1807
+
1808
+ fn merge_conflict_ids(plan: &TrackedStateMergePlan) -> Vec<String> {
1809
+ plan.conflicts
1810
+ .iter()
1811
+ .map(|entry| {
1812
+ entry
1813
+ .identity
1814
+ .entity_id
1815
+ .as_single_string_owned()
1816
+ .expect("identity")
1817
+ })
1818
+ .collect()
1819
+ }
1820
+
1821
+ async fn write_root_for_test(
1822
+ transaction: &mut dyn StorageWriteTransaction,
1823
+ tracked_state: &TrackedStateContext,
1824
+ commit_id: &str,
1825
+ parent_commit_id: Option<&str>,
1826
+ rows: &[MaterializedTrackedStateRow],
1827
+ ) -> Result<(), LixError> {
1828
+ crate::test_support::stage_tracked_root_from_materialized(
1829
+ transaction,
1830
+ tracked_state,
1831
+ commit_id,
1832
+ parent_commit_id,
1833
+ rows,
1834
+ )
1835
+ .await
1836
+ }
1837
+
1838
+ fn tombstone(entity_id: &str, change_id: &str, commit_id: &str) -> MaterializedTrackedStateRow {
1839
+ let mut row = row(entity_id, change_id, commit_id);
1840
+ row.snapshot_content = None;
1841
+ row
1842
+ }
1843
+
1844
+ fn row(entity_id: &str, change_id: &str, commit_id: &str) -> MaterializedTrackedStateRow {
1845
+ row_with_value(entity_id, change_id, commit_id, "value")
1846
+ }
1847
+
1848
+ fn row_with_value(
1849
+ entity_id: &str,
1850
+ change_id: &str,
1851
+ commit_id: &str,
1852
+ value: &str,
1853
+ ) -> MaterializedTrackedStateRow {
1854
+ MaterializedTrackedStateRow {
1855
+ entity_id: crate::entity_identity::EntityIdentity::single(entity_id),
1856
+ schema_key: "test_schema".to_string(),
1857
+ file_id: None,
1858
+ snapshot_content: Some(format!("{{\"value\":\"{value}\"}}")),
1859
+ metadata: None,
1860
+ deleted: false,
1861
+ created_at: "2026-01-01T00:00:00Z".to_string(),
1862
+ updated_at: "2026-01-01T00:00:00Z".to_string(),
1863
+ change_id: change_id.to_string(),
1864
+ commit_id: commit_id.to_string(),
1865
+ }
1866
+ }
1867
+ }