@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,16 +1,23 @@
1
- use std::{collections::BTreeMap, future::Future, ops::Range, pin::Pin};
1
+ use std::{
2
+ collections::{BTreeMap, VecDeque},
3
+ future::Future,
4
+ ops::Range,
5
+ pin::Pin,
6
+ };
2
7
 
3
8
  use crate::storage::{StorageReader, StorageWriteSet};
4
9
  use crate::tracked_state::codec::{
5
- boundary_trigger, child_summary_from_node, decode_key, decode_node, decode_value,
6
- encode_internal_node, encode_key, encode_leaf_node, encode_schema_file_prefix,
7
- encode_schema_key_prefix, encode_value, ChildSummary, DecodedNode, EncodedLeafEntry,
10
+ boundary_trigger, child_summary_from_node, decode_key, decode_key_with_trusted_prefix,
11
+ decode_node, decode_node_ref, decode_value, decode_visible_value, encode_internal_node,
12
+ encode_internal_node_refs, encode_key, encode_leaf_node, encode_leaf_node_refs,
13
+ encode_schema_file_prefix, encode_schema_key_prefix, ChildSummary, ChildSummaryRef,
14
+ DecodedLeafNodeRef, DecodedNode, DecodedNodeRef, EncodedLeafEntry, EncodedLeafEntryRef,
8
15
  PendingChunkWrite,
9
16
  };
10
17
  use crate::tracked_state::storage;
11
- use crate::tracked_state::tree_types::{
12
- TrackedStateApplyResult, TrackedStateKey, TrackedStateMutation, TrackedStateRootId,
13
- TrackedStateTreeDiffEntry, TrackedStateTreeScanRequest, TrackedStateValue,
18
+ use crate::tracked_state::types::{
19
+ TrackedStateApplyResult, TrackedStateIndexValue, TrackedStateKey, TrackedStateMutation,
20
+ TrackedStateRootId, TrackedStateTreeDiffEntry, TrackedStateTreeScanRequest,
14
21
  TRACKED_STATE_HASH_BYTES,
15
22
  };
16
23
  use crate::{LixError, NullableKeyFilter};
@@ -22,6 +29,11 @@ pub(crate) struct TrackedStateTreeOptions {
22
29
  pub(crate) max_chunk_bytes: usize,
23
30
  }
24
31
 
32
+ enum MutationApply<T> {
33
+ Applied(TrackedStateApplyResult),
34
+ Fallback(T),
35
+ }
36
+
25
37
  impl Default for TrackedStateTreeOptions {
26
38
  fn default() -> Self {
27
39
  Self {
@@ -61,12 +73,13 @@ impl TrackedStateTree {
61
73
  storage::load_root(store, commit_id).await
62
74
  }
63
75
 
76
+ #[cfg(test)]
64
77
  pub(crate) async fn get(
65
78
  &self,
66
79
  store: &mut impl StorageReader,
67
80
  root_id: &TrackedStateRootId,
68
81
  key: &TrackedStateKey,
69
- ) -> Result<Option<TrackedStateValue>, LixError> {
82
+ ) -> Result<Option<TrackedStateIndexValue>, LixError> {
70
83
  let encoded_key = encode_key(key);
71
84
  let mut current = *root_id.as_bytes();
72
85
  loop {
@@ -102,7 +115,7 @@ impl TrackedStateTree {
102
115
  store: &mut impl StorageReader,
103
116
  root_id: &TrackedStateRootId,
104
117
  keys: &[TrackedStateKey],
105
- ) -> Result<Vec<Option<TrackedStateValue>>, LixError> {
118
+ ) -> Result<Vec<Option<TrackedStateIndexValue>>, LixError> {
106
119
  if keys.is_empty() {
107
120
  return Ok(Vec::new());
108
121
  }
@@ -140,15 +153,23 @@ impl TrackedStateTree {
140
153
  store: &mut impl StorageReader,
141
154
  root_id: &TrackedStateRootId,
142
155
  request: &TrackedStateTreeScanRequest,
143
- ) -> Result<Vec<(TrackedStateKey, TrackedStateValue)>, LixError> {
156
+ ) -> Result<Vec<(TrackedStateKey, TrackedStateIndexValue)>, LixError> {
144
157
  if request.limit == Some(0) {
145
158
  return Ok(Vec::new());
146
159
  }
147
160
 
148
161
  let ranges = scan_ranges(request);
162
+ let key_decode_hint = scan_key_decode_hint(request, &ranges);
149
163
  let mut rows = Vec::new();
150
- self.scan_node(store, *root_id.as_bytes(), request, &ranges, &mut rows)
151
- .await?;
164
+ self.scan_node(
165
+ store,
166
+ *root_id.as_bytes(),
167
+ request,
168
+ &ranges,
169
+ key_decode_hint,
170
+ &mut rows,
171
+ )
172
+ .await?;
152
173
  Ok(rows)
153
174
  }
154
175
 
@@ -215,38 +236,41 @@ impl TrackedStateTree {
215
236
  store: &mut (impl StorageReader + ?Sized),
216
237
  writes: &mut StorageWriteSet,
217
238
  base_root: Option<&TrackedStateRootId>,
218
- mutations: Vec<TrackedStateMutation>,
239
+ mut mutations: Vec<TrackedStateMutation>,
219
240
  commit_id: Option<&str>,
220
241
  ) -> Result<TrackedStateApplyResult, LixError> {
221
242
  let mut overlay = storage::TrackedStateChunkOverlay::new();
222
243
  if let Some(root_id) = base_root {
223
244
  if mutations.len() == 1 {
224
- if let Some(result) = self
245
+ let mutation = mutations.pop().expect("single mutation should exist");
246
+ match self
225
247
  .apply_single_mutation(
226
248
  store,
227
249
  writes,
228
250
  &mut overlay,
229
251
  root_id,
230
- &mutations[0],
252
+ mutation,
231
253
  commit_id,
232
254
  )
233
255
  .await?
234
256
  {
235
- return Ok(result);
257
+ MutationApply::Applied(result) => return Ok(result),
258
+ MutationApply::Fallback(mutation) => mutations = vec![mutation],
236
259
  }
237
260
  } else if mutations.len() > 1 {
238
- if let Some(result) = self
261
+ match self
239
262
  .apply_sorted_mutations_chunker(
240
263
  store,
241
264
  writes,
242
265
  &mut overlay,
243
266
  root_id,
244
- &mutations,
267
+ mutations,
245
268
  commit_id,
246
269
  )
247
270
  .await?
248
271
  {
249
- return Ok(result);
272
+ MutationApply::Applied(result) => return Ok(result),
273
+ MutationApply::Fallback(fallback_mutations) => mutations = fallback_mutations,
250
274
  }
251
275
  }
252
276
  }
@@ -264,11 +288,7 @@ impl TrackedStateTree {
264
288
  // Apply in caller order so repeated writes to the same key behave like
265
289
  // normal transaction staging: the latest mutation wins.
266
290
  for mutation in mutations {
267
- match mutation {
268
- TrackedStateMutation::Put { key, value } => {
269
- entries.insert(encode_key(&key), encode_value(&value));
270
- }
271
- }
291
+ entries.insert(mutation.encoded_key, mutation.encoded_value);
272
292
  }
273
293
 
274
294
  let built = self.build_tree_from_entries(
@@ -301,60 +321,71 @@ impl TrackedStateTree {
301
321
  writes: &mut StorageWriteSet,
302
322
  overlay: &mut storage::TrackedStateChunkOverlay,
303
323
  root_id: &TrackedStateRootId,
304
- mutation: &TrackedStateMutation,
324
+ mutation: TrackedStateMutation,
305
325
  commit_id: Option<&str>,
306
- ) -> Result<Option<TrackedStateApplyResult>, LixError> {
307
- let TrackedStateMutation::Put { key, value } = mutation;
308
- let encoded_key = encode_key(key);
309
- let encoded_value = encode_value(value);
310
-
311
- if let Some(result) = self
326
+ ) -> Result<MutationApply<TrackedStateMutation>, LixError> {
327
+ let mutation = match self
312
328
  .apply_single_mutation_from_seek_path(
313
- store,
314
- writes,
315
- overlay,
316
- root_id,
317
- &encoded_key,
318
- &encoded_value,
319
- commit_id,
329
+ store, writes, overlay, root_id, mutation, commit_id,
320
330
  )
321
331
  .await?
322
332
  {
323
- return Ok(result);
324
- }
333
+ MutationApply::Applied(result) => return Ok(MutationApply::Applied(result)),
334
+ MutationApply::Fallback(mutation) => mutation,
335
+ };
336
+
337
+ let TrackedStateMutation {
338
+ encoded_key,
339
+ encoded_value,
340
+ } = mutation;
325
341
 
326
342
  let levels = self
327
343
  .collect_summary_levels_with_overlay(store, overlay, root_id)
328
344
  .await?;
329
345
  let Some(leaves) = levels.first() else {
330
- return Ok(None);
346
+ return Ok(MutationApply::Fallback(TrackedStateMutation {
347
+ encoded_key,
348
+ encoded_value,
349
+ }));
331
350
  };
332
351
  let target_leaf_index = leaves
333
352
  .iter()
334
353
  .position(|leaf| leaf.last_key.as_slice() >= encoded_key.as_slice())
335
354
  .unwrap_or_else(|| leaves.len().saturating_sub(1));
336
355
  let Some(target_leaf) = leaves.get(target_leaf_index).cloned() else {
337
- return Ok(None);
356
+ return Ok(MutationApply::Fallback(TrackedStateMutation {
357
+ encoded_key,
358
+ encoded_value,
359
+ }));
338
360
  };
339
361
 
340
362
  let mut entries = self
341
363
  .load_leaf_entries_with_overlay(store, overlay, &target_leaf.child_hash)
342
364
  .await?;
343
- match entries.binary_search_by(|entry| entry.key.as_slice().cmp(&encoded_key)) {
365
+ let mutation_entry_index = match entries
366
+ .binary_search_by(|entry| entry.key.as_slice().cmp(encoded_key.as_slice()))
367
+ {
344
368
  Ok(index) => {
345
- if entries[index].value == encoded_value {
346
- return Ok(None);
369
+ if entries[index].value.as_slice() == encoded_value.as_slice() {
370
+ return Ok(MutationApply::Fallback(TrackedStateMutation {
371
+ encoded_key,
372
+ encoded_value,
373
+ }));
347
374
  }
348
375
  entries[index].value = encoded_value;
376
+ index
349
377
  }
350
- Err(index) => entries.insert(
351
- index,
352
- EncodedLeafEntry {
353
- key: encoded_key.clone(),
354
- value: encoded_value,
355
- },
356
- ),
357
- }
378
+ Err(index) => {
379
+ entries.insert(
380
+ index,
381
+ EncodedLeafEntry {
382
+ key: encoded_key,
383
+ value: encoded_value,
384
+ },
385
+ );
386
+ index
387
+ }
388
+ };
358
389
 
359
390
  let mut chunks = BTreeMap::new();
360
391
  let mut suffix_entries = entries;
@@ -366,20 +397,25 @@ impl TrackedStateTree {
366
397
  // existing post-mutation leaf, then reuse the rest of the old suffix.
367
398
  loop {
368
399
  let mut candidate_chunks = BTreeMap::new();
369
- let candidate_summaries =
370
- self.build_leaf_level(suffix_entries.clone(), &mut candidate_chunks);
400
+ let candidate_summaries = self.build_leaf_level_from_refs(
401
+ suffix_entries.iter().map(EncodedLeafEntry::as_ref),
402
+ &mut candidate_chunks,
403
+ );
371
404
 
372
405
  if let Some((generated_resync_index, existing_resync_index)) = first_resync_index(
373
406
  &candidate_summaries,
374
407
  &leaves[target_leaf_index..],
375
- &encoded_key,
408
+ suffix_entries[mutation_entry_index].key.as_slice(),
376
409
  ) {
377
410
  for summary in &candidate_summaries[..generated_resync_index] {
378
411
  if let Some(chunk) = candidate_chunks.remove(&summary.child_hash) {
379
412
  chunks.entry(chunk.hash).or_insert(chunk);
380
413
  }
381
414
  }
382
- replacement_leaves = candidate_summaries[..generated_resync_index].to_vec();
415
+ replacement_leaves = candidate_summaries
416
+ .into_iter()
417
+ .take(generated_resync_index)
418
+ .collect();
383
419
  old_leaf_count = existing_resync_index;
384
420
  break;
385
421
  }
@@ -408,7 +444,7 @@ impl TrackedStateTree {
408
444
  old_leaf_count,
409
445
  std::mem::take(&mut replacement_leaves),
410
446
  chunks,
411
- &encoded_key,
447
+ suffix_entries[mutation_entry_index].key.as_slice(),
412
448
  )?;
413
449
  overlay.stage_chunks(writes, &built.chunks);
414
450
  let persisted_root = if let Some(commit_id) = commit_id {
@@ -418,7 +454,7 @@ impl TrackedStateTree {
418
454
  false
419
455
  };
420
456
 
421
- Ok(Some(TrackedStateApplyResult {
457
+ Ok(MutationApply::Applied(TrackedStateApplyResult {
422
458
  root_id: built.root_id,
423
459
  row_count: built.row_count,
424
460
  tree_height: built.tree_height,
@@ -652,48 +688,65 @@ impl TrackedStateTree {
652
688
  writes: &mut StorageWriteSet,
653
689
  overlay: &mut storage::TrackedStateChunkOverlay,
654
690
  root_id: &TrackedStateRootId,
655
- mutations: &[TrackedStateMutation],
691
+ mutations: Vec<TrackedStateMutation>,
656
692
  commit_id: Option<&str>,
657
- ) -> Result<Option<TrackedStateApplyResult>, LixError> {
693
+ ) -> Result<MutationApply<Vec<TrackedStateMutation>>, LixError> {
658
694
  let mut mutation_map = BTreeMap::new();
659
695
  for mutation in mutations {
660
- let TrackedStateMutation::Put { key, value } = mutation;
661
- mutation_map.insert(encode_key(key), encode_value(value));
696
+ mutation_map.insert(mutation.encoded_key, mutation.encoded_value);
662
697
  }
663
698
  if mutation_map.is_empty() {
664
- return Ok(None);
699
+ return Ok(MutationApply::Fallback(Vec::new()));
665
700
  }
666
701
 
667
- let mutations = mutation_map.into_iter().collect::<Vec<_>>();
668
-
669
702
  let levels = self
670
703
  .collect_summary_levels_with_overlay(store, overlay, root_id)
671
704
  .await?;
672
705
  let Some(leaves) = levels.first() else {
673
- return Ok(None);
706
+ return Ok(MutationApply::Fallback(
707
+ mutation_map
708
+ .into_iter()
709
+ .map(|(encoded_key, encoded_value)| TrackedStateMutation {
710
+ encoded_key,
711
+ encoded_value,
712
+ })
713
+ .collect(),
714
+ ));
674
715
  };
675
716
 
676
717
  let base_row_count = leaves
677
718
  .iter()
678
719
  .map(|leaf| leaf.subtree_count as usize)
679
720
  .sum::<usize>();
721
+ let first_mutation_key = mutation_map
722
+ .keys()
723
+ .next()
724
+ .expect("non-empty mutation map should have first key");
680
725
  let append_only = leaves
681
726
  .last()
682
- .is_some_and(|leaf| mutations[0].0.as_slice() > leaf.last_key.as_slice());
683
- if !append_only && mutations.len() * 2 > base_row_count {
684
- return Ok(None);
727
+ .is_some_and(|leaf| first_mutation_key.as_slice() > leaf.last_key.as_slice());
728
+ if !append_only && mutation_map.len() * 2 > base_row_count {
729
+ return Ok(MutationApply::Fallback(
730
+ mutation_map
731
+ .into_iter()
732
+ .map(|(encoded_key, encoded_value)| TrackedStateMutation {
733
+ encoded_key,
734
+ encoded_value,
735
+ })
736
+ .collect(),
737
+ ));
685
738
  }
686
739
 
740
+ let mut mutations = mutation_map.into_iter().collect::<VecDeque<_>>();
687
741
  let mut output_leaves = Vec::new();
688
742
  let mut chunks = BTreeMap::new();
689
743
  let mut leaf_index = 0usize;
690
- let mut next_mutation_index = 0usize;
691
744
 
692
745
  while leaf_index < leaves.len() {
693
- if next_mutation_index >= mutations.len()
694
- || mutations[next_mutation_index].0.as_slice()
695
- > leaves[leaf_index].last_key.as_slice()
696
- {
746
+ let current_leaf_has_mutation = mutations
747
+ .front()
748
+ .is_some_and(|(key, _)| key.as_slice() <= leaves[leaf_index].last_key.as_slice());
749
+ if !current_leaf_has_mutation {
697
750
  output_leaves.push(leaves[leaf_index].clone());
698
751
  leaf_index += 1;
699
752
  continue;
@@ -701,7 +754,10 @@ impl TrackedStateTree {
701
754
 
702
755
  let window_start = leaf_index;
703
756
  let mut window_entries = BTreeMap::new();
704
- let mut window_mutation_ceiling = mutations[next_mutation_index].0.clone();
757
+ let mut window_mutation_ceiling = mutations
758
+ .front()
759
+ .map(|(key, _)| key.clone())
760
+ .expect("window with mutation should have front mutation");
705
761
 
706
762
  loop {
707
763
  if leaf_index < leaves.len() {
@@ -713,46 +769,45 @@ impl TrackedStateTree {
713
769
  window_entries.insert(entry.key, entry.value);
714
770
  }
715
771
 
716
- while next_mutation_index < mutations.len()
717
- && mutations[next_mutation_index].0.as_slice() <= leaf.last_key.as_slice()
772
+ while mutations
773
+ .front()
774
+ .is_some_and(|(key, _)| key.as_slice() <= leaf.last_key.as_slice())
718
775
  {
719
- let (key, value) = &mutations[next_mutation_index];
720
- window_entries.insert(key.clone(), value.clone());
776
+ let (key, value) = mutations
777
+ .pop_front()
778
+ .expect("front mutation should be present");
721
779
  window_mutation_ceiling = key.clone();
722
- next_mutation_index += 1;
780
+ window_entries.insert(key, value);
723
781
  }
724
782
  leaf_index += 1;
725
783
  }
726
784
 
727
- while next_mutation_index < mutations.len() {
728
- let (key, value) = &mutations[next_mutation_index];
785
+ while let Some((key, _)) = mutations.front() {
729
786
  if leaf_index < leaves.len()
730
787
  && key.as_slice() >= leaves[leaf_index].first_key.as_slice()
731
788
  {
732
789
  break;
733
790
  }
734
- window_entries.insert(key.clone(), value.clone());
791
+ let (key, value) = mutations
792
+ .pop_front()
793
+ .expect("front mutation should be present");
735
794
  window_mutation_ceiling = key.clone();
736
- next_mutation_index += 1;
795
+ window_entries.insert(key, value);
737
796
  }
738
797
 
739
- if next_mutation_index < mutations.len()
740
- && leaf_index < leaves.len()
741
- && mutations[next_mutation_index].0.as_slice()
742
- <= leaves[leaf_index].last_key.as_slice()
798
+ if leaf_index < leaves.len()
799
+ && mutations.front().is_some_and(|(key, _)| {
800
+ key.as_slice() <= leaves[leaf_index].last_key.as_slice()
801
+ })
743
802
  {
744
803
  continue;
745
804
  }
746
805
 
747
806
  let mut candidate_chunks = BTreeMap::new();
748
- let candidate_leaves = self.build_leaf_level(
807
+ let candidate_leaves = self.build_leaf_level_from_refs(
749
808
  window_entries
750
809
  .iter()
751
- .map(|(key, value)| EncodedLeafEntry {
752
- key: key.clone(),
753
- value: value.clone(),
754
- })
755
- .collect(),
810
+ .map(|(key, value)| EncodedLeafEntryRef { key, value }),
756
811
  &mut candidate_chunks,
757
812
  );
758
813
 
@@ -766,7 +821,7 @@ impl TrackedStateTree {
766
821
  chunks.entry(chunk.hash).or_insert(chunk);
767
822
  }
768
823
  }
769
- output_leaves.extend_from_slice(&candidate_leaves[..generated_resync_index]);
824
+ output_leaves.extend(candidate_leaves.into_iter().take(generated_resync_index));
770
825
  leaf_index = window_start + existing_resync_index;
771
826
  break;
772
827
  }
@@ -779,20 +834,19 @@ impl TrackedStateTree {
779
834
  }
780
835
  }
781
836
 
782
- if next_mutation_index < mutations.len() {
783
- let mut entries = Vec::new();
784
- for (key, value) in &mutations[next_mutation_index..] {
785
- entries.push(EncodedLeafEntry {
786
- key: key.clone(),
787
- value: value.clone(),
788
- });
789
- }
837
+ if !mutations.is_empty() {
838
+ let entries = mutations
839
+ .into_iter()
840
+ .map(|(key, value)| EncodedLeafEntry { key, value })
841
+ .collect();
790
842
  output_leaves.extend(self.build_leaf_level(entries, &mut chunks));
791
843
  }
792
844
 
793
845
  let built = self.build_tree_from_leaf_summaries(output_leaves, chunks)?;
794
- self.persist_built_tree(writes, overlay, built, commit_id)
795
- .await
846
+ Ok(MutationApply::Applied(
847
+ self.persist_built_tree(writes, overlay, built, commit_id)
848
+ .await?,
849
+ ))
796
850
  }
797
851
 
798
852
  async fn apply_single_mutation_from_seek_path(
@@ -801,10 +855,13 @@ impl TrackedStateTree {
801
855
  writes: &mut StorageWriteSet,
802
856
  overlay: &mut storage::TrackedStateChunkOverlay,
803
857
  root_id: &TrackedStateRootId,
804
- encoded_key: &[u8],
805
- encoded_value: &[u8],
858
+ mutation: TrackedStateMutation,
806
859
  commit_id: Option<&str>,
807
- ) -> Result<Option<Option<TrackedStateApplyResult>>, LixError> {
860
+ ) -> Result<MutationApply<TrackedStateMutation>, LixError> {
861
+ let TrackedStateMutation {
862
+ encoded_key,
863
+ encoded_value,
864
+ } = mutation;
808
865
  let mut current = *root_id.as_bytes();
809
866
  let mut path = Vec::new();
810
867
  let mut entries = loop {
@@ -817,7 +874,7 @@ impl TrackedStateTree {
817
874
  let children = internal.children().to_vec();
818
875
  let child_index = children
819
876
  .iter()
820
- .position(|child| child.last_key.as_slice() >= encoded_key)
877
+ .position(|child| child.last_key.as_slice() >= encoded_key.as_slice())
821
878
  .or_else(|| (!children.is_empty()).then_some(children.len() - 1))
822
879
  .ok_or_else(|| {
823
880
  LixError::new(
@@ -834,21 +891,30 @@ impl TrackedStateTree {
834
891
  }
835
892
  };
836
893
 
837
- match entries.binary_search_by(|entry| entry.key.as_slice().cmp(encoded_key)) {
894
+ let mutation_entry_index = match entries
895
+ .binary_search_by(|entry| entry.key.as_slice().cmp(encoded_key.as_slice()))
896
+ {
838
897
  Ok(index) => {
839
- if entries[index].value == encoded_value {
840
- return Ok(Some(None));
898
+ if entries[index].value.as_slice() == encoded_value.as_slice() {
899
+ return Ok(MutationApply::Fallback(TrackedStateMutation {
900
+ encoded_key,
901
+ encoded_value,
902
+ }));
841
903
  }
842
- entries[index].value = encoded_value.to_vec();
904
+ entries[index].value = encoded_value;
905
+ index
843
906
  }
844
- Err(index) => entries.insert(
845
- index,
846
- EncodedLeafEntry {
847
- key: encoded_key.to_vec(),
848
- value: encoded_value.to_vec(),
849
- },
850
- ),
851
- }
907
+ Err(index) => {
908
+ entries.insert(
909
+ index,
910
+ EncodedLeafEntry {
911
+ key: encoded_key,
912
+ value: encoded_value,
913
+ },
914
+ );
915
+ index
916
+ }
917
+ };
852
918
 
853
919
  let mut chunks = BTreeMap::new();
854
920
  let mut replacement_children;
@@ -856,10 +922,10 @@ impl TrackedStateTree {
856
922
 
857
923
  let Some(leaf_parent) = path.pop() else {
858
924
  let built = self.build_tree_from_entries(entries)?;
859
- return self
860
- .persist_built_tree(writes, overlay, built, commit_id)
861
- .await
862
- .map(Some);
925
+ return Ok(MutationApply::Applied(
926
+ self.persist_built_tree(writes, overlay, built, commit_id)
927
+ .await?,
928
+ ));
863
929
  };
864
930
  let mutation_is_right_edge = leaf_parent.child_index + 1 == leaf_parent.children.len()
865
931
  && path
@@ -870,26 +936,35 @@ impl TrackedStateTree {
870
936
  let mut next_leaf_index = leaf_parent.child_index + 1;
871
937
  loop {
872
938
  let mut candidate_chunks = BTreeMap::new();
873
- let candidate_leaves =
874
- self.build_leaf_level(leaf_entries.clone(), &mut candidate_chunks);
939
+ let candidate_leaves = self.build_leaf_level_from_refs(
940
+ leaf_entries.iter().map(EncodedLeafEntry::as_ref),
941
+ &mut candidate_chunks,
942
+ );
875
943
  if let Some((generated_resync_index, existing_resync_index)) = first_resync_index(
876
944
  &candidate_leaves,
877
945
  &leaf_parent.children[leaf_parent.child_index..],
878
- encoded_key,
946
+ leaf_entries[mutation_entry_index].key.as_slice(),
879
947
  ) {
880
948
  for summary in &candidate_leaves[..generated_resync_index] {
881
949
  if let Some(chunk) = candidate_chunks.remove(&summary.child_hash) {
882
950
  chunks.entry(chunk.hash).or_insert(chunk);
883
951
  }
884
952
  }
885
- replacement_children = candidate_leaves[..generated_resync_index].to_vec();
953
+ replacement_children = candidate_leaves
954
+ .into_iter()
955
+ .take(generated_resync_index)
956
+ .collect();
886
957
  old_child_count = existing_resync_index;
887
958
  break;
888
959
  }
889
960
 
890
961
  if next_leaf_index >= leaf_parent.children.len() {
891
962
  if !mutation_is_right_edge {
892
- return Ok(None);
963
+ let entry = leaf_entries.remove(mutation_entry_index);
964
+ return Ok(MutationApply::Fallback(TrackedStateMutation {
965
+ encoded_key: entry.key,
966
+ encoded_value: entry.value,
967
+ }));
893
968
  }
894
969
  chunks.extend(candidate_chunks);
895
970
  replacement_children = candidate_leaves;
@@ -941,10 +1016,10 @@ impl TrackedStateTree {
941
1016
  tree_height,
942
1017
  chunk_bytes,
943
1018
  };
944
- return self
945
- .persist_built_tree(writes, overlay, built, commit_id)
946
- .await
947
- .map(Some);
1019
+ return Ok(MutationApply::Applied(
1020
+ self.persist_built_tree(writes, overlay, built, commit_id)
1021
+ .await?,
1022
+ ));
948
1023
  };
949
1024
 
950
1025
  child_index = frame.child_index;
@@ -959,7 +1034,7 @@ impl TrackedStateTree {
959
1034
  overlay: &mut storage::TrackedStateChunkOverlay,
960
1035
  built: BuiltTree,
961
1036
  commit_id: Option<&str>,
962
- ) -> Result<Option<TrackedStateApplyResult>, LixError> {
1037
+ ) -> Result<TrackedStateApplyResult, LixError> {
963
1038
  overlay.stage_chunks(writes, &built.chunks);
964
1039
  let persisted_root = if let Some(commit_id) = commit_id {
965
1040
  storage::stage_root(writes, commit_id, &built.root_id);
@@ -967,14 +1042,14 @@ impl TrackedStateTree {
967
1042
  } else {
968
1043
  false
969
1044
  };
970
- Ok(Some(TrackedStateApplyResult {
1045
+ Ok(TrackedStateApplyResult {
971
1046
  root_id: built.root_id,
972
1047
  row_count: built.row_count,
973
1048
  tree_height: built.tree_height,
974
1049
  chunk_count: built.chunks.len(),
975
1050
  chunk_bytes: built.chunk_bytes,
976
1051
  persisted_root,
977
- }))
1052
+ })
978
1053
  }
979
1054
 
980
1055
  fn build_tree_from_entries(
@@ -1130,15 +1205,23 @@ impl TrackedStateTree {
1130
1205
  let parent_end_child_range =
1131
1206
  child_range_for_parent(old_children, &old_parents[parent_end])?;
1132
1207
  let mut window_children = Vec::new();
1133
- window_children.extend_from_slice(&old_children[parent_child_range.start..child_start]);
1134
- window_children.extend(replacement_children);
1135
- window_children.extend_from_slice(&old_children[old_child_end..parent_end_child_range.end]);
1208
+ window_children.extend(
1209
+ old_children[parent_child_range.start..child_start]
1210
+ .iter()
1211
+ .map(ChildSummary::as_ref),
1212
+ );
1213
+ window_children.extend(replacement_children.iter().map(ChildSummary::as_ref));
1214
+ window_children.extend(
1215
+ old_children[old_child_end..parent_end_child_range.end]
1216
+ .iter()
1217
+ .map(ChildSummary::as_ref),
1218
+ );
1136
1219
  let mut next_parent_index = parent_end + 1;
1137
1220
 
1138
1221
  loop {
1139
1222
  let mut candidate_chunks = BTreeMap::new();
1140
- let candidate_parents = self.build_internal_level(
1141
- window_children.clone(),
1223
+ let candidate_parents = self.build_internal_level_from_refs(
1224
+ window_children.iter().copied(),
1142
1225
  parent_level,
1143
1226
  &mut candidate_chunks,
1144
1227
  );
@@ -1156,7 +1239,10 @@ impl TrackedStateTree {
1156
1239
  return Ok(ParentLevelPatch {
1157
1240
  parent_start,
1158
1241
  old_parent_count: existing_resync_index,
1159
- replacement_parents: candidate_parents[..generated_resync_index].to_vec(),
1242
+ replacement_parents: candidate_parents
1243
+ .into_iter()
1244
+ .take(generated_resync_index)
1245
+ .collect(),
1160
1246
  });
1161
1247
  }
1162
1248
 
@@ -1170,7 +1256,7 @@ impl TrackedStateTree {
1170
1256
  }
1171
1257
 
1172
1258
  let next_range = child_range_for_parent(old_children, &old_parents[next_parent_index])?;
1173
- window_children.extend_from_slice(&old_children[next_range]);
1259
+ window_children.extend(old_children[next_range].iter().map(ChildSummary::as_ref));
1174
1260
  next_parent_index += 1;
1175
1261
  }
1176
1262
  }
@@ -1204,6 +1290,35 @@ impl TrackedStateTree {
1204
1290
  .collect()
1205
1291
  }
1206
1292
 
1293
+ fn build_leaf_level_from_refs<'a>(
1294
+ &self,
1295
+ entries: impl IntoIterator<Item = EncodedLeafEntryRef<'a>>,
1296
+ chunks: &mut BTreeMap<[u8; TRACKED_STATE_HASH_BYTES], PendingChunkWrite>,
1297
+ ) -> Vec<ChildSummary> {
1298
+ let groups = chunk_leaf_entry_refs(entries, &self.options);
1299
+ groups
1300
+ .into_iter()
1301
+ .map(|group| {
1302
+ let subtree_count = group.entries.len() as u64;
1303
+ let first_key = group
1304
+ .entries
1305
+ .first()
1306
+ .map(|entry| entry.key.to_vec())
1307
+ .unwrap_or_default();
1308
+ let last_key = group
1309
+ .entries
1310
+ .last()
1311
+ .map(|entry| entry.key.to_vec())
1312
+ .unwrap_or_default();
1313
+ let node = encode_leaf_node_refs(&group.entries);
1314
+ let (chunk, summary) =
1315
+ child_summary_from_node(node, first_key, last_key, subtree_count);
1316
+ chunks.entry(chunk.hash).or_insert(chunk);
1317
+ summary
1318
+ })
1319
+ .collect()
1320
+ }
1321
+
1207
1322
  fn build_internal_level(
1208
1323
  &self,
1209
1324
  children: Vec<ChildSummary>,
@@ -1234,6 +1349,36 @@ impl TrackedStateTree {
1234
1349
  .collect()
1235
1350
  }
1236
1351
 
1352
+ fn build_internal_level_from_refs<'a>(
1353
+ &self,
1354
+ children: impl IntoIterator<Item = ChildSummaryRef<'a>>,
1355
+ level: usize,
1356
+ chunks: &mut BTreeMap<[u8; TRACKED_STATE_HASH_BYTES], PendingChunkWrite>,
1357
+ ) -> Vec<ChildSummary> {
1358
+ let groups = chunk_internal_entry_refs(children, &self.options, level);
1359
+ groups
1360
+ .into_iter()
1361
+ .map(|group| {
1362
+ let subtree_count = group.children.iter().map(|child| child.subtree_count).sum();
1363
+ let first_key = group
1364
+ .children
1365
+ .first()
1366
+ .map(|child| child.first_key.to_vec())
1367
+ .unwrap_or_default();
1368
+ let last_key = group
1369
+ .children
1370
+ .last()
1371
+ .map(|child| child.last_key.to_vec())
1372
+ .unwrap_or_default();
1373
+ let node = encode_internal_node_refs(&group.children);
1374
+ let (chunk, summary) =
1375
+ child_summary_from_node(node, first_key, last_key, subtree_count);
1376
+ chunks.entry(chunk.hash).or_insert(chunk);
1377
+ summary
1378
+ })
1379
+ .collect()
1380
+ }
1381
+
1237
1382
  async fn collect_leaf_entries(
1238
1383
  &self,
1239
1384
  store: &mut (impl StorageReader + ?Sized),
@@ -1261,7 +1406,7 @@ impl TrackedStateTree {
1261
1406
  store: &mut impl StorageReader,
1262
1407
  root_id: &TrackedStateRootId,
1263
1408
  request: &TrackedStateTreeScanRequest,
1264
- ) -> Result<Vec<(TrackedStateKey, TrackedStateValue)>, LixError> {
1409
+ ) -> Result<Vec<(TrackedStateKey, TrackedStateIndexValue)>, LixError> {
1265
1410
  self.scan(store, root_id, request).await
1266
1411
  }
1267
1412
 
@@ -1271,39 +1416,66 @@ impl TrackedStateTree {
1271
1416
  hash: [u8; TRACKED_STATE_HASH_BYTES],
1272
1417
  request: &'a TrackedStateTreeScanRequest,
1273
1418
  ranges: &'a [EncodedScanRange],
1274
- rows: &'a mut Vec<(TrackedStateKey, TrackedStateValue)>,
1419
+ key_decode_hint: Option<ScanKeyDecodeHint<'a>>,
1420
+ rows: &'a mut Vec<(TrackedStateKey, TrackedStateIndexValue)>,
1275
1421
  ) -> Pin<Box<dyn Future<Output = Result<(), LixError>> + Send + 'a>>
1276
1422
  where
1277
1423
  S: StorageReader + Send + 'a,
1278
1424
  {
1279
1425
  Box::pin(async move {
1280
- match self.load_node(store, &hash).await? {
1281
- DecodedNode::Leaf(leaf) => {
1282
- for entry in leaf.entries() {
1426
+ let bytes = self.load_node_bytes(store, &hash).await?;
1427
+ match decode_node_ref(&bytes)? {
1428
+ DecodedNodeRef::Leaf(leaf) => {
1429
+ for index in 0..leaf.len() {
1283
1430
  if scan_limit_reached(request, rows.len()) {
1284
1431
  break;
1285
1432
  }
1286
- if !encoded_key_in_scan_ranges(&entry.key, ranges) {
1433
+ let entry = leaf.entry(index)?.ok_or_else(|| {
1434
+ LixError::new(
1435
+ "LIX_ERROR_UNKNOWN",
1436
+ "tracked-state leaf entry disappeared during scan",
1437
+ )
1438
+ })?;
1439
+ if !encoded_key_in_scan_ranges(entry.key, ranges) {
1287
1440
  continue;
1288
1441
  }
1289
- let key = decode_key(&entry.key)?;
1290
- if !key_matches_scan_filters(request, &key) {
1442
+ let key = match key_decode_hint {
1443
+ Some(hint) => decode_key_with_trusted_prefix(
1444
+ entry.key,
1445
+ hint.schema_key,
1446
+ hint.file_id,
1447
+ hint.prefix_len,
1448
+ )?,
1449
+ None => decode_key(entry.key)?,
1450
+ };
1451
+ if key_decode_hint.is_none() && !key_matches_scan_filters(request, &key) {
1291
1452
  continue;
1292
1453
  }
1293
- let value = decode_value(&entry.value)?;
1294
- if request.matches(&key, &value) {
1454
+ let Some(value) =
1455
+ decode_visible_value(entry.value, request.include_tombstones)?
1456
+ else {
1457
+ continue;
1458
+ };
1459
+ if key_decode_hint.is_some() || request.matches(&key, &value) {
1295
1460
  rows.push((key, value));
1296
1461
  }
1297
1462
  }
1298
1463
  }
1299
- DecodedNode::Internal(internal) => {
1464
+ DecodedNodeRef::Internal(internal) => {
1300
1465
  for child in internal.children() {
1301
1466
  if scan_limit_reached(request, rows.len()) {
1302
1467
  break;
1303
1468
  }
1304
1469
  if child_summary_overlaps_scan_ranges(child, ranges) {
1305
- self.scan_node(store, child.child_hash, request, ranges, rows)
1306
- .await?;
1470
+ self.scan_node(
1471
+ store,
1472
+ child.child_hash,
1473
+ request,
1474
+ ranges,
1475
+ key_decode_hint,
1476
+ rows,
1477
+ )
1478
+ .await?;
1307
1479
  }
1308
1480
  }
1309
1481
  }
@@ -1317,7 +1489,7 @@ impl TrackedStateTree {
1317
1489
  store: &'a mut S,
1318
1490
  hash: [u8; TRACKED_STATE_HASH_BYTES],
1319
1491
  encoded_keys: &'a [(usize, Vec<u8>)],
1320
- values: &'a mut [Option<TrackedStateValue>],
1492
+ values: &'a mut [Option<TrackedStateIndexValue>],
1321
1493
  ) -> Pin<Box<dyn Future<Output = Result<(), LixError>> + Send + 'a>>
1322
1494
  where
1323
1495
  S: StorageReader + Send + 'a,
@@ -1327,21 +1499,22 @@ impl TrackedStateTree {
1327
1499
  return Ok(());
1328
1500
  }
1329
1501
 
1330
- match self.load_node(store, &hash).await? {
1331
- DecodedNode::Leaf(leaf) => {
1502
+ let bytes = self.load_node_bytes(store, &hash).await?;
1503
+ match decode_node_ref(&bytes)? {
1504
+ DecodedNodeRef::Leaf(leaf) => {
1332
1505
  for (original_index, encoded_key) in encoded_keys {
1333
- let Some(entry_index) = leaf
1334
- .entries()
1335
- .binary_search_by(|entry| entry.key.as_slice().cmp(encoded_key))
1336
- .ok()
1337
- else {
1338
- continue;
1339
- };
1340
- values[*original_index] =
1341
- Some(decode_value(&leaf.entries()[entry_index].value)?);
1506
+ if let Some(entry_index) = binary_search_leaf_key(&leaf, encoded_key)? {
1507
+ let entry = leaf.entry(entry_index)?.ok_or_else(|| {
1508
+ LixError::new(
1509
+ "LIX_ERROR_UNKNOWN",
1510
+ "tracked-state leaf entry disappeared during get_many",
1511
+ )
1512
+ })?;
1513
+ values[*original_index] = Some(decode_value(entry.value)?);
1514
+ }
1342
1515
  }
1343
1516
  }
1344
- DecodedNode::Internal(internal) => {
1517
+ DecodedNodeRef::Internal(internal) => {
1345
1518
  let mut start = 0usize;
1346
1519
  let children = internal.children();
1347
1520
  for (child_index, child) in children.iter().enumerate() {
@@ -1540,11 +1713,20 @@ impl TrackedStateTree {
1540
1713
  store: &mut (impl StorageReader + ?Sized),
1541
1714
  hash: &[u8; TRACKED_STATE_HASH_BYTES],
1542
1715
  ) -> Result<DecodedNode, LixError> {
1716
+ let bytes = self.load_node_bytes(store, hash).await?;
1717
+ decode_node(&bytes)
1718
+ }
1719
+
1720
+ async fn load_node_bytes(
1721
+ &self,
1722
+ store: &mut (impl StorageReader + ?Sized),
1723
+ hash: &[u8; TRACKED_STATE_HASH_BYTES],
1724
+ ) -> Result<Vec<u8>, LixError> {
1543
1725
  let bytes = storage::read_chunk(store, hash).await?.ok_or_else(|| {
1544
1726
  LixError::new("LIX_ERROR_UNKNOWN", "tracked-state tree chunk is missing")
1545
1727
  })?;
1546
1728
  storage::verify_chunk_hash(hash, &bytes)?;
1547
- decode_node(&bytes)
1729
+ Ok(bytes)
1548
1730
  }
1549
1731
 
1550
1732
  async fn load_node_with_overlay(
@@ -1587,6 +1769,36 @@ struct EncodedScanRange {
1587
1769
  end: Option<Vec<u8>>,
1588
1770
  }
1589
1771
 
1772
+ #[derive(Debug, Clone, Copy)]
1773
+ struct ScanKeyDecodeHint<'a> {
1774
+ schema_key: &'a str,
1775
+ file_id: Option<&'a str>,
1776
+ prefix_len: usize,
1777
+ }
1778
+
1779
+ fn binary_search_leaf_key(
1780
+ leaf: &DecodedLeafNodeRef<'_>,
1781
+ encoded_key: &[u8],
1782
+ ) -> Result<Option<usize>, LixError> {
1783
+ let mut low = 0usize;
1784
+ let mut high = leaf.len();
1785
+ while low < high {
1786
+ let mid = low + (high - low) / 2;
1787
+ let key = leaf.key(mid)?.ok_or_else(|| {
1788
+ LixError::new(
1789
+ "LIX_ERROR_UNKNOWN",
1790
+ "tracked-state leaf key disappeared during binary search",
1791
+ )
1792
+ })?;
1793
+ match key.cmp(encoded_key) {
1794
+ std::cmp::Ordering::Less => low = mid + 1,
1795
+ std::cmp::Ordering::Equal => return Ok(Some(mid)),
1796
+ std::cmp::Ordering::Greater => high = mid,
1797
+ }
1798
+ }
1799
+ Ok(None)
1800
+ }
1801
+
1590
1802
  struct LeafSummaryCursor {
1591
1803
  stack: Vec<LeafSummaryCursorFrame>,
1592
1804
  current: Option<ChildSummary>,
@@ -1702,6 +1914,13 @@ struct LeafChunkAccumulator {
1702
1914
  value_bytes: usize,
1703
1915
  }
1704
1916
 
1917
+ #[derive(Debug, Default)]
1918
+ struct LeafChunkRefAccumulator<'a> {
1919
+ entries: Vec<EncodedLeafEntryRef<'a>>,
1920
+ key_bytes: usize,
1921
+ value_bytes: usize,
1922
+ }
1923
+
1705
1924
  #[derive(Debug, Default)]
1706
1925
  struct InternalChunkAccumulator {
1707
1926
  children: Vec<ChildSummary>,
@@ -1709,6 +1928,13 @@ struct InternalChunkAccumulator {
1709
1928
  last_key_bytes: usize,
1710
1929
  }
1711
1930
 
1931
+ #[derive(Debug, Default)]
1932
+ struct InternalChunkRefAccumulator<'a> {
1933
+ children: Vec<ChildSummaryRef<'a>>,
1934
+ first_key_bytes: usize,
1935
+ last_key_bytes: usize,
1936
+ }
1937
+
1712
1938
  fn chunk_leaf_entries(
1713
1939
  entries: Vec<EncodedLeafEntry>,
1714
1940
  options: &TrackedStateTreeOptions,
@@ -1719,7 +1945,7 @@ fn chunk_leaf_entries(
1719
1945
  let mut groups = Vec::new();
1720
1946
  let mut current = LeafChunkAccumulator::default();
1721
1947
  for entry in entries {
1722
- let item_size = entry.key.len() + entry.value.len();
1948
+ let item_size = estimate_leaf_entry_size(entry.key.len(), entry.value.len());
1723
1949
  let projected_size = estimate_leaf_chunk_size(
1724
1950
  current.entries.len() + 1,
1725
1951
  current.key_bytes + entry.key.len(),
@@ -1758,6 +1984,56 @@ fn chunk_leaf_entries(
1758
1984
  groups
1759
1985
  }
1760
1986
 
1987
+ fn chunk_leaf_entry_refs<'a>(
1988
+ entries: impl IntoIterator<Item = EncodedLeafEntryRef<'a>>,
1989
+ options: &TrackedStateTreeOptions,
1990
+ ) -> Vec<LeafChunkRefAccumulator<'a>> {
1991
+ let mut iter = entries.into_iter().peekable();
1992
+ if iter.peek().is_none() {
1993
+ return vec![LeafChunkRefAccumulator::default()];
1994
+ }
1995
+ let mut groups = Vec::new();
1996
+ let mut current = LeafChunkRefAccumulator::default();
1997
+ for entry in iter {
1998
+ let item_size = estimate_leaf_entry_size(entry.key.len(), entry.value.len());
1999
+ let projected_size = estimate_leaf_chunk_size(
2000
+ current.entries.len() + 1,
2001
+ current.key_bytes + entry.key.len(),
2002
+ current.value_bytes + entry.value.len(),
2003
+ );
2004
+ if !current.entries.is_empty() && projected_size > options.max_chunk_bytes {
2005
+ groups.push(std::mem::take(&mut current));
2006
+ }
2007
+
2008
+ current.key_bytes += entry.key.len();
2009
+ current.value_bytes += entry.value.len();
2010
+ current.entries.push(entry);
2011
+ let current_size = estimate_leaf_chunk_size(
2012
+ current.entries.len(),
2013
+ current.key_bytes,
2014
+ current.value_bytes,
2015
+ );
2016
+ if current_size >= options.min_chunk_bytes
2017
+ && (current_size >= options.max_chunk_bytes
2018
+ || current.entries.last().is_some_and(|entry| {
2019
+ boundary_trigger(
2020
+ entry.key,
2021
+ 0,
2022
+ current_size,
2023
+ item_size,
2024
+ options.target_chunk_bytes,
2025
+ )
2026
+ }))
2027
+ {
2028
+ groups.push(std::mem::take(&mut current));
2029
+ }
2030
+ }
2031
+ if !current.entries.is_empty() {
2032
+ groups.push(current);
2033
+ }
2034
+ groups
2035
+ }
2036
+
1761
2037
  fn chunk_internal_entries(
1762
2038
  children: Vec<ChildSummary>,
1763
2039
  options: &TrackedStateTreeOptions,
@@ -1808,8 +2084,62 @@ fn chunk_internal_entries(
1808
2084
  groups
1809
2085
  }
1810
2086
 
2087
+ fn chunk_internal_entry_refs<'a>(
2088
+ children: impl IntoIterator<Item = ChildSummaryRef<'a>>,
2089
+ options: &TrackedStateTreeOptions,
2090
+ level: usize,
2091
+ ) -> Vec<InternalChunkRefAccumulator<'a>> {
2092
+ let mut groups = Vec::new();
2093
+ let mut current = InternalChunkRefAccumulator::default();
2094
+ for child in children {
2095
+ let item_size = child.first_key.len()
2096
+ + child.last_key.len()
2097
+ + TRACKED_STATE_HASH_BYTES
2098
+ + std::mem::size_of::<u64>();
2099
+ let projected_size = estimate_internal_chunk_size(
2100
+ current.children.len() + 1,
2101
+ current.first_key_bytes + child.first_key.len(),
2102
+ current.last_key_bytes + child.last_key.len(),
2103
+ );
2104
+ if !current.children.is_empty() && projected_size > options.max_chunk_bytes {
2105
+ groups.push(std::mem::take(&mut current));
2106
+ }
2107
+
2108
+ current.first_key_bytes += child.first_key.len();
2109
+ current.last_key_bytes += child.last_key.len();
2110
+ current.children.push(child);
2111
+ let current_size = estimate_internal_chunk_size(
2112
+ current.children.len(),
2113
+ current.first_key_bytes,
2114
+ current.last_key_bytes,
2115
+ );
2116
+ if current_size >= options.min_chunk_bytes
2117
+ && (current_size >= options.max_chunk_bytes
2118
+ || current.children.last().is_some_and(|child| {
2119
+ boundary_trigger(
2120
+ child.first_key,
2121
+ level,
2122
+ current_size,
2123
+ item_size,
2124
+ options.target_chunk_bytes,
2125
+ )
2126
+ }))
2127
+ {
2128
+ groups.push(std::mem::take(&mut current));
2129
+ }
2130
+ }
2131
+ if !current.children.is_empty() {
2132
+ groups.push(current);
2133
+ }
2134
+ groups
2135
+ }
2136
+
1811
2137
  fn estimate_leaf_chunk_size(entry_count: usize, key_bytes: usize, value_bytes: usize) -> usize {
1812
- 16 + entry_count * 8 + key_bytes + value_bytes
2138
+ 10 + entry_count * 12 + key_bytes + value_bytes
2139
+ }
2140
+
2141
+ fn estimate_leaf_entry_size(key_bytes: usize, value_bytes: usize) -> usize {
2142
+ 12 + key_bytes + value_bytes
1813
2143
  }
1814
2144
 
1815
2145
  fn estimate_internal_chunk_size(
@@ -1863,7 +2193,7 @@ async fn child_summaries_are_leaves(
1863
2193
 
1864
2194
  fn decode_entry(
1865
2195
  entry: &EncodedLeafEntry,
1866
- ) -> Result<(TrackedStateKey, TrackedStateValue), LixError> {
2196
+ ) -> Result<(TrackedStateKey, TrackedStateIndexValue), LixError> {
1867
2197
  Ok((decode_key(&entry.key)?, decode_value(&entry.value)?))
1868
2198
  }
1869
2199
 
@@ -2024,6 +2354,28 @@ fn scan_ranges(request: &TrackedStateTreeScanRequest) -> Vec<EncodedScanRange> {
2024
2354
  ranges
2025
2355
  }
2026
2356
 
2357
+ fn scan_key_decode_hint<'a>(
2358
+ request: &'a TrackedStateTreeScanRequest,
2359
+ ranges: &[EncodedScanRange],
2360
+ ) -> Option<ScanKeyDecodeHint<'a>> {
2361
+ if ranges.len() != 1 || request.schema_keys.len() != 1 || request.file_ids.len() != 1 {
2362
+ return None;
2363
+ }
2364
+ if !request.entity_ids.is_empty() {
2365
+ return None;
2366
+ }
2367
+ let file_id = match request.file_ids.first()? {
2368
+ NullableKeyFilter::Null => None,
2369
+ NullableKeyFilter::Value(file_id) => Some(file_id.as_str()),
2370
+ NullableKeyFilter::Any => return None,
2371
+ };
2372
+ Some(ScanKeyDecodeHint {
2373
+ schema_key: request.schema_keys.first()?.as_str(),
2374
+ file_id,
2375
+ prefix_len: ranges.first()?.start.len(),
2376
+ })
2377
+ }
2378
+
2027
2379
  fn prefix_scan_range(prefix: Vec<u8>) -> EncodedScanRange {
2028
2380
  EncodedScanRange {
2029
2381
  end: lexicographic_successor(&prefix),
@@ -2113,6 +2465,7 @@ mod tests {
2113
2465
  use crate::backend::testing::UnitTestBackend;
2114
2466
  use crate::entity_identity::EntityIdentity;
2115
2467
  use crate::storage::{StorageContext, StorageWriteTransaction};
2468
+ use crate::tracked_state::codec::encode_value;
2116
2469
 
2117
2470
  #[tokio::test]
2118
2471
  async fn exact_read_roundtrips_from_stored_root() {
@@ -2129,7 +2482,7 @@ mod tests {
2129
2482
  &tree,
2130
2483
  transaction.as_mut(),
2131
2484
  None,
2132
- vec![TrackedStateMutation::put(key.clone(), value.clone())],
2485
+ vec![mutation(&key, &value)],
2133
2486
  Some("commit-1"),
2134
2487
  )
2135
2488
  .await
@@ -2159,6 +2512,8 @@ mod tests {
2159
2512
  let storage = StorageContext::new(Arc::new(UnitTestBackend::new()));
2160
2513
  let tree = TrackedStateTree::new();
2161
2514
  let key = key("schema", None, "entity");
2515
+ let old_value = value("change-old", Some("{\"v\":1}"));
2516
+ let new_value = value("change-new", Some("{\"v\":2}"));
2162
2517
 
2163
2518
  let mut transaction = storage
2164
2519
  .begin_write_transaction()
@@ -2168,10 +2523,7 @@ mod tests {
2168
2523
  &tree,
2169
2524
  transaction.as_mut(),
2170
2525
  None,
2171
- vec![
2172
- TrackedStateMutation::put(key.clone(), value("change-old", Some("{\"v\":1}"))),
2173
- TrackedStateMutation::put(key.clone(), value("change-new", Some("{\"v\":2}"))),
2174
- ],
2526
+ vec![mutation(&key, &old_value), mutation(&key, &new_value)],
2175
2527
  None,
2176
2528
  )
2177
2529
  .await
@@ -2187,15 +2539,12 @@ mod tests {
2187
2539
  .await
2188
2540
  .expect("row should load")
2189
2541
  .expect("row should exist");
2190
- assert_eq!(loaded.change_id, "change-new");
2191
- assert_eq!(
2192
- loaded.snapshot_ref,
2193
- Some(crate::json_store::JsonRef::from_hash_bytes([2; 32]))
2194
- );
2542
+ assert_eq!(loaded.change_locator.change_id, "change-new");
2543
+ assert_eq!(loaded.change_locator.source_commit_id, "commit");
2195
2544
  }
2196
2545
 
2197
2546
  #[tokio::test]
2198
- async fn scan_filters_and_hides_tombstones_by_default() {
2547
+ async fn scan_filters_by_index_key_without_materializing_tombstones() {
2199
2548
  let storage = StorageContext::new(Arc::new(UnitTestBackend::new()));
2200
2549
  let tree = TrackedStateTree::new();
2201
2550
 
@@ -2208,12 +2557,9 @@ mod tests {
2208
2557
  transaction.as_mut(),
2209
2558
  None,
2210
2559
  vec![
2211
- TrackedStateMutation::put(
2212
- key("schema-a", None, "visible"),
2213
- value("c1", Some("{}")),
2214
- ),
2215
- TrackedStateMutation::put(key("schema-a", None, "deleted"), value("c2", None)),
2216
- TrackedStateMutation::put(key("schema-b", None, "other"), value("c3", Some("{}"))),
2560
+ mutation_owned(key("schema-a", None, "visible"), value("c1", Some("{}"))),
2561
+ mutation_owned(key("schema-a", None, "deleted"), value("c2", None)),
2562
+ mutation_owned(key("schema-b", None, "other"), value("c3", Some("{}"))),
2217
2563
  ],
2218
2564
  None,
2219
2565
  )
@@ -2236,11 +2582,30 @@ mod tests {
2236
2582
  )
2237
2583
  .await
2238
2584
  .expect("scan should succeed");
2239
- assert_eq!(rows.len(), 1);
2240
- assert_eq!(
2241
- rows[0].0.entity_id.as_string().expect("identity"),
2242
- "visible"
2243
- );
2585
+ assert_eq!(rows.len(), 2);
2586
+ let identities = rows
2587
+ .iter()
2588
+ .map(|(key, _)| key.entity_id.as_single_string_owned().expect("identity"))
2589
+ .collect::<Vec<_>>();
2590
+ assert_eq!(identities, vec!["deleted", "visible"]);
2591
+
2592
+ let live_rows = tree
2593
+ .scan(
2594
+ &mut store,
2595
+ &result.root_id,
2596
+ &TrackedStateTreeScanRequest {
2597
+ schema_keys: vec!["schema-a".to_string()],
2598
+ include_tombstones: false,
2599
+ ..Default::default()
2600
+ },
2601
+ )
2602
+ .await
2603
+ .expect("live scan should succeed");
2604
+ let live_identities = live_rows
2605
+ .iter()
2606
+ .map(|(key, _)| key.entity_id.as_single_string_owned().expect("identity"))
2607
+ .collect::<Vec<_>>();
2608
+ assert_eq!(live_identities, vec!["visible"]);
2244
2609
  }
2245
2610
 
2246
2611
  #[tokio::test]
@@ -2257,19 +2622,19 @@ mod tests {
2257
2622
  transaction.as_mut(),
2258
2623
  None,
2259
2624
  vec![
2260
- TrackedStateMutation::put(
2625
+ mutation_owned(
2261
2626
  key("schema-a", Some("file-a"), "entity-a"),
2262
2627
  value("c1", Some("{}")),
2263
2628
  ),
2264
- TrackedStateMutation::put(
2629
+ mutation_owned(
2265
2630
  key("schema-a", Some("file-b"), "entity-a"),
2266
2631
  value("c2", Some("{}")),
2267
2632
  ),
2268
- TrackedStateMutation::put(
2633
+ mutation_owned(
2269
2634
  key("schema-a", Some("file-a"), "entity-b"),
2270
2635
  value("c3", Some("{}")),
2271
2636
  ),
2272
- TrackedStateMutation::put(
2637
+ mutation_owned(
2273
2638
  key("schema-b", Some("file-a"), "entity-a"),
2274
2639
  value("c4", Some("{}")),
2275
2640
  ),
@@ -2301,18 +2666,93 @@ mod tests {
2301
2666
  assert_eq!(rows.len(), 1);
2302
2667
  assert_eq!(rows[0].0.schema_key, "schema-a");
2303
2668
  assert_eq!(
2304
- rows[0].0.entity_id.as_string().expect("identity"),
2669
+ rows[0]
2670
+ .0
2671
+ .entity_id
2672
+ .as_single_string_owned()
2673
+ .expect("identity"),
2305
2674
  "entity-a"
2306
2675
  );
2307
2676
  assert_eq!(rows[0].0.file_id.as_deref(), Some("file-a"));
2308
2677
  }
2309
2678
 
2679
+ #[tokio::test]
2680
+ async fn scan_schema_file_prefix_honors_tombstones_and_limit() {
2681
+ let storage = StorageContext::new(Arc::new(UnitTestBackend::new()));
2682
+ let tree = TrackedStateTree::new();
2683
+
2684
+ let mut transaction = storage
2685
+ .begin_write_transaction()
2686
+ .await
2687
+ .expect("transaction should open");
2688
+ let result = apply_mutations_for_test(
2689
+ &tree,
2690
+ transaction.as_mut(),
2691
+ None,
2692
+ vec![
2693
+ mutation_owned(
2694
+ key("schema-a", Some("file-a"), "entity-a"),
2695
+ value("c1", Some("{}")),
2696
+ ),
2697
+ mutation_owned(
2698
+ key("schema-a", Some("file-a"), "entity-b"),
2699
+ value("c2", None),
2700
+ ),
2701
+ mutation_owned(
2702
+ key("schema-a", Some("file-a"), "entity-c"),
2703
+ value("c3", Some("{}")),
2704
+ ),
2705
+ mutation_owned(
2706
+ key("schema-a", Some("file-b"), "entity-d"),
2707
+ value("c4", Some("{}")),
2708
+ ),
2709
+ ],
2710
+ None,
2711
+ )
2712
+ .await
2713
+ .expect("mutations should apply");
2714
+ transaction
2715
+ .commit()
2716
+ .await
2717
+ .expect("transaction should commit");
2718
+
2719
+ let mut store = storage.clone();
2720
+ let rows = tree
2721
+ .scan(
2722
+ &mut store,
2723
+ &result.root_id,
2724
+ &TrackedStateTreeScanRequest {
2725
+ schema_keys: vec!["schema-a".to_string()],
2726
+ file_ids: vec![crate::NullableKeyFilter::Value("file-a".to_string())],
2727
+ include_tombstones: false,
2728
+ limit: Some(2),
2729
+ ..Default::default()
2730
+ },
2731
+ )
2732
+ .await
2733
+ .expect("scan should succeed");
2734
+
2735
+ assert_eq!(rows.len(), 2);
2736
+ assert!(rows.iter().all(
2737
+ |(key, _)| key.schema_key == "schema-a" && key.file_id.as_deref() == Some("file-a")
2738
+ ));
2739
+ assert_eq!(
2740
+ rows.iter()
2741
+ .map(|(key, _)| key.entity_id.as_single_string_owned().expect("identity"))
2742
+ .collect::<Vec<_>>(),
2743
+ vec!["entity-a", "entity-c"]
2744
+ );
2745
+ }
2746
+
2310
2747
  #[tokio::test]
2311
2748
  async fn applying_to_base_root_reuses_existing_rows_and_overwrites_changed_rows() {
2312
2749
  let storage = StorageContext::new(Arc::new(UnitTestBackend::new()));
2313
2750
  let tree = TrackedStateTree::new();
2314
2751
  let unchanged_key = key("schema", None, "unchanged");
2315
2752
  let changed_key = key("schema", None, "changed");
2753
+ let unchanged_value = value("c1", Some("{}"));
2754
+ let old_changed_value = value("c2", Some("{\"old\":true}"));
2755
+ let new_changed_value = value("c3", Some("{\"new\":true}"));
2316
2756
 
2317
2757
  let mut transaction = storage
2318
2758
  .begin_write_transaction()
@@ -2323,8 +2763,8 @@ mod tests {
2323
2763
  transaction.as_mut(),
2324
2764
  None,
2325
2765
  vec![
2326
- TrackedStateMutation::put(unchanged_key.clone(), value("c1", Some("{}"))),
2327
- TrackedStateMutation::put(changed_key.clone(), value("c2", Some("{\"old\":true}"))),
2766
+ mutation(&unchanged_key, &unchanged_value),
2767
+ mutation(&changed_key, &old_changed_value),
2328
2768
  ],
2329
2769
  None,
2330
2770
  )
@@ -2334,10 +2774,7 @@ mod tests {
2334
2774
  &tree,
2335
2775
  transaction.as_mut(),
2336
2776
  Some(&base.root_id),
2337
- vec![TrackedStateMutation::put(
2338
- changed_key.clone(),
2339
- value("c3", Some("{\"new\":true}")),
2340
- )],
2777
+ vec![mutation(&changed_key, &new_changed_value)],
2341
2778
  None,
2342
2779
  )
2343
2780
  .await
@@ -2353,6 +2790,7 @@ mod tests {
2353
2790
  .await
2354
2791
  .expect("unchanged read")
2355
2792
  .expect("unchanged exists")
2793
+ .change_locator
2356
2794
  .change_id,
2357
2795
  "c1"
2358
2796
  );
@@ -2361,6 +2799,7 @@ mod tests {
2361
2799
  .await
2362
2800
  .expect("changed read")
2363
2801
  .expect("changed exists")
2802
+ .change_locator
2364
2803
  .change_id,
2365
2804
  "c3"
2366
2805
  );
@@ -2373,6 +2812,9 @@ mod tests {
2373
2812
  let shared_key = key("schema", None, "shared");
2374
2813
  let branch_a_key = key("schema", None, "branch-a");
2375
2814
  let branch_b_key = key("schema", None, "branch-b");
2815
+ let shared_value = value("shared-change", Some("{\"shared\":true}"));
2816
+ let branch_a_value = value("branch-a-change", Some("{\"branch\":\"a\"}"));
2817
+ let branch_b_value = value("branch-b-change", Some("{\"branch\":\"b\"}"));
2376
2818
 
2377
2819
  let mut transaction = storage
2378
2820
  .begin_write_transaction()
@@ -2382,10 +2824,7 @@ mod tests {
2382
2824
  &tree,
2383
2825
  transaction.as_mut(),
2384
2826
  None,
2385
- vec![TrackedStateMutation::put(
2386
- shared_key.clone(),
2387
- value("shared-change", Some("{\"shared\":true}")),
2388
- )],
2827
+ vec![mutation(&shared_key, &shared_value)],
2389
2828
  Some("commit-base"),
2390
2829
  )
2391
2830
  .await
@@ -2394,10 +2833,7 @@ mod tests {
2394
2833
  &tree,
2395
2834
  transaction.as_mut(),
2396
2835
  Some(&base.root_id),
2397
- vec![TrackedStateMutation::put(
2398
- branch_a_key.clone(),
2399
- value("branch-a-change", Some("{\"branch\":\"a\"}")),
2400
- )],
2836
+ vec![mutation(&branch_a_key, &branch_a_value)],
2401
2837
  Some("commit-a"),
2402
2838
  )
2403
2839
  .await
@@ -2406,10 +2842,7 @@ mod tests {
2406
2842
  &tree,
2407
2843
  transaction.as_mut(),
2408
2844
  Some(&base.root_id),
2409
- vec![TrackedStateMutation::put(
2410
- branch_b_key.clone(),
2411
- value("branch-b-change", Some("{\"branch\":\"b\"}")),
2412
- )],
2845
+ vec![mutation(&branch_b_key, &branch_b_value)],
2413
2846
  Some("commit-b"),
2414
2847
  )
2415
2848
  .await
@@ -2455,7 +2888,7 @@ mod tests {
2455
2888
  });
2456
2889
  let rows = (0..100)
2457
2890
  .map(|index| {
2458
- TrackedStateMutation::put(
2891
+ mutation_owned(
2459
2892
  key("schema", None, &format!("entity-{index:03}")),
2460
2893
  value(&format!("c-{index}"), Some(&format!("{{\"v\":{index}}}"))),
2461
2894
  )
@@ -2475,10 +2908,7 @@ mod tests {
2475
2908
  &tree,
2476
2909
  transaction.as_mut(),
2477
2910
  Some(&base.root_id),
2478
- vec![TrackedStateMutation::put(
2479
- changed_key.clone(),
2480
- changed_value.clone(),
2481
- )],
2911
+ vec![mutation(&changed_key, &changed_value)],
2482
2912
  None,
2483
2913
  )
2484
2914
  .await
@@ -2513,7 +2943,7 @@ mod tests {
2513
2943
  });
2514
2944
  let rows = (0..100)
2515
2945
  .map(|index| {
2516
- TrackedStateMutation::put(
2946
+ mutation_owned(
2517
2947
  key("schema", None, &format!("entity-{index:03}")),
2518
2948
  value(&format!("c-{index}"), Some(&format!("{{\"v\":{index}}}"))),
2519
2949
  )
@@ -2533,10 +2963,7 @@ mod tests {
2533
2963
  &tree,
2534
2964
  transaction.as_mut(),
2535
2965
  Some(&base.root_id),
2536
- vec![TrackedStateMutation::put(
2537
- inserted_key.clone(),
2538
- inserted_value.clone(),
2539
- )],
2966
+ vec![mutation(&inserted_key, &inserted_value)],
2540
2967
  None,
2541
2968
  )
2542
2969
  .await
@@ -2574,7 +3001,7 @@ mod tests {
2574
3001
  });
2575
3002
  let rows = (0..100)
2576
3003
  .map(|index| {
2577
- TrackedStateMutation::put(
3004
+ mutation_owned(
2578
3005
  key("schema", None, &format!("entity-{index:03}")),
2579
3006
  value(&format!("c-{index}"), Some(&format!("{{\"v\":{index}}}"))),
2580
3007
  )
@@ -2582,7 +3009,7 @@ mod tests {
2582
3009
  .collect::<Vec<_>>();
2583
3010
  let updates = (10..25)
2584
3011
  .map(|index| {
2585
- TrackedStateMutation::put(
3012
+ (
2586
3013
  key("schema", None, &format!("entity-{index:03}")),
2587
3014
  value(
2588
3015
  &format!("changed-{index}"),
@@ -2603,7 +3030,10 @@ mod tests {
2603
3030
  &tree,
2604
3031
  transaction.as_mut(),
2605
3032
  Some(&base.root_id),
2606
- updates.clone(),
3033
+ updates
3034
+ .iter()
3035
+ .map(|(key, value)| mutation(key, value))
3036
+ .collect(),
2607
3037
  None,
2608
3038
  )
2609
3039
  .await
@@ -2612,8 +3042,7 @@ mod tests {
2612
3042
  .collect_leaf_entries(&mut transaction.as_mut(), &base.root_id)
2613
3043
  .await
2614
3044
  .expect("base entries should collect");
2615
- for update in updates {
2616
- let TrackedStateMutation::Put { key, value } = update;
3045
+ for (key, value) in updates {
2617
3046
  let encoded_key = encode_key(&key);
2618
3047
  let encoded_value = encode_value(&value);
2619
3048
  let index = canonical_entries
@@ -2638,7 +3067,7 @@ mod tests {
2638
3067
  });
2639
3068
  let rows = (0..100)
2640
3069
  .map(|index| {
2641
- TrackedStateMutation::put(
3070
+ mutation_owned(
2642
3071
  key("schema", None, &format!("entity-{index:03}")),
2643
3072
  value(&format!("c-{index}"), Some(&format!("{{\"v\":{index}}}"))),
2644
3073
  )
@@ -2648,7 +3077,7 @@ mod tests {
2648
3077
  .into_iter()
2649
3078
  .enumerate()
2650
3079
  .map(|(index, entity_id)| {
2651
- TrackedStateMutation::put(
3080
+ (
2652
3081
  key("schema", None, entity_id),
2653
3082
  value(
2654
3083
  &format!("inserted-{index}"),
@@ -2669,7 +3098,10 @@ mod tests {
2669
3098
  &tree,
2670
3099
  transaction.as_mut(),
2671
3100
  Some(&base.root_id),
2672
- inserts.clone(),
3101
+ inserts
3102
+ .iter()
3103
+ .map(|(key, value)| mutation(key, value))
3104
+ .collect(),
2673
3105
  None,
2674
3106
  )
2675
3107
  .await
@@ -2678,8 +3110,7 @@ mod tests {
2678
3110
  .collect_leaf_entries(&mut transaction.as_mut(), &base.root_id)
2679
3111
  .await
2680
3112
  .expect("base entries should collect");
2681
- for insert in inserts {
2682
- let TrackedStateMutation::Put { key, value } = insert;
3113
+ for (key, value) in inserts {
2683
3114
  let encoded_key = encode_key(&key);
2684
3115
  let encoded_value = encode_value(&value);
2685
3116
  let index = canonical_entries
@@ -2715,6 +3146,14 @@ mod tests {
2715
3146
  Ok(result)
2716
3147
  }
2717
3148
 
3149
+ fn mutation(key: &TrackedStateKey, value: &TrackedStateIndexValue) -> TrackedStateMutation {
3150
+ TrackedStateMutation::put_encoded(encode_key(key), encode_value(value))
3151
+ }
3152
+
3153
+ fn mutation_owned(key: TrackedStateKey, value: TrackedStateIndexValue) -> TrackedStateMutation {
3154
+ mutation(&key, &value)
3155
+ }
3156
+
2718
3157
  fn key(schema_key: &str, file_id: Option<&str>, entity_id: &str) -> TrackedStateKey {
2719
3158
  TrackedStateKey {
2720
3159
  schema_key: schema_key.to_string(),
@@ -2723,22 +3162,26 @@ mod tests {
2723
3162
  }
2724
3163
  }
2725
3164
 
2726
- fn value(change_id: &str, snapshot_content: Option<&str>) -> TrackedStateValue {
2727
- let snapshot_ref = match snapshot_content {
2728
- Some("{\"v\":1}") => Some(crate::json_store::JsonRef::from_hash_bytes([1; 32])),
2729
- Some("{\"v\":2}") => Some(crate::json_store::JsonRef::from_hash_bytes([2; 32])),
2730
- Some(_) => Some(crate::json_store::JsonRef::from_hash_bytes([3; 32])),
2731
- None => None,
3165
+ fn value(change_id: &str, snapshot_content: Option<&str>) -> TrackedStateIndexValue {
3166
+ let source_ordinal = match snapshot_content {
3167
+ Some("{\"v\":1}") => 1,
3168
+ Some("{\"v\":2}") => 2,
3169
+ Some(_) => 3,
3170
+ None => 0,
2732
3171
  };
2733
- TrackedStateValue {
2734
- snapshot_ref,
3172
+ TrackedStateIndexValue {
3173
+ change_locator: crate::commit_store::ChangeLocator {
3174
+ source_commit_id: "commit".to_string(),
3175
+ source_pack_id: 0,
3176
+ source_ordinal,
3177
+ change_id: change_id.to_string(),
3178
+ },
3179
+ deleted: snapshot_content.is_none(),
3180
+ snapshot_ref: snapshot_content
3181
+ .map(|content| crate::json_store::JsonRef::for_content(content.as_bytes())),
2735
3182
  metadata_ref: None,
2736
- schema_version: "1".to_string(),
2737
3183
  created_at: "2026-01-01T00:00:00Z".to_string(),
2738
3184
  updated_at: "2026-01-01T00:00:00Z".to_string(),
2739
- change_id: change_id.to_string(),
2740
- commit_id: "commit".to_string(),
2741
- deleted: snapshot_content.is_none(),
2742
3185
  }
2743
3186
  }
2744
3187
  }