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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/SKILL.md +4 -5
  2. package/dist/engine-wasm/wasm/lix_engine.js +1 -1
  3. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  4. package/dist/generated/builtin-schemas.d.ts +87 -162
  5. package/dist/generated/builtin-schemas.js +139 -236
  6. package/dist/open-lix.d.ts +1 -1
  7. package/dist-engine-src/src/binary_cas/types.rs +0 -6
  8. package/dist-engine-src/src/catalog/context.rs +412 -0
  9. package/dist-engine-src/src/catalog/mod.rs +10 -0
  10. package/dist-engine-src/src/catalog/schema.rs +4 -0
  11. package/dist-engine-src/src/catalog/snapshot.rs +1114 -0
  12. package/dist-engine-src/src/cel/mod.rs +1 -1
  13. package/dist-engine-src/src/cel/provider.rs +1 -1
  14. package/dist-engine-src/src/commit_graph/context.rs +328 -1015
  15. package/dist-engine-src/src/commit_graph/mod.rs +2 -3
  16. package/dist-engine-src/src/commit_graph/types.rs +7 -43
  17. package/dist-engine-src/src/commit_graph/walker.rs +57 -81
  18. package/dist-engine-src/src/commit_store/codec.rs +887 -0
  19. package/dist-engine-src/src/commit_store/context.rs +944 -0
  20. package/dist-engine-src/src/commit_store/materialization.rs +84 -0
  21. package/dist-engine-src/src/commit_store/mod.rs +16 -0
  22. package/dist-engine-src/src/commit_store/storage.rs +600 -0
  23. package/dist-engine-src/src/commit_store/types.rs +215 -0
  24. package/dist-engine-src/src/common/identity.rs +15 -5
  25. package/dist-engine-src/src/common/json_pointer.rs +67 -0
  26. package/dist-engine-src/src/common/metadata.rs +17 -12
  27. package/dist-engine-src/src/common/mod.rs +5 -5
  28. package/dist-engine-src/src/domain.rs +324 -0
  29. package/dist-engine-src/src/engine.rs +29 -43
  30. package/dist-engine-src/src/entity_identity.rs +238 -118
  31. package/dist-engine-src/src/functions/context.rs +17 -52
  32. package/dist-engine-src/src/functions/deterministic.rs +1 -1
  33. package/dist-engine-src/src/functions/mod.rs +1 -1
  34. package/dist-engine-src/src/functions/provider.rs +4 -4
  35. package/dist-engine-src/src/functions/state.rs +39 -66
  36. package/dist-engine-src/src/functions/types.rs +1 -1
  37. package/dist-engine-src/src/init.rs +204 -151
  38. package/dist-engine-src/src/json_store/context.rs +354 -60
  39. package/dist-engine-src/src/json_store/encoded.rs +6 -6
  40. package/dist-engine-src/src/json_store/mod.rs +4 -1
  41. package/dist-engine-src/src/json_store/store.rs +884 -11
  42. package/dist-engine-src/src/json_store/types.rs +166 -1
  43. package/dist-engine-src/src/lib.rs +10 -9
  44. package/dist-engine-src/src/live_state/context.rs +608 -830
  45. package/dist-engine-src/src/live_state/mod.rs +3 -3
  46. package/dist-engine-src/src/live_state/overlay.rs +7 -7
  47. package/dist-engine-src/src/live_state/reader.rs +5 -5
  48. package/dist-engine-src/src/live_state/types.rs +19 -36
  49. package/dist-engine-src/src/live_state/visibility.rs +19 -14
  50. package/dist-engine-src/src/plugin/archive.rs +3 -6
  51. package/dist-engine-src/src/plugin/install.rs +0 -18
  52. package/dist-engine-src/src/plugin/plugin_manifest.json +0 -1
  53. package/dist-engine-src/src/schema/annotations/defaults.rs +2 -7
  54. package/dist-engine-src/src/schema/builtin/lix_account.json +0 -1
  55. package/dist-engine-src/src/schema/builtin/lix_active_account.json +0 -1
  56. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +0 -1
  57. package/dist-engine-src/src/schema/builtin/lix_change.json +11 -10
  58. package/dist-engine-src/src/schema/builtin/lix_change_author.json +0 -1
  59. package/dist-engine-src/src/schema/builtin/lix_commit.json +8 -46
  60. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +29 -22
  61. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +0 -1
  62. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +0 -1
  63. package/dist-engine-src/src/schema/builtin/lix_key_value.json +0 -1
  64. package/dist-engine-src/src/schema/builtin/lix_label.json +10 -3
  65. package/dist-engine-src/src/schema/builtin/lix_label_assignment.json +74 -0
  66. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +2 -8
  67. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +0 -1
  68. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +0 -1
  69. package/dist-engine-src/src/schema/builtin/mod.rs +10 -59
  70. package/dist-engine-src/src/schema/compatibility.rs +787 -0
  71. package/dist-engine-src/src/schema/definition.json +47 -17
  72. package/dist-engine-src/src/schema/definition.rs +202 -96
  73. package/dist-engine-src/src/schema/key.rs +9 -77
  74. package/dist-engine-src/src/schema/mod.rs +4 -4
  75. package/dist-engine-src/src/schema/tests.rs +133 -92
  76. package/dist-engine-src/src/session/context.rs +40 -42
  77. package/dist-engine-src/src/session/create_version.rs +22 -14
  78. package/dist-engine-src/src/session/execute.rs +45 -14
  79. package/dist-engine-src/src/session/merge/apply.rs +4 -4
  80. package/dist-engine-src/src/session/merge/conflicts.rs +3 -2
  81. package/dist-engine-src/src/session/merge/stats.rs +1 -1
  82. package/dist-engine-src/src/session/merge/version.rs +35 -45
  83. package/dist-engine-src/src/session/mod.rs +4 -2
  84. package/dist-engine-src/src/session/optimization9_sql2_bench.rs +100 -0
  85. package/dist-engine-src/src/session/switch_version.rs +16 -28
  86. package/dist-engine-src/src/sql2/change_provider.rs +14 -20
  87. package/dist-engine-src/src/sql2/classify.rs +61 -26
  88. package/dist-engine-src/src/sql2/context.rs +22 -18
  89. package/dist-engine-src/src/sql2/directory_history_provider.rs +28 -20
  90. package/dist-engine-src/src/sql2/directory_provider.rs +131 -83
  91. package/dist-engine-src/src/sql2/entity_history_provider.rs +10 -14
  92. package/dist-engine-src/src/sql2/entity_provider.rs +680 -169
  93. package/dist-engine-src/src/sql2/error.rs +21 -1
  94. package/dist-engine-src/src/sql2/execute.rs +325 -264
  95. package/dist-engine-src/src/sql2/file_history_provider.rs +29 -21
  96. package/dist-engine-src/src/sql2/file_provider.rs +533 -108
  97. package/dist-engine-src/src/sql2/filesystem_planner.rs +58 -94
  98. package/dist-engine-src/src/sql2/filesystem_visibility.rs +37 -23
  99. package/dist-engine-src/src/sql2/history_projection.rs +3 -27
  100. package/dist-engine-src/src/sql2/history_provider.rs +11 -17
  101. package/dist-engine-src/src/sql2/history_route.rs +22 -8
  102. package/dist-engine-src/src/sql2/lix_state_provider.rs +178 -96
  103. package/dist-engine-src/src/sql2/mod.rs +6 -3
  104. package/dist-engine-src/src/sql2/predicate_typecheck.rs +246 -0
  105. package/dist-engine-src/src/sql2/public_bind/assignment.rs +46 -0
  106. package/dist-engine-src/src/sql2/public_bind/capability.rs +41 -0
  107. package/dist-engine-src/src/sql2/public_bind/dml.rs +166 -0
  108. package/dist-engine-src/src/sql2/public_bind/mod.rs +25 -0
  109. package/dist-engine-src/src/sql2/public_bind/table.rs +168 -0
  110. package/dist-engine-src/src/sql2/read_only.rs +10 -12
  111. package/dist-engine-src/src/sql2/session.rs +7 -10
  112. package/dist-engine-src/src/sql2/udfs/lix_timestamp.rs +76 -0
  113. package/dist-engine-src/src/sql2/udfs/mod.rs +8 -1
  114. package/dist-engine-src/src/sql2/udfs/public_call.rs +211 -0
  115. package/dist-engine-src/src/sql2/version_provider.rs +46 -31
  116. package/dist-engine-src/src/sql2/version_scope.rs +4 -4
  117. package/dist-engine-src/src/storage_bench.rs +1782 -325
  118. package/dist-engine-src/src/test_support.rs +183 -36
  119. package/dist-engine-src/src/tracked_state/by_file_index.rs +20 -24
  120. package/dist-engine-src/src/tracked_state/codec.rs +1519 -181
  121. package/dist-engine-src/src/tracked_state/context.rs +1155 -271
  122. package/dist-engine-src/src/tracked_state/diff.rs +249 -57
  123. package/dist-engine-src/src/tracked_state/materialization.rs +365 -103
  124. package/dist-engine-src/src/tracked_state/materializer.rs +488 -0
  125. package/dist-engine-src/src/tracked_state/merge.rs +37 -19
  126. package/dist-engine-src/src/tracked_state/mod.rs +8 -7
  127. package/dist-engine-src/src/tracked_state/storage.rs +138 -6
  128. package/dist-engine-src/src/tracked_state/tree.rs +695 -252
  129. package/dist-engine-src/src/tracked_state/types.rs +176 -6
  130. package/dist-engine-src/src/transaction/commit.rs +695 -435
  131. package/dist-engine-src/src/transaction/context.rs +551 -310
  132. package/dist-engine-src/src/transaction/live_state_overlay.rs +9 -8
  133. package/dist-engine-src/src/transaction/mod.rs +2 -0
  134. package/dist-engine-src/src/transaction/normalization.rs +311 -447
  135. package/dist-engine-src/src/transaction/prep.rs +37 -0
  136. package/dist-engine-src/src/transaction/schema_resolver.rs +93 -71
  137. package/dist-engine-src/src/transaction/staging.rs +701 -406
  138. package/dist-engine-src/src/transaction/types.rs +231 -122
  139. package/dist-engine-src/src/transaction/validation.rs +2717 -1698
  140. package/dist-engine-src/src/untracked_state/codec.rs +40 -96
  141. package/dist-engine-src/src/untracked_state/context.rs +21 -5
  142. package/dist-engine-src/src/untracked_state/materialization.rs +10 -104
  143. package/dist-engine-src/src/untracked_state/mod.rs +3 -5
  144. package/dist-engine-src/src/untracked_state/storage.rs +105 -57
  145. package/dist-engine-src/src/untracked_state/types.rs +63 -13
  146. package/dist-engine-src/src/version/context.rs +1 -13
  147. package/dist-engine-src/src/version/lifecycle.rs +221 -0
  148. package/dist-engine-src/src/version/mod.rs +3 -2
  149. package/dist-engine-src/src/version/refs.rs +12 -103
  150. package/dist-engine-src/src/version/stage_rows.rs +15 -19
  151. package/package.json +1 -1
  152. package/dist-engine-src/src/changelog/codec.rs +0 -321
  153. package/dist-engine-src/src/changelog/context.rs +0 -92
  154. package/dist-engine-src/src/changelog/materialization.rs +0 -121
  155. package/dist-engine-src/src/changelog/mod.rs +0 -13
  156. package/dist-engine-src/src/changelog/reader.rs +0 -20
  157. package/dist-engine-src/src/changelog/storage.rs +0 -220
  158. package/dist-engine-src/src/changelog/types.rs +0 -38
  159. package/dist-engine-src/src/schema/builtin/lix_change_set.json +0 -18
  160. package/dist-engine-src/src/schema/builtin/lix_change_set_element.json +0 -75
  161. package/dist-engine-src/src/schema/builtin/lix_entity_label.json +0 -63
  162. package/dist-engine-src/src/schema_registry.rs +0 -294
  163. package/dist-engine-src/src/sql2/commit_derived_provider.rs +0 -591
  164. package/dist-engine-src/src/tracked_state/rebuild.rs +0 -771
  165. package/dist-engine-src/src/tracked_state/tree_types.rs +0 -176
@@ -1,25 +1,29 @@
1
1
  use async_trait::async_trait;
2
2
  use tokio::sync::Mutex;
3
3
 
4
- use crate::commit_graph::{CommitGraphCommit, CommitGraphContext};
4
+ use crate::commit_graph::CommitGraphContext;
5
+ use crate::entity_identity::EntityIdentity;
5
6
  use crate::live_state::visibility;
6
7
  use crate::live_state::{
7
- LiveStateFilter, LiveStateReader, LiveStateRow, LiveStateRowRequest, LiveStateScanRequest,
8
+ LiveStateReader, LiveStateRowRequest, LiveStateScanRequest, MaterializedLiveStateRow,
8
9
  };
9
- use crate::storage::{StorageReader, StorageWriteSet};
10
+ use crate::storage::StorageReader;
10
11
  use crate::tracked_state::{
11
- TrackedStateContext, TrackedStateFilter, TrackedStateProjection, TrackedStateRow,
12
+ MaterializedTrackedStateRow, TrackedStateContext, TrackedStateFilter, TrackedStateProjection,
12
13
  TrackedStateRowRequest, TrackedStateScanRequest,
13
14
  };
14
15
  use crate::untracked_state::{
15
- canonicalize_materialized_row, MaterializedUntrackedStateRow, UntrackedStateContext,
16
- UntrackedStateIdentity, UntrackedStateRowRequest, UntrackedStateScanRequest,
16
+ UntrackedStateContext, UntrackedStateRowRequest, UntrackedStateScanRequest,
17
17
  };
18
18
  use crate::version::VERSION_REF_SCHEMA_KEY;
19
19
  use crate::LixError;
20
+ use crate::NullableKeyFilter;
20
21
  use crate::GLOBAL_VERSION_ID;
21
22
 
22
- /// Serving facade for visible live-state readers and writers.
23
+ const COMMIT_SCHEMA_KEY: &str = "lix_commit";
24
+ const COMMIT_EDGE_SCHEMA_KEY: &str = "lix_commit_edge";
25
+
26
+ /// Serving facade for visible live-state reads.
23
27
  ///
24
28
  /// Live state composes the rebuildable tracked projection with the durable
25
29
  /// untracked local overlay. Lower stores own persistence; this facade owns the
@@ -55,22 +59,6 @@ impl LiveStateContext {
55
59
  commit_graph: self.commit_graph.clone(),
56
60
  }
57
61
  }
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
62
  }
75
63
 
76
64
  /// Visible live-state reader backed by a caller-provided KV store.
@@ -88,30 +76,35 @@ where
88
76
  pub(crate) async fn scan_rows(
89
77
  &self,
90
78
  request: &LiveStateScanRequest,
91
- ) -> Result<Vec<LiveStateRow>, LixError> {
79
+ ) -> Result<Vec<MaterializedLiveStateRow>, LixError> {
92
80
  let mut store = self.store.lock().await;
93
81
  let scope = scan_scope(&mut *store, &self.untracked_state, request).await?;
82
+ let derived_rows =
83
+ scan_commit_derived_rows(&mut *store, &self.commit_graph, request, &scope).await?;
94
84
  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
- );
85
+ if request.filter.untracked != Some(true) && !is_commit_derived_only_request(request) {
86
+ for version_id in &scope.storage_version_ids {
87
+ let Some(commit_id) =
88
+ load_version_ref_commit_id(&mut *store, &self.untracked_state, version_id)
89
+ .await?
90
+ else {
91
+ continue;
92
+ };
93
+ let tracked_request = tracked_scan_request_from_live(request);
94
+ let source = tracked_source_from_version_id(version_id);
95
+ let store: &mut dyn StorageReader = &mut *store;
96
+ tracked_rows.extend(
97
+ self.tracked_state
98
+ .reader(store)
99
+ .scan_rows_at_commit(&commit_id, &tracked_request)
100
+ .await?
101
+ .into_iter()
102
+ .map(|row| project_tracked_row(row, version_id, source)),
103
+ );
104
+ }
112
105
  }
113
106
 
114
- let untracked_rows = {
107
+ let untracked_rows = if request.filter.untracked != Some(false) {
115
108
  let store: &mut dyn StorageReader = &mut *store;
116
109
  self.untracked_state
117
110
  .reader(store)
@@ -120,28 +113,25 @@ where
120
113
  &scope.storage_version_ids,
121
114
  ))
122
115
  .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
116
  .into_iter()
135
- .map(live_state_row_from_commit)
117
+ .map(MaterializedLiveStateRow::from)
136
118
  .collect::<Vec<_>>()
137
119
  } else {
138
120
  Vec::new()
139
121
  };
140
- commit_rows.retain(|row| live_state_row_matches_filter(row, &request.filter));
141
122
 
142
- let mut rows =
143
- crate::live_state::overlay::overlay_untracked_rows(tracked_rows, untracked_rows);
144
- rows.extend(commit_rows);
123
+ let mut rows = if request.filter.untracked.is_some() {
124
+ tracked_rows
125
+ .into_iter()
126
+ .chain(untracked_rows)
127
+ .chain(derived_rows)
128
+ .collect()
129
+ } else {
130
+ crate::live_state::overlay::overlay_untracked_rows(tracked_rows, untracked_rows)
131
+ .into_iter()
132
+ .chain(derived_rows)
133
+ .collect()
134
+ };
145
135
  rows = visibility::resolve_scan_rows(
146
136
  rows,
147
137
  &scope.projection_version_ids,
@@ -156,14 +146,40 @@ where
156
146
  pub(crate) async fn load_row(
157
147
  &self,
158
148
  request: &LiveStateRowRequest,
159
- ) -> Result<Option<LiveStateRow>, LixError> {
149
+ ) -> Result<Option<MaterializedLiveStateRow>, LixError> {
160
150
  let mut store = self.store.lock().await;
161
151
  if !version_ref_exists(&mut *store, &self.untracked_state, &request.version_id).await? {
162
152
  return Ok(None);
163
153
  }
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;
154
+ if is_commit_derived_schema(&request.schema_key)
155
+ && request.file_id == NullableKeyFilter::Null
156
+ {
157
+ let scope = LiveStateScanScope {
158
+ storage_version_ids: vec![request.version_id.clone()],
159
+ projection_version_ids: vec![request.version_id.clone()],
160
+ };
161
+ let rows = scan_commit_derived_rows(
162
+ &mut *store,
163
+ &self.commit_graph,
164
+ &LiveStateScanRequest {
165
+ filter: crate::live_state::LiveStateFilter {
166
+ schema_keys: vec![request.schema_key.clone()],
167
+ entity_ids: vec![request.entity_id.clone()],
168
+ version_ids: vec![request.version_id.clone()],
169
+ file_ids: vec![NullableKeyFilter::Null],
170
+ untracked: Some(false),
171
+ include_tombstones: false,
172
+ ..Default::default()
173
+ },
174
+ limit: Some(1),
175
+ ..Default::default()
176
+ },
177
+ &scope,
178
+ )
179
+ .await?;
180
+ if let Some(row) = rows.into_iter().next() {
181
+ return Ok(Some(row));
182
+ }
167
183
  }
168
184
  for candidate in load_row_candidates(request) {
169
185
  match candidate.source {
@@ -179,7 +195,7 @@ where
179
195
  .await?
180
196
  {
181
197
  return Ok(Some(visibility::project_loaded_row(
182
- LiveStateRow::from(row),
198
+ MaterializedLiveStateRow::from(row),
183
199
  &request.version_id,
184
200
  &candidate.version_id,
185
201
  )));
@@ -196,12 +212,13 @@ where
196
212
  continue;
197
213
  };
198
214
  let store: &mut dyn StorageReader = &mut *store;
199
- if let Some(row) = self
215
+ let tracked_request = tracked_row_request_from_live(request);
216
+ let mut rows = self
200
217
  .tracked_state
201
218
  .reader(store)
202
- .load_row_at_commit(&commit_id, &tracked_row_request_from_live(request))
203
- .await?
204
- {
219
+ .load_rows_at_commit(&commit_id, &[tracked_request])
220
+ .await?;
221
+ if let Some(row) = rows.pop().flatten() {
205
222
  return Ok(Some(project_tracked_row(
206
223
  row,
207
224
  &request.version_id,
@@ -213,30 +230,6 @@ where
213
230
  }
214
231
  Ok(None)
215
232
  }
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
233
  }
241
234
 
242
235
  #[async_trait]
@@ -247,105 +240,162 @@ where
247
240
  async fn scan_rows(
248
241
  &self,
249
242
  request: &LiveStateScanRequest,
250
- ) -> Result<Vec<LiveStateRow>, LixError> {
243
+ ) -> Result<Vec<MaterializedLiveStateRow>, LixError> {
251
244
  LiveStateStoreReader::scan_rows(self, request).await
252
245
  }
253
246
 
254
247
  async fn load_row(
255
248
  &self,
256
249
  request: &LiveStateRowRequest,
257
- ) -> Result<Option<LiveStateRow>, LixError> {
250
+ ) -> Result<Option<MaterializedLiveStateRow>, LixError> {
258
251
  LiveStateStoreReader::load_row(self, request).await
259
252
  }
260
253
  }
261
254
 
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,
255
+ async fn scan_commit_derived_rows(
256
+ store: &mut dyn StorageReader,
257
+ commit_graph: &CommitGraphContext,
258
+ request: &LiveStateScanRequest,
259
+ scope: &LiveStateScanScope,
260
+ ) -> Result<Vec<MaterializedLiveStateRow>, LixError> {
261
+ if request.filter.untracked == Some(true) || !request_may_include_commit_derived(request) {
262
+ return Ok(Vec::new());
263
+ }
264
+ if !file_filter_allows_null(&request.filter.file_ids) {
265
+ return Ok(Vec::new());
266
+ }
267
+
268
+ let version_ids = if scope.projection_version_ids.is_empty() {
269
+ vec![GLOBAL_VERSION_ID.to_string()]
270
+ } else {
271
+ scope.projection_version_ids.clone()
272
+ };
273
+ let mut graph = commit_graph.reader(store);
274
+ let commits = graph.all_commits().await?;
275
+ let include_commit = schema_filter_allows(&request.filter.schema_keys, COMMIT_SCHEMA_KEY);
276
+ let include_commit_edge =
277
+ schema_filter_allows(&request.filter.schema_keys, COMMIT_EDGE_SCHEMA_KEY);
278
+
279
+ let mut rows = Vec::new();
280
+ for version_id in &version_ids {
281
+ if include_commit {
282
+ for commit in &commits {
283
+ rows.push(commit_row(commit, version_id)?);
284
+ }
285
+ }
286
+ if include_commit_edge {
287
+ for edge in graph.commit_edges(&commits) {
288
+ rows.push(commit_edge_row(&edge, version_id)?);
289
+ }
290
+ }
291
+ }
292
+
293
+ rows.retain(|row| {
294
+ (request.filter.entity_ids.is_empty() || request.filter.entity_ids.contains(&row.entity_id))
295
+ && (request.filter.version_ids.is_empty()
296
+ || request.filter.version_ids.contains(&row.version_id))
297
+ });
298
+ Ok(rows)
267
299
  }
268
300
 
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);
301
+ fn request_may_include_commit_derived(request: &LiveStateScanRequest) -> bool {
302
+ request.filter.schema_keys.is_empty()
303
+ || request
304
+ .filter
305
+ .schema_keys
306
+ .iter()
307
+ .any(|schema_key| is_commit_derived_schema(schema_key))
308
+ }
281
309
 
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
- }
310
+ fn is_commit_derived_only_request(request: &LiveStateScanRequest) -> bool {
311
+ !request.filter.schema_keys.is_empty()
312
+ && request
313
+ .filter
314
+ .schema_keys
315
+ .iter()
316
+ .all(|schema_key| is_commit_derived_schema(schema_key))
317
+ }
295
318
 
296
- if tracked_rows.is_empty() {
297
- return Ok(());
298
- }
319
+ fn is_commit_derived_schema(schema_key: &str) -> bool {
320
+ matches!(schema_key, COMMIT_SCHEMA_KEY | COMMIT_EDGE_SCHEMA_KEY)
321
+ }
322
+
323
+ fn schema_filter_allows(schema_keys: &[String], schema_key: &str) -> bool {
324
+ schema_keys.is_empty() || schema_keys.iter().any(|candidate| candidate == schema_key)
325
+ }
299
326
 
300
- let identities = tracked_rows
327
+ fn file_filter_allows_null(file_ids: &[NullableKeyFilter<String>]) -> bool {
328
+ file_ids.is_empty()
329
+ || file_ids
301
330
  .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
- }
331
+ .any(|file_id| matches!(file_id, NullableKeyFilter::Any | NullableKeyFilter::Null))
332
+ }
338
333
 
339
- Ok(())
340
- }
334
+ fn commit_row(
335
+ commit: &crate::commit_graph::CommitGraphCommit,
336
+ version_id: &str,
337
+ ) -> Result<MaterializedLiveStateRow, LixError> {
338
+ let snapshot_content = serde_json::to_string(&serde_json::json!({
339
+ "id": commit.commit_id,
340
+ }))
341
+ .map_err(|error| {
342
+ LixError::new(
343
+ LixError::CODE_INTERNAL_ERROR,
344
+ format!("failed to encode derived lix_commit snapshot: {error}"),
345
+ )
346
+ })?;
347
+ Ok(MaterializedLiveStateRow {
348
+ entity_id: EntityIdentity::single(commit.commit_id.clone()),
349
+ schema_key: COMMIT_SCHEMA_KEY.to_string(),
350
+ file_id: None,
351
+ snapshot_content: Some(snapshot_content),
352
+ metadata: None,
353
+ deleted: false,
354
+ created_at: commit.change.created_at.clone(),
355
+ updated_at: commit.change.created_at.clone(),
356
+ global: true,
357
+ change_id: Some(commit.change.id.clone()),
358
+ commit_id: Some(commit.commit_id.clone()),
359
+ untracked: false,
360
+ version_id: version_id.to_string(),
361
+ })
341
362
  }
342
363
 
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
- }
364
+ fn commit_edge_row(
365
+ edge: &crate::commit_graph::CommitGraphEdge,
366
+ version_id: &str,
367
+ ) -> Result<MaterializedLiveStateRow, LixError> {
368
+ let snapshot_content = serde_json::to_string(&serde_json::json!({
369
+ "parent_id": edge.parent_commit_id,
370
+ "child_id": edge.child_commit_id,
371
+ "parent_order": edge.parent_order,
372
+ }))
373
+ .map_err(|error| {
374
+ LixError::new(
375
+ LixError::CODE_INTERNAL_ERROR,
376
+ format!("failed to encode derived lix_commit_edge snapshot: {error}"),
377
+ )
378
+ })?;
379
+ Ok(MaterializedLiveStateRow {
380
+ entity_id: EntityIdentity {
381
+ parts: vec![edge.parent_commit_id.clone(), edge.child_commit_id.clone()],
382
+ },
383
+ schema_key: COMMIT_EDGE_SCHEMA_KEY.to_string(),
384
+ file_id: None,
385
+ snapshot_content: Some(snapshot_content),
386
+ metadata: None,
387
+ deleted: false,
388
+ created_at: "1970-01-01T00:00:00.000Z".to_string(),
389
+ updated_at: "1970-01-01T00:00:00.000Z".to_string(),
390
+ global: true,
391
+ change_id: None,
392
+ commit_id: Some(edge.child_commit_id.clone()),
393
+ untracked: false,
394
+ version_id: version_id.to_string(),
395
+ })
396
+ }
348
397
 
398
+ fn tracked_scan_request_from_live(request: &LiveStateScanRequest) -> TrackedStateScanRequest {
349
399
  TrackedStateScanRequest {
350
400
  filter: TrackedStateFilter {
351
401
  schema_keys: request.filter.schema_keys.clone(),
@@ -355,7 +405,9 @@ fn tracked_scan_request_from_live(request: &LiveStateScanRequest) -> TrackedStat
355
405
  // global fallback rows before the serving facade filters them.
356
406
  include_tombstones: true,
357
407
  },
358
- projection: TrackedStateProjection { columns },
408
+ projection: TrackedStateProjection {
409
+ columns: request.projection.columns.clone(),
410
+ },
359
411
  limit: None,
360
412
  }
361
413
  }
@@ -368,7 +420,9 @@ fn untracked_scan_request_from_live(
368
420
  filter.version_ids = version_ids.to_vec();
369
421
  UntrackedStateScanRequest {
370
422
  filter,
371
- projection: Default::default(),
423
+ projection: crate::untracked_state::UntrackedStateProjection {
424
+ columns: request.projection.columns.clone(),
425
+ },
372
426
  limit: None,
373
427
  }
374
428
  }
@@ -377,7 +431,6 @@ fn untracked_scan_request_from_live(
377
431
  struct LiveStateScanScope {
378
432
  storage_version_ids: Vec<String>,
379
433
  projection_version_ids: Vec<String>,
380
- includes_commit_graph_projection: bool,
381
434
  }
382
435
 
383
436
  async fn scan_scope(
@@ -389,7 +442,6 @@ async fn scan_scope(
389
442
  return Ok(LiveStateScanScope {
390
443
  storage_version_ids: all_version_ref_ids(store, untracked_state).await?,
391
444
  projection_version_ids: Vec::new(),
392
- includes_commit_graph_projection: true,
393
445
  });
394
446
  }
395
447
 
@@ -403,7 +455,6 @@ async fn scan_scope(
403
455
  let storage_version_ids = visibility::expanded_version_ids(&projection_version_ids);
404
456
  Ok(LiveStateScanScope {
405
457
  storage_version_ids,
406
- includes_commit_graph_projection: !projection_version_ids.is_empty(),
407
458
  projection_version_ids,
408
459
  })
409
460
  }
@@ -424,7 +475,7 @@ async fn all_version_ref_ids(
424
475
  })
425
476
  .await?;
426
477
  rows.into_iter()
427
- .map(|row| row.entity_id.as_string())
478
+ .map(|row| row.entity_id.as_single_string_owned())
428
479
  .collect()
429
480
  }
430
481
 
@@ -473,27 +524,6 @@ async fn version_ref_exists(
473
524
  )
474
525
  }
475
526
 
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
527
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
498
528
  enum TrackedRowSource {
499
529
  Global,
@@ -509,17 +539,17 @@ fn tracked_source_from_version_id(version_id: &str) -> TrackedRowSource {
509
539
  }
510
540
 
511
541
  fn project_tracked_row(
512
- row: TrackedStateRow,
542
+ row: MaterializedTrackedStateRow,
513
543
  view_version_id: &str,
514
544
  source: TrackedRowSource,
515
- ) -> LiveStateRow {
516
- LiveStateRow {
545
+ ) -> MaterializedLiveStateRow {
546
+ MaterializedLiveStateRow {
517
547
  entity_id: row.entity_id,
518
548
  schema_key: row.schema_key,
519
549
  file_id: row.file_id,
520
550
  snapshot_content: row.snapshot_content,
521
551
  metadata: row.metadata,
522
- schema_version: row.schema_version,
552
+ deleted: row.deleted,
523
553
  created_at: row.created_at,
524
554
  updated_at: row.updated_at,
525
555
  global: source == TrackedRowSource::Global,
@@ -530,164 +560,6 @@ fn project_tracked_row(
530
560
  }
531
561
  }
532
562
 
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
563
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
692
564
  enum LiveStateLookupSource {
693
565
  Untracked,
@@ -754,21 +626,25 @@ mod tests {
754
626
 
755
627
  use super::*;
756
628
  use crate::backend::{testing::UnitTestBackend, Backend};
757
- use crate::changelog::{canonicalize_materialized_change, MaterializedCanonicalChange};
629
+ use crate::commit_store::{CommitDraftRef, CommitStoreContext};
758
630
  use crate::entity_identity::EntityIdentity;
759
- use crate::json_store::JsonStoreContext;
631
+ use crate::json_store::{
632
+ JsonStoreContext, JsonWritePlacementRef, NormalizedJson, NormalizedJsonRef,
633
+ };
760
634
  use crate::live_state::LiveStateFilter;
761
- use crate::storage::{StorageContext, StorageWriteTransaction};
762
- use crate::tracked_state::TrackedStateScanRequest;
635
+ use crate::storage::{StorageContext, StorageWriteSet, StorageWriteTransaction};
636
+ use crate::tracked_state::{TrackedStateDeltaRef, TrackedStateScanRequest};
763
637
  use crate::untracked_state::{MaterializedUntrackedStateRow, UntrackedStateContext};
764
638
  use crate::NullableKeyFilter;
765
639
  use serde_json::json;
766
640
 
641
+ const COMMIT_SCHEMA_KEY: &str = "lix_commit";
642
+
767
643
  fn live_state_context() -> LiveStateContext {
768
644
  LiveStateContext::new(
769
645
  crate::tracked_state::TrackedStateContext::new(),
770
646
  crate::untracked_state::UntrackedStateContext::new(),
771
- crate::commit_graph::CommitGraphContext::new(crate::changelog::ChangelogContext::new()),
647
+ crate::commit_graph::CommitGraphContext::new(),
772
648
  )
773
649
  }
774
650
 
@@ -777,16 +653,14 @@ mod tests {
777
653
  rows: &[MaterializedUntrackedStateRow],
778
654
  ) {
779
655
  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
- };
656
+ let canonical_rows = rows
657
+ .iter()
658
+ .map(|row| crate::test_support::untracked_state_row_from_materialized(&mut writes, row))
659
+ .collect::<Result<Vec<_>, _>>()
660
+ .expect("untracked rows should canonicalize");
787
661
  UntrackedStateContext::new()
788
662
  .writer(&mut writes)
789
- .stage_rows(&canonical_rows)
663
+ .stage_rows(canonical_rows.iter().map(|row| row.as_ref()))
790
664
  .expect("untracked rows should write");
791
665
  writes
792
666
  .apply(store)
@@ -794,6 +668,174 @@ mod tests {
794
668
  .expect("untracked rows should apply");
795
669
  }
796
670
 
671
+ async fn write_empty_commits_to_store(
672
+ store: &mut (impl StorageWriteTransaction + ?Sized),
673
+ commit_ids: &[&str],
674
+ ) {
675
+ let mut writes = StorageWriteSet::new();
676
+ for commit_id in commit_ids {
677
+ let commit_change_id = format!("{commit_id}:commit");
678
+ CommitStoreContext::new()
679
+ .writer(&mut *store, &mut writes)
680
+ .stage_commit_draft(
681
+ CommitDraftRef {
682
+ id: commit_id,
683
+ change_id: &commit_change_id,
684
+ parent_ids: &[],
685
+ author_account_ids: &[],
686
+ created_at: "1970-01-01T00:00:00.000Z",
687
+ },
688
+ Vec::new(),
689
+ Vec::new(),
690
+ )
691
+ .await
692
+ .expect("empty commit should stage");
693
+ }
694
+ writes
695
+ .apply(store)
696
+ .await
697
+ .expect("empty commits should apply");
698
+ }
699
+
700
+ async fn stage_materialized_live_rows(
701
+ store: &mut (impl StorageReader + ?Sized),
702
+ writes: &mut StorageWriteSet,
703
+ _json_writer: &mut crate::json_store::JsonStoreWriter,
704
+ rows: &[MaterializedLiveStateRow],
705
+ ) -> Result<(), LixError> {
706
+ let mut untracked_rows = Vec::new();
707
+ let mut tracked_rows_by_commit = std::collections::BTreeMap::<
708
+ String,
709
+ Vec<(crate::commit_store::Change, String, String)>,
710
+ >::new();
711
+ let mut parent_by_commit = std::collections::BTreeMap::<String, Option<String>>::new();
712
+
713
+ for row in rows {
714
+ if row.untracked {
715
+ let materialized = crate::untracked_state::MaterializedUntrackedStateRow::from(row);
716
+ let canonical = crate::test_support::untracked_state_row_from_materialized(
717
+ writes,
718
+ &materialized,
719
+ )?;
720
+ untracked_rows.push(canonical);
721
+ continue;
722
+ }
723
+ let materialized = MaterializedTrackedStateRow::try_from(row)?;
724
+ let commit_id = row.commit_id.clone().ok_or_else(|| {
725
+ LixError::new("LIX_ERROR_UNKNOWN", "test tracked row missing commit_id")
726
+ })?;
727
+ if row.schema_key == COMMIT_SCHEMA_KEY {
728
+ parent_by_commit.insert(
729
+ commit_id.clone(),
730
+ parent_commit_id_from_test_commit_row(row)?,
731
+ );
732
+ }
733
+ if row.schema_key != COMMIT_SCHEMA_KEY {
734
+ let change = crate::test_support::tracked_change_from_materialized(&materialized)?;
735
+ stage_tracked_materialized_json(writes, &commit_id, &materialized)?;
736
+ tracked_rows_by_commit.entry(commit_id).or_default().push((
737
+ change,
738
+ materialized.created_at,
739
+ materialized.updated_at,
740
+ ));
741
+ }
742
+ }
743
+
744
+ UntrackedStateContext::new()
745
+ .writer(writes)
746
+ .stage_rows(untracked_rows.iter().map(|row| row.as_ref()))?;
747
+ for (commit_id, rows) in tracked_rows_by_commit {
748
+ let parent_commit_id = parent_by_commit.remove(&commit_id).flatten();
749
+ let parent_ids = parent_commit_id
750
+ .as_ref()
751
+ .map(|parent| vec![parent.clone()])
752
+ .unwrap_or_default();
753
+ let commit_change_id = format!("{commit_id}:commit");
754
+ let commit = CommitDraftRef {
755
+ id: &commit_id,
756
+ change_id: &commit_change_id,
757
+ parent_ids: &parent_ids,
758
+ author_account_ids: &[],
759
+ created_at: rows
760
+ .first()
761
+ .map(|(change, _, _)| change.created_at.as_str())
762
+ .unwrap_or("1970-01-01T00:00:00.000Z"),
763
+ };
764
+ let staged = CommitStoreContext::new()
765
+ .writer(&mut *store, writes)
766
+ .stage_tracked_commit_draft(
767
+ commit,
768
+ rows.iter().map(|(change, _, _)| change.as_ref()).collect(),
769
+ Vec::new(),
770
+ )
771
+ .await?;
772
+ let deltas = rows
773
+ .iter()
774
+ .zip(&staged.authored_locators)
775
+ .map(
776
+ |((change, created_at, updated_at), locator)| TrackedStateDeltaRef {
777
+ change: change.as_ref(),
778
+ locator: locator.as_ref(),
779
+ created_at,
780
+ updated_at,
781
+ },
782
+ )
783
+ .collect::<Vec<_>>();
784
+ TrackedStateContext::new()
785
+ .writer(&mut *store, writes)
786
+ .stage_delta(&commit_id, parent_commit_id.as_deref(), &deltas)
787
+ .await?;
788
+ }
789
+ Ok(())
790
+ }
791
+
792
+ fn stage_tracked_materialized_json(
793
+ writes: &mut StorageWriteSet,
794
+ commit_id: &str,
795
+ row: &MaterializedTrackedStateRow,
796
+ ) -> Result<(), LixError> {
797
+ let mut payloads = Vec::new();
798
+ if let Some(snapshot) = row.snapshot_content.as_deref() {
799
+ payloads.push(NormalizedJson::from_arc_unchecked(Arc::from(snapshot)));
800
+ }
801
+ if let Some(metadata) = row.metadata.as_ref() {
802
+ payloads.push(NormalizedJson::from_arc_unchecked(Arc::from(
803
+ crate::serialize_row_metadata(metadata),
804
+ )));
805
+ }
806
+ JsonStoreContext::new().writer().stage_batch(
807
+ writes,
808
+ JsonWritePlacementRef::CommitPack {
809
+ commit_id,
810
+ pack_id: 0,
811
+ },
812
+ payloads
813
+ .iter()
814
+ .map(|payload| NormalizedJsonRef::from(payload)),
815
+ )?;
816
+ Ok(())
817
+ }
818
+
819
+ fn parent_commit_id_from_test_commit_row(
820
+ row: &MaterializedLiveStateRow,
821
+ ) -> Result<Option<String>, LixError> {
822
+ let Some(metadata) = row.metadata.as_deref() else {
823
+ return Ok(None);
824
+ };
825
+ let metadata = serde_json::from_str::<serde_json::Value>(metadata).map_err(|error| {
826
+ LixError::new(
827
+ "LIX_ERROR_UNKNOWN",
828
+ format!("test commit row has invalid metadata: {error}"),
829
+ )
830
+ })?;
831
+ Ok(metadata
832
+ .get("test_parents")
833
+ .and_then(serde_json::Value::as_array)
834
+ .and_then(|parents| parents.first())
835
+ .and_then(serde_json::Value::as_str)
836
+ .map(str::to_string))
837
+ }
838
+
797
839
  #[tokio::test]
798
840
  async fn live_state_overlays_untracked_rows() {
799
841
  let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
@@ -808,19 +850,18 @@ mod tests {
808
850
  let mut writes = StorageWriteSet::new();
809
851
  let mut json_writer = JsonStoreContext::new().writer();
810
852
  {
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");
853
+ stage_materialized_live_rows(
854
+ transaction.as_mut(),
855
+ &mut writes,
856
+ &mut json_writer,
857
+ &[tracked_row_with_commit(
858
+ "tracked-value",
859
+ Some("change-tracked"),
860
+ "commit-tracked",
861
+ )],
862
+ )
863
+ .await
864
+ .expect("tracked row should stage");
824
865
  }
825
866
  writes
826
867
  .apply(&mut transaction.as_mut())
@@ -880,19 +921,18 @@ mod tests {
880
921
  let mut writes = StorageWriteSet::new();
881
922
  let mut json_writer = JsonStoreContext::new().writer();
882
923
  {
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");
924
+ stage_materialized_live_rows(
925
+ transaction.as_mut(),
926
+ &mut writes,
927
+ &mut json_writer,
928
+ &[tracked_row_with_commit(
929
+ "tracked-value",
930
+ Some("change-tracked"),
931
+ "commit-tracked",
932
+ )],
933
+ )
934
+ .await
935
+ .expect("tracked row should stage");
896
936
  }
897
937
  writes
898
938
  .apply(&mut transaction.as_mut())
@@ -932,19 +972,18 @@ mod tests {
932
972
  let mut writes = StorageWriteSet::new();
933
973
  let mut json_writer = JsonStoreContext::new().writer();
934
974
  {
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");
975
+ stage_materialized_live_rows(
976
+ transaction.as_mut(),
977
+ &mut writes,
978
+ &mut json_writer,
979
+ &[tracked_row_with_commit(
980
+ "tracked-value",
981
+ Some("change-tracked"),
982
+ "commit-tracked",
983
+ )],
984
+ )
985
+ .await
986
+ .expect("tracked row should stage");
948
987
  }
949
988
  writes
950
989
  .apply(&mut transaction.as_mut())
@@ -961,14 +1000,15 @@ mod tests {
961
1000
  .await;
962
1001
  {
963
1002
  let mut writes = StorageWriteSet::new();
1003
+ let identity = crate::untracked_state::UntrackedStateIdentity {
1004
+ version_id: "global".to_string(),
1005
+ schema_key: "lix_key_value".to_string(),
1006
+ entity_id: EntityIdentity::single("selected-tab"),
1007
+ file_id: None,
1008
+ };
964
1009
  UntrackedStateContext::new()
965
1010
  .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
- }]);
1011
+ .stage_delete_rows(std::iter::once(identity.as_ref()));
972
1012
  writes
973
1013
  .apply(&mut transaction.as_mut())
974
1014
  .await
@@ -1007,11 +1047,14 @@ mod tests {
1007
1047
  let mut writes = StorageWriteSet::new();
1008
1048
  let mut json_writer = JsonStoreContext::new().writer();
1009
1049
  {
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");
1050
+ stage_materialized_live_rows(
1051
+ transaction.as_mut(),
1052
+ &mut writes,
1053
+ &mut json_writer,
1054
+ &rows,
1055
+ )
1056
+ .await
1057
+ .expect("tracked row should stage");
1015
1058
  }
1016
1059
  writes
1017
1060
  .apply(&mut transaction.as_mut())
@@ -1026,6 +1069,7 @@ mod tests {
1026
1069
  ],
1027
1070
  )
1028
1071
  .await;
1072
+ write_empty_commits_to_store(transaction.as_mut(), &["commit-version-a"]).await;
1029
1073
  transaction.commit().await.expect("commit should persist");
1030
1074
 
1031
1075
  let loaded = load_selected_tab_at(&live_state, storage.clone(), "version-a")
@@ -1050,7 +1094,7 @@ mod tests {
1050
1094
  let live_state = LiveStateContext::new(
1051
1095
  tracked_state.clone(),
1052
1096
  UntrackedStateContext::new(),
1053
- crate::commit_graph::CommitGraphContext::new(crate::changelog::ChangelogContext::new()),
1097
+ crate::commit_graph::CommitGraphContext::new(),
1054
1098
  );
1055
1099
 
1056
1100
  let mut transaction = storage
@@ -1066,11 +1110,14 @@ mod tests {
1066
1110
  let mut writes = StorageWriteSet::new();
1067
1111
  let mut json_writer = JsonStoreContext::new().writer();
1068
1112
  {
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");
1113
+ stage_materialized_live_rows(
1114
+ transaction.as_mut(),
1115
+ &mut writes,
1116
+ &mut json_writer,
1117
+ &rows,
1118
+ )
1119
+ .await
1120
+ .expect("global tracked row should stage");
1074
1121
  }
1075
1122
  writes
1076
1123
  .apply(&mut transaction.as_mut())
@@ -1085,6 +1132,7 @@ mod tests {
1085
1132
  ],
1086
1133
  )
1087
1134
  .await;
1135
+ write_empty_commits_to_store(transaction.as_mut(), &["commit-main"]).await;
1088
1136
  transaction.commit().await.expect("commit should persist");
1089
1137
 
1090
1138
  let loaded = load_selected_tab_at(&live_state, storage.clone(), "main")
@@ -1130,11 +1178,14 @@ mod tests {
1130
1178
  let mut writes = StorageWriteSet::new();
1131
1179
  let mut json_writer = JsonStoreContext::new().writer();
1132
1180
  {
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");
1181
+ stage_materialized_live_rows(
1182
+ transaction.as_mut(),
1183
+ &mut writes,
1184
+ &mut json_writer,
1185
+ &rows,
1186
+ )
1187
+ .await
1188
+ .expect("tracked rows should stage");
1138
1189
  }
1139
1190
  writes
1140
1191
  .apply(&mut transaction.as_mut())
@@ -1187,11 +1238,14 @@ mod tests {
1187
1238
  let mut writes = StorageWriteSet::new();
1188
1239
  let mut json_writer = JsonStoreContext::new().writer();
1189
1240
  {
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");
1241
+ stage_materialized_live_rows(
1242
+ transaction.as_mut(),
1243
+ &mut writes,
1244
+ &mut json_writer,
1245
+ &rows,
1246
+ )
1247
+ .await
1248
+ .expect("tracked rows should stage");
1195
1249
  }
1196
1250
  writes
1197
1251
  .apply(&mut transaction.as_mut())
@@ -1244,11 +1298,14 @@ mod tests {
1244
1298
  let mut writes = StorageWriteSet::new();
1245
1299
  let mut json_writer = JsonStoreContext::new().writer();
1246
1300
  {
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");
1301
+ stage_materialized_live_rows(
1302
+ transaction.as_mut(),
1303
+ &mut writes,
1304
+ &mut json_writer,
1305
+ &rows,
1306
+ )
1307
+ .await
1308
+ .expect("tracked rows should stage");
1252
1309
  }
1253
1310
  writes
1254
1311
  .apply(&mut transaction.as_mut())
@@ -1303,11 +1360,14 @@ mod tests {
1303
1360
  let mut writes = StorageWriteSet::new();
1304
1361
  let mut json_writer = JsonStoreContext::new().writer();
1305
1362
  {
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");
1363
+ stage_materialized_live_rows(
1364
+ transaction.as_mut(),
1365
+ &mut writes,
1366
+ &mut json_writer,
1367
+ &rows,
1368
+ )
1369
+ .await
1370
+ .expect("rows should stage");
1311
1371
  }
1312
1372
  writes
1313
1373
  .apply(&mut transaction.as_mut())
@@ -1355,11 +1415,14 @@ mod tests {
1355
1415
  let mut writes = StorageWriteSet::new();
1356
1416
  let mut json_writer = JsonStoreContext::new().writer();
1357
1417
  {
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");
1418
+ stage_materialized_live_rows(
1419
+ transaction.as_mut(),
1420
+ &mut writes,
1421
+ &mut json_writer,
1422
+ &rows,
1423
+ )
1424
+ .await
1425
+ .expect("rows should stage");
1363
1426
  }
1364
1427
  writes
1365
1428
  .apply(&mut transaction.as_mut())
@@ -1374,6 +1437,7 @@ mod tests {
1374
1437
  ],
1375
1438
  )
1376
1439
  .await;
1440
+ write_empty_commits_to_store(transaction.as_mut(), &["commit-version-a"]).await;
1377
1441
  transaction.commit().await.expect("commit should persist");
1378
1442
 
1379
1443
  let rows = scan_selected_tab_at(&live_state, storage.clone(), "version-a", false)
@@ -1408,11 +1472,14 @@ mod tests {
1408
1472
  let mut writes = StorageWriteSet::new();
1409
1473
  let mut json_writer = JsonStoreContext::new().writer();
1410
1474
  {
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");
1475
+ stage_materialized_live_rows(
1476
+ transaction.as_mut(),
1477
+ &mut writes,
1478
+ &mut json_writer,
1479
+ &rows,
1480
+ )
1481
+ .await
1482
+ .expect("tracked row should stage");
1416
1483
  }
1417
1484
  writes
1418
1485
  .apply(&mut transaction.as_mut())
@@ -1459,11 +1526,14 @@ mod tests {
1459
1526
  let mut writes = StorageWriteSet::new();
1460
1527
  let mut json_writer = JsonStoreContext::new().writer();
1461
1528
  {
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");
1529
+ stage_materialized_live_rows(
1530
+ transaction.as_mut(),
1531
+ &mut writes,
1532
+ &mut json_writer,
1533
+ &rows,
1534
+ )
1535
+ .await
1536
+ .expect("rows should stage");
1467
1537
  }
1468
1538
  writes
1469
1539
  .apply(&mut transaction.as_mut())
@@ -1515,11 +1585,14 @@ mod tests {
1515
1585
  let mut writes = StorageWriteSet::new();
1516
1586
  let mut json_writer = JsonStoreContext::new().writer();
1517
1587
  {
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");
1588
+ stage_materialized_live_rows(
1589
+ transaction.as_mut(),
1590
+ &mut writes,
1591
+ &mut json_writer,
1592
+ &rows,
1593
+ )
1594
+ .await
1595
+ .expect("tracked rows should stage");
1523
1596
  }
1524
1597
  writes
1525
1598
  .apply(&mut transaction.as_mut())
@@ -1550,158 +1623,6 @@ mod tests {
1550
1623
  assert_eq!(tombstones[0].snapshot_content, None);
1551
1624
  }
1552
1625
 
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
1626
  #[tokio::test]
1706
1627
  async fn writer_allows_commit_fact_to_share_the_touched_version_commit_id() {
1707
1628
  let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
@@ -1725,11 +1646,14 @@ mod tests {
1725
1646
  let mut writes = StorageWriteSet::new();
1726
1647
  let mut json_writer = JsonStoreContext::new().writer();
1727
1648
  {
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");
1649
+ stage_materialized_live_rows(
1650
+ transaction.as_mut(),
1651
+ &mut writes,
1652
+ &mut json_writer,
1653
+ &rows,
1654
+ )
1655
+ .await
1656
+ .expect("commit facts are changelog projections, not root-local rows");
1733
1657
  }
1734
1658
  writes
1735
1659
  .apply(&mut transaction.as_mut())
@@ -1757,25 +1681,31 @@ mod tests {
1757
1681
  async fn writer_uses_first_parent_as_merge_root_base() {
1758
1682
  let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1759
1683
  let storage = StorageContext::new(Arc::clone(&backend));
1760
- let live_state = live_state_context();
1761
1684
  let mut seed_transaction = storage
1762
1685
  .begin_write_transaction()
1763
1686
  .await
1764
1687
  .expect("seed transaction should open");
1765
1688
  let mut writes = StorageWriteSet::new();
1766
1689
  {
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
- &[],
1690
+ CommitStoreContext::new()
1691
+ .writer(&mut seed_transaction.as_mut(), &mut writes)
1692
+ .stage_commit_draft(
1693
+ CommitDraftRef {
1694
+ id: "parent-left",
1695
+ change_id: "parent-left:commit",
1696
+ parent_ids: &[],
1697
+ author_account_ids: &[],
1698
+ created_at: "1970-01-01T00:00:00.000Z",
1699
+ },
1700
+ Vec::new(),
1701
+ Vec::new(),
1777
1702
  )
1778
1703
  .await
1704
+ .expect("first parent commit should stage");
1705
+ TrackedStateContext::new()
1706
+ .writer(&mut seed_transaction.as_mut(), &mut writes)
1707
+ .stage_delta("parent-left", None, &[])
1708
+ .await
1779
1709
  .expect("first parent root should exist");
1780
1710
  }
1781
1711
  writes
@@ -1808,11 +1738,14 @@ mod tests {
1808
1738
  let mut writes = StorageWriteSet::new();
1809
1739
  let mut json_writer = JsonStoreContext::new().writer();
1810
1740
  {
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");
1741
+ stage_materialized_live_rows(
1742
+ transaction.as_mut(),
1743
+ &mut writes,
1744
+ &mut json_writer,
1745
+ &rows,
1746
+ )
1747
+ .await
1748
+ .expect("merge commit should use first parent as tracked-root base");
1816
1749
  }
1817
1750
  writes
1818
1751
  .apply(&mut transaction.as_mut())
@@ -1821,101 +1754,11 @@ mod tests {
1821
1754
  }
1822
1755
  }
1823
1756
 
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
1757
  #[tokio::test]
1910
1758
  async fn non_global_root_does_not_store_global_rows() {
1911
1759
  let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1912
1760
  let storage = StorageContext::new(Arc::clone(&backend));
1913
1761
  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
1762
  let mut transaction = storage
1920
1763
  .begin_write_transaction()
1921
1764
  .await
@@ -1934,11 +1777,14 @@ mod tests {
1934
1777
  let mut writes = StorageWriteSet::new();
1935
1778
  let mut json_writer = JsonStoreContext::new().writer();
1936
1779
  {
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");
1780
+ stage_materialized_live_rows(
1781
+ transaction.as_mut(),
1782
+ &mut writes,
1783
+ &mut json_writer,
1784
+ &rows,
1785
+ )
1786
+ .await
1787
+ .expect("tracked rows should stage");
1942
1788
  }
1943
1789
  writes
1944
1790
  .apply(&mut transaction.as_mut())
@@ -1967,7 +1813,7 @@ mod tests {
1967
1813
  async fn load_selected_tab(
1968
1814
  live_state: &LiveStateContext,
1969
1815
  storage: StorageContext,
1970
- ) -> Result<Option<LiveStateRow>, LixError> {
1816
+ ) -> Result<Option<MaterializedLiveStateRow>, LixError> {
1971
1817
  live_state
1972
1818
  .reader(storage)
1973
1819
  .load_row(&LiveStateRowRequest {
@@ -1983,7 +1829,7 @@ mod tests {
1983
1829
  live_state: &LiveStateContext,
1984
1830
  storage: StorageContext,
1985
1831
  version_id: &str,
1986
- ) -> Result<Option<LiveStateRow>, LixError> {
1832
+ ) -> Result<Option<MaterializedLiveStateRow>, LixError> {
1987
1833
  live_state
1988
1834
  .reader(storage)
1989
1835
  .load_row(&LiveStateRowRequest {
@@ -2000,7 +1846,7 @@ mod tests {
2000
1846
  storage: StorageContext,
2001
1847
  version_id: &str,
2002
1848
  include_tombstones: bool,
2003
- ) -> Result<Vec<LiveStateRow>, LixError> {
1849
+ ) -> Result<Vec<MaterializedLiveStateRow>, LixError> {
2004
1850
  live_state
2005
1851
  .reader(storage)
2006
1852
  .scan_rows(&LiveStateScanRequest {
@@ -2023,7 +1869,7 @@ mod tests {
2023
1869
  tracked_state: &TrackedStateContext,
2024
1870
  storage: StorageContext,
2025
1871
  commit_id: &str,
2026
- ) -> Vec<TrackedStateRow> {
1872
+ ) -> Vec<MaterializedTrackedStateRow> {
2027
1873
  tracked_state
2028
1874
  .reader(storage)
2029
1875
  .scan_rows_at_commit(
@@ -2044,7 +1890,7 @@ mod tests {
2044
1890
  value: &str,
2045
1891
  change_id: Option<&str>,
2046
1892
  commit_id: &str,
2047
- ) -> LiveStateRow {
1893
+ ) -> MaterializedLiveStateRow {
2048
1894
  tracked_row_at_with_commit("global", value, change_id, commit_id)
2049
1895
  }
2050
1896
 
@@ -2053,14 +1899,14 @@ mod tests {
2053
1899
  value: &str,
2054
1900
  change_id: Option<&str>,
2055
1901
  commit_id: &str,
2056
- ) -> LiveStateRow {
2057
- LiveStateRow {
1902
+ ) -> MaterializedLiveStateRow {
1903
+ MaterializedLiveStateRow {
2058
1904
  entity_id: identity("selected-tab"),
2059
1905
  schema_key: "lix_key_value".to_string(),
2060
1906
  file_id: None,
2061
1907
  snapshot_content: Some(format!("{{\"value\":\"{value}\"}}")),
2062
1908
  metadata: None,
2063
- schema_version: "1".to_string(),
1909
+ deleted: false,
2064
1910
  created_at: "2026-01-01T00:00:00Z".to_string(),
2065
1911
  updated_at: "2026-01-01T00:00:00Z".to_string(),
2066
1912
  global: version_id == "global",
@@ -2075,9 +1921,10 @@ mod tests {
2075
1921
  version_id: &str,
2076
1922
  change_id: Option<&str>,
2077
1923
  commit_id: &str,
2078
- ) -> LiveStateRow {
2079
- LiveStateRow {
1924
+ ) -> MaterializedLiveStateRow {
1925
+ MaterializedLiveStateRow {
2080
1926
  snapshot_content: None,
1927
+ deleted: true,
2081
1928
  ..tracked_row_at_with_commit(version_id, "ignored", change_id, commit_id)
2082
1929
  }
2083
1930
  }
@@ -2093,7 +1940,7 @@ mod tests {
2093
1940
  file_id: None,
2094
1941
  snapshot_content: Some(format!("{{\"value\":\"{value}\"}}")),
2095
1942
  metadata: None,
2096
- schema_version: "1".to_string(),
1943
+ deleted: false,
2097
1944
  created_at: "2026-01-01T00:00:00Z".to_string(),
2098
1945
  updated_at: "2026-01-01T00:00:00Z".to_string(),
2099
1946
  global: version_id == "global",
@@ -2114,7 +1961,7 @@ mod tests {
2114
1961
  .expect("version ref should serialize"),
2115
1962
  ),
2116
1963
  metadata: None,
2117
- schema_version: "1".to_string(),
1964
+ deleted: false,
2118
1965
  created_at: "2026-01-01T00:00:00Z".to_string(),
2119
1966
  updated_at: "2026-01-01T00:00:00Z".to_string(),
2120
1967
  global: true,
@@ -2122,57 +1969,32 @@ mod tests {
2122
1969
  }
2123
1970
  }
2124
1971
 
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 {
1972
+ fn commit_live_state_row(commit_id: &str) -> MaterializedLiveStateRow {
2153
1973
  commit_live_state_row_with_parents(commit_id, &[])
2154
1974
  }
2155
1975
 
2156
1976
  fn commit_live_state_row_with_parents(
2157
1977
  commit_id: &str,
2158
- parent_commit_ids: &[&str],
2159
- ) -> LiveStateRow {
2160
- commit_live_state_row_with_snapshot(
1978
+ parent_ids: &[&str],
1979
+ ) -> MaterializedLiveStateRow {
1980
+ let mut row = commit_live_state_row_with_snapshot(
2161
1981
  commit_id,
2162
1982
  json!({
2163
1983
  "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
1984
  }),
2168
- )
1985
+ );
1986
+ row.metadata = Some(
1987
+ serde_json::to_string(&json!({ "test_parents": parent_ids }))
1988
+ .expect("test metadata should serialize"),
1989
+ );
1990
+ row
2169
1991
  }
2170
1992
 
2171
1993
  fn commit_live_state_row_with_snapshot(
2172
1994
  commit_id: &str,
2173
1995
  snapshot: serde_json::Value,
2174
- ) -> LiveStateRow {
2175
- LiveStateRow {
1996
+ ) -> MaterializedLiveStateRow {
1997
+ MaterializedLiveStateRow {
2176
1998
  entity_id: identity(commit_id),
2177
1999
  schema_key: COMMIT_SCHEMA_KEY.to_string(),
2178
2000
  file_id: None,
@@ -2180,7 +2002,7 @@ mod tests {
2180
2002
  serde_json::to_string(&snapshot).expect("commit snapshot should serialize"),
2181
2003
  ),
2182
2004
  metadata: None,
2183
- schema_version: "1".to_string(),
2005
+ deleted: false,
2184
2006
  created_at: "2026-01-01T00:00:00Z".to_string(),
2185
2007
  updated_at: "2026-01-01T00:00:00Z".to_string(),
2186
2008
  global: true,
@@ -2191,50 +2013,6 @@ mod tests {
2191
2013
  }
2192
2014
  }
2193
2015
 
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
2016
  fn identity(entity_id: &str) -> EntityIdentity {
2239
2017
  EntityIdentity::single(entity_id)
2240
2018
  }