@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,21 +1,49 @@
1
+ use std::collections::HashMap;
2
+
1
3
  use xxhash_rust::xxh3::xxh3_64_with_seed;
2
4
 
3
- use crate::entity_identity::{EntityIdentity, EntityIdentityPart};
5
+ use crate::commit_store::ChangeLocator;
6
+ use crate::entity_identity::EntityIdentity;
4
7
  use crate::json_store::JsonRef;
5
- use crate::tracked_state::tree_types::{
6
- TrackedStateKey, TrackedStateValue, TRACKED_STATE_HASH_BYTES,
8
+ use crate::tracked_state::types::{
9
+ TrackedStateDeltaEntry, TrackedStateDeltaRef, TrackedStateIndexValue,
10
+ TrackedStateIndexValueRef, TrackedStateKey, TrackedStateKeyRef, TRACKED_STATE_HASH_BYTES,
7
11
  };
8
12
  use crate::LixError;
9
13
 
10
- const NODE_VERSION: u8 = 1;
11
- const VALUE_VERSION: u8 = 2;
14
+ const NODE_VERSION: u8 = 2;
15
+ const VALUE_VERSION: u8 = 7;
16
+ const VALUE_DELETED_FLAG: u8 = 0b1000_0000;
17
+ const VALUE_VERSION_MASK: u8 = 0b0111_1111;
18
+ const DELTA_PACK_VERSION: u8 = 7;
19
+ const DELTA_LOCATOR_SAME_COMMIT: u8 = 0;
20
+ const DELTA_LOCATOR_FULL: u8 = 1;
21
+ const DELTA_JSON_REFS_INLINE: u8 = 0;
22
+ const DELTA_JSON_REFS_MIXED_PACK_INDEX: u8 = 1;
23
+ const DELTA_JSON_REF_NONE: u8 = 0;
24
+ const DELTA_JSON_REF_PACK_INDEX: u8 = 1;
25
+ const DELTA_JSON_REF_INLINE: u8 = 2;
26
+ const DELTA_CHANGE_ID_FULL: u8 = 0;
27
+ const DELTA_CHANGE_ID_COMMIT_SUFFIX: u8 = 1;
28
+ const TIMESTAMP_UPDATED_SAME: u8 = 0;
29
+ const TIMESTAMP_UPDATED_DISTINCT: u8 = 1;
12
30
  const NODE_KIND_LEAF: u8 = 1;
13
31
  const NODE_KIND_INTERNAL: u8 = 2;
14
32
  const WEIBULL_K: i32 = 4;
15
33
  const ENTITY_IDENTITY_END: u8 = 0;
16
34
  const ENTITY_IDENTITY_STRING: u8 = 1;
17
- const ENTITY_IDENTITY_BOOL: u8 = 2;
18
- const ENTITY_IDENTITY_NUMBER: u8 = 3;
35
+
36
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
37
+ struct DeltaKeyPrefixRef<'a> {
38
+ schema_key: &'a str,
39
+ file_id: Option<&'a str>,
40
+ }
41
+
42
+ #[derive(Debug, Clone, PartialEq, Eq)]
43
+ struct DeltaKeyPrefix {
44
+ schema_key: String,
45
+ file_id: Option<String>,
46
+ }
19
47
 
20
48
  #[derive(Debug, Clone, PartialEq, Eq)]
21
49
  pub(crate) struct EncodedLeafEntry {
@@ -23,6 +51,21 @@ pub(crate) struct EncodedLeafEntry {
23
51
  pub(crate) value: Vec<u8>,
24
52
  }
25
53
 
54
+ #[derive(Debug, Clone, Copy)]
55
+ pub(crate) struct EncodedLeafEntryRef<'a> {
56
+ pub(crate) key: &'a [u8],
57
+ pub(crate) value: &'a [u8],
58
+ }
59
+
60
+ impl EncodedLeafEntry {
61
+ pub(crate) fn as_ref(&self) -> EncodedLeafEntryRef<'_> {
62
+ EncodedLeafEntryRef {
63
+ key: &self.key,
64
+ value: &self.value,
65
+ }
66
+ }
67
+ }
68
+
26
69
  #[derive(Debug, Clone, PartialEq, Eq)]
27
70
  pub(crate) struct PendingChunkWrite {
28
71
  pub(crate) hash: [u8; TRACKED_STATE_HASH_BYTES],
@@ -37,12 +80,37 @@ pub(crate) struct ChildSummary {
37
80
  pub(crate) subtree_count: u64,
38
81
  }
39
82
 
83
+ #[derive(Debug, Clone, Copy)]
84
+ pub(crate) struct ChildSummaryRef<'a> {
85
+ pub(crate) first_key: &'a [u8],
86
+ pub(crate) last_key: &'a [u8],
87
+ pub(crate) child_hash: [u8; TRACKED_STATE_HASH_BYTES],
88
+ pub(crate) subtree_count: u64,
89
+ }
90
+
91
+ impl ChildSummary {
92
+ pub(crate) fn as_ref(&self) -> ChildSummaryRef<'_> {
93
+ ChildSummaryRef {
94
+ first_key: &self.first_key,
95
+ last_key: &self.last_key,
96
+ child_hash: self.child_hash,
97
+ subtree_count: self.subtree_count,
98
+ }
99
+ }
100
+ }
101
+
40
102
  #[derive(Debug, Clone)]
41
103
  pub(crate) enum DecodedNode {
42
104
  Leaf(DecodedLeafNode),
43
105
  Internal(DecodedInternalNode),
44
106
  }
45
107
 
108
+ #[derive(Debug, Clone)]
109
+ pub(crate) enum DecodedNodeRef<'a> {
110
+ Leaf(DecodedLeafNodeRef<'a>),
111
+ Internal(DecodedInternalNode),
112
+ }
113
+
46
114
  #[derive(Debug, Clone)]
47
115
  pub(crate) struct DecodedLeafNode {
48
116
  entries: Vec<EncodedLeafEntry>,
@@ -54,6 +122,50 @@ impl DecodedLeafNode {
54
122
  }
55
123
  }
56
124
 
125
+ #[derive(Debug, Clone)]
126
+ pub(crate) struct DecodedLeafNodeRef<'a> {
127
+ bytes: &'a [u8],
128
+ payload_start: usize,
129
+ offsets: Vec<usize>,
130
+ }
131
+
132
+ impl<'a> DecodedLeafNodeRef<'a> {
133
+ pub(crate) fn len(&self) -> usize {
134
+ self.offsets.len().saturating_sub(1)
135
+ }
136
+
137
+ pub(crate) fn entry(&self, index: usize) -> Result<Option<EncodedLeafEntryRef<'a>>, LixError> {
138
+ if index >= self.len() {
139
+ return Ok(None);
140
+ }
141
+ let start = self.payload_start + self.offsets[index];
142
+ let end = self.payload_start + self.offsets[index + 1];
143
+ let record = self.bytes.get(start..end).ok_or_else(|| {
144
+ LixError::new(
145
+ "LIX_ERROR_UNKNOWN",
146
+ "tracked-state leaf offset points outside node payload",
147
+ )
148
+ })?;
149
+ let mut cursor = 0usize;
150
+ let key = read_sized_slice(record, &mut cursor, "leaf key")?;
151
+ let value = read_sized_slice(record, &mut cursor, "leaf value")?;
152
+ if cursor != record.len() {
153
+ return Err(LixError::new(
154
+ "LIX_ERROR_UNKNOWN",
155
+ "tracked-state leaf entry decode found trailing bytes",
156
+ ));
157
+ }
158
+ Ok(Some(EncodedLeafEntryRef { key, value }))
159
+ }
160
+
161
+ pub(crate) fn key(&self, index: usize) -> Result<Option<&'a [u8]>, LixError> {
162
+ let Some(entry) = self.entry(index)? else {
163
+ return Ok(None);
164
+ };
165
+ Ok(Some(entry.key))
166
+ }
167
+ }
168
+
57
169
  #[derive(Debug, Clone)]
58
170
  pub(crate) struct DecodedInternalNode {
59
171
  children: Vec<ChildSummary>,
@@ -70,17 +182,29 @@ pub(crate) fn hash_bytes(bytes: &[u8]) -> [u8; TRACKED_STATE_HASH_BYTES] {
70
182
  }
71
183
 
72
184
  pub(crate) fn encode_key(key: &TrackedStateKey) -> Vec<u8> {
185
+ encode_key_ref(TrackedStateKeyRef {
186
+ schema_key: &key.schema_key,
187
+ file_id: key.file_id.as_deref(),
188
+ entity_id: &key.entity_id,
189
+ })
190
+ }
191
+
192
+ pub(crate) fn encode_key_ref(key: TrackedStateKeyRef<'_>) -> Vec<u8> {
73
193
  let mut out = Vec::new();
74
- push_sized_bytes(&mut out, key.schema_key.as_bytes());
75
- match &key.file_id {
194
+ append_key_ref(&mut out, key);
195
+ out
196
+ }
197
+
198
+ fn append_key_ref(out: &mut Vec<u8>, key: TrackedStateKeyRef<'_>) {
199
+ push_sized_bytes(out, key.schema_key.as_bytes());
200
+ match key.file_id {
76
201
  Some(file_id) => {
77
202
  out.push(1);
78
- push_sized_bytes(&mut out, file_id.as_bytes());
203
+ push_sized_bytes(out, file_id.as_bytes());
79
204
  }
80
205
  None => out.push(0),
81
206
  }
82
- push_entity_identity(&mut out, &key.entity_id);
83
- out
207
+ push_entity_identity(out, key.entity_id);
84
208
  }
85
209
 
86
210
  pub(crate) fn encode_schema_key_prefix(schema_key: &str) -> Vec<u8> {
@@ -128,137 +252,573 @@ pub(crate) fn decode_key(bytes: &[u8]) -> Result<TrackedStateKey, LixError> {
128
252
  })
129
253
  }
130
254
 
131
- pub(crate) fn encode_value(value: &TrackedStateValue) -> Vec<u8> {
255
+ /// Decodes a key after the caller has already proven the schema/file prefix.
256
+ ///
257
+ /// This is for scan paths that have matched an encoded prefix range and only
258
+ /// need to materialize the entity suffix plus the known projection fields.
259
+ pub(crate) fn decode_key_with_trusted_prefix(
260
+ bytes: &[u8],
261
+ schema_key: &str,
262
+ file_id: Option<&str>,
263
+ prefix_len: usize,
264
+ ) -> Result<TrackedStateKey, LixError> {
265
+ let mut cursor = prefix_len;
266
+ let entity_id = read_entity_identity(bytes, &mut cursor)?;
267
+ if cursor != bytes.len() {
268
+ return Err(LixError::new(
269
+ "LIX_ERROR_UNKNOWN",
270
+ "tracked-state tree key decode found trailing bytes",
271
+ ));
272
+ }
273
+ Ok(TrackedStateKey {
274
+ schema_key: schema_key.to_string(),
275
+ file_id: file_id.map(str::to_string),
276
+ entity_id,
277
+ })
278
+ }
279
+
280
+ #[cfg(test)]
281
+ pub(crate) fn encode_value(value: &TrackedStateIndexValue) -> Vec<u8> {
282
+ encode_value_ref(TrackedStateIndexValueRef {
283
+ change_locator: value.change_locator.as_ref(),
284
+ deleted: value.deleted,
285
+ snapshot_ref: value.snapshot_ref.as_ref(),
286
+ metadata_ref: value.metadata_ref.as_ref(),
287
+ created_at: &value.created_at,
288
+ updated_at: &value.updated_at,
289
+ })
290
+ }
291
+
292
+ pub(crate) fn encode_value_ref(value: TrackedStateIndexValueRef<'_>) -> Vec<u8> {
132
293
  let mut out = Vec::new();
133
- out.push(VALUE_VERSION);
134
- push_optional_json_ref(&mut out, value.snapshot_ref.as_ref());
135
- push_optional_json_ref(&mut out, value.metadata_ref.as_ref());
136
- push_sized_bytes(&mut out, value.schema_version.as_bytes());
137
- push_sized_bytes(&mut out, value.created_at.as_bytes());
138
- push_sized_bytes(&mut out, value.updated_at.as_bytes());
139
- push_sized_bytes(&mut out, value.change_id.as_bytes());
140
- push_sized_bytes(&mut out, value.commit_id.as_bytes());
141
- out.push(u8::from(value.deleted));
294
+ append_value_ref(&mut out, value);
142
295
  out
143
296
  }
144
297
 
298
+ fn append_value_ref(out: &mut Vec<u8>, value: TrackedStateIndexValueRef<'_>) {
299
+ out.push(VALUE_VERSION | if value.deleted { VALUE_DELETED_FLAG } else { 0 });
300
+ push_sized_bytes(out, value.change_locator.source_commit_id.as_bytes());
301
+ out.extend_from_slice(&value.change_locator.source_pack_id.to_be_bytes());
302
+ out.extend_from_slice(&value.change_locator.source_ordinal.to_be_bytes());
303
+ push_sized_bytes(out, value.change_locator.change_id.as_bytes());
304
+ push_timestamp_pair(out, value.created_at, value.updated_at);
305
+ push_optional_json_ref(out, value.snapshot_ref);
306
+ push_optional_json_ref(out, value.metadata_ref);
307
+ }
308
+
145
309
  #[cfg(test)]
146
- pub(crate) fn encoded_value_len(value: &TrackedStateValue) -> usize {
147
- 1 + optional_json_ref_len(value.snapshot_ref.as_ref())
310
+ pub(crate) fn encoded_value_len(value: &TrackedStateIndexValue) -> usize {
311
+ 1 + sized_bytes_len(value.change_locator.source_commit_id.as_bytes())
312
+ + 4
313
+ + 4
314
+ + sized_bytes_len(value.change_locator.change_id.as_bytes())
315
+ + timestamp_pair_len(&value.created_at, &value.updated_at)
316
+ + optional_json_ref_len(value.snapshot_ref.as_ref())
148
317
  + optional_json_ref_len(value.metadata_ref.as_ref())
149
- + sized_bytes_len(value.schema_version.as_bytes())
150
- + sized_bytes_len(value.created_at.as_bytes())
151
- + sized_bytes_len(value.updated_at.as_bytes())
152
- + sized_bytes_len(value.change_id.as_bytes())
153
- + sized_bytes_len(value.commit_id.as_bytes())
154
- + 1
155
318
  }
156
319
 
157
- pub(crate) fn decode_value(bytes: &[u8]) -> Result<TrackedStateValue, LixError> {
320
+ pub(crate) fn decode_value(bytes: &[u8]) -> Result<TrackedStateIndexValue, LixError> {
321
+ let mut cursor = 0usize;
322
+ let value_header = read_u8(bytes, &mut cursor, "value header")?;
323
+ let deleted = decode_value_header(value_header)?;
324
+ decode_value_after_header(bytes, cursor, deleted)
325
+ }
326
+
327
+ pub(crate) fn decode_visible_value(
328
+ bytes: &[u8],
329
+ include_tombstones: bool,
330
+ ) -> Result<Option<TrackedStateIndexValue>, LixError> {
158
331
  let mut cursor = 0usize;
159
- let version = read_u8(bytes, &mut cursor, "value version")?;
332
+ let value_header = read_u8(bytes, &mut cursor, "value header")?;
333
+ let deleted = decode_value_header(value_header)?;
334
+ if deleted && !include_tombstones {
335
+ return Ok(None);
336
+ }
337
+ decode_value_after_header(bytes, cursor, deleted).map(Some)
338
+ }
339
+
340
+ fn decode_value_header(value_header: u8) -> Result<bool, LixError> {
341
+ let version = value_header & VALUE_VERSION_MASK;
342
+ let deleted = value_header & VALUE_DELETED_FLAG != 0;
160
343
  if version != VALUE_VERSION {
161
344
  return Err(LixError::new(
162
345
  "LIX_ERROR_UNKNOWN",
163
346
  format!("unsupported tracked-state tree value version {version}"),
164
347
  ));
165
348
  }
349
+ Ok(deleted)
350
+ }
351
+
352
+ fn decode_value_after_header(
353
+ bytes: &[u8],
354
+ mut cursor: usize,
355
+ deleted: bool,
356
+ ) -> Result<TrackedStateIndexValue, LixError> {
357
+ let source_commit_id = read_sized_string(bytes, &mut cursor, "source_commit_id")?;
358
+ let source_pack_id =
359
+ u32::try_from(read_u32(bytes, &mut cursor, "source_pack_id")?).map_err(|_| {
360
+ LixError::new(
361
+ LixError::CODE_INTERNAL_ERROR,
362
+ "tracked-state source_pack_id exceeds u32",
363
+ )
364
+ })?;
365
+ let source_ordinal =
366
+ u32::try_from(read_u32(bytes, &mut cursor, "source_ordinal")?).map_err(|_| {
367
+ LixError::new(
368
+ LixError::CODE_INTERNAL_ERROR,
369
+ "tracked-state source_ordinal exceeds u32",
370
+ )
371
+ })?;
372
+ let change_id = read_sized_string(bytes, &mut cursor, "change_id")?;
373
+ let (created_at, updated_at) = read_timestamp_pair(bytes, &mut cursor)?;
166
374
  let snapshot_ref = read_optional_json_ref(bytes, &mut cursor, "snapshot_ref")?;
167
375
  let metadata_ref = read_optional_json_ref(bytes, &mut cursor, "metadata_ref")?;
168
- let schema_version = read_sized_string(bytes, &mut cursor, "schema_version")?;
169
- let created_at = read_sized_string(bytes, &mut cursor, "created_at")?;
170
- let updated_at = read_sized_string(bytes, &mut cursor, "updated_at")?;
171
- let change_id = read_sized_string(bytes, &mut cursor, "change_id")?;
172
- let commit_id = read_sized_string(bytes, &mut cursor, "commit_id")?;
173
- let deleted = match read_u8(bytes, &mut cursor, "deleted")? {
174
- 0 => false,
175
- 1 => true,
176
- other => {
177
- return Err(LixError::new(
178
- "LIX_ERROR_UNKNOWN",
179
- format!("tracked-state tree value has invalid deleted byte {other}"),
180
- ))
181
- }
182
- };
183
376
  if cursor != bytes.len() {
184
377
  return Err(LixError::new(
185
378
  "LIX_ERROR_UNKNOWN",
186
379
  "tracked-state tree value decode found trailing bytes",
187
380
  ));
188
381
  }
189
- Ok(TrackedStateValue {
382
+ Ok(TrackedStateIndexValue {
383
+ change_locator: ChangeLocator {
384
+ source_commit_id,
385
+ source_pack_id,
386
+ source_ordinal,
387
+ change_id,
388
+ },
389
+ deleted,
190
390
  snapshot_ref,
191
391
  metadata_ref,
192
- schema_version,
193
392
  created_at,
194
393
  updated_at,
195
- change_id,
196
- commit_id,
197
- deleted,
198
394
  })
199
395
  }
200
396
 
201
- #[cfg(test)]
202
- fn sized_bytes_len(bytes: &[u8]) -> usize {
203
- 4 + bytes.len()
397
+ pub(crate) fn encode_delta_pack_refs(
398
+ commit_id: &str,
399
+ deltas: &[TrackedStateDeltaRef<'_>],
400
+ ) -> Result<Vec<u8>, LixError> {
401
+ encode_delta_pack_refs_with_json_pack_indexes(commit_id, deltas, None)
402
+ }
403
+
404
+ pub(crate) fn encode_delta_pack_refs_with_json_pack_indexes(
405
+ commit_id: &str,
406
+ deltas: &[TrackedStateDeltaRef<'_>],
407
+ json_pack_indexes: Option<&HashMap<[u8; TRACKED_STATE_HASH_BYTES], usize>>,
408
+ ) -> Result<Vec<u8>, LixError> {
409
+ let json_pack_indexes = json_pack_indexes.filter(|indexes| !indexes.is_empty());
410
+ let mut out = Vec::new();
411
+ out.extend_from_slice(b"LXTD");
412
+ out.push(DELTA_PACK_VERSION);
413
+ push_var_sized_bytes(&mut out, commit_id.as_bytes(), "delta pack commit_id")?;
414
+ let (key_prefixes, delta_prefix_indexes) = delta_key_prefixes(deltas);
415
+ push_var_u32(&mut out, key_prefixes.len(), "delta key prefix count")?;
416
+ for prefix in &key_prefixes {
417
+ append_delta_key_prefix_ref(&mut out, *prefix)?;
418
+ }
419
+ push_var_u32(&mut out, deltas.len(), "delta pack entry count")?;
420
+ out.push(if json_pack_indexes.is_some() {
421
+ DELTA_JSON_REFS_MIXED_PACK_INDEX
422
+ } else {
423
+ DELTA_JSON_REFS_INLINE
424
+ });
425
+ for (delta, prefix_index) in deltas.iter().zip(delta_prefix_indexes) {
426
+ append_delta_key_ref(
427
+ &mut out,
428
+ &key_prefixes,
429
+ prefix_index,
430
+ TrackedStateKeyRef {
431
+ schema_key: delta.change.schema_key,
432
+ file_id: delta.change.file_id,
433
+ entity_id: delta.change.entity_id,
434
+ },
435
+ )?;
436
+ append_delta_value_ref(
437
+ &mut out,
438
+ commit_id,
439
+ json_pack_indexes,
440
+ TrackedStateIndexValueRef {
441
+ change_locator: delta.locator,
442
+ deleted: delta.change.snapshot_ref.is_none(),
443
+ snapshot_ref: delta.change.snapshot_ref,
444
+ metadata_ref: delta.change.metadata_ref,
445
+ created_at: delta.created_at,
446
+ updated_at: delta.updated_at,
447
+ },
448
+ )?;
449
+ }
450
+ Ok(out)
451
+ }
452
+
453
+ fn delta_key_prefixes<'a>(
454
+ deltas: &'a [TrackedStateDeltaRef<'a>],
455
+ ) -> (Vec<DeltaKeyPrefixRef<'a>>, Vec<usize>) {
456
+ let mut prefixes = Vec::new();
457
+ let mut delta_prefix_indexes = Vec::with_capacity(deltas.len());
458
+ for delta in deltas {
459
+ let prefix = DeltaKeyPrefixRef {
460
+ schema_key: delta.change.schema_key,
461
+ file_id: delta.change.file_id,
462
+ };
463
+ let prefix_index = match prefixes.iter().position(|candidate| *candidate == prefix) {
464
+ Some(prefix_index) => prefix_index,
465
+ None => {
466
+ let prefix_index = prefixes.len();
467
+ prefixes.push(prefix);
468
+ prefix_index
469
+ }
470
+ };
471
+ delta_prefix_indexes.push(prefix_index);
472
+ }
473
+ (prefixes, delta_prefix_indexes)
204
474
  }
205
475
 
206
- fn push_optional_json_ref(out: &mut Vec<u8>, value: Option<&JsonRef>) {
207
- match value {
208
- Some(value) => {
476
+ fn append_delta_key_prefix_ref(
477
+ out: &mut Vec<u8>,
478
+ prefix: DeltaKeyPrefixRef<'_>,
479
+ ) -> Result<(), LixError> {
480
+ push_var_sized_bytes(
481
+ out,
482
+ prefix.schema_key.as_bytes(),
483
+ "delta key prefix schema_key",
484
+ )?;
485
+ match prefix.file_id {
486
+ Some(file_id) => {
209
487
  out.push(1);
210
- out.extend_from_slice(value.as_hash_bytes());
488
+ push_var_sized_bytes(out, file_id.as_bytes(), "delta key prefix file_id")?;
211
489
  }
212
490
  None => out.push(0),
213
491
  }
492
+ Ok(())
214
493
  }
215
494
 
216
- #[cfg(test)]
217
- fn optional_json_ref_len(value: Option<&JsonRef>) -> usize {
218
- match value {
219
- Some(_) => 1 + TRACKED_STATE_HASH_BYTES,
220
- None => 1,
495
+ fn decode_delta_key_prefix(bytes: &[u8], cursor: &mut usize) -> Result<DeltaKeyPrefix, LixError> {
496
+ let schema_key = read_var_sized_string(bytes, cursor, "delta key prefix schema_key")?;
497
+ let file_id = match read_u8(bytes, cursor, "delta key prefix file_id presence")? {
498
+ 0 => None,
499
+ 1 => Some(read_var_sized_string(
500
+ bytes,
501
+ cursor,
502
+ "delta key prefix file_id",
503
+ )?),
504
+ other => {
505
+ return Err(LixError::new(
506
+ "LIX_ERROR_UNKNOWN",
507
+ format!("tracked-state delta key prefix has invalid file_id presence byte {other}"),
508
+ ))
509
+ }
510
+ };
511
+ Ok(DeltaKeyPrefix {
512
+ schema_key,
513
+ file_id,
514
+ })
515
+ }
516
+
517
+ fn append_delta_key_ref(
518
+ out: &mut Vec<u8>,
519
+ prefixes: &[DeltaKeyPrefixRef<'_>],
520
+ prefix_index: usize,
521
+ key: TrackedStateKeyRef<'_>,
522
+ ) -> Result<(), LixError> {
523
+ let prefix = DeltaKeyPrefixRef {
524
+ schema_key: key.schema_key,
525
+ file_id: key.file_id,
526
+ };
527
+ debug_assert_eq!(prefixes.get(prefix_index), Some(&prefix));
528
+ push_var_u32(out, prefix_index, "delta key prefix index")?;
529
+ push_var_entity_identity(out, key.entity_id)?;
530
+ Ok(())
531
+ }
532
+
533
+ fn append_delta_value_ref(
534
+ out: &mut Vec<u8>,
535
+ pack_commit_id: &str,
536
+ json_pack_indexes: Option<&HashMap<[u8; TRACKED_STATE_HASH_BYTES], usize>>,
537
+ value: TrackedStateIndexValueRef<'_>,
538
+ ) -> Result<(), LixError> {
539
+ out.push(VALUE_VERSION | if value.deleted { VALUE_DELETED_FLAG } else { 0 });
540
+ if value.change_locator.source_commit_id == pack_commit_id {
541
+ out.push(DELTA_LOCATOR_SAME_COMMIT);
542
+ } else {
543
+ out.push(DELTA_LOCATOR_FULL);
544
+ push_var_sized_bytes(
545
+ out,
546
+ value.change_locator.source_commit_id.as_bytes(),
547
+ "source_commit_id",
548
+ )?;
549
+ }
550
+ push_var_u32(
551
+ out,
552
+ value.change_locator.source_pack_id as usize,
553
+ "source_pack_id",
554
+ )?;
555
+ push_var_u32(
556
+ out,
557
+ value.change_locator.source_ordinal as usize,
558
+ "source_ordinal",
559
+ )?;
560
+ push_var_delta_change_id(
561
+ out,
562
+ value.change_locator.source_commit_id,
563
+ value.change_locator.change_id,
564
+ )?;
565
+ push_var_timestamp_pair(out, value.created_at, value.updated_at)?;
566
+ match json_pack_indexes {
567
+ Some(indexes) => {
568
+ push_mixed_optional_json_ref(out, indexes, value.snapshot_ref)?;
569
+ push_mixed_optional_json_ref(out, indexes, value.metadata_ref)?;
570
+ }
571
+ None => {
572
+ push_optional_json_ref(out, value.snapshot_ref);
573
+ push_optional_json_ref(out, value.metadata_ref);
574
+ }
221
575
  }
576
+ Ok(())
222
577
  }
223
578
 
224
- fn read_optional_json_ref(
579
+ pub(crate) fn decode_delta_pack(
580
+ bytes: &[u8],
581
+ pack_json_refs: Option<&[JsonRef]>,
582
+ ) -> Result<(String, Vec<TrackedStateDeltaEntry>), LixError> {
583
+ let mut cursor = 0usize;
584
+ let magic = bytes.get(0..4).ok_or_else(|| {
585
+ LixError::new(
586
+ "LIX_ERROR_UNKNOWN",
587
+ "tracked-state delta pack is truncated before magic",
588
+ )
589
+ })?;
590
+ if magic != b"LXTD" {
591
+ return Err(LixError::new(
592
+ "LIX_ERROR_UNKNOWN",
593
+ "tracked-state delta pack has invalid magic",
594
+ ));
595
+ }
596
+ cursor += 4;
597
+ let version = read_u8(bytes, &mut cursor, "delta pack version")?;
598
+ if version != DELTA_PACK_VERSION {
599
+ return Err(LixError::new(
600
+ "LIX_ERROR_UNKNOWN",
601
+ format!("unsupported tracked-state delta pack version {version}"),
602
+ ));
603
+ }
604
+ let commit_id = read_var_sized_string(bytes, &mut cursor, "delta pack commit_id")?;
605
+ let prefix_count = read_var_u32(bytes, &mut cursor, "delta key prefix count")?;
606
+ let mut key_prefixes = Vec::new();
607
+ for _ in 0..prefix_count {
608
+ key_prefixes.push(decode_delta_key_prefix(bytes, &mut cursor)?);
609
+ }
610
+ let count = read_var_u32(bytes, &mut cursor, "delta pack entry count")?;
611
+ let json_ref_mode = decode_delta_json_ref_mode(bytes, &mut cursor, pack_json_refs)?;
612
+ let mut entries = Vec::new();
613
+ for _ in 0..count {
614
+ let key = decode_delta_key(bytes, &mut cursor, &key_prefixes)?;
615
+ let value = decode_delta_value(bytes, &mut cursor, &commit_id, &json_ref_mode)?;
616
+ entries.push(TrackedStateDeltaEntry { key, value });
617
+ }
618
+ if cursor != bytes.len() {
619
+ return Err(LixError::new(
620
+ "LIX_ERROR_UNKNOWN",
621
+ "tracked-state delta pack decode found trailing bytes",
622
+ ));
623
+ }
624
+ Ok((commit_id, entries))
625
+ }
626
+
627
+ pub(crate) fn delta_pack_uses_json_pack_indexes(bytes: &[u8]) -> Result<bool, LixError> {
628
+ let mut cursor = 0usize;
629
+ let magic = bytes.get(0..4).ok_or_else(|| {
630
+ LixError::new(
631
+ "LIX_ERROR_UNKNOWN",
632
+ "tracked-state delta pack is truncated before magic",
633
+ )
634
+ })?;
635
+ if magic != b"LXTD" {
636
+ return Err(LixError::new(
637
+ "LIX_ERROR_UNKNOWN",
638
+ "tracked-state delta pack has invalid magic",
639
+ ));
640
+ }
641
+ cursor += 4;
642
+ let version = read_u8(bytes, &mut cursor, "delta pack version")?;
643
+ if version != DELTA_PACK_VERSION {
644
+ return Err(LixError::new(
645
+ "LIX_ERROR_UNKNOWN",
646
+ format!("unsupported tracked-state delta pack version {version}"),
647
+ ));
648
+ }
649
+ let _commit_id = read_var_sized_string(bytes, &mut cursor, "delta pack commit_id")?;
650
+ let prefix_count = read_var_u32(bytes, &mut cursor, "delta key prefix count")?;
651
+ for _ in 0..prefix_count {
652
+ let _ = decode_delta_key_prefix(bytes, &mut cursor)?;
653
+ }
654
+ let _count = read_var_u32(bytes, &mut cursor, "delta pack entry count")?;
655
+ match read_u8(bytes, &mut cursor, "delta JSON ref mode")? {
656
+ DELTA_JSON_REFS_INLINE => Ok(false),
657
+ DELTA_JSON_REFS_MIXED_PACK_INDEX => Ok(true),
658
+ other => Err(LixError::new(
659
+ "LIX_ERROR_UNKNOWN",
660
+ format!("tracked-state delta pack has invalid JSON ref mode {other}"),
661
+ )),
662
+ }
663
+ }
664
+
665
+ fn decode_delta_key(
225
666
  bytes: &[u8],
226
667
  cursor: &mut usize,
227
- field: &str,
228
- ) -> Result<Option<JsonRef>, LixError> {
229
- match read_u8(bytes, cursor, field)? {
230
- 0 => Ok(None),
231
- 1 => {
232
- let hash = read_fixed_hash(bytes, cursor, field)?;
233
- Ok(Some(JsonRef::from_hash_bytes(hash)))
668
+ prefixes: &[DeltaKeyPrefix],
669
+ ) -> Result<TrackedStateKey, LixError> {
670
+ let prefix_index = read_var_u32(bytes, cursor, "delta key prefix index")?;
671
+ let prefix = prefixes.get(prefix_index).ok_or_else(|| {
672
+ LixError::new(
673
+ "LIX_ERROR_UNKNOWN",
674
+ format!("tracked-state delta key prefix index {prefix_index} is out of bounds"),
675
+ )
676
+ })?;
677
+ let entity_id = read_var_entity_identity(bytes, cursor)?;
678
+ Ok(TrackedStateKey {
679
+ schema_key: prefix.schema_key.clone(),
680
+ file_id: prefix.file_id.clone(),
681
+ entity_id,
682
+ })
683
+ }
684
+
685
+ enum DeltaJsonRefDecodeMode<'a> {
686
+ Inline,
687
+ MixedPackIndex(&'a [JsonRef]),
688
+ }
689
+
690
+ fn decode_delta_json_ref_mode<'a>(
691
+ bytes: &[u8],
692
+ cursor: &mut usize,
693
+ pack_json_refs: Option<&'a [JsonRef]>,
694
+ ) -> Result<DeltaJsonRefDecodeMode<'a>, LixError> {
695
+ match read_u8(bytes, cursor, "delta JSON ref mode")? {
696
+ DELTA_JSON_REFS_INLINE => Ok(DeltaJsonRefDecodeMode::Inline),
697
+ DELTA_JSON_REFS_MIXED_PACK_INDEX => {
698
+ let refs = pack_json_refs.ok_or_else(|| {
699
+ LixError::new(
700
+ LixError::CODE_INTERNAL_ERROR,
701
+ "tracked-state delta pack needs JSON pack refs but none were provided",
702
+ )
703
+ })?;
704
+ Ok(DeltaJsonRefDecodeMode::MixedPackIndex(refs))
234
705
  }
235
706
  other => Err(LixError::new(
236
707
  "LIX_ERROR_UNKNOWN",
237
- format!("tracked-state tree value has invalid {field} presence byte {other}"),
708
+ format!("tracked-state delta pack has invalid JSON ref mode {other}"),
238
709
  )),
239
710
  }
240
711
  }
241
712
 
713
+ fn decode_delta_value(
714
+ bytes: &[u8],
715
+ cursor: &mut usize,
716
+ pack_commit_id: &str,
717
+ json_ref_mode: &DeltaJsonRefDecodeMode<'_>,
718
+ ) -> Result<TrackedStateIndexValue, LixError> {
719
+ let value_header = read_u8(bytes, cursor, "delta value header")?;
720
+ let deleted = decode_value_header(value_header)?;
721
+ let source_commit_id = match read_u8(bytes, cursor, "delta locator tag")? {
722
+ DELTA_LOCATOR_SAME_COMMIT => pack_commit_id.to_string(),
723
+ DELTA_LOCATOR_FULL => read_var_sized_string(bytes, cursor, "source_commit_id")?,
724
+ other => {
725
+ return Err(LixError::new(
726
+ "LIX_ERROR_UNKNOWN",
727
+ format!("tracked-state delta value has invalid locator tag {other}"),
728
+ ))
729
+ }
730
+ };
731
+ let source_pack_id =
732
+ u32::try_from(read_var_u32(bytes, cursor, "source_pack_id")?).map_err(|_| {
733
+ LixError::new(
734
+ LixError::CODE_INTERNAL_ERROR,
735
+ "tracked-state source_pack_id exceeds u32",
736
+ )
737
+ })?;
738
+ let source_ordinal =
739
+ u32::try_from(read_var_u32(bytes, cursor, "source_ordinal")?).map_err(|_| {
740
+ LixError::new(
741
+ LixError::CODE_INTERNAL_ERROR,
742
+ "tracked-state source_ordinal exceeds u32",
743
+ )
744
+ })?;
745
+ let change_id = read_var_delta_change_id(bytes, cursor, &source_commit_id)?;
746
+ let (created_at, updated_at) = read_var_timestamp_pair(bytes, cursor)?;
747
+ let (snapshot_ref, metadata_ref) = match json_ref_mode {
748
+ DeltaJsonRefDecodeMode::Inline => (
749
+ read_optional_json_ref(bytes, cursor, "snapshot_ref")?,
750
+ read_optional_json_ref(bytes, cursor, "metadata_ref")?,
751
+ ),
752
+ DeltaJsonRefDecodeMode::MixedPackIndex(refs) => (
753
+ read_mixed_optional_json_ref(bytes, cursor, refs, "snapshot_ref")?,
754
+ read_mixed_optional_json_ref(bytes, cursor, refs, "metadata_ref")?,
755
+ ),
756
+ };
757
+ Ok(TrackedStateIndexValue {
758
+ change_locator: ChangeLocator {
759
+ source_commit_id,
760
+ source_pack_id,
761
+ source_ordinal,
762
+ change_id,
763
+ },
764
+ deleted,
765
+ snapshot_ref,
766
+ metadata_ref,
767
+ created_at,
768
+ updated_at,
769
+ })
770
+ }
771
+
772
+ #[cfg(test)]
773
+ fn sized_bytes_len(bytes: &[u8]) -> usize {
774
+ 4 + bytes.len()
775
+ }
776
+
242
777
  pub(crate) fn encode_leaf_node(entries: &[EncodedLeafEntry]) -> Vec<u8> {
778
+ let entries = entries
779
+ .iter()
780
+ .map(EncodedLeafEntry::as_ref)
781
+ .collect::<Vec<_>>();
782
+ encode_leaf_node_refs(&entries)
783
+ }
784
+
785
+ pub(crate) fn encode_leaf_node_refs(entries: &[EncodedLeafEntryRef<'_>]) -> Vec<u8> {
243
786
  let mut out = Vec::new();
244
787
  out.push(NODE_KIND_LEAF);
245
788
  out.push(NODE_VERSION);
246
789
  push_u32(&mut out, entries.len());
790
+
791
+ let mut offsets = Vec::with_capacity(entries.len().saturating_add(1));
792
+ let mut payload = Vec::new();
793
+ offsets.push(0usize);
247
794
  for entry in entries {
248
- push_sized_bytes(&mut out, &entry.key);
249
- push_sized_bytes(&mut out, &entry.value);
795
+ push_sized_bytes(&mut payload, entry.key);
796
+ push_sized_bytes(&mut payload, entry.value);
797
+ offsets.push(payload.len());
250
798
  }
799
+ for offset in offsets {
800
+ push_u32(&mut out, offset);
801
+ }
802
+ out.extend_from_slice(&payload);
251
803
  out
252
804
  }
253
805
 
254
806
  pub(crate) fn encode_internal_node(children: &[ChildSummary]) -> Vec<u8> {
807
+ let children = children
808
+ .iter()
809
+ .map(ChildSummary::as_ref)
810
+ .collect::<Vec<_>>();
811
+ encode_internal_node_refs(&children)
812
+ }
813
+
814
+ pub(crate) fn encode_internal_node_refs(children: &[ChildSummaryRef<'_>]) -> Vec<u8> {
255
815
  let mut out = Vec::new();
256
816
  out.push(NODE_KIND_INTERNAL);
257
817
  out.push(NODE_VERSION);
258
818
  push_u32(&mut out, children.len());
259
819
  for child in children {
260
- push_sized_bytes(&mut out, &child.first_key);
261
- push_sized_bytes(&mut out, &child.last_key);
820
+ push_sized_bytes(&mut out, child.first_key);
821
+ push_sized_bytes(&mut out, child.last_key);
262
822
  out.extend_from_slice(&child.child_hash);
263
823
  out.extend_from_slice(&child.subtree_count.to_be_bytes());
264
824
  }
@@ -266,6 +826,28 @@ pub(crate) fn encode_internal_node(children: &[ChildSummary]) -> Vec<u8> {
266
826
  }
267
827
 
268
828
  pub(crate) fn decode_node(bytes: &[u8]) -> Result<DecodedNode, LixError> {
829
+ match decode_node_ref(bytes)? {
830
+ DecodedNodeRef::Leaf(leaf) => {
831
+ let mut entries = Vec::with_capacity(leaf.len());
832
+ for index in 0..leaf.len() {
833
+ let entry = leaf.entry(index)?.ok_or_else(|| {
834
+ LixError::new(
835
+ "LIX_ERROR_UNKNOWN",
836
+ "tracked-state leaf entry disappeared during owned decode",
837
+ )
838
+ })?;
839
+ entries.push(EncodedLeafEntry {
840
+ key: entry.key.to_vec(),
841
+ value: entry.value.to_vec(),
842
+ });
843
+ }
844
+ Ok(DecodedNode::Leaf(DecodedLeafNode { entries }))
845
+ }
846
+ DecodedNodeRef::Internal(internal) => Ok(DecodedNode::Internal(internal)),
847
+ }
848
+ }
849
+
850
+ pub(crate) fn decode_node_ref(bytes: &[u8]) -> Result<DecodedNodeRef<'_>, LixError> {
269
851
  let mut cursor = 0usize;
270
852
  let kind = read_u8(bytes, &mut cursor, "node kind")?;
271
853
  let version = read_u8(bytes, &mut cursor, "node version")?;
@@ -278,14 +860,8 @@ pub(crate) fn decode_node(bytes: &[u8]) -> Result<DecodedNode, LixError> {
278
860
  let count = read_u32(bytes, &mut cursor, "entry count")?;
279
861
  let node = match kind {
280
862
  NODE_KIND_LEAF => {
281
- let mut entries = Vec::with_capacity(count);
282
- for _ in 0..count {
283
- entries.push(EncodedLeafEntry {
284
- key: read_sized_bytes(bytes, &mut cursor, "leaf key")?,
285
- value: read_sized_bytes(bytes, &mut cursor, "leaf value")?,
286
- });
287
- }
288
- DecodedNode::Leaf(DecodedLeafNode { entries })
863
+ let leaf = decode_leaf_node_ref_after_count(bytes, &mut cursor, count)?;
864
+ DecodedNodeRef::Leaf(leaf)
289
865
  }
290
866
  NODE_KIND_INTERNAL => {
291
867
  let mut children = Vec::with_capacity(count);
@@ -301,7 +877,7 @@ pub(crate) fn decode_node(bytes: &[u8]) -> Result<DecodedNode, LixError> {
301
877
  subtree_count,
302
878
  });
303
879
  }
304
- DecodedNode::Internal(DecodedInternalNode { children })
880
+ DecodedNodeRef::Internal(DecodedInternalNode { children })
305
881
  }
306
882
  other => {
307
883
  return Err(LixError::new(
@@ -319,6 +895,50 @@ pub(crate) fn decode_node(bytes: &[u8]) -> Result<DecodedNode, LixError> {
319
895
  Ok(node)
320
896
  }
321
897
 
898
+ fn decode_leaf_node_ref_after_count<'a>(
899
+ bytes: &'a [u8],
900
+ cursor: &mut usize,
901
+ count: usize,
902
+ ) -> Result<DecodedLeafNodeRef<'a>, LixError> {
903
+ let mut offsets = Vec::with_capacity(count.saturating_add(1));
904
+ for _ in 0..=count {
905
+ offsets.push(read_u32(bytes, cursor, "leaf entry offset")?);
906
+ }
907
+ if offsets.first().copied() != Some(0) {
908
+ return Err(LixError::new(
909
+ "LIX_ERROR_UNKNOWN",
910
+ "tracked-state leaf offset table must start at zero",
911
+ ));
912
+ }
913
+ for window in offsets.windows(2) {
914
+ if window[0] > window[1] {
915
+ return Err(LixError::new(
916
+ "LIX_ERROR_UNKNOWN",
917
+ "tracked-state leaf offsets must be monotonic",
918
+ ));
919
+ }
920
+ }
921
+ let payload_len = bytes.len().checked_sub(*cursor).ok_or_else(|| {
922
+ LixError::new(
923
+ "LIX_ERROR_UNKNOWN",
924
+ "tracked-state leaf payload start is past node end",
925
+ )
926
+ })?;
927
+ if offsets.last().copied().unwrap_or_default() != payload_len {
928
+ return Err(LixError::new(
929
+ "LIX_ERROR_UNKNOWN",
930
+ "tracked-state leaf offset table does not cover full payload",
931
+ ));
932
+ }
933
+ let payload_start = *cursor;
934
+ *cursor = bytes.len();
935
+ Ok(DecodedLeafNodeRef {
936
+ bytes,
937
+ payload_start,
938
+ offsets,
939
+ })
940
+ }
941
+
322
942
  pub(crate) fn child_summary_from_node(
323
943
  node_bytes: Vec<u8>,
324
944
  first_key: Vec<u8>,
@@ -384,20 +1004,8 @@ fn push_entity_identity(out: &mut Vec<u8>, identity: &EntityIdentity) {
384
1004
  "tracked-state key entity identity must contain at least one part"
385
1005
  );
386
1006
  for part in &identity.parts {
387
- match part {
388
- EntityIdentityPart::String(value) => {
389
- out.push(ENTITY_IDENTITY_STRING);
390
- push_sized_bytes(out, value.as_bytes());
391
- }
392
- EntityIdentityPart::Bool(value) => {
393
- out.push(ENTITY_IDENTITY_BOOL);
394
- out.push(u8::from(*value));
395
- }
396
- EntityIdentityPart::Number(value) => {
397
- out.push(ENTITY_IDENTITY_NUMBER);
398
- push_sized_bytes(out, value.as_bytes());
399
- }
400
- }
1007
+ out.push(ENTITY_IDENTITY_STRING);
1008
+ push_sized_bytes(out, part.as_bytes());
401
1009
  }
402
1010
  out.push(ENTITY_IDENTITY_END);
403
1011
  }
@@ -409,33 +1017,11 @@ fn read_entity_identity(bytes: &[u8], cursor: &mut usize) -> Result<EntityIdenti
409
1017
  match tag {
410
1018
  ENTITY_IDENTITY_END => break,
411
1019
  ENTITY_IDENTITY_STRING => {
412
- parts.push(EntityIdentityPart::String(read_sized_string(
1020
+ parts.push(read_sized_string(
413
1021
  bytes,
414
1022
  cursor,
415
1023
  "entity identity string part",
416
- )?));
417
- }
418
- ENTITY_IDENTITY_BOOL => {
419
- let value = match read_u8(bytes, cursor, "entity identity bool part")? {
420
- 0 => false,
421
- 1 => true,
422
- other => {
423
- return Err(LixError::new(
424
- "LIX_ERROR_UNKNOWN",
425
- format!(
426
- "tracked-state tree key has invalid entity identity bool byte {other}"
427
- ),
428
- ))
429
- }
430
- };
431
- parts.push(EntityIdentityPart::Bool(value));
432
- }
433
- ENTITY_IDENTITY_NUMBER => {
434
- parts.push(EntityIdentityPart::Number(read_sized_string(
435
- bytes,
436
- cursor,
437
- "entity identity number part",
438
- )?));
1024
+ )?);
439
1025
  }
440
1026
  other => {
441
1027
  return Err(LixError::new(
@@ -459,6 +1045,180 @@ fn push_sized_bytes(out: &mut Vec<u8>, bytes: &[u8]) {
459
1045
  out.extend_from_slice(bytes);
460
1046
  }
461
1047
 
1048
+ fn push_var_u32(out: &mut Vec<u8>, value: usize, field_name: &str) -> Result<(), LixError> {
1049
+ let (encoded, len) = var_u32_bytes(value, field_name)?;
1050
+ out.extend_from_slice(&encoded[..len]);
1051
+ Ok(())
1052
+ }
1053
+
1054
+ fn var_u32_bytes(value: usize, field_name: &str) -> Result<([u8; 5], usize), LixError> {
1055
+ let mut value = u32::try_from(value).map_err(|_| {
1056
+ LixError::new(
1057
+ LixError::CODE_INTERNAL_ERROR,
1058
+ format!("tracked-state delta pack field '{field_name}' exceeds u32"),
1059
+ )
1060
+ })?;
1061
+ let mut encoded = [0_u8; 5];
1062
+ let mut len = 0usize;
1063
+ while value >= 0x80 {
1064
+ encoded[len] = (value as u8 & 0x7f) | 0x80;
1065
+ len += 1;
1066
+ value >>= 7;
1067
+ }
1068
+ encoded[len] = value as u8;
1069
+ len += 1;
1070
+ Ok((encoded, len))
1071
+ }
1072
+
1073
+ fn push_var_sized_bytes(out: &mut Vec<u8>, bytes: &[u8], field_name: &str) -> Result<(), LixError> {
1074
+ push_var_u32(out, bytes.len(), field_name)?;
1075
+ out.extend_from_slice(bytes);
1076
+ Ok(())
1077
+ }
1078
+
1079
+ fn push_var_entity_identity(out: &mut Vec<u8>, identity: &EntityIdentity) -> Result<(), LixError> {
1080
+ assert!(
1081
+ !identity.parts.is_empty(),
1082
+ "tracked-state delta key entity identity must contain at least one part"
1083
+ );
1084
+ push_var_u32(out, identity.parts.len(), "entity identity part count")?;
1085
+ for part in &identity.parts {
1086
+ push_var_sized_bytes(out, part.as_bytes(), "entity identity string part")?;
1087
+ }
1088
+ Ok(())
1089
+ }
1090
+
1091
+ fn push_optional_json_ref(out: &mut Vec<u8>, json_ref: Option<&JsonRef>) {
1092
+ match json_ref {
1093
+ Some(json_ref) => {
1094
+ out.push(1);
1095
+ out.extend_from_slice(json_ref.as_hash_bytes());
1096
+ }
1097
+ None => out.push(0),
1098
+ }
1099
+ }
1100
+
1101
+ fn push_mixed_optional_json_ref(
1102
+ out: &mut Vec<u8>,
1103
+ indexes: &HashMap<[u8; TRACKED_STATE_HASH_BYTES], usize>,
1104
+ json_ref: Option<&JsonRef>,
1105
+ ) -> Result<(), LixError> {
1106
+ let Some(json_ref) = json_ref else {
1107
+ out.push(DELTA_JSON_REF_NONE);
1108
+ return Ok(());
1109
+ };
1110
+ if let Some(index) = indexes.get(json_ref.as_hash_array()).copied() {
1111
+ out.push(DELTA_JSON_REF_PACK_INDEX);
1112
+ push_var_u32(out, index, "json ref pack index")
1113
+ } else {
1114
+ out.push(DELTA_JSON_REF_INLINE);
1115
+ out.extend_from_slice(json_ref.as_hash_bytes());
1116
+ Ok(())
1117
+ }
1118
+ }
1119
+
1120
+ fn push_var_delta_change_id(
1121
+ out: &mut Vec<u8>,
1122
+ source_commit_id: &str,
1123
+ change_id: &str,
1124
+ ) -> Result<(), LixError> {
1125
+ if let Some(suffix) = change_id.strip_prefix(source_commit_id) {
1126
+ out.push(DELTA_CHANGE_ID_COMMIT_SUFFIX);
1127
+ push_var_sized_bytes(out, suffix.as_bytes(), "change_id")
1128
+ } else {
1129
+ out.push(DELTA_CHANGE_ID_FULL);
1130
+ push_var_sized_bytes(out, change_id.as_bytes(), "change_id")
1131
+ }
1132
+ }
1133
+
1134
+ fn read_var_delta_change_id(
1135
+ bytes: &[u8],
1136
+ cursor: &mut usize,
1137
+ source_commit_id: &str,
1138
+ ) -> Result<String, LixError> {
1139
+ let tag = read_u8(bytes, cursor, "delta change_id tag")?;
1140
+ let value = read_var_sized_string(bytes, cursor, "change_id")?;
1141
+ match tag {
1142
+ DELTA_CHANGE_ID_FULL => Ok(value),
1143
+ DELTA_CHANGE_ID_COMMIT_SUFFIX => Ok(format!("{source_commit_id}{value}")),
1144
+ other => Err(LixError::new(
1145
+ "LIX_ERROR_UNKNOWN",
1146
+ format!("tracked-state delta value has invalid change_id tag {other}"),
1147
+ )),
1148
+ }
1149
+ }
1150
+
1151
+ #[cfg(test)]
1152
+ fn optional_json_ref_len(json_ref: Option<&JsonRef>) -> usize {
1153
+ 1 + json_ref.map_or(0, |_| TRACKED_STATE_HASH_BYTES)
1154
+ }
1155
+
1156
+ fn push_timestamp_pair(out: &mut Vec<u8>, created_at: &str, updated_at: &str) {
1157
+ push_sized_bytes(out, created_at.as_bytes());
1158
+ if updated_at == created_at {
1159
+ out.push(TIMESTAMP_UPDATED_SAME);
1160
+ } else {
1161
+ out.push(TIMESTAMP_UPDATED_DISTINCT);
1162
+ push_sized_bytes(out, updated_at.as_bytes());
1163
+ }
1164
+ }
1165
+
1166
+ fn push_var_timestamp_pair(
1167
+ out: &mut Vec<u8>,
1168
+ created_at: &str,
1169
+ updated_at: &str,
1170
+ ) -> Result<(), LixError> {
1171
+ push_var_sized_bytes(out, created_at.as_bytes(), "created_at")?;
1172
+ if updated_at == created_at {
1173
+ out.push(TIMESTAMP_UPDATED_SAME);
1174
+ } else {
1175
+ out.push(TIMESTAMP_UPDATED_DISTINCT);
1176
+ push_var_sized_bytes(out, updated_at.as_bytes(), "updated_at")?;
1177
+ }
1178
+ Ok(())
1179
+ }
1180
+
1181
+ #[cfg(test)]
1182
+ fn timestamp_pair_len(created_at: &str, updated_at: &str) -> usize {
1183
+ sized_bytes_len(created_at.as_bytes())
1184
+ + 1
1185
+ + if updated_at == created_at {
1186
+ 0
1187
+ } else {
1188
+ sized_bytes_len(updated_at.as_bytes())
1189
+ }
1190
+ }
1191
+
1192
+ fn read_timestamp_pair(bytes: &[u8], cursor: &mut usize) -> Result<(String, String), LixError> {
1193
+ let created_at = read_sized_string(bytes, cursor, "created_at")?;
1194
+ let updated_at = match read_u8(bytes, cursor, "updated_at tag")? {
1195
+ TIMESTAMP_UPDATED_SAME => created_at.clone(),
1196
+ TIMESTAMP_UPDATED_DISTINCT => read_sized_string(bytes, cursor, "updated_at")?,
1197
+ other => {
1198
+ return Err(LixError::new(
1199
+ "LIX_ERROR_UNKNOWN",
1200
+ format!("tracked-state timestamp pair has invalid updated_at tag {other}"),
1201
+ ))
1202
+ }
1203
+ };
1204
+ Ok((created_at, updated_at))
1205
+ }
1206
+
1207
+ fn read_var_timestamp_pair(bytes: &[u8], cursor: &mut usize) -> Result<(String, String), LixError> {
1208
+ let created_at = read_var_sized_string(bytes, cursor, "created_at")?;
1209
+ let updated_at = match read_u8(bytes, cursor, "updated_at tag")? {
1210
+ TIMESTAMP_UPDATED_SAME => created_at.clone(),
1211
+ TIMESTAMP_UPDATED_DISTINCT => read_var_sized_string(bytes, cursor, "updated_at")?,
1212
+ other => {
1213
+ return Err(LixError::new(
1214
+ "LIX_ERROR_UNKNOWN",
1215
+ format!("tracked-state timestamp pair has invalid updated_at tag {other}"),
1216
+ ))
1217
+ }
1218
+ };
1219
+ Ok((created_at, updated_at))
1220
+ }
1221
+
462
1222
  fn push_u32(out: &mut Vec<u8>, value: usize) {
463
1223
  out.extend_from_slice(&(value as u32).to_be_bytes());
464
1224
  }
@@ -481,6 +1241,14 @@ fn read_sized_bytes(
481
1241
  cursor: &mut usize,
482
1242
  field_name: &str,
483
1243
  ) -> Result<Vec<u8>, LixError> {
1244
+ read_sized_slice(bytes, cursor, field_name).map(<[u8]>::to_vec)
1245
+ }
1246
+
1247
+ fn read_sized_slice<'a>(
1248
+ bytes: &'a [u8],
1249
+ cursor: &mut usize,
1250
+ field_name: &str,
1251
+ ) -> Result<&'a [u8], LixError> {
484
1252
  let len = read_u32(bytes, cursor, field_name)?;
485
1253
  let end = cursor.checked_add(len).ok_or_else(|| {
486
1254
  LixError::new(
@@ -495,7 +1263,61 @@ fn read_sized_bytes(
495
1263
  )
496
1264
  })?;
497
1265
  *cursor = end;
498
- Ok(slice.to_vec())
1266
+ Ok(slice)
1267
+ }
1268
+
1269
+ fn read_var_sized_string(
1270
+ bytes: &[u8],
1271
+ cursor: &mut usize,
1272
+ field_name: &str,
1273
+ ) -> Result<String, LixError> {
1274
+ String::from_utf8(read_var_sized_slice(bytes, cursor, field_name)?.to_vec()).map_err(|error| {
1275
+ LixError::new(
1276
+ "LIX_ERROR_UNKNOWN",
1277
+ format!("tracked-state delta pack field '{field_name}' is invalid UTF-8: {error}"),
1278
+ )
1279
+ })
1280
+ }
1281
+
1282
+ fn read_var_sized_slice<'a>(
1283
+ bytes: &'a [u8],
1284
+ cursor: &mut usize,
1285
+ field_name: &str,
1286
+ ) -> Result<&'a [u8], LixError> {
1287
+ let len = read_var_u32(bytes, cursor, field_name)?;
1288
+ let end = cursor.checked_add(len).ok_or_else(|| {
1289
+ LixError::new(
1290
+ "LIX_ERROR_UNKNOWN",
1291
+ format!("tracked-state delta pack field '{field_name}' length overflow"),
1292
+ )
1293
+ })?;
1294
+ let slice = bytes.get(*cursor..end).ok_or_else(|| {
1295
+ LixError::new(
1296
+ "LIX_ERROR_UNKNOWN",
1297
+ format!("tracked-state delta pack field '{field_name}' is truncated"),
1298
+ )
1299
+ })?;
1300
+ *cursor = end;
1301
+ Ok(slice)
1302
+ }
1303
+
1304
+ fn read_var_entity_identity(bytes: &[u8], cursor: &mut usize) -> Result<EntityIdentity, LixError> {
1305
+ let count = read_var_u32(bytes, cursor, "entity identity part count")?;
1306
+ let mut parts = Vec::new();
1307
+ for _ in 0..count {
1308
+ parts.push(read_var_sized_string(
1309
+ bytes,
1310
+ cursor,
1311
+ "entity identity string part",
1312
+ )?);
1313
+ }
1314
+ if parts.is_empty() {
1315
+ return Err(LixError::new(
1316
+ "LIX_ERROR_UNKNOWN",
1317
+ "tracked-state delta key entity identity must contain at least one part",
1318
+ ));
1319
+ }
1320
+ Ok(EntityIdentity { parts })
499
1321
  }
500
1322
 
501
1323
  fn read_fixed_hash(
@@ -516,6 +1338,51 @@ fn read_fixed_hash(
516
1338
  Ok(out)
517
1339
  }
518
1340
 
1341
+ fn read_optional_json_ref(
1342
+ bytes: &[u8],
1343
+ cursor: &mut usize,
1344
+ field_name: &str,
1345
+ ) -> Result<Option<JsonRef>, LixError> {
1346
+ match read_u8(bytes, cursor, field_name)? {
1347
+ 0 => Ok(None),
1348
+ 1 => Ok(Some(JsonRef::from_hash_bytes(read_fixed_hash(
1349
+ bytes, cursor, field_name,
1350
+ )?))),
1351
+ other => Err(LixError::new(
1352
+ "LIX_ERROR_UNKNOWN",
1353
+ format!("tracked-state tree field '{field_name}' has invalid JSON ref tag {other}"),
1354
+ )),
1355
+ }
1356
+ }
1357
+
1358
+ fn read_mixed_optional_json_ref(
1359
+ bytes: &[u8],
1360
+ cursor: &mut usize,
1361
+ refs: &[JsonRef],
1362
+ field_name: &str,
1363
+ ) -> Result<Option<JsonRef>, LixError> {
1364
+ match read_u8(bytes, cursor, field_name)? {
1365
+ DELTA_JSON_REF_NONE => Ok(None),
1366
+ DELTA_JSON_REF_PACK_INDEX => {
1367
+ let index = read_var_u32(bytes, cursor, field_name)?;
1368
+ refs.get(index).copied().map(Some).ok_or_else(|| {
1369
+ LixError::new(
1370
+ "LIX_ERROR_UNKNOWN",
1371
+ format!("tracked-state delta JSON ref index {index} is out of bounds"),
1372
+ )
1373
+ })
1374
+ }
1375
+ DELTA_JSON_REF_INLINE => {
1376
+ let hash = read_fixed_hash(bytes, cursor, field_name)?;
1377
+ Ok(Some(JsonRef::from_hash_bytes(hash)))
1378
+ }
1379
+ other => Err(LixError::new(
1380
+ "LIX_ERROR_UNKNOWN",
1381
+ format!("tracked-state tree field '{field_name}' has invalid JSON ref tag {other}"),
1382
+ )),
1383
+ }
1384
+ }
1385
+
519
1386
  fn read_u8(bytes: &[u8], cursor: &mut usize, field_name: &str) -> Result<u8, LixError> {
520
1387
  let value = *bytes.get(*cursor).ok_or_else(|| {
521
1388
  LixError::new(
@@ -527,6 +1394,35 @@ fn read_u8(bytes: &[u8], cursor: &mut usize, field_name: &str) -> Result<u8, Lix
527
1394
  Ok(value)
528
1395
  }
529
1396
 
1397
+ fn read_var_u32(bytes: &[u8], cursor: &mut usize, field_name: &str) -> Result<usize, LixError> {
1398
+ let mut value = 0u32;
1399
+ let mut shift = 0u32;
1400
+ for byte_index in 0..5 {
1401
+ let byte = read_u8(bytes, cursor, field_name)?;
1402
+ if shift == 28 && (byte & 0x80 != 0 || byte & 0x70 != 0) {
1403
+ return Err(LixError::new(
1404
+ "LIX_ERROR_UNKNOWN",
1405
+ format!("tracked-state delta pack field '{field_name}' varint exceeds u32"),
1406
+ ));
1407
+ }
1408
+ if byte_index > 0 && byte & 0x80 == 0 && byte == 0 {
1409
+ return Err(LixError::new(
1410
+ "LIX_ERROR_UNKNOWN",
1411
+ format!("tracked-state delta pack field '{field_name}' has non-canonical varint"),
1412
+ ));
1413
+ }
1414
+ value |= ((byte & 0x7f) as u32) << shift;
1415
+ if byte & 0x80 == 0 {
1416
+ return Ok(value as usize);
1417
+ }
1418
+ shift += 7;
1419
+ }
1420
+ Err(LixError::new(
1421
+ "LIX_ERROR_UNKNOWN",
1422
+ format!("tracked-state delta pack field '{field_name}' varint exceeds u32"),
1423
+ ))
1424
+ }
1425
+
530
1426
  fn read_u32(bytes: &[u8], cursor: &mut usize, field_name: &str) -> Result<usize, LixError> {
531
1427
  let end = *cursor + 4;
532
1428
  let slice = bytes.get(*cursor..end).ok_or_else(|| {
@@ -592,15 +1488,15 @@ mod tests {
592
1488
  }
593
1489
 
594
1490
  #[test]
595
- fn key_codec_encodes_composite_identity_as_typed_tuple_parts() {
1491
+ fn key_codec_encodes_composite_identity_as_string_tuple_parts() {
596
1492
  let key = TrackedStateKey {
597
1493
  schema_key: "schema".to_string(),
598
1494
  file_id: None,
599
1495
  entity_id: EntityIdentity {
600
1496
  parts: vec![
601
- EntityIdentityPart::String("namespace".to_string()),
602
- EntityIdentityPart::Bool(true),
603
- EntityIdentityPart::Number("42".to_string()),
1497
+ "namespace".to_string(),
1498
+ "true".to_string(),
1499
+ "42".to_string(),
604
1500
  ],
605
1501
  },
606
1502
  };
@@ -608,32 +1504,45 @@ mod tests {
608
1504
  let encoded = encode_key(&key);
609
1505
 
610
1506
  assert_eq!(decode_key(&encoded).expect("key should decode"), key);
611
- assert!(
612
- !encoded
613
- .windows(b"pk:v1:".len())
614
- .any(|window| window == b"pk:v1:"),
615
- "tracked-state keys should not store the SQL entity_id projection"
616
- );
617
1507
  }
618
1508
 
619
1509
  #[test]
620
- fn key_codec_distinguishes_typed_identity_parts() {
621
- let string_true = encode_key(&TrackedStateKey {
1510
+ fn key_codec_decodes_entity_suffix_with_trusted_prefix() {
1511
+ let key = TrackedStateKey {
622
1512
  schema_key: "schema".to_string(),
623
- file_id: None,
1513
+ file_id: Some("file".to_string()),
624
1514
  entity_id: EntityIdentity {
625
- parts: vec![EntityIdentityPart::String("true".to_string())],
1515
+ parts: vec!["namespace".to_string(), "id".to_string()],
626
1516
  },
627
- });
628
- let bool_true = encode_key(&TrackedStateKey {
1517
+ };
1518
+ let encoded = encode_key(&key);
1519
+ let prefix = encode_schema_file_prefix("schema", Some("file"));
1520
+
1521
+ assert_eq!(
1522
+ decode_key_with_trusted_prefix(&encoded, "schema", Some("file"), prefix.len())
1523
+ .expect("key suffix should decode"),
1524
+ key
1525
+ );
1526
+ }
1527
+
1528
+ #[test]
1529
+ fn key_codec_rejects_non_string_identity_part_tags() {
1530
+ let mut encoded = encode_key(&TrackedStateKey {
629
1531
  schema_key: "schema".to_string(),
630
1532
  file_id: None,
631
1533
  entity_id: EntityIdentity {
632
- parts: vec![EntityIdentityPart::Bool(true)],
1534
+ parts: vec!["true".to_string()],
633
1535
  },
634
1536
  });
1537
+ let schema_key_len = "schema".len();
1538
+ let file_scope_offset = 4 + schema_key_len;
1539
+ let entity_tag_offset = file_scope_offset + 1;
1540
+ encoded[entity_tag_offset] = 2;
635
1541
 
636
- assert_ne!(string_true, bool_true);
1542
+ let error = decode_key(&encoded).expect_err("non-string identity tag should reject");
1543
+ assert!(error
1544
+ .to_string()
1545
+ .contains("invalid entity identity part tag 2"));
637
1546
  }
638
1547
 
639
1548
  #[test]
@@ -642,17 +1551,14 @@ mod tests {
642
1551
  schema_key: "schema".to_string(),
643
1552
  file_id: None,
644
1553
  entity_id: EntityIdentity {
645
- parts: vec![EntityIdentityPart::String("a".to_string())],
1554
+ parts: vec!["a".to_string()],
646
1555
  },
647
1556
  });
648
1557
  let extended = encode_key(&TrackedStateKey {
649
1558
  schema_key: "schema".to_string(),
650
1559
  file_id: None,
651
1560
  entity_id: EntityIdentity {
652
- parts: vec![
653
- EntityIdentityPart::String("a".to_string()),
654
- EntityIdentityPart::String("b".to_string()),
655
- ],
1561
+ parts: vec!["a".to_string(), "b".to_string()],
656
1562
  },
657
1563
  });
658
1564
 
@@ -660,16 +1566,19 @@ mod tests {
660
1566
  }
661
1567
 
662
1568
  #[test]
663
- fn value_codec_roundtrips_tombstone_value() {
664
- let value = TrackedStateValue {
665
- snapshot_ref: None,
666
- metadata_ref: Some(JsonRef::from_hash_bytes([1; 32])),
667
- schema_version: "1".to_string(),
1569
+ fn value_codec_roundtrips_locator_value() {
1570
+ let value = TrackedStateIndexValue {
1571
+ change_locator: ChangeLocator {
1572
+ source_commit_id: "commit".to_string(),
1573
+ source_pack_id: 7,
1574
+ source_ordinal: 11,
1575
+ change_id: "change".to_string(),
1576
+ },
1577
+ deleted: false,
1578
+ snapshot_ref: Some(JsonRef::from_hash_bytes([1; 32])),
1579
+ metadata_ref: Some(JsonRef::from_hash_bytes([2; 32])),
668
1580
  created_at: "2026-01-01T00:00:00Z".to_string(),
669
1581
  updated_at: "2026-01-02T00:00:00Z".to_string(),
670
- change_id: "change".to_string(),
671
- commit_id: "commit".to_string(),
672
- deleted: true,
673
1582
  };
674
1583
 
675
1584
  let encoded = encode_value(&value);
@@ -677,54 +1586,397 @@ mod tests {
677
1586
  }
678
1587
 
679
1588
  #[test]
680
- fn value_codec_roundtrips_snapshot_ref() {
681
- let value = TrackedStateValue {
682
- snapshot_ref: Some(JsonRef::from_hash_bytes([2; 32])),
1589
+ fn value_codec_roundtrips_second_locator_value() {
1590
+ let value = TrackedStateIndexValue {
1591
+ change_locator: ChangeLocator {
1592
+ source_commit_id: "other-commit".to_string(),
1593
+ source_pack_id: 0,
1594
+ source_ordinal: 1,
1595
+ change_id: "other-change".to_string(),
1596
+ },
1597
+ deleted: true,
1598
+ snapshot_ref: None,
683
1599
  metadata_ref: None,
684
- schema_version: "1".to_string(),
685
1600
  created_at: "2026-01-01T00:00:00Z".to_string(),
686
1601
  updated_at: "2026-01-02T00:00:00Z".to_string(),
687
- change_id: "change".to_string(),
688
- commit_id: "commit".to_string(),
689
- deleted: false,
690
1602
  };
691
1603
 
692
1604
  let encoded = encode_value(&value);
693
1605
  assert_eq!(decode_value(&encoded).expect("value"), value);
694
1606
  }
695
1607
 
1608
+ #[test]
1609
+ fn value_codec_compacts_matching_timestamps() {
1610
+ let mut compact = TrackedStateIndexValue {
1611
+ change_locator: ChangeLocator {
1612
+ source_commit_id: "commit".to_string(),
1613
+ source_pack_id: 0,
1614
+ source_ordinal: 1,
1615
+ change_id: "change".to_string(),
1616
+ },
1617
+ deleted: false,
1618
+ snapshot_ref: None,
1619
+ metadata_ref: None,
1620
+ created_at: "2026-01-01T00:00:00Z".to_string(),
1621
+ updated_at: "2026-01-01T00:00:00Z".to_string(),
1622
+ };
1623
+ let compact_len = encode_value(&compact).len();
1624
+ assert_eq!(
1625
+ decode_value(&encode_value(&compact)).expect("value"),
1626
+ compact
1627
+ );
1628
+
1629
+ compact.updated_at = "2026-01-02T00:00:00Z".to_string();
1630
+ let distinct_len = encode_value(&compact).len();
1631
+
1632
+ assert!(compact_len < distinct_len);
1633
+ assert_eq!(
1634
+ distinct_len - compact_len,
1635
+ sized_bytes_len(compact.updated_at.as_bytes())
1636
+ );
1637
+ }
1638
+
1639
+ #[test]
1640
+ fn delta_pack_ref_encoder_roundtrips_entries() {
1641
+ let entity_id = EntityIdentity {
1642
+ parts: vec!["entity-a".to_string()],
1643
+ };
1644
+ let snapshot_ref = JsonRef::from_hash_bytes([1; 32]);
1645
+ let metadata_ref = JsonRef::from_hash_bytes([2; 32]);
1646
+ let live_change = crate::commit_store::ChangeRef {
1647
+ id: "commit-a:change-live",
1648
+ entity_id: &entity_id,
1649
+ schema_key: "schema",
1650
+ file_id: Some("file-a"),
1651
+ snapshot_ref: Some(&snapshot_ref),
1652
+ metadata_ref: Some(&metadata_ref),
1653
+ created_at: "2026-01-01T00:00:00Z",
1654
+ };
1655
+ let tombstone_change = crate::commit_store::ChangeRef {
1656
+ id: "change-deleted",
1657
+ entity_id: &entity_id,
1658
+ schema_key: "schema",
1659
+ file_id: None,
1660
+ snapshot_ref: None,
1661
+ metadata_ref: None,
1662
+ created_at: "2026-01-01T00:00:00Z",
1663
+ };
1664
+ let live_locator = crate::commit_store::ChangeLocatorRef {
1665
+ source_commit_id: "commit-a",
1666
+ source_pack_id: 3,
1667
+ source_ordinal: 5,
1668
+ change_id: "commit-a:change-live",
1669
+ };
1670
+ let tombstone_locator = crate::commit_store::ChangeLocatorRef {
1671
+ source_commit_id: "source-commit",
1672
+ source_pack_id: 3,
1673
+ source_ordinal: 6,
1674
+ change_id: "commit-a:borrowed",
1675
+ };
1676
+ let encoded = encode_delta_pack_refs(
1677
+ "commit-a",
1678
+ &[
1679
+ TrackedStateDeltaRef {
1680
+ change: live_change,
1681
+ locator: live_locator,
1682
+ created_at: "2026-01-01T00:00:00Z",
1683
+ updated_at: "2026-01-02T00:00:00Z",
1684
+ },
1685
+ TrackedStateDeltaRef {
1686
+ change: tombstone_change,
1687
+ locator: tombstone_locator,
1688
+ created_at: "2026-01-03T00:00:00Z",
1689
+ updated_at: "2026-01-04T00:00:00Z",
1690
+ },
1691
+ ],
1692
+ )
1693
+ .expect("delta pack should encode");
1694
+
1695
+ let mut cursor = 5usize;
1696
+ assert_eq!(
1697
+ read_var_sized_string(&encoded, &mut cursor, "delta pack commit_id")
1698
+ .expect("commit id should decode"),
1699
+ "commit-a"
1700
+ );
1701
+ assert_eq!(
1702
+ read_var_u32(&encoded, &mut cursor, "delta key prefix count")
1703
+ .expect("prefix count should decode"),
1704
+ 2
1705
+ );
1706
+
1707
+ let (decoded_commit_id, decoded) =
1708
+ decode_delta_pack(&encoded, None).expect("delta pack should decode");
1709
+
1710
+ assert_eq!(decoded_commit_id, "commit-a");
1711
+ assert_eq!(
1712
+ decoded,
1713
+ vec![
1714
+ TrackedStateDeltaEntry {
1715
+ key: TrackedStateKey {
1716
+ schema_key: "schema".to_string(),
1717
+ file_id: Some("file-a".to_string()),
1718
+ entity_id: entity_id.clone(),
1719
+ },
1720
+ value: TrackedStateIndexValue {
1721
+ change_locator: ChangeLocator {
1722
+ source_commit_id: "commit-a".to_string(),
1723
+ source_pack_id: 3,
1724
+ source_ordinal: 5,
1725
+ change_id: "commit-a:change-live".to_string(),
1726
+ },
1727
+ deleted: false,
1728
+ snapshot_ref: Some(snapshot_ref),
1729
+ metadata_ref: Some(metadata_ref),
1730
+ created_at: "2026-01-01T00:00:00Z".to_string(),
1731
+ updated_at: "2026-01-02T00:00:00Z".to_string(),
1732
+ },
1733
+ },
1734
+ TrackedStateDeltaEntry {
1735
+ key: TrackedStateKey {
1736
+ schema_key: "schema".to_string(),
1737
+ file_id: None,
1738
+ entity_id,
1739
+ },
1740
+ value: TrackedStateIndexValue {
1741
+ change_locator: ChangeLocator {
1742
+ source_commit_id: "source-commit".to_string(),
1743
+ source_pack_id: 3,
1744
+ source_ordinal: 6,
1745
+ change_id: "commit-a:borrowed".to_string(),
1746
+ },
1747
+ deleted: true,
1748
+ snapshot_ref: None,
1749
+ metadata_ref: None,
1750
+ created_at: "2026-01-03T00:00:00Z".to_string(),
1751
+ updated_at: "2026-01-04T00:00:00Z".to_string(),
1752
+ },
1753
+ },
1754
+ ]
1755
+ );
1756
+ }
1757
+
1758
+ #[test]
1759
+ fn delta_pack_ref_encoder_roundtrips_mixed_json_pack_indexes() {
1760
+ let entity_id = EntityIdentity::single("entity-a");
1761
+ let snapshot_ref = JsonRef::from_hash_bytes([1; 32]);
1762
+ let metadata_ref = JsonRef::from_hash_bytes([2; 32]);
1763
+ let change = crate::commit_store::ChangeRef {
1764
+ id: "commit-a:change-live",
1765
+ entity_id: &entity_id,
1766
+ schema_key: "schema",
1767
+ file_id: Some("file-a"),
1768
+ snapshot_ref: Some(&snapshot_ref),
1769
+ metadata_ref: Some(&metadata_ref),
1770
+ created_at: "2026-01-01T00:00:00Z",
1771
+ };
1772
+ let locator = crate::commit_store::ChangeLocatorRef {
1773
+ source_commit_id: "commit-a",
1774
+ source_pack_id: 0,
1775
+ source_ordinal: 0,
1776
+ change_id: "commit-a:change-live",
1777
+ };
1778
+ let delta = TrackedStateDeltaRef {
1779
+ change,
1780
+ locator,
1781
+ created_at: "2026-01-01T00:00:00Z",
1782
+ updated_at: "2026-01-01T00:00:00Z",
1783
+ };
1784
+ let mut pack_indexes = HashMap::new();
1785
+ pack_indexes.insert(*snapshot_ref.as_hash_array(), 1);
1786
+ let pack_refs = vec![JsonRef::from_hash_bytes([9; 32]), snapshot_ref];
1787
+
1788
+ let inline = encode_delta_pack_refs("commit-a", &[delta]).expect("inline delta pack");
1789
+ assert!(!delta_pack_uses_json_pack_indexes(&inline).expect("inline mode should peek"));
1790
+ let empty_indexes = HashMap::new();
1791
+ let empty_index_pack = encode_delta_pack_refs_with_json_pack_indexes(
1792
+ "commit-a",
1793
+ &[delta],
1794
+ Some(&empty_indexes),
1795
+ )
1796
+ .expect("empty-index delta pack");
1797
+ assert_eq!(empty_index_pack, inline);
1798
+ assert!(!delta_pack_uses_json_pack_indexes(&empty_index_pack)
1799
+ .expect("empty index mode should peek"));
1800
+ decode_delta_pack(&empty_index_pack, None).expect("empty index pack should decode inline");
1801
+
1802
+ let mixed = encode_delta_pack_refs_with_json_pack_indexes(
1803
+ "commit-a",
1804
+ &[delta],
1805
+ Some(&pack_indexes),
1806
+ )
1807
+ .expect("mixed delta pack");
1808
+ assert!(delta_pack_uses_json_pack_indexes(&mixed).expect("mixed mode should peek"));
1809
+
1810
+ assert!(
1811
+ mixed.len() < inline.len(),
1812
+ "pack-index refs should be smaller than inline refs"
1813
+ );
1814
+ assert!(decode_delta_pack(&mixed, None)
1815
+ .expect_err("mixed refs require JSON pack refs")
1816
+ .to_string()
1817
+ .contains("needs JSON pack refs"));
1818
+ let (_, decoded) =
1819
+ decode_delta_pack(&mixed, Some(&pack_refs)).expect("mixed delta pack should decode");
1820
+ assert_eq!(decoded[0].value.snapshot_ref, Some(snapshot_ref));
1821
+ assert_eq!(decoded[0].value.metadata_ref, Some(metadata_ref));
1822
+ }
1823
+
1824
+ #[test]
1825
+ fn delta_pack_stream_decoder_rejects_trailing_entry_bytes() {
1826
+ let entity_id = EntityIdentity::single("entity");
1827
+ let change = crate::commit_store::ChangeRef {
1828
+ id: "commit-a:change-0",
1829
+ entity_id: &entity_id,
1830
+ schema_key: "schema",
1831
+ file_id: None,
1832
+ snapshot_ref: None,
1833
+ metadata_ref: None,
1834
+ created_at: "2026-01-01T00:00:00Z",
1835
+ };
1836
+ let locator = crate::commit_store::ChangeLocatorRef {
1837
+ source_commit_id: "commit-a",
1838
+ source_pack_id: 0,
1839
+ source_ordinal: 0,
1840
+ change_id: "commit-a:change-0",
1841
+ };
1842
+ let mut encoded = encode_delta_pack_refs(
1843
+ "commit-a",
1844
+ &[TrackedStateDeltaRef {
1845
+ change,
1846
+ locator,
1847
+ created_at: "2026-01-01T00:00:00Z",
1848
+ updated_at: "2026-01-01T00:00:00Z",
1849
+ }],
1850
+ )
1851
+ .expect("delta pack should encode");
1852
+
1853
+ let mut cursor = 5usize;
1854
+ let _ = read_var_sized_string(&encoded, &mut cursor, "delta pack commit_id")
1855
+ .expect("commit id should decode");
1856
+ assert_eq!(
1857
+ read_var_u32(&encoded, &mut cursor, "delta key prefix count")
1858
+ .expect("prefix count should decode"),
1859
+ 1
1860
+ );
1861
+ let _ =
1862
+ decode_delta_key_prefix(&encoded, &mut cursor).expect("delta key prefix should decode");
1863
+ encoded[cursor] = 0;
1864
+
1865
+ let error =
1866
+ decode_delta_pack(&encoded, None).expect_err("trailing entry bytes should reject");
1867
+ assert!(
1868
+ error.to_string().contains("trailing bytes"),
1869
+ "error should mention trailing bytes: {error}"
1870
+ );
1871
+ }
1872
+
1873
+ #[test]
1874
+ fn delta_pack_rejects_overlong_varint() {
1875
+ let mut encoded = Vec::new();
1876
+ encoded.extend_from_slice(b"LXTD");
1877
+ encoded.push(DELTA_PACK_VERSION);
1878
+ encoded.extend_from_slice(&[0x80, 0x80, 0x80, 0x80, 0x80]);
1879
+
1880
+ let error = decode_delta_pack(&encoded, None).expect_err("overlong varint should reject");
1881
+ assert!(
1882
+ error.to_string().contains("varint exceeds u32"),
1883
+ "error should mention overlong varint: {error}"
1884
+ );
1885
+ }
1886
+
1887
+ #[test]
1888
+ fn delta_pack_rejects_varint_above_u32() {
1889
+ let mut encoded = Vec::new();
1890
+ encoded.extend_from_slice(b"LXTD");
1891
+ encoded.push(DELTA_PACK_VERSION);
1892
+ encoded.extend_from_slice(&[0xff, 0xff, 0xff, 0xff, 0x1f]);
1893
+
1894
+ let error = decode_delta_pack(&encoded, None).expect_err("too-large varint should reject");
1895
+ assert!(
1896
+ error.to_string().contains("varint exceeds u32"),
1897
+ "error should mention oversized varint: {error}"
1898
+ );
1899
+ }
1900
+
1901
+ #[test]
1902
+ fn delta_pack_rejects_non_canonical_varint() {
1903
+ let mut encoded = Vec::new();
1904
+ encoded.extend_from_slice(b"LXTD");
1905
+ encoded.push(DELTA_PACK_VERSION);
1906
+ encoded.extend_from_slice(&[0x80, 0x00]);
1907
+
1908
+ let error =
1909
+ decode_delta_pack(&encoded, None).expect_err("non-canonical varint should reject");
1910
+ assert!(
1911
+ error.to_string().contains("non-canonical varint"),
1912
+ "error should mention non-canonical varint: {error}"
1913
+ );
1914
+ }
1915
+
1916
+ #[test]
1917
+ fn delta_key_decoder_rejects_out_of_bounds_prefix_index() {
1918
+ let mut encoded_key = Vec::new();
1919
+ push_var_u32(&mut encoded_key, 1, "delta key prefix index").expect("prefix index");
1920
+ push_var_entity_identity(&mut encoded_key, &EntityIdentity::single("entity"))
1921
+ .expect("entity identity");
1922
+
1923
+ let mut cursor = 0usize;
1924
+ let err = decode_delta_key(
1925
+ &encoded_key,
1926
+ &mut cursor,
1927
+ &[DeltaKeyPrefix {
1928
+ schema_key: "schema".to_string(),
1929
+ file_id: None,
1930
+ }],
1931
+ )
1932
+ .expect_err("out-of-bounds prefix index should reject");
1933
+
1934
+ assert!(err
1935
+ .to_string()
1936
+ .contains("tracked-state delta key prefix index 1 is out of bounds"));
1937
+ }
1938
+
696
1939
  #[test]
697
1940
  fn encoded_value_len_matches_encoded_value_bytes() {
698
1941
  let values = [
699
- TrackedStateValue {
1942
+ TrackedStateIndexValue {
1943
+ change_locator: ChangeLocator {
1944
+ source_commit_id: "commit".to_string(),
1945
+ source_pack_id: 0,
1946
+ source_ordinal: 0,
1947
+ change_id: "change".to_string(),
1948
+ },
1949
+ deleted: false,
700
1950
  snapshot_ref: None,
701
1951
  metadata_ref: None,
702
- schema_version: "1".to_string(),
703
1952
  created_at: "2026-01-01T00:00:00Z".to_string(),
704
1953
  updated_at: "2026-01-02T00:00:00Z".to_string(),
705
- change_id: "change".to_string(),
706
- commit_id: "commit".to_string(),
707
- deleted: true,
708
1954
  },
709
- TrackedStateValue {
1955
+ TrackedStateIndexValue {
1956
+ change_locator: ChangeLocator {
1957
+ source_commit_id: "commit".to_string(),
1958
+ source_pack_id: 1,
1959
+ source_ordinal: 2,
1960
+ change_id: "change-2".to_string(),
1961
+ },
1962
+ deleted: true,
710
1963
  snapshot_ref: Some(JsonRef::from_hash_bytes([3; 32])),
711
- metadata_ref: Some(JsonRef::from_hash_bytes([4; 32])),
712
- schema_version: "1".to_string(),
1964
+ metadata_ref: None,
713
1965
  created_at: "2026-01-01T00:00:00Z".to_string(),
714
1966
  updated_at: "2026-01-02T00:00:00Z".to_string(),
715
- change_id: "change".to_string(),
716
- commit_id: "commit".to_string(),
717
- deleted: false,
718
1967
  },
719
- TrackedStateValue {
720
- snapshot_ref: Some(JsonRef::from_hash_bytes([5; 32])),
721
- metadata_ref: None,
722
- schema_version: "1".to_string(),
1968
+ TrackedStateIndexValue {
1969
+ change_locator: ChangeLocator {
1970
+ source_commit_id: "other".to_string(),
1971
+ source_pack_id: 4,
1972
+ source_ordinal: 8,
1973
+ change_id: "change-3".to_string(),
1974
+ },
1975
+ deleted: false,
1976
+ snapshot_ref: None,
1977
+ metadata_ref: Some(JsonRef::from_hash_bytes([4; 32])),
723
1978
  created_at: "2026-01-01T00:00:00Z".to_string(),
724
1979
  updated_at: "2026-01-02T00:00:00Z".to_string(),
725
- change_id: "change".to_string(),
726
- commit_id: "commit".to_string(),
727
- deleted: false,
728
1980
  },
729
1981
  ];
730
1982
 
@@ -733,6 +1985,92 @@ mod tests {
733
1985
  }
734
1986
  }
735
1987
 
1988
+ #[test]
1989
+ fn leaf_node_codec_uses_indexable_offset_table() {
1990
+ let entries = vec![
1991
+ EncodedLeafEntry {
1992
+ key: b"alpha".to_vec(),
1993
+ value: b"one".to_vec(),
1994
+ },
1995
+ EncodedLeafEntry {
1996
+ key: b"bravo".to_vec(),
1997
+ value: b"two-two".to_vec(),
1998
+ },
1999
+ ];
2000
+
2001
+ let encoded = encode_leaf_node(&entries);
2002
+ assert_eq!(encoded[0], NODE_KIND_LEAF);
2003
+ assert_eq!(encoded[1], NODE_VERSION);
2004
+ assert_eq!(&encoded[2..6], 2u32.to_be_bytes().as_slice());
2005
+ assert_eq!(&encoded[6..10], 0u32.to_be_bytes().as_slice());
2006
+
2007
+ let DecodedNodeRef::Leaf(leaf) = decode_node_ref(&encoded).expect("leaf ref") else {
2008
+ panic!("expected leaf node");
2009
+ };
2010
+ assert_eq!(leaf.len(), 2);
2011
+ assert_eq!(leaf.key(1).expect("second key"), Some(b"bravo".as_slice()));
2012
+ let second = leaf
2013
+ .entry(1)
2014
+ .expect("second entry")
2015
+ .expect("second entry exists");
2016
+ assert_eq!(second.key, b"bravo");
2017
+ assert_eq!(second.value, b"two-two");
2018
+
2019
+ let DecodedNode::Leaf(owned) = decode_node(&encoded).expect("owned leaf") else {
2020
+ panic!("expected owned leaf node");
2021
+ };
2022
+ assert_eq!(owned.entries(), entries.as_slice());
2023
+ }
2024
+
2025
+ #[test]
2026
+ fn leaf_node_codec_roundtrips_empty_leaf() {
2027
+ let encoded = encode_leaf_node(&[]);
2028
+ assert_eq!(encoded.len(), 10);
2029
+
2030
+ let DecodedNodeRef::Leaf(leaf) = decode_node_ref(&encoded).expect("leaf ref") else {
2031
+ panic!("expected leaf node");
2032
+ };
2033
+ assert_eq!(leaf.len(), 0);
2034
+ assert!(leaf.entry(0).expect("missing entry").is_none());
2035
+ }
2036
+
2037
+ #[test]
2038
+ fn leaf_node_codec_rejects_malformed_offsets() {
2039
+ let entries = vec![
2040
+ EncodedLeafEntry {
2041
+ key: b"alpha".to_vec(),
2042
+ value: b"one".to_vec(),
2043
+ },
2044
+ EncodedLeafEntry {
2045
+ key: b"bravo".to_vec(),
2046
+ value: b"two".to_vec(),
2047
+ },
2048
+ ];
2049
+ let encoded = encode_leaf_node(&entries);
2050
+
2051
+ let mut non_zero_first = encoded.clone();
2052
+ non_zero_first[6..10].copy_from_slice(&1u32.to_be_bytes());
2053
+ assert!(decode_node_ref(&non_zero_first)
2054
+ .expect_err("non-zero first offset should reject")
2055
+ .to_string()
2056
+ .contains("offset table must start at zero"));
2057
+
2058
+ let mut non_monotonic = encoded.clone();
2059
+ non_monotonic[10..14].copy_from_slice(&100u32.to_be_bytes());
2060
+ assert!(decode_node_ref(&non_monotonic)
2061
+ .expect_err("non-monotonic offsets should reject")
2062
+ .to_string()
2063
+ .contains("offsets must be monotonic"));
2064
+
2065
+ let mut short_coverage = encoded;
2066
+ let payload_len = short_coverage.len() - 18;
2067
+ short_coverage[14..18].copy_from_slice(&((payload_len - 1) as u32).to_be_bytes());
2068
+ assert!(decode_node_ref(&short_coverage)
2069
+ .expect_err("short offset coverage should reject")
2070
+ .to_string()
2071
+ .contains("offset table does not cover full payload"));
2072
+ }
2073
+
736
2074
  #[test]
737
2075
  fn content_hash_is_blake3() {
738
2076
  assert_eq!(hash_bytes(b"abc"), *blake3::hash(b"abc").as_bytes());