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

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 (191) hide show
  1. package/SKILL.md +305 -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/open-lix.d.ts +103 -14
  7. package/dist/open-lix.js +3 -0
  8. package/dist/sqlite/index.js +99 -22
  9. package/dist-engine-src/README.md +18 -0
  10. package/dist-engine-src/src/backend/kv.rs +358 -0
  11. package/dist-engine-src/src/backend/mod.rs +12 -0
  12. package/dist-engine-src/src/backend/testing.rs +658 -0
  13. package/dist-engine-src/src/backend/types.rs +96 -0
  14. package/dist-engine-src/src/binary_cas/chunking.rs +31 -0
  15. package/dist-engine-src/src/binary_cas/codec.rs +346 -0
  16. package/dist-engine-src/src/binary_cas/context.rs +139 -0
  17. package/dist-engine-src/src/binary_cas/kv.rs +1063 -0
  18. package/dist-engine-src/src/binary_cas/mod.rs +11 -0
  19. package/dist-engine-src/src/binary_cas/types.rs +127 -0
  20. package/dist-engine-src/src/cel/context.rs +86 -0
  21. package/dist-engine-src/src/cel/error.rs +19 -0
  22. package/dist-engine-src/src/cel/mod.rs +8 -0
  23. package/dist-engine-src/src/cel/provider.rs +9 -0
  24. package/dist-engine-src/src/cel/runtime.rs +167 -0
  25. package/dist-engine-src/src/cel/value.rs +50 -0
  26. package/dist-engine-src/src/changelog/codec.rs +321 -0
  27. package/dist-engine-src/src/changelog/context.rs +92 -0
  28. package/dist-engine-src/src/changelog/materialization.rs +121 -0
  29. package/dist-engine-src/src/changelog/mod.rs +13 -0
  30. package/dist-engine-src/src/changelog/reader.rs +20 -0
  31. package/dist-engine-src/src/changelog/storage.rs +220 -0
  32. package/dist-engine-src/src/changelog/types.rs +38 -0
  33. package/dist-engine-src/src/commit_graph/context.rs +1588 -0
  34. package/dist-engine-src/src/commit_graph/mod.rs +12 -0
  35. package/dist-engine-src/src/commit_graph/types.rs +145 -0
  36. package/dist-engine-src/src/commit_graph/walker.rs +780 -0
  37. package/dist-engine-src/src/common/error.rs +313 -0
  38. package/dist-engine-src/src/common/fingerprint.rs +3 -0
  39. package/dist-engine-src/src/common/fs_path.rs +1336 -0
  40. package/dist-engine-src/src/common/identity.rs +135 -0
  41. package/dist-engine-src/src/common/metadata.rs +35 -0
  42. package/dist-engine-src/src/common/mod.rs +23 -0
  43. package/dist-engine-src/src/common/types.rs +105 -0
  44. package/dist-engine-src/src/common/wire.rs +222 -0
  45. package/dist-engine-src/src/engine.rs +239 -0
  46. package/dist-engine-src/src/entity_identity.rs +285 -0
  47. package/dist-engine-src/src/functions/context.rs +327 -0
  48. package/dist-engine-src/src/functions/deterministic.rs +113 -0
  49. package/dist-engine-src/src/functions/mod.rs +18 -0
  50. package/dist-engine-src/src/functions/provider.rs +130 -0
  51. package/dist-engine-src/src/functions/state.rs +363 -0
  52. package/dist-engine-src/src/functions/types.rs +37 -0
  53. package/dist-engine-src/src/init.rs +505 -0
  54. package/dist-engine-src/src/json_store/compression.rs +77 -0
  55. package/dist-engine-src/src/json_store/context.rs +129 -0
  56. package/dist-engine-src/src/json_store/encoded.rs +15 -0
  57. package/dist-engine-src/src/json_store/mod.rs +9 -0
  58. package/dist-engine-src/src/json_store/store.rs +236 -0
  59. package/dist-engine-src/src/json_store/types.rs +52 -0
  60. package/dist-engine-src/src/lib.rs +61 -0
  61. package/dist-engine-src/src/live_state/context.rs +2241 -0
  62. package/dist-engine-src/src/live_state/mod.rs +15 -0
  63. package/dist-engine-src/src/live_state/overlay.rs +75 -0
  64. package/dist-engine-src/src/live_state/reader.rs +23 -0
  65. package/dist-engine-src/src/live_state/types.rs +239 -0
  66. package/dist-engine-src/src/live_state/visibility.rs +218 -0
  67. package/dist-engine-src/src/plugin/archive.rs +441 -0
  68. package/dist-engine-src/src/plugin/component.rs +183 -0
  69. package/dist-engine-src/src/plugin/install.rs +637 -0
  70. package/dist-engine-src/src/plugin/manifest.rs +516 -0
  71. package/dist-engine-src/src/plugin/materializer.rs +477 -0
  72. package/dist-engine-src/src/plugin/mod.rs +33 -0
  73. package/dist-engine-src/src/plugin/plugin_manifest.json +119 -0
  74. package/dist-engine-src/src/plugin/storage.rs +74 -0
  75. package/dist-engine-src/src/schema/annotations/defaults.rs +280 -0
  76. package/dist-engine-src/src/schema/annotations/mod.rs +1 -0
  77. package/dist-engine-src/src/schema/builtin/lix_account.json +22 -0
  78. package/dist-engine-src/src/schema/builtin/lix_active_account.json +30 -0
  79. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +30 -0
  80. package/dist-engine-src/src/schema/builtin/lix_change.json +62 -0
  81. package/dist-engine-src/src/schema/builtin/lix_change_author.json +46 -0
  82. package/dist-engine-src/src/schema/builtin/lix_change_set.json +18 -0
  83. package/dist-engine-src/src/schema/builtin/lix_change_set_element.json +75 -0
  84. package/dist-engine-src/src/schema/builtin/lix_commit.json +62 -0
  85. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +46 -0
  86. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +53 -0
  87. package/dist-engine-src/src/schema/builtin/lix_entity_label.json +63 -0
  88. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +53 -0
  89. package/dist-engine-src/src/schema/builtin/lix_key_value.json +41 -0
  90. package/dist-engine-src/src/schema/builtin/lix_label.json +22 -0
  91. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +31 -0
  92. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +35 -0
  93. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +49 -0
  94. package/dist-engine-src/src/schema/builtin/mod.rs +271 -0
  95. package/dist-engine-src/src/schema/definition.json +157 -0
  96. package/dist-engine-src/src/schema/definition.rs +636 -0
  97. package/dist-engine-src/src/schema/key.rs +206 -0
  98. package/dist-engine-src/src/schema/mod.rs +20 -0
  99. package/dist-engine-src/src/schema/seed.rs +14 -0
  100. package/dist-engine-src/src/schema/tests.rs +739 -0
  101. package/dist-engine-src/src/schema_registry.rs +294 -0
  102. package/dist-engine-src/src/session/context.rs +366 -0
  103. package/dist-engine-src/src/session/create_version.rs +80 -0
  104. package/dist-engine-src/src/session/execute.rs +447 -0
  105. package/dist-engine-src/src/session/merge/analysis.rs +102 -0
  106. package/dist-engine-src/src/session/merge/apply.rs +23 -0
  107. package/dist-engine-src/src/session/merge/conflicts.rs +62 -0
  108. package/dist-engine-src/src/session/merge/mod.rs +11 -0
  109. package/dist-engine-src/src/session/merge/stats.rs +65 -0
  110. package/dist-engine-src/src/session/merge/version.rs +437 -0
  111. package/dist-engine-src/src/session/mod.rs +25 -0
  112. package/dist-engine-src/src/session/switch_version.rs +121 -0
  113. package/dist-engine-src/src/sql2/change_provider.rs +337 -0
  114. package/dist-engine-src/src/sql2/classify.rs +147 -0
  115. package/dist-engine-src/src/sql2/commit_derived_provider.rs +591 -0
  116. package/dist-engine-src/src/sql2/context.rs +307 -0
  117. package/dist-engine-src/src/sql2/directory_history_provider.rs +623 -0
  118. package/dist-engine-src/src/sql2/directory_provider.rs +2405 -0
  119. package/dist-engine-src/src/sql2/dml.rs +148 -0
  120. package/dist-engine-src/src/sql2/entity_history_provider.rs +444 -0
  121. package/dist-engine-src/src/sql2/entity_provider.rs +2700 -0
  122. package/dist-engine-src/src/sql2/error.rs +196 -0
  123. package/dist-engine-src/src/sql2/execute.rs +3379 -0
  124. package/dist-engine-src/src/sql2/file_history_provider.rs +902 -0
  125. package/dist-engine-src/src/sql2/file_provider.rs +3254 -0
  126. package/dist-engine-src/src/sql2/filesystem_planner.rs +1526 -0
  127. package/dist-engine-src/src/sql2/filesystem_predicates.rs +159 -0
  128. package/dist-engine-src/src/sql2/filesystem_visibility.rs +369 -0
  129. package/dist-engine-src/src/sql2/history_projection.rs +80 -0
  130. package/dist-engine-src/src/sql2/history_provider.rs +418 -0
  131. package/dist-engine-src/src/sql2/history_route.rs +643 -0
  132. package/dist-engine-src/src/sql2/lix_state_provider.rs +2430 -0
  133. package/dist-engine-src/src/sql2/mod.rs +43 -0
  134. package/dist-engine-src/src/sql2/read_only.rs +65 -0
  135. package/dist-engine-src/src/sql2/record_batch.rs +17 -0
  136. package/dist-engine-src/src/sql2/result_metadata.rs +29 -0
  137. package/dist-engine-src/src/sql2/runtime.rs +60 -0
  138. package/dist-engine-src/src/sql2/session.rs +135 -0
  139. package/dist-engine-src/src/sql2/udfs/common.rs +295 -0
  140. package/dist-engine-src/src/sql2/udfs/lix_active_version_commit_id.rs +53 -0
  141. package/dist-engine-src/src/sql2/udfs/lix_empty_blob.rs +47 -0
  142. package/dist-engine-src/src/sql2/udfs/lix_json.rs +100 -0
  143. package/dist-engine-src/src/sql2/udfs/lix_json_get.rs +99 -0
  144. package/dist-engine-src/src/sql2/udfs/lix_json_get_text.rs +99 -0
  145. package/dist-engine-src/src/sql2/udfs/lix_text_decode.rs +82 -0
  146. package/dist-engine-src/src/sql2/udfs/lix_text_encode.rs +85 -0
  147. package/dist-engine-src/src/sql2/udfs/lix_uuid_v7.rs +76 -0
  148. package/dist-engine-src/src/sql2/udfs/mod.rs +82 -0
  149. package/dist-engine-src/src/sql2/version_provider.rs +1187 -0
  150. package/dist-engine-src/src/sql2/version_scope.rs +394 -0
  151. package/dist-engine-src/src/sql2/write_normalization.rs +345 -0
  152. package/dist-engine-src/src/storage/context.rs +356 -0
  153. package/dist-engine-src/src/storage/mod.rs +14 -0
  154. package/dist-engine-src/src/storage/read_scope.rs +88 -0
  155. package/dist-engine-src/src/storage/types.rs +501 -0
  156. package/dist-engine-src/src/storage_bench.rs +3406 -0
  157. package/dist-engine-src/src/test_support.rs +81 -0
  158. package/dist-engine-src/src/tracked_state/by_file_index.rs +102 -0
  159. package/dist-engine-src/src/tracked_state/codec.rs +747 -0
  160. package/dist-engine-src/src/tracked_state/context.rs +983 -0
  161. package/dist-engine-src/src/tracked_state/diff.rs +494 -0
  162. package/dist-engine-src/src/tracked_state/materialization.rs +141 -0
  163. package/dist-engine-src/src/tracked_state/merge.rs +474 -0
  164. package/dist-engine-src/src/tracked_state/mod.rs +31 -0
  165. package/dist-engine-src/src/tracked_state/rebuild.rs +771 -0
  166. package/dist-engine-src/src/tracked_state/storage.rs +243 -0
  167. package/dist-engine-src/src/tracked_state/tree.rs +2744 -0
  168. package/dist-engine-src/src/tracked_state/tree_types.rs +176 -0
  169. package/dist-engine-src/src/tracked_state/types.rs +61 -0
  170. package/dist-engine-src/src/transaction/commit.rs +1224 -0
  171. package/dist-engine-src/src/transaction/context.rs +1307 -0
  172. package/dist-engine-src/src/transaction/live_state_overlay.rs +34 -0
  173. package/dist-engine-src/src/transaction/mod.rs +11 -0
  174. package/dist-engine-src/src/transaction/normalization.rs +1026 -0
  175. package/dist-engine-src/src/transaction/schema_resolver.rs +127 -0
  176. package/dist-engine-src/src/transaction/staging.rs +1436 -0
  177. package/dist-engine-src/src/transaction/types.rs +351 -0
  178. package/dist-engine-src/src/transaction/validation.rs +4811 -0
  179. package/dist-engine-src/src/untracked_state/codec.rs +363 -0
  180. package/dist-engine-src/src/untracked_state/context.rs +82 -0
  181. package/dist-engine-src/src/untracked_state/materialization.rs +157 -0
  182. package/dist-engine-src/src/untracked_state/mod.rs +17 -0
  183. package/dist-engine-src/src/untracked_state/storage.rs +348 -0
  184. package/dist-engine-src/src/untracked_state/types.rs +96 -0
  185. package/dist-engine-src/src/version/context.rs +52 -0
  186. package/dist-engine-src/src/version/mod.rs +12 -0
  187. package/dist-engine-src/src/version/refs.rs +421 -0
  188. package/dist-engine-src/src/version/stage_rows.rs +71 -0
  189. package/dist-engine-src/src/version/types.rs +21 -0
  190. package/dist-engine-src/src/wasm/mod.rs +60 -0
  191. package/package.json +68 -64
@@ -0,0 +1,2241 @@
1
+ use async_trait::async_trait;
2
+ use tokio::sync::Mutex;
3
+
4
+ use crate::commit_graph::{CommitGraphCommit, CommitGraphContext};
5
+ use crate::live_state::visibility;
6
+ use crate::live_state::{
7
+ LiveStateFilter, LiveStateReader, LiveStateRow, LiveStateRowRequest, LiveStateScanRequest,
8
+ };
9
+ use crate::storage::{StorageReader, StorageWriteSet};
10
+ use crate::tracked_state::{
11
+ TrackedStateContext, TrackedStateFilter, TrackedStateProjection, TrackedStateRow,
12
+ TrackedStateRowRequest, TrackedStateScanRequest,
13
+ };
14
+ use crate::untracked_state::{
15
+ canonicalize_materialized_row, MaterializedUntrackedStateRow, UntrackedStateContext,
16
+ UntrackedStateIdentity, UntrackedStateRowRequest, UntrackedStateScanRequest,
17
+ };
18
+ use crate::version::VERSION_REF_SCHEMA_KEY;
19
+ use crate::LixError;
20
+ use crate::GLOBAL_VERSION_ID;
21
+
22
+ /// Serving facade for visible live-state readers and writers.
23
+ ///
24
+ /// Live state composes the rebuildable tracked projection with the durable
25
+ /// untracked local overlay. Lower stores own persistence; this facade owns the
26
+ /// visibility rule.
27
+ pub(crate) struct LiveStateContext {
28
+ tracked_state: TrackedStateContext,
29
+ untracked_state: UntrackedStateContext,
30
+ commit_graph: CommitGraphContext,
31
+ }
32
+
33
+ impl LiveStateContext {
34
+ pub(crate) fn new(
35
+ tracked_state: TrackedStateContext,
36
+ untracked_state: UntrackedStateContext,
37
+ commit_graph: CommitGraphContext,
38
+ ) -> Self {
39
+ Self {
40
+ tracked_state,
41
+ untracked_state,
42
+ commit_graph,
43
+ }
44
+ }
45
+
46
+ /// Creates a visible live-state reader over a caller-provided KV store.
47
+ pub(crate) fn reader<S>(&self, store: S) -> LiveStateStoreReader<S>
48
+ where
49
+ S: StorageReader,
50
+ {
51
+ LiveStateStoreReader {
52
+ store: Mutex::new(store),
53
+ tracked_state: self.tracked_state.clone(),
54
+ untracked_state: self.untracked_state,
55
+ commit_graph: self.commit_graph.clone(),
56
+ }
57
+ }
58
+
59
+ /// Creates a visible live-state writer over a caller-provided KV reader.
60
+ ///
61
+ /// The writer owns the tracked/untracked routing rule: tracked rows update
62
+ /// the tracked projection and clear matching untracked overlay rows, while
63
+ /// untracked rows update only the local untracked overlay.
64
+ pub(crate) fn writer<S>(&self, store: S) -> LiveStateWriter<S>
65
+ where
66
+ S: StorageReader,
67
+ {
68
+ LiveStateWriter {
69
+ store,
70
+ tracked_state: self.tracked_state.clone(),
71
+ untracked_state: self.untracked_state,
72
+ }
73
+ }
74
+ }
75
+
76
+ /// Visible live-state reader backed by a caller-provided KV store.
77
+ pub(crate) struct LiveStateStoreReader<S> {
78
+ store: Mutex<S>,
79
+ tracked_state: TrackedStateContext,
80
+ untracked_state: UntrackedStateContext,
81
+ commit_graph: CommitGraphContext,
82
+ }
83
+
84
+ impl<S> LiveStateStoreReader<S>
85
+ where
86
+ S: StorageReader,
87
+ {
88
+ pub(crate) async fn scan_rows(
89
+ &self,
90
+ request: &LiveStateScanRequest,
91
+ ) -> Result<Vec<LiveStateRow>, LixError> {
92
+ let mut store = self.store.lock().await;
93
+ let scope = scan_scope(&mut *store, &self.untracked_state, request).await?;
94
+ let mut tracked_rows = Vec::new();
95
+ for version_id in &scope.storage_version_ids {
96
+ let Some(commit_id) =
97
+ load_version_ref_commit_id(&mut *store, &self.untracked_state, version_id).await?
98
+ else {
99
+ continue;
100
+ };
101
+ let tracked_request = tracked_scan_request_from_live(request);
102
+ let source = tracked_source_from_version_id(version_id);
103
+ let store: &mut dyn StorageReader = &mut *store;
104
+ tracked_rows.extend(
105
+ self.tracked_state
106
+ .reader(store)
107
+ .scan_rows_at_commit(&commit_id, &tracked_request)
108
+ .await?
109
+ .into_iter()
110
+ .map(|row| project_tracked_row(row, version_id, source)),
111
+ );
112
+ }
113
+
114
+ let untracked_rows = {
115
+ let store: &mut dyn StorageReader = &mut *store;
116
+ self.untracked_state
117
+ .reader(store)
118
+ .scan_rows(&untracked_scan_request_from_live(
119
+ request,
120
+ &scope.storage_version_ids,
121
+ ))
122
+ .await?
123
+ }
124
+ .into_iter()
125
+ .map(LiveStateRow::from)
126
+ .collect::<Vec<_>>();
127
+
128
+ let mut commit_rows = if scope.includes_commit_graph_projection {
129
+ let store: &mut dyn StorageReader = &mut *store;
130
+ self.commit_graph
131
+ .reader(store)
132
+ .all_commits()
133
+ .await?
134
+ .into_iter()
135
+ .map(live_state_row_from_commit)
136
+ .collect::<Vec<_>>()
137
+ } else {
138
+ Vec::new()
139
+ };
140
+ commit_rows.retain(|row| live_state_row_matches_filter(row, &request.filter));
141
+
142
+ let mut rows =
143
+ crate::live_state::overlay::overlay_untracked_rows(tracked_rows, untracked_rows);
144
+ rows.extend(commit_rows);
145
+ rows = visibility::resolve_scan_rows(
146
+ rows,
147
+ &scope.projection_version_ids,
148
+ request.filter.include_tombstones,
149
+ );
150
+ if let Some(limit) = request.limit {
151
+ rows.truncate(limit);
152
+ }
153
+ Ok(rows)
154
+ }
155
+
156
+ pub(crate) async fn load_row(
157
+ &self,
158
+ request: &LiveStateRowRequest,
159
+ ) -> Result<Option<LiveStateRow>, LixError> {
160
+ let mut store = self.store.lock().await;
161
+ if !version_ref_exists(&mut *store, &self.untracked_state, &request.version_id).await? {
162
+ return Ok(None);
163
+ }
164
+ if request.schema_key == COMMIT_SCHEMA_KEY {
165
+ let store: &mut dyn StorageReader = &mut *store;
166
+ return self.load_commit_row(store, request).await;
167
+ }
168
+ for candidate in load_row_candidates(request) {
169
+ match candidate.source {
170
+ LiveStateLookupSource::Untracked => {
171
+ let store: &mut dyn StorageReader = &mut *store;
172
+ if let Some(row) = self
173
+ .untracked_state
174
+ .reader(store)
175
+ .load_row(&untracked_row_request_from_live(
176
+ request,
177
+ &candidate.version_id,
178
+ ))
179
+ .await?
180
+ {
181
+ return Ok(Some(visibility::project_loaded_row(
182
+ LiveStateRow::from(row),
183
+ &request.version_id,
184
+ &candidate.version_id,
185
+ )));
186
+ }
187
+ }
188
+ LiveStateLookupSource::Tracked => {
189
+ let Some(commit_id) = load_version_ref_commit_id(
190
+ &mut *store,
191
+ &self.untracked_state,
192
+ &candidate.version_id,
193
+ )
194
+ .await?
195
+ else {
196
+ continue;
197
+ };
198
+ let store: &mut dyn StorageReader = &mut *store;
199
+ if let Some(row) = self
200
+ .tracked_state
201
+ .reader(store)
202
+ .load_row_at_commit(&commit_id, &tracked_row_request_from_live(request))
203
+ .await?
204
+ {
205
+ return Ok(Some(project_tracked_row(
206
+ row,
207
+ &request.version_id,
208
+ tracked_source_from_version_id(&candidate.version_id),
209
+ )));
210
+ }
211
+ }
212
+ }
213
+ }
214
+ Ok(None)
215
+ }
216
+
217
+ async fn load_commit_row(
218
+ &self,
219
+ store: &mut dyn StorageReader,
220
+ request: &LiveStateRowRequest,
221
+ ) -> Result<Option<LiveStateRow>, LixError> {
222
+ if !nullable_filter_matches(&request.file_id, &None) {
223
+ return Ok(None);
224
+ }
225
+ let Some(commit) = self
226
+ .commit_graph
227
+ .reader(store)
228
+ .load_commit(&request.entity_id.as_string()?)
229
+ .await?
230
+ else {
231
+ return Ok(None);
232
+ };
233
+ let row = live_state_row_from_commit(commit);
234
+ Ok(Some(visibility::project_loaded_row(
235
+ row,
236
+ &request.version_id,
237
+ GLOBAL_VERSION_ID,
238
+ )))
239
+ }
240
+ }
241
+
242
+ #[async_trait]
243
+ impl<S> LiveStateReader for LiveStateStoreReader<S>
244
+ where
245
+ S: StorageReader + Sync,
246
+ {
247
+ async fn scan_rows(
248
+ &self,
249
+ request: &LiveStateScanRequest,
250
+ ) -> Result<Vec<LiveStateRow>, LixError> {
251
+ LiveStateStoreReader::scan_rows(self, request).await
252
+ }
253
+
254
+ async fn load_row(
255
+ &self,
256
+ request: &LiveStateRowRequest,
257
+ ) -> Result<Option<LiveStateRow>, LixError> {
258
+ LiveStateStoreReader::load_row(self, request).await
259
+ }
260
+ }
261
+
262
+ /// Writer for visible live-state rows over a caller-provided KV reader.
263
+ pub(crate) struct LiveStateWriter<S> {
264
+ store: S,
265
+ tracked_state: TrackedStateContext,
266
+ untracked_state: UntrackedStateContext,
267
+ }
268
+
269
+ impl<S> LiveStateWriter<S>
270
+ where
271
+ S: StorageReader,
272
+ {
273
+ pub(crate) async fn stage_rows(
274
+ &mut self,
275
+ writes: &mut StorageWriteSet,
276
+ json_writer: &mut crate::json_store::JsonStoreWriter,
277
+ rows: &[LiveStateRow],
278
+ ) -> Result<(), LixError> {
279
+ let (tracked_rows, untracked_rows): (Vec<_>, Vec<_>) =
280
+ rows.iter().partition(|row| !row.untracked);
281
+
282
+ if !untracked_rows.is_empty() {
283
+ let untracked_rows = untracked_rows
284
+ .into_iter()
285
+ .map(MaterializedUntrackedStateRow::from)
286
+ .collect::<Vec<_>>();
287
+ let canonical_rows = untracked_rows
288
+ .iter()
289
+ .map(|row| canonicalize_materialized_row(writes, json_writer, row))
290
+ .collect::<Result<Vec<_>, _>>()?;
291
+ self.untracked_state
292
+ .writer(writes)
293
+ .stage_rows(&canonical_rows)?;
294
+ }
295
+
296
+ if tracked_rows.is_empty() {
297
+ return Ok(());
298
+ }
299
+
300
+ let identities = tracked_rows
301
+ .iter()
302
+ .map(|row| {
303
+ Ok(UntrackedStateIdentity {
304
+ version_id: row.version_id.clone(),
305
+ schema_key: row.schema_key.clone(),
306
+ entity_id: row.entity_id.clone(),
307
+ file_id: row.file_id.clone(),
308
+ })
309
+ })
310
+ .collect::<Result<Vec<_>, LixError>>()?;
311
+ self.untracked_state
312
+ .writer(writes)
313
+ .stage_delete_rows(&identities);
314
+
315
+ for (commit_id, rows) in grouped_live_rows_by_commit(&tracked_rows)? {
316
+ let parent_commit_id = parent_commit_id_for_commit_rows(commit_id, &rows)?;
317
+ validate_root_local_write_batch(commit_id, &rows)?;
318
+ // Commit graph facts live in the changelog/commit_graph projection.
319
+ // They are present in the write batch so the tracked root can inherit
320
+ // parent metadata, but they are not stored as version entities.
321
+ let root_rows = rows
322
+ .iter()
323
+ .filter(|row| row.schema_key != COMMIT_SCHEMA_KEY)
324
+ .map(|row| TrackedStateRow::try_from(*row))
325
+ .collect::<Result<Vec<_>, _>>()?;
326
+ self.tracked_state
327
+ .writer()
328
+ .stage_root(
329
+ &mut self.store,
330
+ writes,
331
+ json_writer,
332
+ commit_id,
333
+ parent_commit_id.as_deref(),
334
+ &root_rows,
335
+ )
336
+ .await?;
337
+ }
338
+
339
+ Ok(())
340
+ }
341
+ }
342
+
343
+ fn tracked_scan_request_from_live(request: &LiveStateScanRequest) -> TrackedStateScanRequest {
344
+ let mut columns = request.projection.columns.clone();
345
+ if !columns.is_empty() && !columns.iter().any(|column| column == "snapshot_content") {
346
+ columns.push("snapshot_content".to_string());
347
+ }
348
+
349
+ TrackedStateScanRequest {
350
+ filter: TrackedStateFilter {
351
+ schema_keys: request.filter.schema_keys.clone(),
352
+ entity_ids: request.filter.entity_ids.clone(),
353
+ file_ids: request.filter.file_ids.clone(),
354
+ // Scan tombstones internally so version-local tombstones can hide
355
+ // global fallback rows before the serving facade filters them.
356
+ include_tombstones: true,
357
+ },
358
+ projection: TrackedStateProjection { columns },
359
+ limit: None,
360
+ }
361
+ }
362
+
363
+ fn untracked_scan_request_from_live(
364
+ request: &LiveStateScanRequest,
365
+ version_ids: &[String],
366
+ ) -> UntrackedStateScanRequest {
367
+ let mut filter: crate::untracked_state::UntrackedStateFilter = request.filter.clone().into();
368
+ filter.version_ids = version_ids.to_vec();
369
+ UntrackedStateScanRequest {
370
+ filter,
371
+ projection: Default::default(),
372
+ limit: None,
373
+ }
374
+ }
375
+
376
+ #[derive(Debug, Clone, PartialEq, Eq)]
377
+ struct LiveStateScanScope {
378
+ storage_version_ids: Vec<String>,
379
+ projection_version_ids: Vec<String>,
380
+ includes_commit_graph_projection: bool,
381
+ }
382
+
383
+ async fn scan_scope(
384
+ store: &mut dyn StorageReader,
385
+ untracked_state: &UntrackedStateContext,
386
+ request: &LiveStateScanRequest,
387
+ ) -> Result<LiveStateScanScope, LixError> {
388
+ if request.filter.version_ids.is_empty() {
389
+ return Ok(LiveStateScanScope {
390
+ storage_version_ids: all_version_ref_ids(store, untracked_state).await?,
391
+ projection_version_ids: Vec::new(),
392
+ includes_commit_graph_projection: true,
393
+ });
394
+ }
395
+
396
+ let mut projection_version_ids = Vec::new();
397
+ for version_id in &request.filter.version_ids {
398
+ if version_ref_exists(store, untracked_state, version_id).await? {
399
+ projection_version_ids.push(version_id.clone());
400
+ }
401
+ }
402
+
403
+ let storage_version_ids = visibility::expanded_version_ids(&projection_version_ids);
404
+ Ok(LiveStateScanScope {
405
+ storage_version_ids,
406
+ includes_commit_graph_projection: !projection_version_ids.is_empty(),
407
+ projection_version_ids,
408
+ })
409
+ }
410
+
411
+ async fn all_version_ref_ids(
412
+ store: &mut dyn StorageReader,
413
+ untracked_state: &UntrackedStateContext,
414
+ ) -> Result<Vec<String>, LixError> {
415
+ let rows = untracked_state
416
+ .reader(store)
417
+ .scan_rows(&UntrackedStateScanRequest {
418
+ filter: crate::untracked_state::UntrackedStateFilter {
419
+ schema_keys: vec![VERSION_REF_SCHEMA_KEY.to_string()],
420
+ version_ids: vec![GLOBAL_VERSION_ID.to_string()],
421
+ ..Default::default()
422
+ },
423
+ ..Default::default()
424
+ })
425
+ .await?;
426
+ rows.into_iter()
427
+ .map(|row| row.entity_id.as_string())
428
+ .collect()
429
+ }
430
+
431
+ async fn load_version_ref_commit_id(
432
+ store: &mut dyn StorageReader,
433
+ untracked_state: &UntrackedStateContext,
434
+ version_id: &str,
435
+ ) -> Result<Option<String>, LixError> {
436
+ let Some(row) = untracked_state
437
+ .reader(store)
438
+ .load_row(&UntrackedStateRowRequest {
439
+ schema_key: VERSION_REF_SCHEMA_KEY.to_string(),
440
+ version_id: GLOBAL_VERSION_ID.to_string(),
441
+ entity_id: crate::entity_identity::EntityIdentity::single(version_id),
442
+ file_id: crate::NullableKeyFilter::Null,
443
+ })
444
+ .await?
445
+ else {
446
+ return Ok(None);
447
+ };
448
+ let Some(snapshot_content) = row.snapshot_content.as_deref() else {
449
+ return Ok(None);
450
+ };
451
+ let snapshot =
452
+ serde_json::from_str::<serde_json::Value>(snapshot_content).map_err(|error| {
453
+ LixError::new(
454
+ "LIX_ERROR_UNKNOWN",
455
+ format!("live_state version-ref snapshot parse failed: {error}"),
456
+ )
457
+ })?;
458
+ Ok(snapshot
459
+ .get("commit_id")
460
+ .and_then(serde_json::Value::as_str)
461
+ .map(str::to_string))
462
+ }
463
+
464
+ async fn version_ref_exists(
465
+ store: &mut dyn StorageReader,
466
+ untracked_state: &UntrackedStateContext,
467
+ version_id: &str,
468
+ ) -> Result<bool, LixError> {
469
+ Ok(
470
+ load_version_ref_commit_id(store, untracked_state, version_id)
471
+ .await?
472
+ .is_some(),
473
+ )
474
+ }
475
+
476
+ const COMMIT_SCHEMA_KEY: &str = "lix_commit";
477
+
478
+ fn live_state_row_from_commit(commit: CommitGraphCommit) -> LiveStateRow {
479
+ let change = commit.change;
480
+ LiveStateRow {
481
+ entity_id: change.entity_id,
482
+ schema_key: change.schema_key,
483
+ file_id: change.file_id,
484
+ snapshot_content: change.snapshot_content,
485
+ metadata: change.metadata,
486
+ schema_version: change.schema_version,
487
+ created_at: change.created_at.clone(),
488
+ updated_at: change.created_at,
489
+ global: true,
490
+ change_id: Some(change.id),
491
+ commit_id: Some(commit.commit_id),
492
+ untracked: false,
493
+ version_id: GLOBAL_VERSION_ID.to_string(),
494
+ }
495
+ }
496
+
497
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
498
+ enum TrackedRowSource {
499
+ Global,
500
+ Version,
501
+ }
502
+
503
+ fn tracked_source_from_version_id(version_id: &str) -> TrackedRowSource {
504
+ if version_id == GLOBAL_VERSION_ID {
505
+ TrackedRowSource::Global
506
+ } else {
507
+ TrackedRowSource::Version
508
+ }
509
+ }
510
+
511
+ fn project_tracked_row(
512
+ row: TrackedStateRow,
513
+ view_version_id: &str,
514
+ source: TrackedRowSource,
515
+ ) -> LiveStateRow {
516
+ LiveStateRow {
517
+ entity_id: row.entity_id,
518
+ schema_key: row.schema_key,
519
+ file_id: row.file_id,
520
+ snapshot_content: row.snapshot_content,
521
+ metadata: row.metadata,
522
+ schema_version: row.schema_version,
523
+ created_at: row.created_at,
524
+ updated_at: row.updated_at,
525
+ global: source == TrackedRowSource::Global,
526
+ change_id: Some(row.change_id),
527
+ commit_id: Some(row.commit_id),
528
+ untracked: false,
529
+ version_id: view_version_id.to_string(),
530
+ }
531
+ }
532
+
533
+ fn live_state_row_matches_filter(row: &LiveStateRow, filter: &LiveStateFilter) -> bool {
534
+ if !filter.schema_keys.is_empty() && !filter.schema_keys.contains(&row.schema_key) {
535
+ return false;
536
+ }
537
+ if !filter.entity_ids.is_empty() && !filter.entity_ids.contains(&row.entity_id) {
538
+ return false;
539
+ }
540
+ if !filter.file_ids.is_empty()
541
+ && !filter
542
+ .file_ids
543
+ .iter()
544
+ .any(|filter| nullable_filter_matches(filter, &row.file_id))
545
+ {
546
+ return false;
547
+ }
548
+ true
549
+ }
550
+
551
+ fn nullable_filter_matches(
552
+ filter: &crate::NullableKeyFilter<String>,
553
+ value: &Option<String>,
554
+ ) -> bool {
555
+ match filter {
556
+ crate::NullableKeyFilter::Any => true,
557
+ crate::NullableKeyFilter::Null => value.is_none(),
558
+ crate::NullableKeyFilter::Value(expected) => value.as_ref() == Some(expected),
559
+ }
560
+ }
561
+
562
+ fn grouped_live_rows_by_commit<'a>(
563
+ rows: &[&'a LiveStateRow],
564
+ ) -> Result<Vec<(&'a str, Vec<&'a LiveStateRow>)>, LixError> {
565
+ let mut grouped = Vec::<(&str, Vec<&LiveStateRow>)>::new();
566
+ for row in rows {
567
+ let commit_id = row.commit_id.as_deref().ok_or_else(|| {
568
+ LixError::new(
569
+ "LIX_ERROR_UNKNOWN",
570
+ "tracked live-state row is missing commit_id before tracked root write",
571
+ )
572
+ })?;
573
+ if let Some((_, bucket)) = grouped
574
+ .iter_mut()
575
+ .find(|(existing_commit_id, _)| *existing_commit_id == commit_id)
576
+ {
577
+ bucket.push(*row);
578
+ } else {
579
+ grouped.push((commit_id, vec![*row]));
580
+ }
581
+ }
582
+ Ok(grouped)
583
+ }
584
+
585
+ #[derive(Debug, Clone, PartialEq, Eq)]
586
+ struct RootWriteScope {
587
+ version_id: String,
588
+ global: bool,
589
+ }
590
+
591
+ fn validate_root_local_write_batch(
592
+ commit_id: &str,
593
+ rows: &[&LiveStateRow],
594
+ ) -> Result<(), LixError> {
595
+ let mut root_scope = None::<RootWriteScope>;
596
+ for row in rows
597
+ .iter()
598
+ .copied()
599
+ .filter(|row| row.schema_key != COMMIT_SCHEMA_KEY)
600
+ {
601
+ let scope = RootWriteScope {
602
+ version_id: row.version_id.clone(),
603
+ global: row.global,
604
+ };
605
+ if row.global != (row.version_id == GLOBAL_VERSION_ID) {
606
+ return Err(LixError::new(
607
+ "LIX_ERROR_UNKNOWN",
608
+ format!(
609
+ "tracked root write for commit '{commit_id}' has invalid storage scope: version_id='{}', global={}",
610
+ row.version_id, row.global
611
+ ),
612
+ ));
613
+ }
614
+ if let Some(existing) = &root_scope {
615
+ if existing != &scope {
616
+ return Err(LixError::new(
617
+ "LIX_ERROR_UNKNOWN",
618
+ format!(
619
+ "tracked root write for commit '{commit_id}' mixes multiple storage scopes"
620
+ ),
621
+ ));
622
+ }
623
+ } else {
624
+ root_scope = Some(scope);
625
+ }
626
+ }
627
+ Ok(())
628
+ }
629
+
630
+ fn parent_commit_id_for_commit_rows(
631
+ commit_id: &str,
632
+ rows: &[&LiveStateRow],
633
+ ) -> Result<Option<String>, LixError> {
634
+ let Some(row) = rows.iter().find(|row| {
635
+ row.schema_key == COMMIT_SCHEMA_KEY
636
+ && row
637
+ .entity_id
638
+ .as_string()
639
+ .is_ok_and(|entity_id| entity_id == commit_id)
640
+ }) else {
641
+ return Ok(None);
642
+ };
643
+ parent_commit_id_from_commit_row(row)
644
+ }
645
+
646
+ fn parent_commit_id_from_commit_row(row: &&LiveStateRow) -> Result<Option<String>, LixError> {
647
+ let snapshot = serde_json::from_str::<serde_json::Value>(
648
+ row.snapshot_content.as_deref().ok_or_else(|| {
649
+ LixError::new(
650
+ "LIX_ERROR_UNKNOWN",
651
+ "tracked root commit row is missing snapshot_content",
652
+ )
653
+ })?,
654
+ )
655
+ .map_err(|error| {
656
+ LixError::new(
657
+ "LIX_ERROR_UNKNOWN",
658
+ format!("tracked root commit snapshot parse failed: {error}"),
659
+ )
660
+ })?;
661
+ let Some(parent_commit_ids_value) = snapshot.get("parent_commit_ids") else {
662
+ return Err(LixError::new(
663
+ "LIX_ERROR_UNKNOWN",
664
+ "tracked root commit row is missing parent_commit_ids",
665
+ ));
666
+ };
667
+ let Some(parent_commit_ids_array) = parent_commit_ids_value.as_array() else {
668
+ return Err(LixError::new(
669
+ "LIX_ERROR_UNKNOWN",
670
+ "tracked root commit parent_commit_ids must be an array",
671
+ ));
672
+ };
673
+ let parent_commit_ids = parent_commit_ids_array
674
+ .iter()
675
+ .map(|value| {
676
+ value.as_str().map(str::to_string).ok_or_else(|| {
677
+ LixError::new(
678
+ "LIX_ERROR_UNKNOWN",
679
+ "tracked root commit parent_commit_ids must contain strings",
680
+ )
681
+ })
682
+ })
683
+ .collect::<Result<Vec<_>, _>>()?;
684
+
685
+ // Tracked roots inherit from the first parent. Merge commits record
686
+ // additional parents for graph ancestry, but the merge operation has
687
+ // already materialized the source-side rows as target-version writes.
688
+ Ok(parent_commit_ids.into_iter().next())
689
+ }
690
+
691
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
692
+ enum LiveStateLookupSource {
693
+ Untracked,
694
+ Tracked,
695
+ }
696
+
697
+ #[derive(Debug, Clone, PartialEq, Eq)]
698
+ struct LiveStateLookupCandidate {
699
+ source: LiveStateLookupSource,
700
+ version_id: String,
701
+ }
702
+
703
+ fn load_row_candidates(request: &LiveStateRowRequest) -> Vec<LiveStateLookupCandidate> {
704
+ let mut candidates = vec![
705
+ LiveStateLookupCandidate {
706
+ source: LiveStateLookupSource::Untracked,
707
+ version_id: request.version_id.clone(),
708
+ },
709
+ LiveStateLookupCandidate {
710
+ source: LiveStateLookupSource::Tracked,
711
+ version_id: request.version_id.clone(),
712
+ },
713
+ ];
714
+
715
+ if request.version_id != GLOBAL_VERSION_ID {
716
+ candidates.extend([
717
+ LiveStateLookupCandidate {
718
+ source: LiveStateLookupSource::Untracked,
719
+ version_id: GLOBAL_VERSION_ID.to_string(),
720
+ },
721
+ LiveStateLookupCandidate {
722
+ source: LiveStateLookupSource::Tracked,
723
+ version_id: GLOBAL_VERSION_ID.to_string(),
724
+ },
725
+ ]);
726
+ }
727
+
728
+ candidates
729
+ }
730
+
731
+ fn untracked_row_request_from_live(
732
+ request: &LiveStateRowRequest,
733
+ version_id: &str,
734
+ ) -> crate::untracked_state::UntrackedStateRowRequest {
735
+ crate::untracked_state::UntrackedStateRowRequest {
736
+ schema_key: request.schema_key.clone(),
737
+ version_id: version_id.to_string(),
738
+ entity_id: request.entity_id.clone(),
739
+ file_id: request.file_id.clone(),
740
+ }
741
+ }
742
+
743
+ fn tracked_row_request_from_live(request: &LiveStateRowRequest) -> TrackedStateRowRequest {
744
+ TrackedStateRowRequest {
745
+ schema_key: request.schema_key.clone(),
746
+ entity_id: request.entity_id.clone(),
747
+ file_id: request.file_id.clone(),
748
+ }
749
+ }
750
+
751
+ #[cfg(test)]
752
+ mod tests {
753
+ use std::sync::Arc;
754
+
755
+ use super::*;
756
+ use crate::backend::{testing::UnitTestBackend, Backend};
757
+ use crate::changelog::{canonicalize_materialized_change, MaterializedCanonicalChange};
758
+ use crate::entity_identity::EntityIdentity;
759
+ use crate::json_store::JsonStoreContext;
760
+ use crate::live_state::LiveStateFilter;
761
+ use crate::storage::{StorageContext, StorageWriteTransaction};
762
+ use crate::tracked_state::TrackedStateScanRequest;
763
+ use crate::untracked_state::{MaterializedUntrackedStateRow, UntrackedStateContext};
764
+ use crate::NullableKeyFilter;
765
+ use serde_json::json;
766
+
767
+ fn live_state_context() -> LiveStateContext {
768
+ LiveStateContext::new(
769
+ crate::tracked_state::TrackedStateContext::new(),
770
+ crate::untracked_state::UntrackedStateContext::new(),
771
+ crate::commit_graph::CommitGraphContext::new(crate::changelog::ChangelogContext::new()),
772
+ )
773
+ }
774
+
775
+ async fn write_untracked_rows_to_store(
776
+ store: &mut (impl StorageWriteTransaction + ?Sized),
777
+ rows: &[MaterializedUntrackedStateRow],
778
+ ) {
779
+ let mut writes = StorageWriteSet::new();
780
+ let canonical_rows = {
781
+ let mut json_writer = JsonStoreContext::new().writer();
782
+ rows.iter()
783
+ .map(|row| canonicalize_materialized_row(&mut writes, &mut json_writer, row))
784
+ .collect::<Result<Vec<_>, _>>()
785
+ .expect("untracked rows should canonicalize")
786
+ };
787
+ UntrackedStateContext::new()
788
+ .writer(&mut writes)
789
+ .stage_rows(&canonical_rows)
790
+ .expect("untracked rows should write");
791
+ writes
792
+ .apply(store)
793
+ .await
794
+ .expect("untracked rows should apply");
795
+ }
796
+
797
+ #[tokio::test]
798
+ async fn live_state_overlays_untracked_rows() {
799
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
800
+ let storage = StorageContext::new(Arc::clone(&backend));
801
+ let live_state = live_state_context();
802
+
803
+ let mut transaction = storage
804
+ .begin_write_transaction()
805
+ .await
806
+ .expect("transaction should open");
807
+ {
808
+ let mut writes = StorageWriteSet::new();
809
+ let mut json_writer = JsonStoreContext::new().writer();
810
+ {
811
+ let mut writer = live_state.writer(transaction.as_mut());
812
+ writer
813
+ .stage_rows(
814
+ &mut writes,
815
+ &mut json_writer,
816
+ &[tracked_row_with_commit(
817
+ "tracked-value",
818
+ Some("change-tracked"),
819
+ "commit-tracked",
820
+ )],
821
+ )
822
+ .await
823
+ .expect("tracked row should stage");
824
+ }
825
+ writes
826
+ .apply(&mut transaction.as_mut())
827
+ .await
828
+ .expect("tracked row should apply");
829
+ }
830
+ write_untracked_rows_to_store(
831
+ transaction.as_mut(),
832
+ &[
833
+ version_ref_row("global", "commit-tracked"),
834
+ untracked_row("untracked-value"),
835
+ ],
836
+ )
837
+ .await;
838
+ transaction.commit().await.expect("commit should persist");
839
+
840
+ let rows = scan_selected_tab_at(&live_state, storage.clone(), "global", false)
841
+ .await
842
+ .expect("scan should succeed");
843
+ assert_eq!(rows.len(), 1);
844
+ assert_eq!(
845
+ rows[0].snapshot_content.as_deref(),
846
+ Some("{\"value\":\"untracked-value\"}")
847
+ );
848
+ assert!(rows[0].untracked);
849
+ assert_eq!(rows[0].change_id, None);
850
+
851
+ let loaded = live_state
852
+ .reader(storage.clone())
853
+ .load_row(&LiveStateRowRequest {
854
+ schema_key: "lix_key_value".to_string(),
855
+ version_id: "global".to_string(),
856
+ entity_id: crate::entity_identity::EntityIdentity::single("selected-tab"),
857
+ file_id: NullableKeyFilter::Null,
858
+ })
859
+ .await
860
+ .expect("load should succeed")
861
+ .expect("overlay row should be visible");
862
+ assert!(loaded.untracked);
863
+ assert_eq!(
864
+ loaded.snapshot_content.as_deref(),
865
+ Some("{\"value\":\"untracked-value\"}")
866
+ );
867
+ }
868
+
869
+ #[tokio::test]
870
+ async fn tracked_row_is_visible_without_untracked_overlay() {
871
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
872
+ let storage = StorageContext::new(Arc::clone(&backend));
873
+ let live_state = live_state_context();
874
+
875
+ let mut transaction = storage
876
+ .begin_write_transaction()
877
+ .await
878
+ .expect("transaction should open");
879
+ {
880
+ let mut writes = StorageWriteSet::new();
881
+ let mut json_writer = JsonStoreContext::new().writer();
882
+ {
883
+ let mut writer = live_state.writer(transaction.as_mut());
884
+ writer
885
+ .stage_rows(
886
+ &mut writes,
887
+ &mut json_writer,
888
+ &[tracked_row_with_commit(
889
+ "tracked-value",
890
+ Some("change-tracked"),
891
+ "commit-tracked",
892
+ )],
893
+ )
894
+ .await
895
+ .expect("tracked row should stage");
896
+ }
897
+ writes
898
+ .apply(&mut transaction.as_mut())
899
+ .await
900
+ .expect("tracked row should apply");
901
+ }
902
+ write_untracked_rows_to_store(
903
+ transaction.as_mut(),
904
+ &[version_ref_row("global", "commit-tracked")],
905
+ )
906
+ .await;
907
+ transaction.commit().await.expect("commit should persist");
908
+
909
+ let loaded = load_selected_tab(&live_state, storage.clone())
910
+ .await
911
+ .expect("load should succeed")
912
+ .expect("tracked row should be visible");
913
+ assert!(!loaded.untracked);
914
+ assert_eq!(loaded.change_id.as_deref(), Some("change-tracked"));
915
+ assert_eq!(
916
+ loaded.snapshot_content.as_deref(),
917
+ Some("{\"value\":\"tracked-value\"}")
918
+ );
919
+ }
920
+
921
+ #[tokio::test]
922
+ async fn deleting_untracked_row_reveals_tracked_row() {
923
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
924
+ let storage = StorageContext::new(Arc::clone(&backend));
925
+ let live_state = live_state_context();
926
+
927
+ let mut transaction = storage
928
+ .begin_write_transaction()
929
+ .await
930
+ .expect("transaction should open");
931
+ {
932
+ let mut writes = StorageWriteSet::new();
933
+ let mut json_writer = JsonStoreContext::new().writer();
934
+ {
935
+ let mut writer = live_state.writer(transaction.as_mut());
936
+ writer
937
+ .stage_rows(
938
+ &mut writes,
939
+ &mut json_writer,
940
+ &[tracked_row_with_commit(
941
+ "tracked-value",
942
+ Some("change-tracked"),
943
+ "commit-tracked",
944
+ )],
945
+ )
946
+ .await
947
+ .expect("tracked row should stage");
948
+ }
949
+ writes
950
+ .apply(&mut transaction.as_mut())
951
+ .await
952
+ .expect("tracked row should apply");
953
+ }
954
+ write_untracked_rows_to_store(
955
+ transaction.as_mut(),
956
+ &[
957
+ version_ref_row("global", "commit-tracked"),
958
+ untracked_row("untracked-value"),
959
+ ],
960
+ )
961
+ .await;
962
+ {
963
+ let mut writes = StorageWriteSet::new();
964
+ UntrackedStateContext::new()
965
+ .writer(&mut writes)
966
+ .stage_delete_rows(&[crate::untracked_state::UntrackedStateIdentity {
967
+ version_id: "global".to_string(),
968
+ schema_key: "lix_key_value".to_string(),
969
+ entity_id: EntityIdentity::single("selected-tab"),
970
+ file_id: None,
971
+ }]);
972
+ writes
973
+ .apply(&mut transaction.as_mut())
974
+ .await
975
+ .expect("untracked row should delete");
976
+ }
977
+ transaction.commit().await.expect("commit should persist");
978
+
979
+ let loaded = load_selected_tab(&live_state, storage.clone())
980
+ .await
981
+ .expect("load should succeed")
982
+ .expect("tracked row should be visible again");
983
+ assert!(!loaded.untracked);
984
+ assert_eq!(loaded.change_id.as_deref(), Some("change-tracked"));
985
+ assert_eq!(
986
+ loaded.snapshot_content.as_deref(),
987
+ Some("{\"value\":\"tracked-value\"}")
988
+ );
989
+ }
990
+
991
+ #[tokio::test]
992
+ async fn load_row_falls_back_to_global_tracked_row_for_requested_version() {
993
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
994
+ let storage = StorageContext::new(Arc::clone(&backend));
995
+ let live_state = live_state_context();
996
+
997
+ let mut transaction = storage
998
+ .begin_write_transaction()
999
+ .await
1000
+ .expect("transaction should open");
1001
+ {
1002
+ let rows = [tracked_row_with_commit(
1003
+ "global-tracked",
1004
+ Some("change-global"),
1005
+ "commit-global",
1006
+ )];
1007
+ let mut writes = StorageWriteSet::new();
1008
+ let mut json_writer = JsonStoreContext::new().writer();
1009
+ {
1010
+ let mut writer = live_state.writer(transaction.as_mut());
1011
+ writer
1012
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1013
+ .await
1014
+ .expect("tracked row should stage");
1015
+ }
1016
+ writes
1017
+ .apply(&mut transaction.as_mut())
1018
+ .await
1019
+ .expect("tracked row should apply");
1020
+ }
1021
+ write_untracked_rows_to_store(
1022
+ transaction.as_mut(),
1023
+ &[
1024
+ version_ref_row("global", "commit-global"),
1025
+ version_ref_row("version-a", "commit-version-a"),
1026
+ ],
1027
+ )
1028
+ .await;
1029
+ transaction.commit().await.expect("commit should persist");
1030
+
1031
+ let loaded = load_selected_tab_at(&live_state, storage.clone(), "version-a")
1032
+ .await
1033
+ .expect("load should succeed")
1034
+ .expect("global row should be visible for requested version");
1035
+
1036
+ assert_eq!(loaded.version_id, "version-a");
1037
+ assert!(loaded.global);
1038
+ assert!(!loaded.untracked);
1039
+ assert_eq!(
1040
+ loaded.snapshot_content.as_deref(),
1041
+ Some("{\"value\":\"global-tracked\"}")
1042
+ );
1043
+ }
1044
+
1045
+ #[tokio::test]
1046
+ async fn main_sees_global_row_by_reading_global_root_separately() {
1047
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1048
+ let storage = StorageContext::new(Arc::clone(&backend));
1049
+ let tracked_state = TrackedStateContext::new();
1050
+ let live_state = LiveStateContext::new(
1051
+ tracked_state.clone(),
1052
+ UntrackedStateContext::new(),
1053
+ crate::commit_graph::CommitGraphContext::new(crate::changelog::ChangelogContext::new()),
1054
+ );
1055
+
1056
+ let mut transaction = storage
1057
+ .begin_write_transaction()
1058
+ .await
1059
+ .expect("transaction should open");
1060
+ {
1061
+ let rows = [tracked_row_with_commit(
1062
+ "global-tracked",
1063
+ Some("change-global"),
1064
+ "commit-global",
1065
+ )];
1066
+ let mut writes = StorageWriteSet::new();
1067
+ let mut json_writer = JsonStoreContext::new().writer();
1068
+ {
1069
+ let mut writer = live_state.writer(transaction.as_mut());
1070
+ writer
1071
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1072
+ .await
1073
+ .expect("global tracked row should stage");
1074
+ }
1075
+ writes
1076
+ .apply(&mut transaction.as_mut())
1077
+ .await
1078
+ .expect("global tracked row should apply");
1079
+ }
1080
+ write_untracked_rows_to_store(
1081
+ transaction.as_mut(),
1082
+ &[
1083
+ version_ref_row("global", "commit-global"),
1084
+ version_ref_row("main", "commit-main"),
1085
+ ],
1086
+ )
1087
+ .await;
1088
+ transaction.commit().await.expect("commit should persist");
1089
+
1090
+ let loaded = load_selected_tab_at(&live_state, storage.clone(), "main")
1091
+ .await
1092
+ .expect("load should succeed")
1093
+ .expect("global row should be projected into main");
1094
+ assert_eq!(loaded.version_id, "main");
1095
+ assert!(loaded.global);
1096
+ assert_eq!(
1097
+ loaded.snapshot_content.as_deref(),
1098
+ Some("{\"value\":\"global-tracked\"}")
1099
+ );
1100
+
1101
+ let main_root_rows =
1102
+ scan_tracked_root(&tracked_state, storage.clone(), "commit-main").await;
1103
+ assert_eq!(
1104
+ main_root_rows.len(),
1105
+ 0,
1106
+ "global fallback must come from the global root, not a copied main root row"
1107
+ );
1108
+ }
1109
+
1110
+ #[tokio::test]
1111
+ async fn load_row_prefers_requested_version_over_global() {
1112
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1113
+ let storage = StorageContext::new(Arc::clone(&backend));
1114
+ let live_state = live_state_context();
1115
+
1116
+ let mut transaction = storage
1117
+ .begin_write_transaction()
1118
+ .await
1119
+ .expect("transaction should open");
1120
+ {
1121
+ let rows = [
1122
+ tracked_row_with_commit("global-tracked", Some("change-global"), "commit-global"),
1123
+ tracked_row_at_with_commit(
1124
+ "version-a",
1125
+ "version-tracked",
1126
+ Some("change-version"),
1127
+ "commit-version",
1128
+ ),
1129
+ ];
1130
+ let mut writes = StorageWriteSet::new();
1131
+ let mut json_writer = JsonStoreContext::new().writer();
1132
+ {
1133
+ let mut writer = live_state.writer(transaction.as_mut());
1134
+ writer
1135
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1136
+ .await
1137
+ .expect("tracked rows should stage");
1138
+ }
1139
+ writes
1140
+ .apply(&mut transaction.as_mut())
1141
+ .await
1142
+ .expect("tracked rows should apply");
1143
+ }
1144
+ write_untracked_rows_to_store(
1145
+ transaction.as_mut(),
1146
+ &[
1147
+ version_ref_row("global", "commit-global"),
1148
+ version_ref_row("version-a", "commit-version"),
1149
+ ],
1150
+ )
1151
+ .await;
1152
+ transaction.commit().await.expect("commit should persist");
1153
+
1154
+ let loaded = load_selected_tab_at(&live_state, storage.clone(), "version-a")
1155
+ .await
1156
+ .expect("load should succeed")
1157
+ .expect("version row should be visible");
1158
+
1159
+ assert_eq!(loaded.version_id, "version-a");
1160
+ assert!(!loaded.untracked);
1161
+ assert_eq!(
1162
+ loaded.snapshot_content.as_deref(),
1163
+ Some("{\"value\":\"version-tracked\"}")
1164
+ );
1165
+ }
1166
+
1167
+ #[tokio::test]
1168
+ async fn main_override_hides_global_row() {
1169
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1170
+ let storage = StorageContext::new(Arc::clone(&backend));
1171
+ let live_state = live_state_context();
1172
+
1173
+ let mut transaction = storage
1174
+ .begin_write_transaction()
1175
+ .await
1176
+ .expect("transaction should open");
1177
+ {
1178
+ let rows = [
1179
+ tracked_row_with_commit("global-tracked", Some("change-global"), "commit-global"),
1180
+ tracked_row_at_with_commit(
1181
+ "main",
1182
+ "main-tracked",
1183
+ Some("change-main"),
1184
+ "commit-main",
1185
+ ),
1186
+ ];
1187
+ let mut writes = StorageWriteSet::new();
1188
+ let mut json_writer = JsonStoreContext::new().writer();
1189
+ {
1190
+ let mut writer = live_state.writer(transaction.as_mut());
1191
+ writer
1192
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1193
+ .await
1194
+ .expect("tracked rows should stage");
1195
+ }
1196
+ writes
1197
+ .apply(&mut transaction.as_mut())
1198
+ .await
1199
+ .expect("tracked rows should apply");
1200
+ }
1201
+ write_untracked_rows_to_store(
1202
+ transaction.as_mut(),
1203
+ &[
1204
+ version_ref_row("global", "commit-global"),
1205
+ version_ref_row("main", "commit-main"),
1206
+ ],
1207
+ )
1208
+ .await;
1209
+ transaction.commit().await.expect("commit should persist");
1210
+
1211
+ let loaded = load_selected_tab_at(&live_state, storage.clone(), "main")
1212
+ .await
1213
+ .expect("load should succeed")
1214
+ .expect("main row should be visible");
1215
+
1216
+ assert_eq!(loaded.version_id, "main");
1217
+ assert!(!loaded.global);
1218
+ assert_eq!(
1219
+ loaded.snapshot_content.as_deref(),
1220
+ Some("{\"value\":\"main-tracked\"}")
1221
+ );
1222
+ }
1223
+
1224
+ #[tokio::test]
1225
+ async fn load_row_prefers_requested_untracked_over_requested_tracked_and_global_rows() {
1226
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1227
+ let storage = StorageContext::new(Arc::clone(&backend));
1228
+ let live_state = live_state_context();
1229
+
1230
+ let mut transaction = storage
1231
+ .begin_write_transaction()
1232
+ .await
1233
+ .expect("transaction should open");
1234
+ {
1235
+ let rows = [
1236
+ tracked_row_with_commit("global-tracked", Some("change-global"), "commit-global"),
1237
+ tracked_row_at_with_commit(
1238
+ "version-a",
1239
+ "version-tracked",
1240
+ Some("change-version"),
1241
+ "commit-version",
1242
+ ),
1243
+ ];
1244
+ let mut writes = StorageWriteSet::new();
1245
+ let mut json_writer = JsonStoreContext::new().writer();
1246
+ {
1247
+ let mut writer = live_state.writer(transaction.as_mut());
1248
+ writer
1249
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1250
+ .await
1251
+ .expect("tracked rows should stage");
1252
+ }
1253
+ writes
1254
+ .apply(&mut transaction.as_mut())
1255
+ .await
1256
+ .expect("tracked rows should apply");
1257
+ }
1258
+ write_untracked_rows_to_store(
1259
+ transaction.as_mut(),
1260
+ &[
1261
+ version_ref_row("global", "commit-global"),
1262
+ version_ref_row("version-a", "commit-version"),
1263
+ untracked_row_at("global", "global-untracked"),
1264
+ untracked_row_at("version-a", "version-untracked"),
1265
+ ],
1266
+ )
1267
+ .await;
1268
+ transaction.commit().await.expect("commit should persist");
1269
+
1270
+ let loaded = load_selected_tab_at(&live_state, storage.clone(), "version-a")
1271
+ .await
1272
+ .expect("load should succeed")
1273
+ .expect("version untracked row should be visible");
1274
+
1275
+ assert_eq!(loaded.version_id, "version-a");
1276
+ assert!(loaded.untracked);
1277
+ assert_eq!(
1278
+ loaded.snapshot_content.as_deref(),
1279
+ Some("{\"value\":\"version-untracked\"}")
1280
+ );
1281
+ }
1282
+
1283
+ #[tokio::test]
1284
+ async fn scan_rows_overlays_requested_version_over_global() {
1285
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1286
+ let storage = StorageContext::new(Arc::clone(&backend));
1287
+ let live_state = live_state_context();
1288
+
1289
+ let mut transaction = storage
1290
+ .begin_write_transaction()
1291
+ .await
1292
+ .expect("transaction should open");
1293
+ {
1294
+ let rows = [
1295
+ tracked_row_with_commit("global-tracked", Some("change-global"), "commit-global"),
1296
+ tracked_row_at_with_commit(
1297
+ "version-a",
1298
+ "version-tracked",
1299
+ Some("change-version"),
1300
+ "commit-version",
1301
+ ),
1302
+ ];
1303
+ let mut writes = StorageWriteSet::new();
1304
+ let mut json_writer = JsonStoreContext::new().writer();
1305
+ {
1306
+ let mut writer = live_state.writer(transaction.as_mut());
1307
+ writer
1308
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1309
+ .await
1310
+ .expect("rows should stage");
1311
+ }
1312
+ writes
1313
+ .apply(&mut transaction.as_mut())
1314
+ .await
1315
+ .expect("rows should apply");
1316
+ }
1317
+ write_untracked_rows_to_store(
1318
+ transaction.as_mut(),
1319
+ &[
1320
+ version_ref_row("global", "commit-global"),
1321
+ version_ref_row("version-a", "commit-version"),
1322
+ ],
1323
+ )
1324
+ .await;
1325
+ transaction.commit().await.expect("commit should persist");
1326
+
1327
+ let rows = scan_selected_tab_at(&live_state, storage.clone(), "version-a", false)
1328
+ .await
1329
+ .expect("scan should succeed");
1330
+
1331
+ assert_eq!(rows.len(), 1);
1332
+ assert_eq!(rows[0].version_id, "version-a");
1333
+ assert_eq!(
1334
+ rows[0].snapshot_content.as_deref(),
1335
+ Some("{\"value\":\"version-tracked\"}")
1336
+ );
1337
+ }
1338
+
1339
+ #[tokio::test]
1340
+ async fn scan_rows_projects_global_row_into_requested_version() {
1341
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1342
+ let storage = StorageContext::new(Arc::clone(&backend));
1343
+ let live_state = live_state_context();
1344
+
1345
+ let mut transaction = storage
1346
+ .begin_write_transaction()
1347
+ .await
1348
+ .expect("transaction should open");
1349
+ {
1350
+ let rows = [tracked_row_with_commit(
1351
+ "global-tracked",
1352
+ Some("change-global"),
1353
+ "commit-global",
1354
+ )];
1355
+ let mut writes = StorageWriteSet::new();
1356
+ let mut json_writer = JsonStoreContext::new().writer();
1357
+ {
1358
+ let mut writer = live_state.writer(transaction.as_mut());
1359
+ writer
1360
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1361
+ .await
1362
+ .expect("rows should stage");
1363
+ }
1364
+ writes
1365
+ .apply(&mut transaction.as_mut())
1366
+ .await
1367
+ .expect("rows should apply");
1368
+ }
1369
+ write_untracked_rows_to_store(
1370
+ transaction.as_mut(),
1371
+ &[
1372
+ version_ref_row("global", "commit-global"),
1373
+ version_ref_row("version-a", "commit-version-a"),
1374
+ ],
1375
+ )
1376
+ .await;
1377
+ transaction.commit().await.expect("commit should persist");
1378
+
1379
+ let rows = scan_selected_tab_at(&live_state, storage.clone(), "version-a", false)
1380
+ .await
1381
+ .expect("scan should succeed");
1382
+
1383
+ assert_eq!(rows.len(), 1);
1384
+ assert_eq!(rows[0].version_id, "version-a");
1385
+ assert!(rows[0].global);
1386
+ assert_eq!(
1387
+ rows[0].snapshot_content.as_deref(),
1388
+ Some("{\"value\":\"global-tracked\"}")
1389
+ );
1390
+ }
1391
+
1392
+ #[tokio::test]
1393
+ async fn scan_rows_does_not_project_global_rows_into_missing_version() {
1394
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1395
+ let storage = StorageContext::new(Arc::clone(&backend));
1396
+ let live_state = live_state_context();
1397
+
1398
+ let mut transaction = storage
1399
+ .begin_write_transaction()
1400
+ .await
1401
+ .expect("transaction should open");
1402
+ {
1403
+ let rows = [tracked_row_with_commit(
1404
+ "global-tracked",
1405
+ Some("change-global"),
1406
+ "commit-global",
1407
+ )];
1408
+ let mut writes = StorageWriteSet::new();
1409
+ let mut json_writer = JsonStoreContext::new().writer();
1410
+ {
1411
+ let mut writer = live_state.writer(transaction.as_mut());
1412
+ writer
1413
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1414
+ .await
1415
+ .expect("tracked row should stage");
1416
+ }
1417
+ writes
1418
+ .apply(&mut transaction.as_mut())
1419
+ .await
1420
+ .expect("tracked row should apply");
1421
+ }
1422
+ write_untracked_rows_to_store(
1423
+ transaction.as_mut(),
1424
+ &[version_ref_row("global", "commit-global")],
1425
+ )
1426
+ .await;
1427
+ transaction.commit().await.expect("commit should persist");
1428
+
1429
+ let rows = scan_selected_tab_at(&live_state, storage.clone(), "missing-version", false)
1430
+ .await
1431
+ .expect("scan should succeed");
1432
+
1433
+ assert_eq!(
1434
+ rows.len(),
1435
+ 0,
1436
+ "global rows must not be projected into a missing version scope"
1437
+ );
1438
+ }
1439
+
1440
+ #[tokio::test]
1441
+ async fn winning_tombstone_hides_row_unless_tombstones_are_included() {
1442
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1443
+ let storage = StorageContext::new(Arc::clone(&backend));
1444
+ let live_state = live_state_context();
1445
+
1446
+ let mut transaction = storage
1447
+ .begin_write_transaction()
1448
+ .await
1449
+ .expect("transaction should open");
1450
+ {
1451
+ let rows = [
1452
+ tracked_row_with_commit("global-tracked", Some("change-global"), "commit-global"),
1453
+ tombstone_tracked_row_at_with_commit(
1454
+ "version-a",
1455
+ Some("change-tombstone"),
1456
+ "commit-version",
1457
+ ),
1458
+ ];
1459
+ let mut writes = StorageWriteSet::new();
1460
+ let mut json_writer = JsonStoreContext::new().writer();
1461
+ {
1462
+ let mut writer = live_state.writer(transaction.as_mut());
1463
+ writer
1464
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1465
+ .await
1466
+ .expect("rows should stage");
1467
+ }
1468
+ writes
1469
+ .apply(&mut transaction.as_mut())
1470
+ .await
1471
+ .expect("rows should apply");
1472
+ }
1473
+ write_untracked_rows_to_store(
1474
+ transaction.as_mut(),
1475
+ &[
1476
+ version_ref_row("global", "commit-global"),
1477
+ version_ref_row("version-a", "commit-version"),
1478
+ ],
1479
+ )
1480
+ .await;
1481
+ transaction.commit().await.expect("commit should persist");
1482
+
1483
+ let hidden = scan_selected_tab_at(&live_state, storage.clone(), "version-a", false)
1484
+ .await
1485
+ .expect("scan should succeed");
1486
+ assert_eq!(hidden.len(), 0);
1487
+
1488
+ let with_tombstone = scan_selected_tab_at(&live_state, storage.clone(), "version-a", true)
1489
+ .await
1490
+ .expect("scan should succeed");
1491
+ assert_eq!(with_tombstone.len(), 1);
1492
+ assert_eq!(with_tombstone[0].version_id, "version-a");
1493
+ assert_eq!(with_tombstone[0].snapshot_content, None);
1494
+ }
1495
+
1496
+ #[tokio::test]
1497
+ async fn main_tombstone_hides_global_row() {
1498
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1499
+ let storage = StorageContext::new(Arc::clone(&backend));
1500
+ let live_state = live_state_context();
1501
+
1502
+ let mut transaction = storage
1503
+ .begin_write_transaction()
1504
+ .await
1505
+ .expect("transaction should open");
1506
+ {
1507
+ let rows = [
1508
+ tracked_row_with_commit("global-tracked", Some("change-global"), "commit-global"),
1509
+ tombstone_tracked_row_at_with_commit(
1510
+ "main",
1511
+ Some("change-main-tombstone"),
1512
+ "commit-main",
1513
+ ),
1514
+ ];
1515
+ let mut writes = StorageWriteSet::new();
1516
+ let mut json_writer = JsonStoreContext::new().writer();
1517
+ {
1518
+ let mut writer = live_state.writer(transaction.as_mut());
1519
+ writer
1520
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1521
+ .await
1522
+ .expect("tracked rows should stage");
1523
+ }
1524
+ writes
1525
+ .apply(&mut transaction.as_mut())
1526
+ .await
1527
+ .expect("tracked rows should apply");
1528
+ }
1529
+ write_untracked_rows_to_store(
1530
+ transaction.as_mut(),
1531
+ &[
1532
+ version_ref_row("global", "commit-global"),
1533
+ version_ref_row("main", "commit-main"),
1534
+ ],
1535
+ )
1536
+ .await;
1537
+ transaction.commit().await.expect("commit should persist");
1538
+
1539
+ let hidden = scan_selected_tab_at(&live_state, storage.clone(), "main", false)
1540
+ .await
1541
+ .expect("scan should succeed");
1542
+ assert_eq!(hidden.len(), 0);
1543
+
1544
+ let tombstones = scan_selected_tab_at(&live_state, storage.clone(), "main", true)
1545
+ .await
1546
+ .expect("scan should succeed");
1547
+ assert_eq!(tombstones.len(), 1);
1548
+ assert_eq!(tombstones[0].version_id, "main");
1549
+ assert!(!tombstones[0].global);
1550
+ assert_eq!(tombstones[0].snapshot_content, None);
1551
+ }
1552
+
1553
+ #[tokio::test]
1554
+ async fn scan_rows_projects_commit_graph_facts_as_global_rows() {
1555
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1556
+ let storage = StorageContext::new(Arc::clone(&backend));
1557
+ let live_state = live_state_context();
1558
+ append_commit_change(storage.clone(), "commit-a").await;
1559
+ write_version_refs(storage.clone(), &[version_ref_row("version-a", "commit-a")]).await;
1560
+
1561
+ let rows = live_state
1562
+ .reader(storage.clone())
1563
+ .scan_rows(&LiveStateScanRequest {
1564
+ filter: LiveStateFilter {
1565
+ schema_keys: vec![COMMIT_SCHEMA_KEY.to_string()],
1566
+ version_ids: vec!["version-a".to_string()],
1567
+ ..LiveStateFilter::default()
1568
+ },
1569
+ ..LiveStateScanRequest::default()
1570
+ })
1571
+ .await
1572
+ .expect("commit rows should scan");
1573
+
1574
+ assert_eq!(rows.len(), 1);
1575
+ assert_eq!(rows[0].entity_id.as_string().as_deref(), Ok("commit-a"));
1576
+ assert_eq!(rows[0].schema_key, COMMIT_SCHEMA_KEY);
1577
+ assert_eq!(rows[0].version_id, "version-a");
1578
+ assert!(rows[0].global);
1579
+ assert!(!rows[0].untracked);
1580
+ assert_eq!(rows[0].change_id.as_deref(), Some("change-commit-a"));
1581
+ assert_eq!(rows[0].commit_id.as_deref(), Some("commit-a"));
1582
+ }
1583
+
1584
+ #[tokio::test]
1585
+ async fn load_row_reads_commit_graph_fact() {
1586
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1587
+ let storage = StorageContext::new(Arc::clone(&backend));
1588
+ let live_state = live_state_context();
1589
+ append_commit_change(storage.clone(), "commit-a").await;
1590
+ write_version_refs(storage.clone(), &[version_ref_row("version-a", "commit-a")]).await;
1591
+
1592
+ let row = live_state
1593
+ .reader(storage.clone())
1594
+ .load_row(&LiveStateRowRequest {
1595
+ schema_key: COMMIT_SCHEMA_KEY.to_string(),
1596
+ version_id: "version-a".to_string(),
1597
+ entity_id: crate::entity_identity::EntityIdentity::single("commit-a"),
1598
+ file_id: NullableKeyFilter::Null,
1599
+ })
1600
+ .await
1601
+ .expect("commit row should load")
1602
+ .expect("commit row should exist");
1603
+
1604
+ assert_eq!(row.entity_id.as_string().as_deref(), Ok("commit-a"));
1605
+ assert_eq!(row.version_id, "version-a");
1606
+ assert!(row.global);
1607
+ assert_eq!(row.change_id.as_deref(), Some("change-commit-a"));
1608
+ assert_eq!(row.commit_id.as_deref(), Some("commit-a"));
1609
+ }
1610
+
1611
+ #[tokio::test]
1612
+ async fn load_commit_row_does_not_project_into_missing_version() {
1613
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1614
+ let storage = StorageContext::new(Arc::clone(&backend));
1615
+ let live_state = live_state_context();
1616
+ append_commit_change(storage.clone(), "commit-a").await;
1617
+
1618
+ let row = live_state
1619
+ .reader(storage.clone())
1620
+ .load_row(&LiveStateRowRequest {
1621
+ schema_key: COMMIT_SCHEMA_KEY.to_string(),
1622
+ version_id: "missing-version".to_string(),
1623
+ entity_id: crate::entity_identity::EntityIdentity::single("commit-a"),
1624
+ file_id: NullableKeyFilter::Null,
1625
+ })
1626
+ .await
1627
+ .expect("commit row load should succeed");
1628
+
1629
+ assert_eq!(
1630
+ row, None,
1631
+ "commit rows must not be projected into a missing version scope"
1632
+ );
1633
+ }
1634
+
1635
+ #[tokio::test]
1636
+ async fn writer_rejects_tracked_root_batches_that_mix_global_and_version_rows() {
1637
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1638
+ let storage = StorageContext::new(Arc::clone(&backend));
1639
+ let live_state = live_state_context();
1640
+ let mut transaction = storage
1641
+ .begin_write_transaction()
1642
+ .await
1643
+ .expect("transaction should open");
1644
+
1645
+ let error = {
1646
+ let rows = [
1647
+ tracked_row_at_with_commit(
1648
+ "global",
1649
+ "global-row",
1650
+ Some("change-global"),
1651
+ "commit-shared",
1652
+ ),
1653
+ tracked_row_at_with_commit(
1654
+ "version-a",
1655
+ "version-row",
1656
+ Some("change-version"),
1657
+ "commit-shared",
1658
+ ),
1659
+ ];
1660
+ let mut writes = StorageWriteSet::new();
1661
+ let mut json_writer = JsonStoreContext::new().writer();
1662
+ let mut writer = live_state.writer(transaction.as_mut());
1663
+ writer
1664
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1665
+ .await
1666
+ }
1667
+ .expect_err("one tracked root must not mix global and version rows");
1668
+
1669
+ assert!(
1670
+ error.message.contains("mixes multiple storage scopes"),
1671
+ "unexpected error: {error:?}"
1672
+ );
1673
+ }
1674
+
1675
+ #[tokio::test]
1676
+ async fn writer_rejects_tracked_rows_with_invalid_storage_scope() {
1677
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1678
+ let storage = StorageContext::new(Arc::clone(&backend));
1679
+ let live_state = live_state_context();
1680
+ let mut invalid_row =
1681
+ tracked_row_at_with_commit("version-a", "bad-row", Some("change-bad"), "commit-bad");
1682
+ invalid_row.global = true;
1683
+ let mut transaction = storage
1684
+ .begin_write_transaction()
1685
+ .await
1686
+ .expect("transaction should open");
1687
+
1688
+ let error = {
1689
+ let rows = [invalid_row];
1690
+ let mut writes = StorageWriteSet::new();
1691
+ let mut json_writer = JsonStoreContext::new().writer();
1692
+ let mut writer = live_state.writer(transaction.as_mut());
1693
+ writer
1694
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1695
+ .await
1696
+ }
1697
+ .expect_err("global rows must be stored in the global root only");
1698
+
1699
+ assert!(
1700
+ error.message.contains("invalid storage scope"),
1701
+ "unexpected error: {error:?}"
1702
+ );
1703
+ }
1704
+
1705
+ #[tokio::test]
1706
+ async fn writer_allows_commit_fact_to_share_the_touched_version_commit_id() {
1707
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1708
+ let storage = StorageContext::new(Arc::clone(&backend));
1709
+ let live_state = live_state_context();
1710
+ let mut transaction = storage
1711
+ .begin_write_transaction()
1712
+ .await
1713
+ .expect("transaction should open");
1714
+
1715
+ {
1716
+ let rows = [
1717
+ tracked_row_at_with_commit(
1718
+ "version-a",
1719
+ "version-row",
1720
+ Some("change-version"),
1721
+ "commit-version",
1722
+ ),
1723
+ commit_live_state_row("commit-version"),
1724
+ ];
1725
+ let mut writes = StorageWriteSet::new();
1726
+ let mut json_writer = JsonStoreContext::new().writer();
1727
+ {
1728
+ let mut writer = live_state.writer(transaction.as_mut());
1729
+ writer
1730
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1731
+ .await
1732
+ .expect("commit facts are changelog projections, not root-local rows");
1733
+ }
1734
+ writes
1735
+ .apply(&mut transaction.as_mut())
1736
+ .await
1737
+ .expect("commit fact rows should apply");
1738
+ }
1739
+ write_untracked_rows_to_store(
1740
+ transaction.as_mut(),
1741
+ &[version_ref_row("version-a", "commit-version")],
1742
+ )
1743
+ .await;
1744
+ transaction.commit().await.expect("commit should persist");
1745
+
1746
+ let loaded = load_selected_tab_at(&live_state, storage.clone(), "version-a")
1747
+ .await
1748
+ .expect("load should succeed")
1749
+ .expect("version row should be visible");
1750
+ assert_eq!(
1751
+ loaded.snapshot_content.as_deref(),
1752
+ Some("{\"value\":\"version-row\"}")
1753
+ );
1754
+ }
1755
+
1756
+ #[tokio::test]
1757
+ async fn writer_uses_first_parent_as_merge_root_base() {
1758
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1759
+ let storage = StorageContext::new(Arc::clone(&backend));
1760
+ let live_state = live_state_context();
1761
+ let mut seed_transaction = storage
1762
+ .begin_write_transaction()
1763
+ .await
1764
+ .expect("seed transaction should open");
1765
+ let mut writes = StorageWriteSet::new();
1766
+ {
1767
+ let mut json_writer = JsonStoreContext::new().writer();
1768
+ TrackedStateContext::new()
1769
+ .writer()
1770
+ .stage_root(
1771
+ &mut seed_transaction.as_mut(),
1772
+ &mut writes,
1773
+ &mut json_writer,
1774
+ "parent-left",
1775
+ None,
1776
+ &[],
1777
+ )
1778
+ .await
1779
+ .expect("first parent root should exist");
1780
+ }
1781
+ writes
1782
+ .apply(&mut seed_transaction.as_mut())
1783
+ .await
1784
+ .expect("first parent root should apply");
1785
+ seed_transaction
1786
+ .commit()
1787
+ .await
1788
+ .expect("seed transaction should commit");
1789
+
1790
+ let mut transaction = storage
1791
+ .begin_write_transaction()
1792
+ .await
1793
+ .expect("transaction should open");
1794
+
1795
+ {
1796
+ let rows = [
1797
+ tracked_row_at_with_commit(
1798
+ "version-a",
1799
+ "version-row",
1800
+ Some("change-version"),
1801
+ "commit-merge",
1802
+ ),
1803
+ commit_live_state_row_with_parents(
1804
+ "commit-merge",
1805
+ &["parent-left", "parent-right"],
1806
+ ),
1807
+ ];
1808
+ let mut writes = StorageWriteSet::new();
1809
+ let mut json_writer = JsonStoreContext::new().writer();
1810
+ {
1811
+ let mut writer = live_state.writer(transaction.as_mut());
1812
+ writer
1813
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1814
+ .await
1815
+ .expect("merge commit should use first parent as tracked-root base");
1816
+ }
1817
+ writes
1818
+ .apply(&mut transaction.as_mut())
1819
+ .await
1820
+ .expect("merge commit rows should apply");
1821
+ }
1822
+ }
1823
+
1824
+ #[tokio::test]
1825
+ async fn writer_rejects_commit_root_with_missing_parent_commit_ids() {
1826
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1827
+ let storage = StorageContext::new(Arc::clone(&backend));
1828
+ let live_state = live_state_context();
1829
+ let mut transaction = storage
1830
+ .begin_write_transaction()
1831
+ .await
1832
+ .expect("transaction should open");
1833
+
1834
+ let error = {
1835
+ let rows = [
1836
+ tracked_row_at_with_commit(
1837
+ "version-a",
1838
+ "version-row",
1839
+ Some("change-version"),
1840
+ "commit-malformed",
1841
+ ),
1842
+ commit_live_state_row_with_snapshot(
1843
+ "commit-malformed",
1844
+ json!({
1845
+ "id": "commit-malformed",
1846
+ "change_set_id": "change-set-commit-malformed",
1847
+ "change_ids": ["change-version"],
1848
+ }),
1849
+ ),
1850
+ ];
1851
+ let mut writes = StorageWriteSet::new();
1852
+ let mut json_writer = JsonStoreContext::new().writer();
1853
+ let mut writer = live_state.writer(transaction.as_mut());
1854
+ writer
1855
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1856
+ .await
1857
+ }
1858
+ .expect_err("commit roots must declare parent_commit_ids");
1859
+
1860
+ assert!(
1861
+ error.message.contains("missing parent_commit_ids"),
1862
+ "unexpected error: {error:?}"
1863
+ );
1864
+ }
1865
+
1866
+ #[tokio::test]
1867
+ async fn writer_rejects_commit_root_with_non_array_parent_commit_ids() {
1868
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1869
+ let storage = StorageContext::new(Arc::clone(&backend));
1870
+ let live_state = live_state_context();
1871
+ let mut transaction = storage
1872
+ .begin_write_transaction()
1873
+ .await
1874
+ .expect("transaction should open");
1875
+
1876
+ let error = {
1877
+ let rows = [
1878
+ tracked_row_at_with_commit(
1879
+ "version-a",
1880
+ "version-row",
1881
+ Some("change-version"),
1882
+ "commit-malformed",
1883
+ ),
1884
+ commit_live_state_row_with_snapshot(
1885
+ "commit-malformed",
1886
+ json!({
1887
+ "id": "commit-malformed",
1888
+ "change_set_id": "change-set-commit-malformed",
1889
+ "change_ids": ["change-version"],
1890
+ "parent_commit_ids": "parent-1",
1891
+ }),
1892
+ ),
1893
+ ];
1894
+ let mut writes = StorageWriteSet::new();
1895
+ let mut json_writer = JsonStoreContext::new().writer();
1896
+ let mut writer = live_state.writer(transaction.as_mut());
1897
+ writer
1898
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1899
+ .await
1900
+ }
1901
+ .expect_err("commit root parent_commit_ids must be an array");
1902
+
1903
+ assert!(
1904
+ error.message.contains("parent_commit_ids must be an array"),
1905
+ "unexpected error: {error:?}"
1906
+ );
1907
+ }
1908
+
1909
+ #[tokio::test]
1910
+ async fn non_global_root_does_not_store_global_rows() {
1911
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1912
+ let storage = StorageContext::new(Arc::clone(&backend));
1913
+ let tracked_state = TrackedStateContext::new();
1914
+ let live_state = LiveStateContext::new(
1915
+ tracked_state.clone(),
1916
+ UntrackedStateContext::new(),
1917
+ crate::commit_graph::CommitGraphContext::new(crate::changelog::ChangelogContext::new()),
1918
+ );
1919
+ let mut transaction = storage
1920
+ .begin_write_transaction()
1921
+ .await
1922
+ .expect("transaction should open");
1923
+
1924
+ {
1925
+ let rows = [
1926
+ tracked_row_with_commit("global-tracked", Some("change-global"), "commit-global"),
1927
+ tracked_row_at_with_commit(
1928
+ "main",
1929
+ "main-tracked",
1930
+ Some("change-main"),
1931
+ "commit-main",
1932
+ ),
1933
+ ];
1934
+ let mut writes = StorageWriteSet::new();
1935
+ let mut json_writer = JsonStoreContext::new().writer();
1936
+ {
1937
+ let mut writer = live_state.writer(transaction.as_mut());
1938
+ writer
1939
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1940
+ .await
1941
+ .expect("tracked rows should stage");
1942
+ }
1943
+ writes
1944
+ .apply(&mut transaction.as_mut())
1945
+ .await
1946
+ .expect("tracked rows should apply");
1947
+ }
1948
+ transaction.commit().await.expect("commit should persist");
1949
+
1950
+ let global_root_rows =
1951
+ scan_tracked_root(&tracked_state, storage.clone(), "commit-global").await;
1952
+ assert_eq!(global_root_rows.len(), 1);
1953
+ assert_eq!(
1954
+ global_root_rows[0].snapshot_content.as_deref(),
1955
+ Some("{\"value\":\"global-tracked\"}")
1956
+ );
1957
+
1958
+ let main_root_rows =
1959
+ scan_tracked_root(&tracked_state, storage.clone(), "commit-main").await;
1960
+ assert_eq!(main_root_rows.len(), 1);
1961
+ assert_eq!(
1962
+ main_root_rows[0].snapshot_content.as_deref(),
1963
+ Some("{\"value\":\"main-tracked\"}")
1964
+ );
1965
+ }
1966
+
1967
+ async fn load_selected_tab(
1968
+ live_state: &LiveStateContext,
1969
+ storage: StorageContext,
1970
+ ) -> Result<Option<LiveStateRow>, LixError> {
1971
+ live_state
1972
+ .reader(storage)
1973
+ .load_row(&LiveStateRowRequest {
1974
+ schema_key: "lix_key_value".to_string(),
1975
+ version_id: "global".to_string(),
1976
+ entity_id: crate::entity_identity::EntityIdentity::single("selected-tab"),
1977
+ file_id: NullableKeyFilter::Null,
1978
+ })
1979
+ .await
1980
+ }
1981
+
1982
+ async fn load_selected_tab_at(
1983
+ live_state: &LiveStateContext,
1984
+ storage: StorageContext,
1985
+ version_id: &str,
1986
+ ) -> Result<Option<LiveStateRow>, LixError> {
1987
+ live_state
1988
+ .reader(storage)
1989
+ .load_row(&LiveStateRowRequest {
1990
+ schema_key: "lix_key_value".to_string(),
1991
+ version_id: version_id.to_string(),
1992
+ entity_id: crate::entity_identity::EntityIdentity::single("selected-tab"),
1993
+ file_id: NullableKeyFilter::Null,
1994
+ })
1995
+ .await
1996
+ }
1997
+
1998
+ async fn scan_selected_tab_at(
1999
+ live_state: &LiveStateContext,
2000
+ storage: StorageContext,
2001
+ version_id: &str,
2002
+ include_tombstones: bool,
2003
+ ) -> Result<Vec<LiveStateRow>, LixError> {
2004
+ live_state
2005
+ .reader(storage)
2006
+ .scan_rows(&LiveStateScanRequest {
2007
+ filter: LiveStateFilter {
2008
+ schema_keys: vec!["lix_key_value".to_string()],
2009
+ entity_ids: vec![crate::entity_identity::EntityIdentity::single(
2010
+ "selected-tab",
2011
+ )],
2012
+ version_ids: vec![version_id.to_string()],
2013
+ file_ids: vec![NullableKeyFilter::Null],
2014
+ include_tombstones,
2015
+ ..LiveStateFilter::default()
2016
+ },
2017
+ ..LiveStateScanRequest::default()
2018
+ })
2019
+ .await
2020
+ }
2021
+
2022
+ async fn scan_tracked_root(
2023
+ tracked_state: &TrackedStateContext,
2024
+ storage: StorageContext,
2025
+ commit_id: &str,
2026
+ ) -> Vec<TrackedStateRow> {
2027
+ tracked_state
2028
+ .reader(storage)
2029
+ .scan_rows_at_commit(
2030
+ commit_id,
2031
+ &TrackedStateScanRequest {
2032
+ filter: TrackedStateFilter {
2033
+ include_tombstones: true,
2034
+ ..Default::default()
2035
+ },
2036
+ ..Default::default()
2037
+ },
2038
+ )
2039
+ .await
2040
+ .expect("tracked root should scan")
2041
+ }
2042
+
2043
+ fn tracked_row_with_commit(
2044
+ value: &str,
2045
+ change_id: Option<&str>,
2046
+ commit_id: &str,
2047
+ ) -> LiveStateRow {
2048
+ tracked_row_at_with_commit("global", value, change_id, commit_id)
2049
+ }
2050
+
2051
+ fn tracked_row_at_with_commit(
2052
+ version_id: &str,
2053
+ value: &str,
2054
+ change_id: Option<&str>,
2055
+ commit_id: &str,
2056
+ ) -> LiveStateRow {
2057
+ LiveStateRow {
2058
+ entity_id: identity("selected-tab"),
2059
+ schema_key: "lix_key_value".to_string(),
2060
+ file_id: None,
2061
+ snapshot_content: Some(format!("{{\"value\":\"{value}\"}}")),
2062
+ metadata: None,
2063
+ schema_version: "1".to_string(),
2064
+ created_at: "2026-01-01T00:00:00Z".to_string(),
2065
+ updated_at: "2026-01-01T00:00:00Z".to_string(),
2066
+ global: version_id == "global",
2067
+ change_id: change_id.map(str::to_string),
2068
+ commit_id: Some(commit_id.to_string()),
2069
+ untracked: false,
2070
+ version_id: version_id.to_string(),
2071
+ }
2072
+ }
2073
+
2074
+ fn tombstone_tracked_row_at_with_commit(
2075
+ version_id: &str,
2076
+ change_id: Option<&str>,
2077
+ commit_id: &str,
2078
+ ) -> LiveStateRow {
2079
+ LiveStateRow {
2080
+ snapshot_content: None,
2081
+ ..tracked_row_at_with_commit(version_id, "ignored", change_id, commit_id)
2082
+ }
2083
+ }
2084
+
2085
+ fn untracked_row(value: &str) -> MaterializedUntrackedStateRow {
2086
+ untracked_row_at("global", value)
2087
+ }
2088
+
2089
+ fn untracked_row_at(version_id: &str, value: &str) -> MaterializedUntrackedStateRow {
2090
+ MaterializedUntrackedStateRow {
2091
+ entity_id: identity("selected-tab"),
2092
+ schema_key: "lix_key_value".to_string(),
2093
+ file_id: None,
2094
+ snapshot_content: Some(format!("{{\"value\":\"{value}\"}}")),
2095
+ metadata: None,
2096
+ schema_version: "1".to_string(),
2097
+ created_at: "2026-01-01T00:00:00Z".to_string(),
2098
+ updated_at: "2026-01-01T00:00:00Z".to_string(),
2099
+ global: version_id == "global",
2100
+ version_id: version_id.to_string(),
2101
+ }
2102
+ }
2103
+
2104
+ fn version_ref_row(version_id: &str, commit_id: &str) -> MaterializedUntrackedStateRow {
2105
+ MaterializedUntrackedStateRow {
2106
+ entity_id: identity(version_id),
2107
+ schema_key: "lix_version_ref".to_string(),
2108
+ file_id: None,
2109
+ snapshot_content: Some(
2110
+ serde_json::to_string(&json!({
2111
+ "id": version_id,
2112
+ "commit_id": commit_id,
2113
+ }))
2114
+ .expect("version ref should serialize"),
2115
+ ),
2116
+ metadata: None,
2117
+ schema_version: "1".to_string(),
2118
+ created_at: "2026-01-01T00:00:00Z".to_string(),
2119
+ updated_at: "2026-01-01T00:00:00Z".to_string(),
2120
+ global: true,
2121
+ version_id: "global".to_string(),
2122
+ }
2123
+ }
2124
+
2125
+ async fn write_version_refs(storage: StorageContext, refs: &[MaterializedUntrackedStateRow]) {
2126
+ let mut transaction = storage
2127
+ .begin_write_transaction()
2128
+ .await
2129
+ .expect("version-ref transaction should open");
2130
+ let mut writes = StorageWriteSet::new();
2131
+ let canonical_refs = {
2132
+ let mut json_writer = JsonStoreContext::new().writer();
2133
+ refs.iter()
2134
+ .map(|row| canonicalize_materialized_row(&mut writes, &mut json_writer, row))
2135
+ .collect::<Result<Vec<_>, _>>()
2136
+ .expect("version refs should canonicalize")
2137
+ };
2138
+ UntrackedStateContext::new()
2139
+ .writer(&mut writes)
2140
+ .stage_rows(&canonical_refs)
2141
+ .expect("version refs should write");
2142
+ writes
2143
+ .apply(&mut transaction.as_mut())
2144
+ .await
2145
+ .expect("version refs should apply");
2146
+ transaction
2147
+ .commit()
2148
+ .await
2149
+ .expect("version-ref transaction should commit");
2150
+ }
2151
+
2152
+ fn commit_live_state_row(commit_id: &str) -> LiveStateRow {
2153
+ commit_live_state_row_with_parents(commit_id, &[])
2154
+ }
2155
+
2156
+ fn commit_live_state_row_with_parents(
2157
+ commit_id: &str,
2158
+ parent_commit_ids: &[&str],
2159
+ ) -> LiveStateRow {
2160
+ commit_live_state_row_with_snapshot(
2161
+ commit_id,
2162
+ json!({
2163
+ "id": commit_id,
2164
+ "change_set_id": format!("change-set-{commit_id}"),
2165
+ "change_ids": ["change-version"],
2166
+ "parent_commit_ids": parent_commit_ids,
2167
+ }),
2168
+ )
2169
+ }
2170
+
2171
+ fn commit_live_state_row_with_snapshot(
2172
+ commit_id: &str,
2173
+ snapshot: serde_json::Value,
2174
+ ) -> LiveStateRow {
2175
+ LiveStateRow {
2176
+ entity_id: identity(commit_id),
2177
+ schema_key: COMMIT_SCHEMA_KEY.to_string(),
2178
+ file_id: None,
2179
+ snapshot_content: Some(
2180
+ serde_json::to_string(&snapshot).expect("commit snapshot should serialize"),
2181
+ ),
2182
+ metadata: None,
2183
+ schema_version: "1".to_string(),
2184
+ created_at: "2026-01-01T00:00:00Z".to_string(),
2185
+ updated_at: "2026-01-01T00:00:00Z".to_string(),
2186
+ global: true,
2187
+ change_id: Some(format!("change-{commit_id}")),
2188
+ commit_id: Some(commit_id.to_string()),
2189
+ untracked: false,
2190
+ version_id: "global".to_string(),
2191
+ }
2192
+ }
2193
+
2194
+ async fn append_commit_change(storage: StorageContext, commit_id: &str) {
2195
+ let changelog = crate::changelog::ChangelogContext::new();
2196
+ let mut transaction = storage
2197
+ .begin_write_transaction()
2198
+ .await
2199
+ .expect("transaction should open");
2200
+ let change = MaterializedCanonicalChange {
2201
+ id: format!("change-{commit_id}"),
2202
+ entity_id: crate::entity_identity::EntityIdentity::single(commit_id),
2203
+ schema_key: COMMIT_SCHEMA_KEY.to_string(),
2204
+ schema_version: "1".to_string(),
2205
+ file_id: None,
2206
+ snapshot_content: Some(
2207
+ serde_json::to_string(&json!({
2208
+ "id": commit_id,
2209
+ "change_set_id": format!("change-set-{commit_id}"),
2210
+ "change_ids": [],
2211
+ "parent_commit_ids": [],
2212
+ }))
2213
+ .expect("commit snapshot should serialize"),
2214
+ ),
2215
+ metadata: None,
2216
+ created_at: "2026-01-01T00:00:00Z".to_string(),
2217
+ };
2218
+ let mut writes = StorageWriteSet::new();
2219
+ let canonical_change = {
2220
+ let mut json_writer = JsonStoreContext::new().writer();
2221
+ canonicalize_materialized_change(&mut writes, &mut json_writer, &change)
2222
+ .expect("commit change should canonicalize")
2223
+ };
2224
+ changelog
2225
+ .writer(&mut writes)
2226
+ .stage_changes(&[canonical_change])
2227
+ .expect("commit change should append");
2228
+ writes
2229
+ .apply(&mut transaction.as_mut())
2230
+ .await
2231
+ .expect("commit change should apply");
2232
+ transaction
2233
+ .commit()
2234
+ .await
2235
+ .expect("transaction should commit");
2236
+ }
2237
+
2238
+ fn identity(entity_id: &str) -> EntityIdentity {
2239
+ EntityIdentity::single(entity_id)
2240
+ }
2241
+ }