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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.md +9 -0
  2. package/SKILL.md +468 -0
  3. package/dist/engine-wasm/index.d.ts +15 -11
  4. package/dist/engine-wasm/index.js +105 -38
  5. package/dist/engine-wasm/wasm/lix_engine.d.ts +14 -2
  6. package/dist/engine-wasm/wasm/lix_engine.js +18 -17
  7. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  8. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +2 -1
  9. package/dist/generated/builtin-schemas.d.ts +31 -41
  10. package/dist/generated/builtin-schemas.js +52 -56
  11. package/dist/open-lix.d.ts +141 -24
  12. package/dist/open-lix.js +199 -35
  13. package/dist/sqlite/index.js +99 -22
  14. package/dist-engine-src/README.md +18 -0
  15. package/dist-engine-src/src/backend/kv.rs +358 -0
  16. package/dist-engine-src/src/backend/mod.rs +12 -0
  17. package/dist-engine-src/src/backend/testing.rs +658 -0
  18. package/dist-engine-src/src/backend/types.rs +96 -0
  19. package/dist-engine-src/src/binary_cas/chunking.rs +31 -0
  20. package/dist-engine-src/src/binary_cas/codec.rs +346 -0
  21. package/dist-engine-src/src/binary_cas/context.rs +139 -0
  22. package/dist-engine-src/src/binary_cas/kv.rs +1063 -0
  23. package/dist-engine-src/src/binary_cas/mod.rs +11 -0
  24. package/dist-engine-src/src/binary_cas/types.rs +127 -0
  25. package/dist-engine-src/src/cel/context.rs +86 -0
  26. package/dist-engine-src/src/cel/error.rs +19 -0
  27. package/dist-engine-src/src/cel/mod.rs +8 -0
  28. package/dist-engine-src/src/cel/provider.rs +9 -0
  29. package/dist-engine-src/src/cel/runtime.rs +167 -0
  30. package/dist-engine-src/src/cel/value.rs +50 -0
  31. package/dist-engine-src/src/changelog/codec.rs +321 -0
  32. package/dist-engine-src/src/changelog/context.rs +92 -0
  33. package/dist-engine-src/src/changelog/materialization.rs +121 -0
  34. package/dist-engine-src/src/changelog/mod.rs +13 -0
  35. package/dist-engine-src/src/changelog/reader.rs +20 -0
  36. package/dist-engine-src/src/changelog/storage.rs +220 -0
  37. package/dist-engine-src/src/changelog/types.rs +38 -0
  38. package/dist-engine-src/src/commit_graph/context.rs +1588 -0
  39. package/dist-engine-src/src/commit_graph/mod.rs +12 -0
  40. package/dist-engine-src/src/commit_graph/types.rs +145 -0
  41. package/dist-engine-src/src/commit_graph/walker.rs +780 -0
  42. package/dist-engine-src/src/common/error.rs +313 -0
  43. package/dist-engine-src/src/common/fingerprint.rs +3 -0
  44. package/dist-engine-src/src/common/fs_path.rs +1336 -0
  45. package/dist-engine-src/src/common/identity.rs +135 -0
  46. package/dist-engine-src/src/common/metadata.rs +35 -0
  47. package/dist-engine-src/src/common/mod.rs +23 -0
  48. package/dist-engine-src/src/common/types.rs +105 -0
  49. package/dist-engine-src/src/common/wire.rs +222 -0
  50. package/dist-engine-src/src/engine.rs +239 -0
  51. package/dist-engine-src/src/entity_identity.rs +285 -0
  52. package/dist-engine-src/src/functions/context.rs +327 -0
  53. package/dist-engine-src/src/functions/deterministic.rs +113 -0
  54. package/dist-engine-src/src/functions/mod.rs +18 -0
  55. package/dist-engine-src/src/functions/provider.rs +130 -0
  56. package/dist-engine-src/src/functions/state.rs +363 -0
  57. package/dist-engine-src/src/functions/types.rs +37 -0
  58. package/dist-engine-src/src/init.rs +505 -0
  59. package/dist-engine-src/src/json_store/compression.rs +77 -0
  60. package/dist-engine-src/src/json_store/context.rs +129 -0
  61. package/dist-engine-src/src/json_store/encoded.rs +15 -0
  62. package/dist-engine-src/src/json_store/mod.rs +9 -0
  63. package/dist-engine-src/src/json_store/store.rs +236 -0
  64. package/dist-engine-src/src/json_store/types.rs +52 -0
  65. package/dist-engine-src/src/lib.rs +61 -0
  66. package/dist-engine-src/src/live_state/context.rs +2241 -0
  67. package/dist-engine-src/src/live_state/mod.rs +15 -0
  68. package/dist-engine-src/src/live_state/overlay.rs +75 -0
  69. package/dist-engine-src/src/live_state/reader.rs +23 -0
  70. package/dist-engine-src/src/live_state/types.rs +239 -0
  71. package/dist-engine-src/src/live_state/visibility.rs +218 -0
  72. package/dist-engine-src/src/plugin/archive.rs +441 -0
  73. package/dist-engine-src/src/plugin/component.rs +183 -0
  74. package/dist-engine-src/src/plugin/install.rs +637 -0
  75. package/dist-engine-src/src/plugin/manifest.rs +516 -0
  76. package/dist-engine-src/src/plugin/materializer.rs +477 -0
  77. package/dist-engine-src/src/plugin/mod.rs +33 -0
  78. package/dist-engine-src/src/plugin/plugin_manifest.json +119 -0
  79. package/dist-engine-src/src/plugin/storage.rs +74 -0
  80. package/dist-engine-src/src/schema/annotations/defaults.rs +280 -0
  81. package/dist-engine-src/src/schema/annotations/mod.rs +1 -0
  82. package/dist-engine-src/src/schema/builtin/lix_account.json +22 -0
  83. package/dist-engine-src/src/schema/builtin/lix_active_account.json +30 -0
  84. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +30 -0
  85. package/dist-engine-src/src/schema/builtin/lix_change.json +62 -0
  86. package/dist-engine-src/src/schema/builtin/lix_change_author.json +46 -0
  87. package/dist-engine-src/src/schema/builtin/lix_change_set.json +18 -0
  88. package/dist-engine-src/src/schema/builtin/lix_change_set_element.json +75 -0
  89. package/dist-engine-src/src/schema/builtin/lix_commit.json +62 -0
  90. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +46 -0
  91. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +53 -0
  92. package/dist-engine-src/src/schema/builtin/lix_entity_label.json +63 -0
  93. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +53 -0
  94. package/dist-engine-src/src/schema/builtin/lix_key_value.json +41 -0
  95. package/dist-engine-src/src/schema/builtin/lix_label.json +22 -0
  96. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +31 -0
  97. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +35 -0
  98. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +49 -0
  99. package/dist-engine-src/src/schema/builtin/mod.rs +271 -0
  100. package/dist-engine-src/src/schema/definition.json +157 -0
  101. package/dist-engine-src/src/schema/definition.rs +636 -0
  102. package/dist-engine-src/src/schema/key.rs +206 -0
  103. package/dist-engine-src/src/schema/mod.rs +20 -0
  104. package/dist-engine-src/src/schema/seed.rs +14 -0
  105. package/dist-engine-src/src/schema/tests.rs +739 -0
  106. package/dist-engine-src/src/schema_registry.rs +294 -0
  107. package/dist-engine-src/src/session/context.rs +366 -0
  108. package/dist-engine-src/src/session/create_version.rs +80 -0
  109. package/dist-engine-src/src/session/execute.rs +447 -0
  110. package/dist-engine-src/src/session/merge/analysis.rs +102 -0
  111. package/dist-engine-src/src/session/merge/apply.rs +23 -0
  112. package/dist-engine-src/src/session/merge/conflicts.rs +62 -0
  113. package/dist-engine-src/src/session/merge/mod.rs +11 -0
  114. package/dist-engine-src/src/session/merge/stats.rs +65 -0
  115. package/dist-engine-src/src/session/merge/version.rs +437 -0
  116. package/dist-engine-src/src/session/mod.rs +25 -0
  117. package/dist-engine-src/src/session/switch_version.rs +121 -0
  118. package/dist-engine-src/src/sql2/change_provider.rs +337 -0
  119. package/dist-engine-src/src/sql2/classify.rs +147 -0
  120. package/dist-engine-src/src/sql2/commit_derived_provider.rs +591 -0
  121. package/dist-engine-src/src/sql2/context.rs +307 -0
  122. package/dist-engine-src/src/sql2/directory_history_provider.rs +623 -0
  123. package/dist-engine-src/src/sql2/directory_provider.rs +2405 -0
  124. package/dist-engine-src/src/sql2/dml.rs +148 -0
  125. package/dist-engine-src/src/sql2/entity_history_provider.rs +444 -0
  126. package/dist-engine-src/src/sql2/entity_provider.rs +2700 -0
  127. package/dist-engine-src/src/sql2/error.rs +196 -0
  128. package/dist-engine-src/src/sql2/execute.rs +3379 -0
  129. package/dist-engine-src/src/sql2/file_history_provider.rs +902 -0
  130. package/dist-engine-src/src/sql2/file_provider.rs +3254 -0
  131. package/dist-engine-src/src/sql2/filesystem_planner.rs +1526 -0
  132. package/dist-engine-src/src/sql2/filesystem_predicates.rs +159 -0
  133. package/dist-engine-src/src/sql2/filesystem_visibility.rs +369 -0
  134. package/dist-engine-src/src/sql2/history_projection.rs +80 -0
  135. package/dist-engine-src/src/sql2/history_provider.rs +418 -0
  136. package/dist-engine-src/src/sql2/history_route.rs +643 -0
  137. package/dist-engine-src/src/sql2/lix_state_provider.rs +2430 -0
  138. package/dist-engine-src/src/sql2/mod.rs +43 -0
  139. package/dist-engine-src/src/sql2/read_only.rs +65 -0
  140. package/dist-engine-src/src/sql2/record_batch.rs +17 -0
  141. package/dist-engine-src/src/sql2/result_metadata.rs +29 -0
  142. package/dist-engine-src/src/sql2/runtime.rs +60 -0
  143. package/dist-engine-src/src/sql2/session.rs +135 -0
  144. package/dist-engine-src/src/sql2/udfs/common.rs +295 -0
  145. package/dist-engine-src/src/sql2/udfs/lix_active_version_commit_id.rs +53 -0
  146. package/dist-engine-src/src/sql2/udfs/lix_empty_blob.rs +47 -0
  147. package/dist-engine-src/src/sql2/udfs/lix_json.rs +100 -0
  148. package/dist-engine-src/src/sql2/udfs/lix_json_get.rs +99 -0
  149. package/dist-engine-src/src/sql2/udfs/lix_json_get_text.rs +99 -0
  150. package/dist-engine-src/src/sql2/udfs/lix_text_decode.rs +82 -0
  151. package/dist-engine-src/src/sql2/udfs/lix_text_encode.rs +85 -0
  152. package/dist-engine-src/src/sql2/udfs/lix_uuid_v7.rs +76 -0
  153. package/dist-engine-src/src/sql2/udfs/mod.rs +82 -0
  154. package/dist-engine-src/src/sql2/version_provider.rs +1187 -0
  155. package/dist-engine-src/src/sql2/version_scope.rs +394 -0
  156. package/dist-engine-src/src/sql2/write_normalization.rs +345 -0
  157. package/dist-engine-src/src/storage/context.rs +356 -0
  158. package/dist-engine-src/src/storage/mod.rs +14 -0
  159. package/dist-engine-src/src/storage/read_scope.rs +88 -0
  160. package/dist-engine-src/src/storage/types.rs +501 -0
  161. package/dist-engine-src/src/storage_bench.rs +3406 -0
  162. package/dist-engine-src/src/test_support.rs +81 -0
  163. package/dist-engine-src/src/tracked_state/by_file_index.rs +102 -0
  164. package/dist-engine-src/src/tracked_state/codec.rs +747 -0
  165. package/dist-engine-src/src/tracked_state/context.rs +983 -0
  166. package/dist-engine-src/src/tracked_state/diff.rs +494 -0
  167. package/dist-engine-src/src/tracked_state/materialization.rs +141 -0
  168. package/dist-engine-src/src/tracked_state/merge.rs +474 -0
  169. package/dist-engine-src/src/tracked_state/mod.rs +31 -0
  170. package/dist-engine-src/src/tracked_state/rebuild.rs +771 -0
  171. package/dist-engine-src/src/tracked_state/storage.rs +243 -0
  172. package/dist-engine-src/src/tracked_state/tree.rs +2744 -0
  173. package/dist-engine-src/src/tracked_state/tree_types.rs +176 -0
  174. package/dist-engine-src/src/tracked_state/types.rs +61 -0
  175. package/dist-engine-src/src/transaction/commit.rs +1224 -0
  176. package/dist-engine-src/src/transaction/context.rs +1307 -0
  177. package/dist-engine-src/src/transaction/live_state_overlay.rs +34 -0
  178. package/dist-engine-src/src/transaction/mod.rs +11 -0
  179. package/dist-engine-src/src/transaction/normalization.rs +1026 -0
  180. package/dist-engine-src/src/transaction/schema_resolver.rs +127 -0
  181. package/dist-engine-src/src/transaction/staging.rs +1436 -0
  182. package/dist-engine-src/src/transaction/types.rs +351 -0
  183. package/dist-engine-src/src/transaction/validation.rs +4811 -0
  184. package/dist-engine-src/src/untracked_state/codec.rs +363 -0
  185. package/dist-engine-src/src/untracked_state/context.rs +82 -0
  186. package/dist-engine-src/src/untracked_state/materialization.rs +157 -0
  187. package/dist-engine-src/src/untracked_state/mod.rs +17 -0
  188. package/dist-engine-src/src/untracked_state/storage.rs +348 -0
  189. package/dist-engine-src/src/untracked_state/types.rs +96 -0
  190. package/dist-engine-src/src/version/context.rs +52 -0
  191. package/dist-engine-src/src/version/mod.rs +12 -0
  192. package/dist-engine-src/src/version/refs.rs +421 -0
  193. package/dist-engine-src/src/version/stage_rows.rs +71 -0
  194. package/dist-engine-src/src/version/types.rs +21 -0
  195. package/dist-engine-src/src/wasm/mod.rs +60 -0
  196. package/package.json +68 -63
@@ -0,0 +1,494 @@
1
+ use crate::entity_identity::EntityIdentity;
2
+ use crate::tracked_state::tree_types::TrackedStateTreeScanRequest;
3
+ use crate::tracked_state::{TrackedStateFilter, TrackedStateRow, TrackedStateStoreReader};
4
+ use crate::LixError;
5
+
6
+ /// Filter for comparing two tracked-state commit roots.
7
+ #[derive(Debug, Clone, PartialEq, Eq, Default)]
8
+ pub(crate) struct TrackedStateDiffRequest {
9
+ pub(crate) filter: TrackedStateFilter,
10
+ }
11
+
12
+ /// Changed tracked-state rows between two commit roots.
13
+ #[derive(Debug, Clone, PartialEq, Eq, Default)]
14
+ pub(crate) struct TrackedStateDiff {
15
+ pub(crate) entries: Vec<TrackedStateDiffEntry>,
16
+ }
17
+
18
+ /// One changed identity between two commit roots.
19
+ #[derive(Debug, Clone, PartialEq, Eq)]
20
+ pub(crate) struct TrackedStateDiffEntry {
21
+ pub(crate) identity: TrackedStateDiffIdentity,
22
+ pub(crate) kind: TrackedStateDiffKind,
23
+ /// Raw row in the left root.
24
+ ///
25
+ /// This can be a tombstone. Callers that need user-visible semantics
26
+ /// should use `visible_before()` instead of inspecting this directly.
27
+ pub(crate) before: Option<TrackedStateRow>,
28
+ /// Raw row in the right root.
29
+ ///
30
+ /// This can be a tombstone. Keeping the raw tombstone is what lets merge
31
+ /// apply deletes without reloading the source root.
32
+ pub(crate) after: Option<TrackedStateRow>,
33
+ }
34
+
35
+ /// Root-local tracked-state identity.
36
+ ///
37
+ /// Entity identity used by merge/diff logic.
38
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
39
+ pub(crate) struct TrackedStateDiffIdentity {
40
+ pub(crate) schema_key: String,
41
+ pub(crate) entity_id: EntityIdentity,
42
+ pub(crate) file_id: Option<String>,
43
+ }
44
+
45
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
46
+ pub(crate) enum TrackedStateDiffKind {
47
+ Added,
48
+ Modified,
49
+ Removed,
50
+ }
51
+
52
+ /// Diffs two tracked-state commit roots.
53
+ ///
54
+ pub(crate) async fn diff_commits<S>(
55
+ reader: &mut TrackedStateStoreReader<S>,
56
+ left_commit_id: &str,
57
+ right_commit_id: &str,
58
+ request: &TrackedStateDiffRequest,
59
+ ) -> Result<TrackedStateDiff, LixError>
60
+ where
61
+ S: crate::storage::StorageReader,
62
+ {
63
+ let scan_request = scan_request_for_diff(request);
64
+ let mut entries = Vec::new();
65
+ for tree_entry in reader
66
+ .diff_tree_entries_at_commits(left_commit_id, right_commit_id, &scan_request)
67
+ .await?
68
+ {
69
+ let before = match tree_entry.before {
70
+ Some((key, value)) => Some(reader.materialize_tree_value(key, value).await?),
71
+ None => None,
72
+ };
73
+ let after = match tree_entry.after {
74
+ Some((key, value)) => Some(reader.materialize_tree_value(key, value).await?),
75
+ None => None,
76
+ };
77
+ let identity = match before.as_ref().or(after.as_ref()) {
78
+ Some(row) => TrackedStateDiffIdentity::from_row(row)?,
79
+ None => continue,
80
+ };
81
+ let Some(entry) = classify_diff(identity, before, after) else {
82
+ continue;
83
+ };
84
+ entries.push(entry);
85
+ }
86
+
87
+ Ok(TrackedStateDiff { entries })
88
+ }
89
+
90
+ fn scan_request_for_diff(request: &TrackedStateDiffRequest) -> TrackedStateTreeScanRequest {
91
+ let mut filter = request.filter.clone();
92
+ filter.include_tombstones = true;
93
+ TrackedStateTreeScanRequest {
94
+ schema_keys: filter.schema_keys,
95
+ entity_ids: filter.entity_ids,
96
+ file_ids: filter.file_ids,
97
+ include_tombstones: filter.include_tombstones,
98
+ limit: None,
99
+ }
100
+ }
101
+
102
+ fn classify_diff(
103
+ identity: TrackedStateDiffIdentity,
104
+ before: Option<TrackedStateRow>,
105
+ after: Option<TrackedStateRow>,
106
+ ) -> Option<TrackedStateDiffEntry> {
107
+ match (is_live_row(before.as_ref()), is_live_row(after.as_ref())) {
108
+ (None, None) => None,
109
+ (None, Some(_)) => Some(TrackedStateDiffEntry {
110
+ identity,
111
+ kind: TrackedStateDiffKind::Added,
112
+ before,
113
+ after,
114
+ }),
115
+ (Some(_), None) => Some(TrackedStateDiffEntry {
116
+ identity,
117
+ kind: TrackedStateDiffKind::Removed,
118
+ before,
119
+ after,
120
+ }),
121
+ (Some(before), Some(after)) if tracked_row_payload_eq(before, after) => None,
122
+ (Some(_), Some(_)) => Some(TrackedStateDiffEntry {
123
+ identity,
124
+ kind: TrackedStateDiffKind::Modified,
125
+ before,
126
+ after,
127
+ }),
128
+ }
129
+ }
130
+
131
+ fn is_live_row(row: Option<&TrackedStateRow>) -> Option<&TrackedStateRow> {
132
+ row.filter(|row| row.snapshot_content.is_some())
133
+ }
134
+
135
+ fn tracked_row_payload_eq(left: &TrackedStateRow, right: &TrackedStateRow) -> bool {
136
+ left.snapshot_content == right.snapshot_content
137
+ && left.metadata == right.metadata
138
+ && left.schema_version == right.schema_version
139
+ }
140
+
141
+ impl TrackedStateDiffIdentity {
142
+ fn from_row(row: &TrackedStateRow) -> Result<Self, LixError> {
143
+ Ok(Self {
144
+ schema_key: row.schema_key.clone(),
145
+ entity_id: row.entity_id.clone(),
146
+ file_id: row.file_id.clone(),
147
+ })
148
+ }
149
+ }
150
+
151
+ impl TrackedStateDiffEntry {
152
+ #[cfg(test)]
153
+ pub(crate) fn before_is_live(&self) -> bool {
154
+ self.visible_before().is_some()
155
+ }
156
+
157
+ #[cfg(test)]
158
+ pub(crate) fn after_is_live(&self) -> bool {
159
+ self.visible_after().is_some()
160
+ }
161
+
162
+ #[cfg(test)]
163
+ pub(crate) fn visible_before(&self) -> Option<&TrackedStateRow> {
164
+ self.before
165
+ .as_ref()
166
+ .filter(|row| row.snapshot_content.is_some())
167
+ }
168
+
169
+ #[cfg(test)]
170
+ pub(crate) fn visible_after(&self) -> Option<&TrackedStateRow> {
171
+ self.after
172
+ .as_ref()
173
+ .filter(|row| row.snapshot_content.is_some())
174
+ }
175
+ }
176
+
177
+ #[cfg(test)]
178
+ mod tests {
179
+ use std::sync::Arc;
180
+
181
+ use super::*;
182
+ use crate::backend::testing::UnitTestBackend;
183
+ use crate::json_store::JsonStoreContext;
184
+ use crate::storage::{StorageContext, StorageWriteSet, StorageWriteTransaction};
185
+ use crate::tracked_state::TrackedStateContext;
186
+ use crate::NullableKeyFilter;
187
+
188
+ #[tokio::test]
189
+ async fn diff_commits_reports_added_rows() {
190
+ let (storage, tracked_state) = seed_roots(&[], &[row("entity-a", None, "after")]).await;
191
+
192
+ let diff = diff(storage.clone(), &tracked_state).await;
193
+
194
+ assert_eq!(
195
+ kinds(&diff),
196
+ vec![("entity-a".to_string(), TrackedStateDiffKind::Added)]
197
+ );
198
+ assert!(diff.entries[0].before.is_none());
199
+ assert_eq!(
200
+ diff.entries[0]
201
+ .after
202
+ .as_ref()
203
+ .map(|row| row.change_id.as_str()),
204
+ Some("after")
205
+ );
206
+ assert!(!diff.entries[0].before_is_live());
207
+ assert!(diff.entries[0].after_is_live());
208
+ }
209
+
210
+ #[tokio::test]
211
+ async fn diff_commits_reports_removed_rows_when_right_side_is_absent() {
212
+ let (storage, tracked_state) = seed_roots(&[row("entity-a", None, "before")], &[]).await;
213
+
214
+ let diff = diff(storage.clone(), &tracked_state).await;
215
+
216
+ assert_eq!(
217
+ kinds(&diff),
218
+ vec![("entity-a".to_string(), TrackedStateDiffKind::Removed)]
219
+ );
220
+ assert_eq!(
221
+ diff.entries[0]
222
+ .before
223
+ .as_ref()
224
+ .map(|row| row.change_id.as_str()),
225
+ Some("before")
226
+ );
227
+ assert!(diff.entries[0].after.is_none());
228
+ assert!(diff.entries[0].before_is_live());
229
+ assert!(!diff.entries[0].after_is_live());
230
+ }
231
+
232
+ #[tokio::test]
233
+ async fn diff_commits_reports_removed_rows_when_right_side_is_tombstone() {
234
+ let (storage, tracked_state) = seed_roots(
235
+ &[row("entity-a", None, "before")],
236
+ &[tombstone("entity-a", None, "delete")],
237
+ )
238
+ .await;
239
+
240
+ let diff = diff(storage.clone(), &tracked_state).await;
241
+
242
+ assert_eq!(
243
+ kinds(&diff),
244
+ vec![("entity-a".to_string(), TrackedStateDiffKind::Removed)]
245
+ );
246
+ let entry = &diff.entries[0];
247
+ assert_eq!(
248
+ entry.after.as_ref().map(|row| row.change_id.as_str()),
249
+ Some("delete")
250
+ );
251
+ assert!(
252
+ entry
253
+ .after
254
+ .as_ref()
255
+ .is_some_and(|row| row.snapshot_content.is_none()),
256
+ "removed diff should preserve the right-side tombstone for merge"
257
+ );
258
+ assert!(entry.before_is_live());
259
+ assert!(!entry.after_is_live());
260
+ }
261
+
262
+ #[tokio::test]
263
+ async fn diff_commits_reports_added_rows_when_left_side_is_tombstone() {
264
+ let (storage, tracked_state) = seed_roots(
265
+ &[tombstone("entity-a", None, "delete")],
266
+ &[row("entity-a", None, "after")],
267
+ )
268
+ .await;
269
+
270
+ let diff = diff(storage.clone(), &tracked_state).await;
271
+
272
+ assert_eq!(
273
+ kinds(&diff),
274
+ vec![("entity-a".to_string(), TrackedStateDiffKind::Added)]
275
+ );
276
+ let entry = &diff.entries[0];
277
+ assert_eq!(
278
+ entry.before.as_ref().map(|row| row.change_id.as_str()),
279
+ Some("delete")
280
+ );
281
+ assert!(
282
+ entry
283
+ .before
284
+ .as_ref()
285
+ .is_some_and(|row| row.snapshot_content.is_none()),
286
+ "added diff should preserve the left-side tombstone for merge"
287
+ );
288
+ assert!(!entry.before_is_live());
289
+ assert!(entry.after_is_live());
290
+ }
291
+
292
+ #[tokio::test]
293
+ async fn diff_commits_reports_modified_rows_for_changed_payload() {
294
+ let (storage, tracked_state) = seed_roots(
295
+ &[row_with_value("entity-a", None, "before", "one")],
296
+ &[row_with_value("entity-a", None, "after", "two")],
297
+ )
298
+ .await;
299
+
300
+ let diff = diff(storage.clone(), &tracked_state).await;
301
+
302
+ assert_eq!(
303
+ kinds(&diff),
304
+ vec![("entity-a".to_string(), TrackedStateDiffKind::Modified)]
305
+ );
306
+ assert!(diff.entries[0].before_is_live());
307
+ assert!(diff.entries[0].after_is_live());
308
+ }
309
+
310
+ #[tokio::test]
311
+ async fn diff_commits_omits_unchanged_rows_even_when_metadata_differs_only_by_commit() {
312
+ let (storage, tracked_state) = seed_roots(
313
+ &[row_with_value("entity-a", None, "before", "same")],
314
+ &[row_with_value("entity-a", None, "after", "same")],
315
+ )
316
+ .await;
317
+
318
+ let diff = diff(storage.clone(), &tracked_state).await;
319
+
320
+ assert!(diff.entries.is_empty());
321
+ }
322
+
323
+ #[tokio::test]
324
+ async fn diff_commits_distinguishes_same_entity_with_different_file_id() {
325
+ let (storage, tracked_state) = seed_roots(
326
+ &[row("entity-a", Some("file-a"), "before-a")],
327
+ &[
328
+ row("entity-a", Some("file-a"), "before-a"),
329
+ row("entity-a", Some("file-b"), "after-b"),
330
+ ],
331
+ )
332
+ .await;
333
+
334
+ let diff = diff(storage.clone(), &tracked_state).await;
335
+
336
+ assert_eq!(diff.entries.len(), 1);
337
+ assert_eq!(diff.entries[0].identity.file_id.as_deref(), Some("file-b"));
338
+ assert_eq!(diff.entries[0].kind, TrackedStateDiffKind::Added);
339
+ }
340
+
341
+ #[tokio::test]
342
+ async fn diff_commits_filters_by_schema_entity_and_file_id() {
343
+ let (storage, tracked_state) = seed_roots(
344
+ &[],
345
+ &[
346
+ row_with_schema("entity-a", Some("file-a"), "schema-a", "change-a"),
347
+ row_with_schema("entity-b", Some("file-b"), "schema-b", "change-b"),
348
+ ],
349
+ )
350
+ .await;
351
+ let mut reader = tracked_state.reader(storage.clone());
352
+ let diff = reader
353
+ .diff_commits(
354
+ "left",
355
+ "right",
356
+ &TrackedStateDiffRequest {
357
+ filter: TrackedStateFilter {
358
+ schema_keys: vec!["schema-b".to_string()],
359
+ entity_ids: vec![crate::entity_identity::EntityIdentity::single(
360
+ "entity-b",
361
+ )],
362
+ file_ids: vec![NullableKeyFilter::Value("file-b".to_string())],
363
+ ..Default::default()
364
+ },
365
+ },
366
+ )
367
+ .await
368
+ .expect("diff should load");
369
+
370
+ assert_eq!(
371
+ kinds(&diff),
372
+ vec![("entity-b".to_string(), TrackedStateDiffKind::Added)]
373
+ );
374
+ }
375
+
376
+ async fn diff(
377
+ storage: StorageContext,
378
+ tracked_state: &TrackedStateContext,
379
+ ) -> TrackedStateDiff {
380
+ tracked_state
381
+ .reader(storage)
382
+ .diff_commits("left", "right", &TrackedStateDiffRequest::default())
383
+ .await
384
+ .expect("diff should load")
385
+ }
386
+
387
+ async fn seed_roots(
388
+ left_rows: &[TrackedStateRow],
389
+ right_rows: &[TrackedStateRow],
390
+ ) -> (StorageContext, TrackedStateContext) {
391
+ let backend = Arc::new(UnitTestBackend::new());
392
+ let storage = StorageContext::new(backend.clone());
393
+ let tracked_state = TrackedStateContext::new();
394
+ let mut tx = storage
395
+ .begin_write_transaction()
396
+ .await
397
+ .expect("transaction should open");
398
+ write_root_for_test(tx.as_mut(), &tracked_state, "left", None, left_rows)
399
+ .await
400
+ .expect("left root should write");
401
+ write_root_for_test(tx.as_mut(), &tracked_state, "right", None, right_rows)
402
+ .await
403
+ .expect("right root should write");
404
+ tx.commit().await.expect("transaction should commit");
405
+ (storage, tracked_state)
406
+ }
407
+
408
+ async fn write_root_for_test(
409
+ tx: &mut dyn StorageWriteTransaction,
410
+ tracked_state: &TrackedStateContext,
411
+ commit_id: &str,
412
+ parent_commit_id: Option<&str>,
413
+ rows: &[TrackedStateRow],
414
+ ) -> Result<(), LixError> {
415
+ let mut writes = StorageWriteSet::new();
416
+ {
417
+ let mut json_writer = JsonStoreContext::new().writer();
418
+ tracked_state
419
+ .writer()
420
+ .stage_root(
421
+ tx,
422
+ &mut writes,
423
+ &mut json_writer,
424
+ commit_id,
425
+ parent_commit_id,
426
+ rows,
427
+ )
428
+ .await?;
429
+ }
430
+ writes.apply(tx).await?;
431
+ Ok(())
432
+ }
433
+
434
+ fn kinds(diff: &TrackedStateDiff) -> Vec<(String, TrackedStateDiffKind)> {
435
+ diff.entries
436
+ .iter()
437
+ .map(|entry| {
438
+ (
439
+ entry.identity.entity_id.as_string().expect("identity"),
440
+ entry.kind,
441
+ )
442
+ })
443
+ .collect()
444
+ }
445
+
446
+ fn tombstone(entity_id: &str, file_id: Option<&str>, change_id: &str) -> TrackedStateRow {
447
+ let mut row = row(entity_id, file_id, change_id);
448
+ row.snapshot_content = None;
449
+ row
450
+ }
451
+
452
+ fn row(entity_id: &str, file_id: Option<&str>, change_id: &str) -> TrackedStateRow {
453
+ row_with_schema(entity_id, file_id, "test_schema", change_id)
454
+ }
455
+
456
+ fn row_with_schema(
457
+ entity_id: &str,
458
+ file_id: Option<&str>,
459
+ schema_key: &str,
460
+ change_id: &str,
461
+ ) -> TrackedStateRow {
462
+ row_with_schema_and_value(entity_id, file_id, schema_key, change_id, "value")
463
+ }
464
+
465
+ fn row_with_value(
466
+ entity_id: &str,
467
+ file_id: Option<&str>,
468
+ change_id: &str,
469
+ value: &str,
470
+ ) -> TrackedStateRow {
471
+ row_with_schema_and_value(entity_id, file_id, "test_schema", change_id, value)
472
+ }
473
+
474
+ fn row_with_schema_and_value(
475
+ entity_id: &str,
476
+ file_id: Option<&str>,
477
+ schema_key: &str,
478
+ change_id: &str,
479
+ value: &str,
480
+ ) -> TrackedStateRow {
481
+ TrackedStateRow {
482
+ entity_id: EntityIdentity::single(entity_id),
483
+ schema_key: schema_key.to_string(),
484
+ file_id: file_id.map(str::to_string),
485
+ snapshot_content: Some(format!("{{\"value\":\"{value}\"}}")),
486
+ metadata: None,
487
+ schema_version: "1".to_string(),
488
+ created_at: "2026-01-01T00:00:00Z".to_string(),
489
+ updated_at: "2026-01-01T00:00:00Z".to_string(),
490
+ change_id: change_id.to_string(),
491
+ commit_id: change_id.replace("change", "commit"),
492
+ }
493
+ }
494
+ }
@@ -0,0 +1,141 @@
1
+ use crate::json_store::{JsonRef, JsonStoreReader, JsonStoreWriter};
2
+ use crate::storage::{StorageReader, StorageWriteSet};
3
+ use crate::tracked_state::tree_types::{TrackedStateKey, TrackedStateValue};
4
+ use crate::tracked_state::TrackedStateRow;
5
+ use crate::{serialize_row_metadata, validate_row_metadata, LixError, RowMetadata};
6
+
7
+ pub(crate) fn canonicalize_materialized_row(
8
+ writes: &mut StorageWriteSet,
9
+ json_writer: &mut JsonStoreWriter,
10
+ row: &TrackedStateRow,
11
+ ) -> Result<TrackedStateValue, LixError> {
12
+ let snapshot_ref = stage_optional_json(writes, json_writer, row.snapshot_content.as_deref())?;
13
+ let metadata_ref = stage_optional_metadata(writes, json_writer, row.metadata.as_ref())?;
14
+ Ok(TrackedStateValue::from_row_refs(
15
+ row,
16
+ snapshot_ref,
17
+ metadata_ref,
18
+ ))
19
+ }
20
+
21
+ pub(crate) async fn materialize_value<S>(
22
+ json_reader: &mut JsonStoreReader<S>,
23
+ key: TrackedStateKey,
24
+ value: TrackedStateValue,
25
+ projection: &TrackedMaterializationProjection,
26
+ ) -> Result<TrackedStateRow, LixError>
27
+ where
28
+ S: StorageReader,
29
+ {
30
+ let snapshot_content = if projection.snapshot_content {
31
+ load_optional_json(json_reader, value.snapshot_ref.as_ref(), "snapshot_ref").await?
32
+ } else {
33
+ None
34
+ };
35
+ let metadata = if projection.metadata {
36
+ load_optional_metadata(json_reader, value.metadata_ref.as_ref()).await?
37
+ } else {
38
+ None
39
+ };
40
+ Ok(value.into_materialized_row(key, snapshot_content, metadata))
41
+ }
42
+
43
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
44
+ pub(crate) struct TrackedMaterializationProjection {
45
+ pub(crate) snapshot_content: bool,
46
+ pub(crate) metadata: bool,
47
+ }
48
+
49
+ impl TrackedMaterializationProjection {
50
+ pub(crate) fn full() -> Self {
51
+ Self {
52
+ snapshot_content: true,
53
+ metadata: true,
54
+ }
55
+ }
56
+
57
+ pub(crate) fn from_columns(columns: &[String]) -> Self {
58
+ if columns.is_empty() {
59
+ return Self::full();
60
+ }
61
+ Self {
62
+ snapshot_content: columns.iter().any(|column| column == "snapshot_content"),
63
+ metadata: columns.iter().any(|column| column == "metadata"),
64
+ }
65
+ }
66
+ }
67
+
68
+ fn stage_optional_json(
69
+ writes: &mut StorageWriteSet,
70
+ json_writer: &mut JsonStoreWriter,
71
+ value: Option<&str>,
72
+ ) -> Result<Option<JsonRef>, LixError> {
73
+ let Some(value) = value else {
74
+ return Ok(None);
75
+ };
76
+ json_writer.stage_bytes(writes, value.as_bytes()).map(Some)
77
+ }
78
+
79
+ fn stage_optional_metadata(
80
+ writes: &mut StorageWriteSet,
81
+ json_writer: &mut JsonStoreWriter,
82
+ value: Option<&RowMetadata>,
83
+ ) -> Result<Option<JsonRef>, LixError> {
84
+ let Some(value) = value else {
85
+ return Ok(None);
86
+ };
87
+ let serialized = serialize_row_metadata(value);
88
+ json_writer
89
+ .stage_bytes(writes, serialized.as_bytes())
90
+ .map(Some)
91
+ }
92
+
93
+ async fn load_optional_metadata<S>(
94
+ json_reader: &mut JsonStoreReader<S>,
95
+ json_ref: Option<&JsonRef>,
96
+ ) -> Result<Option<RowMetadata>, LixError>
97
+ where
98
+ S: StorageReader,
99
+ {
100
+ let Some(json) = load_optional_json(json_reader, json_ref, "metadata_ref").await? else {
101
+ return Ok(None);
102
+ };
103
+ let metadata = serde_json::from_str::<RowMetadata>(&json).map_err(|error| {
104
+ LixError::new(
105
+ "LIX_ERROR_INVALID_JSON",
106
+ format!("tracked_state metadata_ref is invalid JSON: {error}"),
107
+ )
108
+ })?;
109
+ validate_row_metadata(metadata, "tracked_state metadata_ref").map(Some)
110
+ }
111
+
112
+ async fn load_optional_json<S>(
113
+ json_reader: &mut JsonStoreReader<S>,
114
+ json_ref: Option<&JsonRef>,
115
+ field: &str,
116
+ ) -> Result<Option<String>, LixError>
117
+ where
118
+ S: StorageReader,
119
+ {
120
+ let Some(json_ref) = json_ref else {
121
+ return Ok(None);
122
+ };
123
+ let bytes = json_reader.load_bytes(json_ref).await?.ok_or_else(|| {
124
+ LixError::new(
125
+ "LIX_ERROR_UNKNOWN",
126
+ format!(
127
+ "tracked_state {field} '{}' is missing from json_store",
128
+ json_ref.to_hex()
129
+ ),
130
+ )
131
+ })?;
132
+ String::from_utf8(bytes).map(Some).map_err(|error| {
133
+ LixError::new(
134
+ "LIX_ERROR_UNKNOWN",
135
+ format!(
136
+ "tracked_state {field} '{}' is not valid UTF-8 JSON bytes: {error}",
137
+ json_ref.to_hex()
138
+ ),
139
+ )
140
+ })
141
+ }