@lix-js/sdk 0.6.0-preview.2 → 0.6.0-preview.4

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 (169) hide show
  1. package/SKILL.md +46 -8
  2. package/dist/engine-wasm/wasm/lix_engine.d.ts +25 -1
  3. package/dist/engine-wasm/wasm/lix_engine.js +60 -2
  4. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  5. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +5 -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 +10 -3
  9. package/dist/open-lix.js +39 -0
  10. package/dist-engine-src/src/binary_cas/types.rs +0 -6
  11. package/dist-engine-src/src/catalog/context.rs +412 -0
  12. package/dist-engine-src/src/catalog/mod.rs +10 -0
  13. package/dist-engine-src/src/catalog/schema.rs +4 -0
  14. package/dist-engine-src/src/catalog/snapshot.rs +1114 -0
  15. package/dist-engine-src/src/cel/mod.rs +1 -1
  16. package/dist-engine-src/src/cel/provider.rs +1 -1
  17. package/dist-engine-src/src/commit_graph/context.rs +328 -1015
  18. package/dist-engine-src/src/commit_graph/mod.rs +2 -3
  19. package/dist-engine-src/src/commit_graph/types.rs +7 -43
  20. package/dist-engine-src/src/commit_graph/walker.rs +57 -81
  21. package/dist-engine-src/src/commit_store/codec.rs +887 -0
  22. package/dist-engine-src/src/commit_store/context.rs +944 -0
  23. package/dist-engine-src/src/commit_store/materialization.rs +84 -0
  24. package/dist-engine-src/src/commit_store/mod.rs +16 -0
  25. package/dist-engine-src/src/commit_store/storage.rs +600 -0
  26. package/dist-engine-src/src/commit_store/types.rs +215 -0
  27. package/dist-engine-src/src/common/identity.rs +15 -5
  28. package/dist-engine-src/src/common/json_pointer.rs +67 -0
  29. package/dist-engine-src/src/common/metadata.rs +17 -12
  30. package/dist-engine-src/src/common/mod.rs +5 -5
  31. package/dist-engine-src/src/domain.rs +324 -0
  32. package/dist-engine-src/src/engine.rs +29 -43
  33. package/dist-engine-src/src/entity_identity.rs +238 -118
  34. package/dist-engine-src/src/functions/context.rs +17 -52
  35. package/dist-engine-src/src/functions/deterministic.rs +1 -1
  36. package/dist-engine-src/src/functions/mod.rs +1 -1
  37. package/dist-engine-src/src/functions/provider.rs +4 -4
  38. package/dist-engine-src/src/functions/state.rs +39 -66
  39. package/dist-engine-src/src/functions/types.rs +1 -1
  40. package/dist-engine-src/src/init.rs +204 -151
  41. package/dist-engine-src/src/json_store/context.rs +354 -60
  42. package/dist-engine-src/src/json_store/encoded.rs +6 -6
  43. package/dist-engine-src/src/json_store/mod.rs +4 -1
  44. package/dist-engine-src/src/json_store/store.rs +884 -11
  45. package/dist-engine-src/src/json_store/types.rs +166 -1
  46. package/dist-engine-src/src/lib.rs +11 -10
  47. package/dist-engine-src/src/live_state/context.rs +608 -830
  48. package/dist-engine-src/src/live_state/mod.rs +3 -3
  49. package/dist-engine-src/src/live_state/overlay.rs +7 -7
  50. package/dist-engine-src/src/live_state/reader.rs +5 -5
  51. package/dist-engine-src/src/live_state/types.rs +19 -36
  52. package/dist-engine-src/src/live_state/visibility.rs +19 -14
  53. package/dist-engine-src/src/plugin/archive.rs +3 -6
  54. package/dist-engine-src/src/plugin/install.rs +0 -18
  55. package/dist-engine-src/src/plugin/plugin_manifest.json +0 -1
  56. package/dist-engine-src/src/schema/annotations/defaults.rs +2 -7
  57. package/dist-engine-src/src/schema/builtin/lix_account.json +0 -1
  58. package/dist-engine-src/src/schema/builtin/lix_active_account.json +0 -1
  59. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +0 -1
  60. package/dist-engine-src/src/schema/builtin/lix_change.json +11 -10
  61. package/dist-engine-src/src/schema/builtin/lix_change_author.json +0 -1
  62. package/dist-engine-src/src/schema/builtin/lix_commit.json +8 -46
  63. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +29 -22
  64. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +0 -1
  65. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +0 -1
  66. package/dist-engine-src/src/schema/builtin/lix_key_value.json +0 -1
  67. package/dist-engine-src/src/schema/builtin/lix_label.json +10 -3
  68. package/dist-engine-src/src/schema/builtin/lix_label_assignment.json +74 -0
  69. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +2 -8
  70. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +0 -1
  71. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +0 -1
  72. package/dist-engine-src/src/schema/builtin/mod.rs +10 -59
  73. package/dist-engine-src/src/schema/compatibility.rs +787 -0
  74. package/dist-engine-src/src/schema/definition.json +47 -17
  75. package/dist-engine-src/src/schema/definition.rs +202 -96
  76. package/dist-engine-src/src/schema/key.rs +9 -77
  77. package/dist-engine-src/src/schema/mod.rs +4 -4
  78. package/dist-engine-src/src/schema/tests.rs +133 -92
  79. package/dist-engine-src/src/session/context.rs +86 -48
  80. package/dist-engine-src/src/session/create_version.rs +22 -14
  81. package/dist-engine-src/src/session/execute.rs +117 -23
  82. package/dist-engine-src/src/session/merge/apply.rs +4 -4
  83. package/dist-engine-src/src/session/merge/conflicts.rs +3 -2
  84. package/dist-engine-src/src/session/merge/stats.rs +1 -1
  85. package/dist-engine-src/src/session/merge/version.rs +35 -45
  86. package/dist-engine-src/src/session/mod.rs +9 -7
  87. package/dist-engine-src/src/session/optimization9_sql2_bench.rs +100 -0
  88. package/dist-engine-src/src/session/switch_version.rs +17 -28
  89. package/dist-engine-src/src/session/transaction.rs +76 -0
  90. package/dist-engine-src/src/sql2/change_provider.rs +14 -20
  91. package/dist-engine-src/src/sql2/classify.rs +75 -48
  92. package/dist-engine-src/src/sql2/context.rs +22 -18
  93. package/dist-engine-src/src/sql2/directory_history_provider.rs +28 -20
  94. package/dist-engine-src/src/sql2/directory_provider.rs +131 -83
  95. package/dist-engine-src/src/sql2/entity_history_provider.rs +10 -14
  96. package/dist-engine-src/src/sql2/entity_provider.rs +680 -169
  97. package/dist-engine-src/src/sql2/error.rs +24 -5
  98. package/dist-engine-src/src/sql2/execute.rs +426 -272
  99. package/dist-engine-src/src/sql2/file_history_provider.rs +29 -21
  100. package/dist-engine-src/src/sql2/file_provider.rs +533 -108
  101. package/dist-engine-src/src/sql2/filesystem_planner.rs +58 -94
  102. package/dist-engine-src/src/sql2/filesystem_visibility.rs +37 -23
  103. package/dist-engine-src/src/sql2/history_projection.rs +3 -27
  104. package/dist-engine-src/src/sql2/history_provider.rs +11 -17
  105. package/dist-engine-src/src/sql2/history_route.rs +22 -8
  106. package/dist-engine-src/src/sql2/lix_state_provider.rs +178 -96
  107. package/dist-engine-src/src/sql2/mod.rs +8 -4
  108. package/dist-engine-src/src/sql2/predicate_typecheck.rs +246 -0
  109. package/dist-engine-src/src/sql2/public_bind/assignment.rs +46 -0
  110. package/dist-engine-src/src/sql2/public_bind/capability.rs +41 -0
  111. package/dist-engine-src/src/sql2/public_bind/dml.rs +172 -0
  112. package/dist-engine-src/src/sql2/public_bind/mod.rs +26 -0
  113. package/dist-engine-src/src/sql2/public_bind/table.rs +168 -0
  114. package/dist-engine-src/src/sql2/read_only.rs +10 -12
  115. package/dist-engine-src/src/sql2/session.rs +7 -10
  116. package/dist-engine-src/src/sql2/udfs/lix_timestamp.rs +76 -0
  117. package/dist-engine-src/src/sql2/udfs/mod.rs +8 -1
  118. package/dist-engine-src/src/sql2/udfs/public_call.rs +238 -0
  119. package/dist-engine-src/src/sql2/version_provider.rs +46 -31
  120. package/dist-engine-src/src/sql2/version_scope.rs +4 -4
  121. package/dist-engine-src/src/storage_bench.rs +1782 -325
  122. package/dist-engine-src/src/test_support.rs +183 -36
  123. package/dist-engine-src/src/tracked_state/by_file_index.rs +20 -24
  124. package/dist-engine-src/src/tracked_state/codec.rs +1519 -181
  125. package/dist-engine-src/src/tracked_state/context.rs +1155 -271
  126. package/dist-engine-src/src/tracked_state/diff.rs +249 -57
  127. package/dist-engine-src/src/tracked_state/materialization.rs +365 -103
  128. package/dist-engine-src/src/tracked_state/materializer.rs +488 -0
  129. package/dist-engine-src/src/tracked_state/merge.rs +37 -19
  130. package/dist-engine-src/src/tracked_state/mod.rs +8 -7
  131. package/dist-engine-src/src/tracked_state/storage.rs +138 -6
  132. package/dist-engine-src/src/tracked_state/tree.rs +695 -252
  133. package/dist-engine-src/src/tracked_state/types.rs +176 -6
  134. package/dist-engine-src/src/transaction/commit.rs +695 -435
  135. package/dist-engine-src/src/transaction/context.rs +551 -310
  136. package/dist-engine-src/src/transaction/live_state_overlay.rs +9 -8
  137. package/dist-engine-src/src/transaction/mod.rs +2 -0
  138. package/dist-engine-src/src/transaction/normalization.rs +311 -447
  139. package/dist-engine-src/src/transaction/prep.rs +37 -0
  140. package/dist-engine-src/src/transaction/schema_resolver.rs +93 -71
  141. package/dist-engine-src/src/transaction/staging.rs +701 -406
  142. package/dist-engine-src/src/transaction/types.rs +231 -122
  143. package/dist-engine-src/src/transaction/validation.rs +2717 -1698
  144. package/dist-engine-src/src/untracked_state/codec.rs +40 -96
  145. package/dist-engine-src/src/untracked_state/context.rs +21 -5
  146. package/dist-engine-src/src/untracked_state/materialization.rs +10 -104
  147. package/dist-engine-src/src/untracked_state/mod.rs +3 -5
  148. package/dist-engine-src/src/untracked_state/storage.rs +105 -57
  149. package/dist-engine-src/src/untracked_state/types.rs +63 -13
  150. package/dist-engine-src/src/version/context.rs +1 -13
  151. package/dist-engine-src/src/version/lifecycle.rs +221 -0
  152. package/dist-engine-src/src/version/mod.rs +3 -2
  153. package/dist-engine-src/src/version/refs.rs +12 -103
  154. package/dist-engine-src/src/version/stage_rows.rs +15 -19
  155. package/package.json +1 -1
  156. package/dist-engine-src/src/changelog/codec.rs +0 -321
  157. package/dist-engine-src/src/changelog/context.rs +0 -92
  158. package/dist-engine-src/src/changelog/materialization.rs +0 -121
  159. package/dist-engine-src/src/changelog/mod.rs +0 -13
  160. package/dist-engine-src/src/changelog/reader.rs +0 -20
  161. package/dist-engine-src/src/changelog/storage.rs +0 -220
  162. package/dist-engine-src/src/changelog/types.rs +0 -38
  163. package/dist-engine-src/src/schema/builtin/lix_change_set.json +0 -18
  164. package/dist-engine-src/src/schema/builtin/lix_change_set_element.json +0 -75
  165. package/dist-engine-src/src/schema/builtin/lix_entity_label.json +0 -63
  166. package/dist-engine-src/src/schema_registry.rs +0 -294
  167. package/dist-engine-src/src/sql2/commit_derived_provider.rs +0 -591
  168. package/dist-engine-src/src/tracked_state/rebuild.rs +0 -771
  169. package/dist-engine-src/src/tracked_state/tree_types.rs +0 -176
@@ -1,32 +1,40 @@
1
- use crate::commit_graph::CommitGraphContext;
2
- use crate::json_store::{JsonStoreContext, JsonStoreWriter};
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+
3
+ use crate::commit_store::CommitStoreContext;
3
4
  use crate::storage::{StorageReader, StorageWriteSet};
4
5
  use crate::tracked_state::by_file_index::ByFileIndex;
6
+ use crate::tracked_state::codec::{encode_key_ref, encode_value_ref};
5
7
  use crate::tracked_state::diff::{diff_commits, TrackedStateDiff, TrackedStateDiffRequest};
6
- use crate::tracked_state::materialize_value;
8
+ use crate::tracked_state::materialize_index_entries;
7
9
  use crate::tracked_state::merge::{self, TrackedStateMergePlan};
8
- use crate::tracked_state::rebuild::TrackedStateRebuildReport;
9
10
  use crate::tracked_state::storage;
11
+ use crate::tracked_state::storage::DeltaJsonPackIndexesRef;
10
12
  use crate::tracked_state::tree::TrackedStateTree;
11
- use crate::tracked_state::tree_types::{
12
- TrackedStateKey, TrackedStateMutation, TrackedStateTreeScanRequest, TrackedStateValue,
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,
13
20
  };
14
- use crate::tracked_state::{TrackedStateRow, TrackedStateRowRequest, TrackedStateScanRequest};
15
21
  use crate::LixError;
16
22
 
17
- /// Factory for rebuildable tracked-state readers and writers.
23
+ /// Factory for tracked-state readers, delta writers, and projection-root materializers.
18
24
  ///
19
25
  /// Tracked state is stored as content-addressed roots. Version refs
20
26
  /// choose which commit/root to read; this context only owns root operations.
21
27
  #[derive(Clone)]
22
28
  pub(crate) struct TrackedStateContext {
23
29
  tree: TrackedStateTree,
30
+ commit_store: CommitStoreContext,
24
31
  }
25
32
 
26
33
  impl TrackedStateContext {
27
34
  pub(crate) fn new() -> Self {
28
35
  Self {
29
36
  tree: TrackedStateTree::new(),
37
+ commit_store: CommitStoreContext::new(),
30
38
  }
31
39
  }
32
40
 
@@ -38,40 +46,45 @@ impl TrackedStateContext {
38
46
  TrackedStateStoreReader {
39
47
  store,
40
48
  tree: self.tree.clone(),
49
+ commit_store: self.commit_store,
41
50
  }
42
51
  }
43
52
 
44
- /// Creates a tracked-state writer that stages into a caller-owned write set.
45
- pub(crate) fn writer(&self) -> TrackedStateWriter {
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
+ {
46
62
  TrackedStateWriter {
47
63
  tree: self.tree.clone(),
64
+ store,
65
+ writes,
48
66
  }
49
67
  }
50
68
 
51
- /// Rebuilds tracked state at one commit from commit-graph entities.
52
- pub(crate) async fn rebuild_state_at_commit<R, S>(
53
- &self,
54
- commit_graph: &CommitGraphContext,
55
- read_store: R,
56
- tracked_store: &mut S,
57
- writes: &mut StorageWriteSet,
58
- json_writer: &mut JsonStoreWriter,
59
- head_commit_id: &str,
60
- ) -> Result<TrackedStateRebuildReport, LixError>
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>
61
79
  where
62
- R: StorageReader,
63
80
  S: StorageReader + ?Sized,
64
81
  {
65
- crate::tracked_state::rebuild::rebuild_state_at_commit(
66
- self,
67
- commit_graph,
68
- read_store,
69
- tracked_store,
82
+ TrackedStateMaterializer {
83
+ tracked_state: self,
84
+ store,
70
85
  writes,
71
- json_writer,
72
- head_commit_id,
73
- )
74
- .await
86
+ commit_store,
87
+ }
75
88
  }
76
89
  }
77
90
 
@@ -79,6 +92,7 @@ impl TrackedStateContext {
79
92
  pub(crate) struct TrackedStateStoreReader<S> {
80
93
  store: S,
81
94
  tree: TrackedStateTree,
95
+ commit_store: CommitStoreContext,
82
96
  }
83
97
 
84
98
  impl<S> TrackedStateStoreReader<S>
@@ -89,67 +103,84 @@ where
89
103
  &mut self,
90
104
  commit_id: &str,
91
105
  request: &TrackedStateScanRequest,
92
- ) -> Result<Vec<TrackedStateRow>, LixError> {
93
- let Some(root_id) = self.tree.load_root(&mut self.store, commit_id).await? else {
94
- return Ok(Vec::new());
95
- };
96
- let rows = if ByFileIndex::should_use(request) {
97
- let Some(by_file_root_id) =
98
- storage::load_by_file_root(&mut self.store, commit_id).await?
99
- else {
100
- return Ok(Vec::new());
101
- };
102
- self.scan_rows_at_commit_by_file_index(&root_id, &by_file_root_id, request)
103
- .await?
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
+ }
104
133
  } else {
105
- let rows = self
106
- .tree
107
- .scan(
108
- &mut self.store,
109
- &root_id,
110
- &tree_scan_request_from_tracked(request),
111
- )
112
- .await?;
113
- rows
134
+ self.projection_entries_at_commit(commit_id, &tree_scan_request_from_tracked(request))
135
+ .await?
114
136
  };
115
137
  let projection = crate::tracked_state::TrackedMaterializationProjection::from_columns(
116
138
  &request.projection.columns,
117
139
  );
118
- let mut json_reader = JsonStoreContext::new().reader(&mut self.store);
119
- let mut materialized = Vec::with_capacity(rows.len());
120
- for (key, value) in rows {
121
- materialized.push(materialize_value(&mut json_reader, key, value, &projection).await?);
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);
122
146
  }
123
- Ok(materialized)
147
+ Ok(rows)
124
148
  }
125
149
 
126
- pub(crate) async fn load_row_at_commit(
150
+ pub(crate) async fn load_rows_at_commit(
127
151
  &mut self,
128
152
  commit_id: &str,
129
- request: &TrackedStateRowRequest,
130
- ) -> Result<Option<TrackedStateRow>, LixError> {
131
- let key = tracked_key_from_request(request)?;
132
- let Some(root_id) = self.tree.load_root(&mut self.store, commit_id).await? else {
133
- return Ok(None);
134
- };
135
- let row = self
136
- .tree
137
- .get(&mut self.store, &root_id, &key)
138
- .await?
139
- .map(|value| async {
140
- let mut json_reader = JsonStoreContext::new().reader(&mut self.store);
141
- materialize_value(
142
- &mut json_reader,
143
- key,
144
- value,
145
- &crate::tracked_state::TrackedMaterializationProjection::full(),
146
- )
147
- .await
148
- });
149
- match row {
150
- Some(row) => row.await.map(Some),
151
- None => Ok(None),
153
+ requests: &[TrackedStateRowRequest],
154
+ ) -> Result<Vec<Option<MaterializedTrackedStateRow>>, LixError> {
155
+ if requests.is_empty() {
156
+ return Ok(Vec::new());
152
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)
153
184
  }
154
185
 
155
186
  pub(crate) async fn diff_commits(
@@ -166,34 +197,168 @@ where
166
197
  left_commit_id: &str,
167
198
  right_commit_id: &str,
168
199
  request: &TrackedStateTreeScanRequest,
169
- ) -> Result<Vec<crate::tracked_state::tree_types::TrackedStateTreeDiffEntry>, LixError> {
170
- let left_root = self.tree.load_root(&mut self.store, left_commit_id).await?;
171
- let right_root = self
172
- .tree
173
- .load_root(&mut self.store, right_commit_id)
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)
174
268
  .await?;
175
- let entries = self
176
- .tree
177
- .diff(
178
- &mut self.store,
179
- left_root.as_ref(),
180
- right_root.as_ref(),
181
- request,
182
- )
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)
183
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();
184
352
  Ok(entries)
185
353
  }
186
354
 
187
- pub(crate) async fn materialize_tree_value(
355
+ pub(crate) async fn materialize_tree_values(
188
356
  &mut self,
189
- key: TrackedStateKey,
190
- value: TrackedStateValue,
191
- ) -> Result<TrackedStateRow, LixError> {
192
- let mut json_reader = JsonStoreContext::new().reader(&mut self.store);
193
- materialize_value(
194
- &mut json_reader,
195
- key,
196
- value,
357
+ entries: Vec<(TrackedStateKey, TrackedStateIndexValue)>,
358
+ ) -> Result<Vec<MaterializedTrackedStateRow>, LixError> {
359
+ materialize_index_entries(
360
+ &mut self.store,
361
+ entries,
197
362
  &crate::tracked_state::TrackedMaterializationProjection::full(),
198
363
  )
199
364
  .await
@@ -201,10 +366,10 @@ where
201
366
 
202
367
  async fn scan_rows_at_commit_by_file_index(
203
368
  &mut self,
204
- primary_root_id: &crate::tracked_state::tree_types::TrackedStateRootId,
205
- by_file_root_id: &crate::tracked_state::tree_types::TrackedStateRootId,
369
+ primary_root_id: &crate::tracked_state::types::TrackedStateRootId,
370
+ by_file_root_id: &crate::tracked_state::types::TrackedStateRootId,
206
371
  request: &TrackedStateScanRequest,
207
- ) -> Result<Vec<(TrackedStateKey, TrackedStateValue)>, LixError> {
372
+ ) -> Result<Vec<(TrackedStateKey, TrackedStateIndexValue)>, LixError> {
208
373
  let by_file_request = ByFileIndex::scan_request_from_tracked(request);
209
374
  let index_match_count = self
210
375
  .tree
@@ -244,9 +409,6 @@ where
244
409
  .get_many(&mut self.store, primary_root_id, &primary_keys)
245
410
  .await?;
246
411
  for (primary_key, value) in primary_keys.into_iter().zip(primary_values) {
247
- if request.limit.is_some_and(|limit| rows.len() >= limit) {
248
- break;
249
- }
250
412
  let Some(value) = value else {
251
413
  continue;
252
414
  };
@@ -259,9 +421,6 @@ where
259
421
  }
260
422
 
261
423
  for (index_key, index_value) in index_rows {
262
- if request.limit.is_some_and(|limit| rows.len() >= limit) {
263
- break;
264
- }
265
424
  let Some(primary_key) = ByFileIndex::primary_key_from_index_key(index_key) else {
266
425
  continue;
267
426
  };
@@ -273,6 +432,254 @@ where
273
432
  Ok(rows)
274
433
  }
275
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
+
276
683
  /// Plans a three-way merge by diffing both heads against the same base.
277
684
  ///
278
685
  /// `target_commit_id` is the destination root that should keep its own
@@ -294,39 +701,89 @@ where
294
701
  .await?;
295
702
  merge::plan_merge(&target_diff, &source_diff)
296
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
+ }
297
712
 
298
- #[cfg(test)]
299
- pub(crate) async fn load_root_for_test(
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(
300
726
  &mut self,
301
727
  commit_id: &str,
302
- ) -> Result<Option<crate::tracked_state::tree_types::TrackedStateRootId>, LixError> {
303
- self.tree.load_root(&mut self.store, commit_id).await
728
+ ) -> Result<TrackedStateWriteReport, LixError> {
729
+ crate::tracked_state::materializer::materialize_root_at(self, commit_id).await
304
730
  }
305
731
  }
306
732
 
307
- /// Writer for rebuildable tracked-state roots.
308
- pub(crate) struct TrackedStateWriter {
309
- tree: TrackedStateTree,
310
- }
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
+ }
311
752
 
312
- impl TrackedStateWriter {
313
- /// Stages one root for `commit_id` from the provided row set.
314
- ///
315
- /// `parent_commit_id` is the tracked-state root to layer mutations on top
316
- /// of. Rebuild passes `None` because it has already materialized the full
317
- /// entity set for the requested head.
318
- pub(crate) async fn stage_root(
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>(
319
775
  &mut self,
320
- store: &mut (impl StorageReader + ?Sized),
321
- writes: &mut StorageWriteSet,
322
- json_writer: &mut JsonStoreWriter,
323
776
  commit_id: &str,
324
777
  parent_commit_id: Option<&str>,
325
- rows: &[TrackedStateRow],
326
- ) -> Result<TrackedStateWriteReceipt, LixError> {
778
+ deltas: I,
779
+ ) -> Result<TrackedStateWriteReport, LixError>
780
+ where
781
+ I: IntoIterator<Item = TrackedStateDeltaRef<'a>>,
782
+ {
783
+ let deltas = deltas.into_iter().collect::<Vec<_>>();
327
784
  let base_root = match parent_commit_id {
328
785
  Some(parent_commit_id) => {
329
- let Some(root) = self.tree.load_root(store, parent_commit_id).await? else {
786
+ let Some(root) = self.tree.load_root(self.store, parent_commit_id).await? else {
330
787
  return Err(LixError::new(
331
788
  "LIX_ERROR_UNKNOWN",
332
789
  format!(
@@ -338,22 +795,31 @@ impl TrackedStateWriter {
338
795
  }
339
796
  None => None,
340
797
  };
341
- let mut stored_rows = Vec::with_capacity(rows.len());
342
- let mut mutations = Vec::with_capacity(rows.len());
343
- for row in rows {
344
- let stored_value =
345
- crate::tracked_state::canonicalize_materialized_row(writes, json_writer, row)?;
346
- mutations.push(TrackedStateMutation::put(
347
- TrackedStateKey::from_row(row),
348
- stored_value.clone(),
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),
349
816
  ));
350
- stored_rows.push((row, stored_value));
351
817
  }
352
818
  let result = self
353
819
  .tree
354
820
  .apply_mutations(
355
- store,
356
- writes,
821
+ self.store,
822
+ self.writes,
357
823
  base_root.as_ref(),
358
824
  mutations,
359
825
  Some(commit_id),
@@ -361,62 +827,76 @@ impl TrackedStateWriter {
361
827
  .await?;
362
828
 
363
829
  let by_file_base_root = match parent_commit_id {
364
- Some(parent_commit_id) => storage::load_by_file_root(store, parent_commit_id)
365
- .await?
366
- .ok_or_else(|| {
367
- LixError::new(
368
- "LIX_ERROR_UNKNOWN",
369
- format!(
370
- "tracked-state by-file parent root for commit '{parent_commit_id}' is missing"
371
- ),
372
- )
373
- })
374
- .map(Some)?,
830
+ Some(parent_commit_id) => {
831
+ storage::load_by_file_root(self.store, parent_commit_id).await?
832
+ }
375
833
  None => None,
376
834
  };
377
- let mut by_file_mutations = Vec::with_capacity(rows.len());
378
- for (row, stored_value) in &stored_rows {
379
- by_file_mutations.push(TrackedStateMutation::put(
380
- ByFileIndex::key_from_row(row),
381
- ByFileIndex::header_value_from_primary(stored_value),
382
- ));
383
- }
384
- let by_file_result = self
385
- .tree
386
- .apply_mutations(
387
- store,
388
- writes,
389
- by_file_base_root.as_ref(),
390
- by_file_mutations,
391
- None,
392
- )
393
- .await?;
394
- storage::stage_by_file_root(writes, commit_id, &by_file_result.root_id);
395
- Ok(TrackedStateWriteReceipt {
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 {
396
879
  commit_id: commit_id.to_string(),
397
- row_count: result.row_count,
880
+ changed_rows: deltas.len(),
881
+ primary_chunk_puts: result.chunk_count,
882
+ by_file_chunk_puts,
398
883
  })
399
884
  }
400
-
401
- /// Deletes the root pointer for one commit.
402
- ///
403
- /// This is intentionally root-scoped, not row-scoped. It is useful for
404
- /// rebuild/corruption tests where the changelog remains authoritative and
405
- /// the tracked-state projection must be recreated from the commit id.
406
- #[cfg(test)]
407
- pub(crate) fn stage_delete_root_for_rebuild(
408
- &mut self,
409
- writes: &mut StorageWriteSet,
410
- commit_id: &str,
411
- ) {
412
- storage::stage_delete_root(writes, commit_id)
413
- }
414
885
  }
415
886
 
416
887
  #[derive(Debug, Clone, PartialEq, Eq)]
417
- pub(crate) struct TrackedStateWriteReceipt {
888
+ pub(crate) struct TrackedStateWriteReport {
418
889
  pub(crate) commit_id: String,
419
- pub(crate) row_count: usize,
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
+ )
420
900
  }
421
901
 
422
902
  fn tree_scan_request_from_tracked(
@@ -427,7 +907,10 @@ fn tree_scan_request_from_tracked(
427
907
  entity_ids: request.filter.entity_ids.clone(),
428
908
  file_ids: request.filter.file_ids.clone(),
429
909
  include_tombstones: request.filter.include_tombstones,
430
- limit: request.limit,
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,
431
914
  }
432
915
  }
433
916
 
@@ -466,11 +949,11 @@ mod tests {
466
949
 
467
950
  use super::*;
468
951
  use crate::backend::{testing::UnitTestBackend, Backend};
469
- use crate::storage::{StorageContext, StorageWriteSet, StorageWriteTransaction};
952
+ use crate::storage::{StorageContext, StorageWriteTransaction};
470
953
  use crate::NullableKeyFilter;
471
954
 
472
955
  #[tokio::test]
473
- async fn write_root_rejects_missing_parent_root() {
956
+ async fn stage_delta_does_not_require_parent_projection_root() {
474
957
  let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
475
958
  let storage = StorageContext::new(Arc::clone(&backend));
476
959
  let tracked_state = TrackedStateContext::new();
@@ -479,7 +962,7 @@ mod tests {
479
962
  .await
480
963
  .expect("transaction should open");
481
964
 
482
- let error = write_root_for_test(
965
+ write_root_for_test(
483
966
  transaction.as_mut(),
484
967
  &tracked_state,
485
968
  "commit-child",
@@ -487,12 +970,7 @@ mod tests {
487
970
  &[row("entity-child", "change-child", "commit-child")],
488
971
  )
489
972
  .await
490
- .expect_err("parent root must exist when parent_commit_id is provided");
491
-
492
- assert!(
493
- error.message.contains("parent root") && error.message.contains("missing-parent"),
494
- "unexpected error: {error:?}"
495
- );
973
+ .expect("delta pack staging should not require a parent projection root");
496
974
  }
497
975
 
498
976
  #[tokio::test]
@@ -600,22 +1078,135 @@ mod tests {
600
1078
  &TrackedStateDiffRequest::default(),
601
1079
  )
602
1080
  .await
603
- .expect("merge should plan");
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");
604
1199
 
605
- assert_eq!(merge_patch_ids(&plan), vec!["entity-a"]);
606
- assert_eq!(plan.patches[0].projected_row().snapshot_content, None);
607
- assert_eq!(plan.patches[0].change_id(), "change-source-delete");
1200
+ assert_eq!(header_rows[0].snapshot_content, None);
1201
+ assert_eq!(full_rows[0].snapshot_content, expected_snapshot);
608
1202
  }
609
1203
 
610
1204
  #[tokio::test]
611
- async fn scan_rows_by_file_uses_file_index_shape() {
1205
+ async fn null_file_rows_do_not_stage_by_file_index() {
612
1206
  let backend = Arc::new(UnitTestBackend::new());
613
1207
  let storage = StorageContext::new(backend.clone());
614
1208
  let tracked_state = TrackedStateContext::new();
615
- let mut file_a = row("entity-a", "change-a", "commit-1");
616
- file_a.file_id = Some("file-a.json".to_string());
617
- let mut file_b = row("entity-b", "change-b", "commit-1");
618
- file_b.file_id = Some("file-b.json".to_string());
1209
+ let row = row("entity-a", "change-a", "commit-1");
619
1210
 
620
1211
  let mut transaction = storage
621
1212
  .begin_write_transaction()
@@ -626,7 +1217,7 @@ mod tests {
626
1217
  &tracked_state,
627
1218
  "commit-1",
628
1219
  None,
629
- &[file_a, file_b],
1220
+ std::slice::from_ref(&row),
630
1221
  )
631
1222
  .await
632
1223
  .expect("root should write");
@@ -635,37 +1226,44 @@ mod tests {
635
1226
  .await
636
1227
  .expect("transaction should commit");
637
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
+
638
1234
  let rows = tracked_state
639
1235
  .reader(storage.clone())
640
1236
  .scan_rows_at_commit(
641
1237
  "commit-1",
642
1238
  &TrackedStateScanRequest {
643
1239
  filter: crate::tracked_state::TrackedStateFilter {
644
- file_ids: vec![NullableKeyFilter::Value("file-a.json".to_string())],
1240
+ file_ids: vec![NullableKeyFilter::Null],
645
1241
  ..Default::default()
646
1242
  },
647
1243
  ..Default::default()
648
1244
  },
649
1245
  )
650
1246
  .await
651
- .expect("file scan should read through index");
1247
+ .expect("null file scan should fall back to primary tree");
652
1248
 
653
1249
  assert_eq!(rows.len(), 1);
654
1250
  assert_eq!(
655
- rows[0].entity_id.as_string().expect("entity id"),
1251
+ rows[0]
1252
+ .entity_id
1253
+ .as_single_string_owned()
1254
+ .expect("entity id"),
656
1255
  "entity-a"
657
1256
  );
658
- assert_eq!(rows[0].file_id.as_deref(), Some("file-a.json"));
659
1257
  }
660
1258
 
661
1259
  #[tokio::test]
662
- async fn by_file_header_index_fetches_primary_payload_only_when_requested() {
1260
+ async fn mixed_null_and_concrete_file_scan_uses_primary_tree() {
663
1261
  let backend = Arc::new(UnitTestBackend::new());
664
1262
  let storage = StorageContext::new(backend.clone());
665
1263
  let tracked_state = TrackedStateContext::new();
666
- let mut row = row("entity-a", "change-a", "commit-1");
667
- row.file_id = Some("file-a.json".to_string());
668
- let expected_snapshot = row.snapshot_content.clone();
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());
669
1267
 
670
1268
  let mut transaction = storage
671
1269
  .begin_write_transaction()
@@ -676,7 +1274,70 @@ mod tests {
676
1274
  &tracked_state,
677
1275
  "commit-1",
678
1276
  None,
679
- std::slice::from_ref(&row),
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],
680
1341
  )
681
1342
  .await
682
1343
  .expect("root should write");
@@ -685,8 +1346,8 @@ mod tests {
685
1346
  .await
686
1347
  .expect("transaction should commit");
687
1348
 
688
- let mut reader = tracked_state.reader(storage.clone());
689
- let header_rows = reader
1349
+ let rows = tracked_state
1350
+ .reader(storage.clone())
690
1351
  .scan_rows_at_commit(
691
1352
  "commit-1",
692
1353
  &TrackedStateScanRequest {
@@ -701,34 +1362,202 @@ mod tests {
701
1362
  },
702
1363
  )
703
1364
  .await
704
- .expect("header scan should read through by-file index");
705
- let full_rows = reader
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())
706
1532
  .scan_rows_at_commit(
707
1533
  "commit-1",
708
1534
  &TrackedStateScanRequest {
709
- filter: crate::tracked_state::TrackedStateFilter {
710
- file_ids: vec![NullableKeyFilter::Value("file-a.json".to_string())],
711
- ..Default::default()
712
- },
1535
+ limit: Some(1),
713
1536
  ..Default::default()
714
1537
  },
715
1538
  )
716
1539
  .await
717
- .expect("full scan should fetch primary payload");
1540
+ .expect("limited scan should apply visibility before limit");
718
1541
 
719
- assert_eq!(header_rows[0].snapshot_content, None);
720
- assert_eq!(full_rows[0].snapshot_content, expected_snapshot);
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
+ );
721
1550
  }
722
1551
 
723
1552
  #[tokio::test]
724
- async fn by_file_header_index_filters_tombstones_without_payload_sentinel() {
1553
+ async fn by_file_scan_limit_applies_after_tombstone_visibility() {
725
1554
  let backend = Arc::new(UnitTestBackend::new());
726
1555
  let storage = StorageContext::new(backend.clone());
727
1556
  let tracked_state = TrackedStateContext::new();
728
- let mut live = row("entity-live", "change-live", "commit-1");
729
- live.file_id = Some("file-a.json".to_string());
730
- let mut deleted = tombstone("entity-deleted", "change-delete", "commit-1");
1557
+ let mut deleted = tombstone("entity-a", "change-delete", "commit-1");
731
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());
732
1561
 
733
1562
  let mut transaction = storage
734
1563
  .begin_write_transaction()
@@ -739,7 +1568,7 @@ mod tests {
739
1568
  &tracked_state,
740
1569
  "commit-1",
741
1570
  None,
742
- &[live, deleted],
1571
+ &[deleted, live],
743
1572
  )
744
1573
  .await
745
1574
  .expect("root should write");
@@ -760,16 +1589,19 @@ mod tests {
760
1589
  projection: crate::tracked_state::TrackedStateProjection {
761
1590
  columns: vec!["entity_id".to_string()],
762
1591
  },
763
- ..Default::default()
1592
+ limit: Some(1),
764
1593
  },
765
1594
  )
766
1595
  .await
767
- .expect("file scan should read through index");
1596
+ .expect("limited by-file scan should apply visibility before limit");
768
1597
 
769
1598
  assert_eq!(rows.len(), 1);
770
1599
  assert_eq!(
771
- rows[0].entity_id.as_string().expect("entity id"),
772
- "entity-live"
1600
+ rows[0]
1601
+ .entity_id
1602
+ .as_single_string_owned()
1603
+ .expect("entity id"),
1604
+ "entity-b"
773
1605
  );
774
1606
  }
775
1607
 
@@ -801,16 +1633,18 @@ mod tests {
801
1633
 
802
1634
  let mut reader = tracked_state.reader(storage.clone());
803
1635
  let loaded = reader
804
- .load_row_at_commit(
1636
+ .load_rows_at_commit(
805
1637
  "commit-1",
806
- &TrackedStateRowRequest {
1638
+ &[TrackedStateRowRequest {
807
1639
  schema_key: row.schema_key.clone(),
808
1640
  entity_id: row.entity_id.clone(),
809
1641
  file_id: NullableKeyFilter::Null,
810
- },
1642
+ }],
811
1643
  )
812
1644
  .await
813
1645
  .expect("row should load")
1646
+ .pop()
1647
+ .flatten()
814
1648
  .expect("row should exist");
815
1649
  let scanned = reader
816
1650
  .scan_rows_at_commit("commit-1", &TrackedStateScanRequest::default())
@@ -821,6 +1655,53 @@ mod tests {
821
1655
  assert_eq!(scanned[0].snapshot_content, row.snapshot_content);
822
1656
  }
823
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
+
824
1705
  #[tokio::test]
825
1706
  async fn projected_scans_do_not_materialize_snapshot_when_snapshot_content_is_omitted() {
826
1707
  let backend = Arc::new(UnitTestBackend::new());
@@ -866,9 +1747,9 @@ mod tests {
866
1747
  }
867
1748
 
868
1749
  async fn seed_merge_roots(
869
- base_rows: &[TrackedStateRow],
870
- target_rows: &[TrackedStateRow],
871
- source_rows: &[TrackedStateRow],
1750
+ base_rows: &[MaterializedTrackedStateRow],
1751
+ target_rows: &[MaterializedTrackedStateRow],
1752
+ source_rows: &[MaterializedTrackedStateRow],
872
1753
  ) -> (StorageContext, TrackedStateContext) {
873
1754
  let backend = Arc::new(UnitTestBackend::new());
874
1755
  let storage = StorageContext::new(backend.clone());
@@ -914,14 +1795,26 @@ mod tests {
914
1795
  fn merge_patch_ids(plan: &TrackedStateMergePlan) -> Vec<String> {
915
1796
  plan.patches
916
1797
  .iter()
917
- .map(|entry| entry.identity().entity_id.as_string().expect("identity"))
1798
+ .map(|entry| {
1799
+ entry
1800
+ .identity()
1801
+ .entity_id
1802
+ .as_single_string_owned()
1803
+ .expect("identity")
1804
+ })
918
1805
  .collect()
919
1806
  }
920
1807
 
921
1808
  fn merge_conflict_ids(plan: &TrackedStateMergePlan) -> Vec<String> {
922
1809
  plan.conflicts
923
1810
  .iter()
924
- .map(|entry| entry.identity.entity_id.as_string().expect("identity"))
1811
+ .map(|entry| {
1812
+ entry
1813
+ .identity
1814
+ .entity_id
1815
+ .as_single_string_owned()
1816
+ .expect("identity")
1817
+ })
925
1818
  .collect()
926
1819
  }
927
1820
 
@@ -930,34 +1823,25 @@ mod tests {
930
1823
  tracked_state: &TrackedStateContext,
931
1824
  commit_id: &str,
932
1825
  parent_commit_id: Option<&str>,
933
- rows: &[TrackedStateRow],
934
- ) -> Result<TrackedStateWriteReceipt, LixError> {
935
- let mut writes = StorageWriteSet::new();
936
- let receipt = {
937
- let mut json_writer = JsonStoreContext::new().writer();
938
- tracked_state
939
- .writer()
940
- .stage_root(
941
- transaction,
942
- &mut writes,
943
- &mut json_writer,
944
- commit_id,
945
- parent_commit_id,
946
- rows,
947
- )
948
- .await?
949
- };
950
- writes.apply(transaction).await?;
951
- Ok(receipt)
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
952
1836
  }
953
1837
 
954
- fn tombstone(entity_id: &str, change_id: &str, commit_id: &str) -> TrackedStateRow {
1838
+ fn tombstone(entity_id: &str, change_id: &str, commit_id: &str) -> MaterializedTrackedStateRow {
955
1839
  let mut row = row(entity_id, change_id, commit_id);
956
1840
  row.snapshot_content = None;
957
1841
  row
958
1842
  }
959
1843
 
960
- fn row(entity_id: &str, change_id: &str, commit_id: &str) -> TrackedStateRow {
1844
+ fn row(entity_id: &str, change_id: &str, commit_id: &str) -> MaterializedTrackedStateRow {
961
1845
  row_with_value(entity_id, change_id, commit_id, "value")
962
1846
  }
963
1847
 
@@ -966,14 +1850,14 @@ mod tests {
966
1850
  change_id: &str,
967
1851
  commit_id: &str,
968
1852
  value: &str,
969
- ) -> TrackedStateRow {
970
- TrackedStateRow {
1853
+ ) -> MaterializedTrackedStateRow {
1854
+ MaterializedTrackedStateRow {
971
1855
  entity_id: crate::entity_identity::EntityIdentity::single(entity_id),
972
1856
  schema_key: "test_schema".to_string(),
973
1857
  file_id: None,
974
1858
  snapshot_content: Some(format!("{{\"value\":\"{value}\"}}")),
975
1859
  metadata: None,
976
- schema_version: "1".to_string(),
1860
+ deleted: false,
977
1861
  created_at: "2026-01-01T00:00:00Z".to_string(),
978
1862
  updated_at: "2026-01-01T00:00:00Z".to_string(),
979
1863
  change_id: change_id.to_string(),