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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/SKILL.md +305 -320
  2. package/dist/engine-wasm/wasm/lix_engine.d.ts +5 -0
  3. package/dist/engine-wasm/wasm/lix_engine.js +9 -13
  4. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  5. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +1 -0
  6. package/dist/open-lix.d.ts +103 -14
  7. package/dist/open-lix.js +3 -0
  8. package/dist/sqlite/index.js +99 -22
  9. package/dist-engine-src/README.md +18 -0
  10. package/dist-engine-src/src/backend/kv.rs +358 -0
  11. package/dist-engine-src/src/backend/mod.rs +12 -0
  12. package/dist-engine-src/src/backend/testing.rs +658 -0
  13. package/dist-engine-src/src/backend/types.rs +96 -0
  14. package/dist-engine-src/src/binary_cas/chunking.rs +31 -0
  15. package/dist-engine-src/src/binary_cas/codec.rs +346 -0
  16. package/dist-engine-src/src/binary_cas/context.rs +139 -0
  17. package/dist-engine-src/src/binary_cas/kv.rs +1063 -0
  18. package/dist-engine-src/src/binary_cas/mod.rs +11 -0
  19. package/dist-engine-src/src/binary_cas/types.rs +127 -0
  20. package/dist-engine-src/src/cel/context.rs +86 -0
  21. package/dist-engine-src/src/cel/error.rs +19 -0
  22. package/dist-engine-src/src/cel/mod.rs +8 -0
  23. package/dist-engine-src/src/cel/provider.rs +9 -0
  24. package/dist-engine-src/src/cel/runtime.rs +167 -0
  25. package/dist-engine-src/src/cel/value.rs +50 -0
  26. package/dist-engine-src/src/changelog/codec.rs +321 -0
  27. package/dist-engine-src/src/changelog/context.rs +92 -0
  28. package/dist-engine-src/src/changelog/materialization.rs +121 -0
  29. package/dist-engine-src/src/changelog/mod.rs +13 -0
  30. package/dist-engine-src/src/changelog/reader.rs +20 -0
  31. package/dist-engine-src/src/changelog/storage.rs +220 -0
  32. package/dist-engine-src/src/changelog/types.rs +38 -0
  33. package/dist-engine-src/src/commit_graph/context.rs +1588 -0
  34. package/dist-engine-src/src/commit_graph/mod.rs +12 -0
  35. package/dist-engine-src/src/commit_graph/types.rs +145 -0
  36. package/dist-engine-src/src/commit_graph/walker.rs +780 -0
  37. package/dist-engine-src/src/common/error.rs +313 -0
  38. package/dist-engine-src/src/common/fingerprint.rs +3 -0
  39. package/dist-engine-src/src/common/fs_path.rs +1336 -0
  40. package/dist-engine-src/src/common/identity.rs +135 -0
  41. package/dist-engine-src/src/common/metadata.rs +35 -0
  42. package/dist-engine-src/src/common/mod.rs +23 -0
  43. package/dist-engine-src/src/common/types.rs +105 -0
  44. package/dist-engine-src/src/common/wire.rs +222 -0
  45. package/dist-engine-src/src/engine.rs +239 -0
  46. package/dist-engine-src/src/entity_identity.rs +285 -0
  47. package/dist-engine-src/src/functions/context.rs +327 -0
  48. package/dist-engine-src/src/functions/deterministic.rs +113 -0
  49. package/dist-engine-src/src/functions/mod.rs +18 -0
  50. package/dist-engine-src/src/functions/provider.rs +130 -0
  51. package/dist-engine-src/src/functions/state.rs +363 -0
  52. package/dist-engine-src/src/functions/types.rs +37 -0
  53. package/dist-engine-src/src/init.rs +505 -0
  54. package/dist-engine-src/src/json_store/compression.rs +77 -0
  55. package/dist-engine-src/src/json_store/context.rs +129 -0
  56. package/dist-engine-src/src/json_store/encoded.rs +15 -0
  57. package/dist-engine-src/src/json_store/mod.rs +9 -0
  58. package/dist-engine-src/src/json_store/store.rs +236 -0
  59. package/dist-engine-src/src/json_store/types.rs +52 -0
  60. package/dist-engine-src/src/lib.rs +61 -0
  61. package/dist-engine-src/src/live_state/context.rs +2241 -0
  62. package/dist-engine-src/src/live_state/mod.rs +15 -0
  63. package/dist-engine-src/src/live_state/overlay.rs +75 -0
  64. package/dist-engine-src/src/live_state/reader.rs +23 -0
  65. package/dist-engine-src/src/live_state/types.rs +239 -0
  66. package/dist-engine-src/src/live_state/visibility.rs +218 -0
  67. package/dist-engine-src/src/plugin/archive.rs +441 -0
  68. package/dist-engine-src/src/plugin/component.rs +183 -0
  69. package/dist-engine-src/src/plugin/install.rs +637 -0
  70. package/dist-engine-src/src/plugin/manifest.rs +516 -0
  71. package/dist-engine-src/src/plugin/materializer.rs +477 -0
  72. package/dist-engine-src/src/plugin/mod.rs +33 -0
  73. package/dist-engine-src/src/plugin/plugin_manifest.json +119 -0
  74. package/dist-engine-src/src/plugin/storage.rs +74 -0
  75. package/dist-engine-src/src/schema/annotations/defaults.rs +280 -0
  76. package/dist-engine-src/src/schema/annotations/mod.rs +1 -0
  77. package/dist-engine-src/src/schema/builtin/lix_account.json +22 -0
  78. package/dist-engine-src/src/schema/builtin/lix_active_account.json +30 -0
  79. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +30 -0
  80. package/dist-engine-src/src/schema/builtin/lix_change.json +62 -0
  81. package/dist-engine-src/src/schema/builtin/lix_change_author.json +46 -0
  82. package/dist-engine-src/src/schema/builtin/lix_change_set.json +18 -0
  83. package/dist-engine-src/src/schema/builtin/lix_change_set_element.json +75 -0
  84. package/dist-engine-src/src/schema/builtin/lix_commit.json +62 -0
  85. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +46 -0
  86. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +53 -0
  87. package/dist-engine-src/src/schema/builtin/lix_entity_label.json +63 -0
  88. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +53 -0
  89. package/dist-engine-src/src/schema/builtin/lix_key_value.json +41 -0
  90. package/dist-engine-src/src/schema/builtin/lix_label.json +22 -0
  91. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +31 -0
  92. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +35 -0
  93. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +49 -0
  94. package/dist-engine-src/src/schema/builtin/mod.rs +271 -0
  95. package/dist-engine-src/src/schema/definition.json +157 -0
  96. package/dist-engine-src/src/schema/definition.rs +636 -0
  97. package/dist-engine-src/src/schema/key.rs +206 -0
  98. package/dist-engine-src/src/schema/mod.rs +20 -0
  99. package/dist-engine-src/src/schema/seed.rs +14 -0
  100. package/dist-engine-src/src/schema/tests.rs +739 -0
  101. package/dist-engine-src/src/schema_registry.rs +294 -0
  102. package/dist-engine-src/src/session/context.rs +366 -0
  103. package/dist-engine-src/src/session/create_version.rs +80 -0
  104. package/dist-engine-src/src/session/execute.rs +447 -0
  105. package/dist-engine-src/src/session/merge/analysis.rs +102 -0
  106. package/dist-engine-src/src/session/merge/apply.rs +23 -0
  107. package/dist-engine-src/src/session/merge/conflicts.rs +62 -0
  108. package/dist-engine-src/src/session/merge/mod.rs +11 -0
  109. package/dist-engine-src/src/session/merge/stats.rs +65 -0
  110. package/dist-engine-src/src/session/merge/version.rs +437 -0
  111. package/dist-engine-src/src/session/mod.rs +25 -0
  112. package/dist-engine-src/src/session/switch_version.rs +121 -0
  113. package/dist-engine-src/src/sql2/change_provider.rs +337 -0
  114. package/dist-engine-src/src/sql2/classify.rs +147 -0
  115. package/dist-engine-src/src/sql2/commit_derived_provider.rs +591 -0
  116. package/dist-engine-src/src/sql2/context.rs +307 -0
  117. package/dist-engine-src/src/sql2/directory_history_provider.rs +623 -0
  118. package/dist-engine-src/src/sql2/directory_provider.rs +2405 -0
  119. package/dist-engine-src/src/sql2/dml.rs +148 -0
  120. package/dist-engine-src/src/sql2/entity_history_provider.rs +444 -0
  121. package/dist-engine-src/src/sql2/entity_provider.rs +2700 -0
  122. package/dist-engine-src/src/sql2/error.rs +196 -0
  123. package/dist-engine-src/src/sql2/execute.rs +3379 -0
  124. package/dist-engine-src/src/sql2/file_history_provider.rs +902 -0
  125. package/dist-engine-src/src/sql2/file_provider.rs +3254 -0
  126. package/dist-engine-src/src/sql2/filesystem_planner.rs +1526 -0
  127. package/dist-engine-src/src/sql2/filesystem_predicates.rs +159 -0
  128. package/dist-engine-src/src/sql2/filesystem_visibility.rs +369 -0
  129. package/dist-engine-src/src/sql2/history_projection.rs +80 -0
  130. package/dist-engine-src/src/sql2/history_provider.rs +418 -0
  131. package/dist-engine-src/src/sql2/history_route.rs +643 -0
  132. package/dist-engine-src/src/sql2/lix_state_provider.rs +2430 -0
  133. package/dist-engine-src/src/sql2/mod.rs +43 -0
  134. package/dist-engine-src/src/sql2/read_only.rs +65 -0
  135. package/dist-engine-src/src/sql2/record_batch.rs +17 -0
  136. package/dist-engine-src/src/sql2/result_metadata.rs +29 -0
  137. package/dist-engine-src/src/sql2/runtime.rs +60 -0
  138. package/dist-engine-src/src/sql2/session.rs +135 -0
  139. package/dist-engine-src/src/sql2/udfs/common.rs +295 -0
  140. package/dist-engine-src/src/sql2/udfs/lix_active_version_commit_id.rs +53 -0
  141. package/dist-engine-src/src/sql2/udfs/lix_empty_blob.rs +47 -0
  142. package/dist-engine-src/src/sql2/udfs/lix_json.rs +100 -0
  143. package/dist-engine-src/src/sql2/udfs/lix_json_get.rs +99 -0
  144. package/dist-engine-src/src/sql2/udfs/lix_json_get_text.rs +99 -0
  145. package/dist-engine-src/src/sql2/udfs/lix_text_decode.rs +82 -0
  146. package/dist-engine-src/src/sql2/udfs/lix_text_encode.rs +85 -0
  147. package/dist-engine-src/src/sql2/udfs/lix_uuid_v7.rs +76 -0
  148. package/dist-engine-src/src/sql2/udfs/mod.rs +82 -0
  149. package/dist-engine-src/src/sql2/version_provider.rs +1187 -0
  150. package/dist-engine-src/src/sql2/version_scope.rs +394 -0
  151. package/dist-engine-src/src/sql2/write_normalization.rs +345 -0
  152. package/dist-engine-src/src/storage/context.rs +356 -0
  153. package/dist-engine-src/src/storage/mod.rs +14 -0
  154. package/dist-engine-src/src/storage/read_scope.rs +88 -0
  155. package/dist-engine-src/src/storage/types.rs +501 -0
  156. package/dist-engine-src/src/storage_bench.rs +3406 -0
  157. package/dist-engine-src/src/test_support.rs +81 -0
  158. package/dist-engine-src/src/tracked_state/by_file_index.rs +102 -0
  159. package/dist-engine-src/src/tracked_state/codec.rs +747 -0
  160. package/dist-engine-src/src/tracked_state/context.rs +983 -0
  161. package/dist-engine-src/src/tracked_state/diff.rs +494 -0
  162. package/dist-engine-src/src/tracked_state/materialization.rs +141 -0
  163. package/dist-engine-src/src/tracked_state/merge.rs +474 -0
  164. package/dist-engine-src/src/tracked_state/mod.rs +31 -0
  165. package/dist-engine-src/src/tracked_state/rebuild.rs +771 -0
  166. package/dist-engine-src/src/tracked_state/storage.rs +243 -0
  167. package/dist-engine-src/src/tracked_state/tree.rs +2744 -0
  168. package/dist-engine-src/src/tracked_state/tree_types.rs +176 -0
  169. package/dist-engine-src/src/tracked_state/types.rs +61 -0
  170. package/dist-engine-src/src/transaction/commit.rs +1224 -0
  171. package/dist-engine-src/src/transaction/context.rs +1307 -0
  172. package/dist-engine-src/src/transaction/live_state_overlay.rs +34 -0
  173. package/dist-engine-src/src/transaction/mod.rs +11 -0
  174. package/dist-engine-src/src/transaction/normalization.rs +1026 -0
  175. package/dist-engine-src/src/transaction/schema_resolver.rs +127 -0
  176. package/dist-engine-src/src/transaction/staging.rs +1436 -0
  177. package/dist-engine-src/src/transaction/types.rs +351 -0
  178. package/dist-engine-src/src/transaction/validation.rs +4811 -0
  179. package/dist-engine-src/src/untracked_state/codec.rs +363 -0
  180. package/dist-engine-src/src/untracked_state/context.rs +82 -0
  181. package/dist-engine-src/src/untracked_state/materialization.rs +157 -0
  182. package/dist-engine-src/src/untracked_state/mod.rs +17 -0
  183. package/dist-engine-src/src/untracked_state/storage.rs +348 -0
  184. package/dist-engine-src/src/untracked_state/types.rs +96 -0
  185. package/dist-engine-src/src/version/context.rs +52 -0
  186. package/dist-engine-src/src/version/mod.rs +12 -0
  187. package/dist-engine-src/src/version/refs.rs +421 -0
  188. package/dist-engine-src/src/version/stage_rows.rs +71 -0
  189. package/dist-engine-src/src/version/types.rs +21 -0
  190. package/dist-engine-src/src/wasm/mod.rs +60 -0
  191. package/package.json +68 -64
@@ -0,0 +1,1588 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+
3
+ use crate::changelog::{
4
+ materialize_change, CanonicalChange, ChangelogContext, ChangelogStoreReader,
5
+ MaterializedCanonicalChange,
6
+ };
7
+ use crate::commit_graph::walker::{best_common_ancestors, walk_reachable_commits};
8
+ use crate::commit_graph::{
9
+ CommitGraphChangeHistoryEntry, CommitGraphChangeHistoryRequest, CommitGraphChangeSet,
10
+ CommitGraphChangeSetElement, CommitGraphCommit, CommitGraphEdge, CommitGraphEntity,
11
+ CommitGraphReader, ReachableCommitGraphCommit,
12
+ };
13
+ use crate::entity_identity::EntityIdentity;
14
+ use crate::json_store::{JsonStoreContext, JsonStoreReader};
15
+ use crate::storage::StorageReader;
16
+ use crate::storage::{ScopedStorageReader, StorageReadScope};
17
+ use crate::LixError;
18
+
19
+ const COMMIT_SCHEMA_KEY: &str = "lix_commit";
20
+
21
+ /// Read model for resolving changelog commits into entity state at a head.
22
+ ///
23
+ /// This module does not own durable storage. It reads immutable changelog facts
24
+ /// through a caller-provided KV store and applies commit graph rules on top.
25
+ #[derive(Clone)]
26
+ pub(crate) struct CommitGraphContext {
27
+ changelog: ChangelogContext,
28
+ }
29
+
30
+ impl CommitGraphContext {
31
+ pub(crate) fn new(changelog: ChangelogContext) -> Self {
32
+ Self { changelog }
33
+ }
34
+
35
+ /// Creates a graph reader over a caller-provided KV store.
36
+ pub(crate) fn reader<S>(&self, store: S) -> CommitGraphStoreReader<S>
37
+ where
38
+ S: StorageReader,
39
+ {
40
+ let read_scope = StorageReadScope::new(store);
41
+ CommitGraphStoreReader {
42
+ changelog_reader: self.changelog.reader(read_scope.store()),
43
+ json_reader: JsonStoreContext::new().reader(read_scope.store()),
44
+ }
45
+ }
46
+ }
47
+
48
+ /// Commit-graph reader that resolves changelog entities at a commit head.
49
+ pub(crate) struct CommitGraphStoreReader<S>
50
+ where
51
+ S: StorageReader,
52
+ {
53
+ changelog_reader: ChangelogStoreReader<ScopedStorageReader<S>>,
54
+ json_reader: JsonStoreReader<ScopedStorageReader<S>>,
55
+ }
56
+
57
+ impl<S> CommitGraphStoreReader<S>
58
+ where
59
+ S: StorageReader,
60
+ {
61
+ /// Returns the canonical entities that are effective at `head_commit_id`.
62
+ ///
63
+ /// Reachable commits are visited nearest-first. For each commit, the commit
64
+ /// row itself is visible, then introduced/adopted `change_ids` are visited
65
+ /// in reverse order so later writes in the same commit win.
66
+ pub(crate) async fn entities_at(
67
+ &mut self,
68
+ head_commit_id: &str,
69
+ ) -> Result<Vec<CommitGraphEntity>, LixError> {
70
+ let commits = self.reachable_commits(head_commit_id).await?;
71
+ self.select_entities(commits).await
72
+ }
73
+
74
+ /// Loads and parses a `lix_commit` canonical change by commit id.
75
+ pub(crate) async fn load_commit(
76
+ &mut self,
77
+ commit_id: &str,
78
+ ) -> Result<Option<CommitGraphCommit>, LixError> {
79
+ let Some(change) = self.find_commit_change(commit_id).await? else {
80
+ return Ok(None);
81
+ };
82
+ parse_commit_change(change).map(Some)
83
+ }
84
+
85
+ /// Loads every commit fact from the changelog.
86
+ ///
87
+ /// This is used by global commit surfaces where the caller wants the durable
88
+ /// graph facts themselves, not reachability from a particular version head.
89
+ pub(crate) async fn all_commits(&mut self) -> Result<Vec<CommitGraphCommit>, LixError> {
90
+ let changes = self
91
+ .changelog_reader
92
+ .scan_changes(&crate::changelog::ChangelogScanRequest { limit: None })
93
+ .await?;
94
+ let mut commits = Vec::new();
95
+ for change in changes
96
+ .into_iter()
97
+ .filter(|change| change.schema_key == COMMIT_SCHEMA_KEY)
98
+ {
99
+ commits.push(parse_commit_change(self.materialize_change(change).await?)?);
100
+ }
101
+ commits.sort_by(|left, right| left.commit_id.cmp(&right.commit_id));
102
+ Ok(commits)
103
+ }
104
+
105
+ /// Walks from `head_commit_id` through parent commits and records nearest depth.
106
+ pub(crate) async fn reachable_commits(
107
+ &mut self,
108
+ head_commit_id: &str,
109
+ ) -> Result<Vec<ReachableCommitGraphCommit>, LixError> {
110
+ walk_reachable_commits(self, head_commit_id).await
111
+ }
112
+
113
+ /// Returns the best common ancestors shared by two commit heads.
114
+ ///
115
+ /// This is the commit-DAG primitive. It can return more than one commit in
116
+ /// criss-cross histories. Merge code should layer an explicit merge-base
117
+ /// policy on top when it needs exactly one base for a three-way merge.
118
+ pub(crate) async fn best_common_ancestors(
119
+ &mut self,
120
+ left_commit_id: &str,
121
+ right_commit_id: &str,
122
+ ) -> Result<Vec<CommitGraphCommit>, LixError> {
123
+ best_common_ancestors(self, left_commit_id, right_commit_id).await
124
+ }
125
+
126
+ /// Resolves the single commit base to use for a three-way merge.
127
+ ///
128
+ /// This is merge policy layered over `best_common_ancestors(...)`. Histories
129
+ /// with no shared base or multiple equally good bases are rejected for now
130
+ /// so merge code cannot accidentally hide unsupported graph semantics.
131
+ pub(crate) async fn merge_base(
132
+ &mut self,
133
+ left_commit_id: &str,
134
+ right_commit_id: &str,
135
+ ) -> Result<CommitGraphCommit, LixError> {
136
+ let ancestors = self
137
+ .best_common_ancestors(left_commit_id, right_commit_id)
138
+ .await?;
139
+ match ancestors.as_slice() {
140
+ [] => Err(LixError::new(
141
+ "LIX_ERROR_UNKNOWN",
142
+ format!(
143
+ "commit_graph found no common history between '{left_commit_id}' and '{right_commit_id}'"
144
+ ),
145
+ )),
146
+ [base] => Ok(base.clone()),
147
+ _ => Err(LixError::ambiguous_merge_base(
148
+ left_commit_id,
149
+ right_commit_id,
150
+ ancestors
151
+ .iter()
152
+ .map(|ancestor| ancestor.commit_id.clone())
153
+ .collect(),
154
+ )),
155
+ }
156
+ }
157
+
158
+ /// Derives parent/child edges from parsed commits.
159
+ pub(crate) fn commit_edges(&self, commits: &[CommitGraphCommit]) -> Vec<CommitGraphEdge> {
160
+ commits
161
+ .iter()
162
+ .flat_map(|commit| {
163
+ commit
164
+ .parent_commit_ids
165
+ .iter()
166
+ .map(|parent_commit_id| CommitGraphEdge {
167
+ parent_commit_id: parent_commit_id.clone(),
168
+ child_commit_id: commit.commit_id.clone(),
169
+ })
170
+ })
171
+ .collect()
172
+ }
173
+
174
+ /// Derives one change-set row for each parsed commit.
175
+ pub(crate) fn change_sets(&self, commits: &[CommitGraphCommit]) -> Vec<CommitGraphChangeSet> {
176
+ commits
177
+ .iter()
178
+ .map(|commit| CommitGraphChangeSet {
179
+ id: commit.change_set_id.clone(),
180
+ commit_id: commit.commit_id.clone(),
181
+ })
182
+ .collect()
183
+ }
184
+
185
+ /// Loads the canonical changes introduced/adopted by each commit's change set.
186
+ pub(crate) async fn change_set_elements(
187
+ &mut self,
188
+ commits: &[CommitGraphCommit],
189
+ ) -> Result<Vec<CommitGraphChangeSetElement>, LixError> {
190
+ let mut elements = Vec::new();
191
+ for commit in commits {
192
+ for change_id in &commit.change_ids {
193
+ let change = self
194
+ .load_member_change(change_id, &commit.commit_id)
195
+ .await?;
196
+ elements.push(CommitGraphChangeSetElement {
197
+ change_set_id: commit.change_set_id.clone(),
198
+ change,
199
+ });
200
+ }
201
+ }
202
+ Ok(elements)
203
+ }
204
+
205
+ /// Returns canonical changes reachable from `start_commit_id`.
206
+ ///
207
+ /// This is the primitive history API. It reports the commit/depth where
208
+ /// each matching canonical change was introduced or adopted during graph
209
+ /// traversal and leaves row shaping to callers such as SQL providers.
210
+ pub(crate) async fn change_history_from_commit(
211
+ &mut self,
212
+ start_commit_id: &str,
213
+ request: &CommitGraphChangeHistoryRequest,
214
+ ) -> Result<Vec<CommitGraphChangeHistoryEntry>, LixError> {
215
+ let commits = self.reachable_commits(start_commit_id).await?;
216
+ let mut entries = Vec::new();
217
+ let mut seen_change_ids = BTreeSet::new();
218
+
219
+ for reachable in commits {
220
+ if !depth_matches(reachable.depth, request) {
221
+ continue;
222
+ }
223
+
224
+ let commit_id = reachable.commit.commit_id;
225
+ for change_id in reachable.commit.change_ids {
226
+ if !seen_change_ids.insert(change_id.clone()) {
227
+ continue;
228
+ }
229
+ let change = self
230
+ .load_member_canonical_change(&change_id, &commit_id)
231
+ .await?;
232
+ if change_matches_history_request(&change, request) {
233
+ entries.push(CommitGraphChangeHistoryEntry {
234
+ change,
235
+ observed_commit_id: commit_id.clone(),
236
+ start_commit_id: start_commit_id.to_string(),
237
+ depth: reachable.depth,
238
+ });
239
+ }
240
+ }
241
+ }
242
+
243
+ Ok(entries)
244
+ }
245
+
246
+ async fn load_member_canonical_change(
247
+ &mut self,
248
+ change_id: &str,
249
+ source_commit_id: &str,
250
+ ) -> Result<CanonicalChange, LixError> {
251
+ self.changelog_reader
252
+ .load_change(change_id)
253
+ .await?
254
+ .ok_or_else(|| {
255
+ LixError::new(
256
+ "LIX_ERROR_UNKNOWN",
257
+ format!(
258
+ "commit_graph commit '{source_commit_id}' references missing change '{change_id}'"
259
+ ),
260
+ )
261
+ })
262
+ }
263
+
264
+ /// Selects the first reachable change for each canonical entity identity.
265
+ async fn select_entities(
266
+ &mut self,
267
+ commits: Vec<ReachableCommitGraphCommit>,
268
+ ) -> Result<Vec<CommitGraphEntity>, LixError> {
269
+ let mut order = Vec::new();
270
+ let mut entities = BTreeMap::new();
271
+
272
+ for reachable in commits {
273
+ let depth = reachable.depth;
274
+ let source_commit_id = reachable.commit.commit_id;
275
+
276
+ observe_change(
277
+ &mut order,
278
+ &mut entities,
279
+ reachable.commit.change,
280
+ source_commit_id.clone(),
281
+ depth,
282
+ );
283
+
284
+ for change_id in reachable.commit.change_ids.iter().rev() {
285
+ let change = self
286
+ .load_member_change(change_id, &source_commit_id)
287
+ .await?;
288
+ observe_change(
289
+ &mut order,
290
+ &mut entities,
291
+ change,
292
+ source_commit_id.clone(),
293
+ depth,
294
+ );
295
+ }
296
+ }
297
+
298
+ Ok(order
299
+ .into_iter()
300
+ .filter_map(|identity| {
301
+ entities
302
+ .remove(&identity)
303
+ .map(|accumulator| accumulator.entity)
304
+ })
305
+ .collect())
306
+ }
307
+
308
+ async fn load_member_change(
309
+ &mut self,
310
+ change_id: &str,
311
+ source_commit_id: &str,
312
+ ) -> Result<MaterializedCanonicalChange, LixError> {
313
+ let change = self
314
+ .changelog_reader
315
+ .load_change(change_id)
316
+ .await?
317
+ .ok_or_else(|| {
318
+ LixError::new(
319
+ "LIX_ERROR_UNKNOWN",
320
+ format!(
321
+ "commit_graph commit '{source_commit_id}' references missing change '{change_id}'"
322
+ ),
323
+ )
324
+ })?;
325
+ self.materialize_change(change).await
326
+ }
327
+
328
+ async fn materialize_change(
329
+ &mut self,
330
+ change: CanonicalChange,
331
+ ) -> Result<MaterializedCanonicalChange, LixError> {
332
+ materialize_change(&mut self.json_reader, change).await
333
+ }
334
+
335
+ async fn find_commit_change(
336
+ &mut self,
337
+ commit_id: &str,
338
+ ) -> Result<Option<MaterializedCanonicalChange>, LixError> {
339
+ let changes = self
340
+ .changelog_reader
341
+ .scan_changes(&crate::changelog::ChangelogScanRequest { limit: None })
342
+ .await?;
343
+ let Some(change) = changes.into_iter().find(|change| {
344
+ change.schema_key == COMMIT_SCHEMA_KEY
345
+ && change
346
+ .entity_id
347
+ .as_string()
348
+ .is_ok_and(|entity_id| entity_id == commit_id)
349
+ }) else {
350
+ return Ok(None);
351
+ };
352
+ self.materialize_change(change).await.map(Some)
353
+ }
354
+ }
355
+
356
+ #[async_trait::async_trait]
357
+ impl<S> CommitGraphReader for CommitGraphStoreReader<S>
358
+ where
359
+ S: StorageReader,
360
+ {
361
+ async fn load_commit(
362
+ &mut self,
363
+ commit_id: &str,
364
+ ) -> Result<Option<CommitGraphCommit>, LixError> {
365
+ CommitGraphStoreReader::load_commit(self, commit_id).await
366
+ }
367
+
368
+ async fn all_commits(&mut self) -> Result<Vec<CommitGraphCommit>, LixError> {
369
+ CommitGraphStoreReader::all_commits(self).await
370
+ }
371
+
372
+ async fn reachable_commits(
373
+ &mut self,
374
+ head_commit_id: &str,
375
+ ) -> Result<Vec<ReachableCommitGraphCommit>, LixError> {
376
+ CommitGraphStoreReader::reachable_commits(self, head_commit_id).await
377
+ }
378
+
379
+ async fn best_common_ancestors(
380
+ &mut self,
381
+ left_commit_id: &str,
382
+ right_commit_id: &str,
383
+ ) -> Result<Vec<CommitGraphCommit>, LixError> {
384
+ CommitGraphStoreReader::best_common_ancestors(self, left_commit_id, right_commit_id).await
385
+ }
386
+
387
+ async fn merge_base(
388
+ &mut self,
389
+ left_commit_id: &str,
390
+ right_commit_id: &str,
391
+ ) -> Result<CommitGraphCommit, LixError> {
392
+ CommitGraphStoreReader::merge_base(self, left_commit_id, right_commit_id).await
393
+ }
394
+
395
+ fn commit_edges(&self, commits: &[CommitGraphCommit]) -> Vec<CommitGraphEdge> {
396
+ CommitGraphStoreReader::commit_edges(self, commits)
397
+ }
398
+
399
+ fn change_sets(&self, commits: &[CommitGraphCommit]) -> Vec<CommitGraphChangeSet> {
400
+ CommitGraphStoreReader::change_sets(self, commits)
401
+ }
402
+
403
+ async fn change_set_elements(
404
+ &mut self,
405
+ commits: &[CommitGraphCommit],
406
+ ) -> Result<Vec<CommitGraphChangeSetElement>, LixError> {
407
+ CommitGraphStoreReader::change_set_elements(self, commits).await
408
+ }
409
+
410
+ async fn change_history_from_commit(
411
+ &mut self,
412
+ start_commit_id: &str,
413
+ request: &CommitGraphChangeHistoryRequest,
414
+ ) -> Result<Vec<CommitGraphChangeHistoryEntry>, LixError> {
415
+ CommitGraphStoreReader::change_history_from_commit(self, start_commit_id, request).await
416
+ }
417
+ }
418
+
419
+ fn depth_matches(depth: u32, request: &CommitGraphChangeHistoryRequest) -> bool {
420
+ request.min_depth.map_or(true, |min| depth >= min)
421
+ && request.max_depth.map_or(true, |max| depth <= max)
422
+ }
423
+
424
+ fn change_matches_history_request(
425
+ change: &CanonicalChange,
426
+ request: &CommitGraphChangeHistoryRequest,
427
+ ) -> bool {
428
+ (request.include_tombstones || change.snapshot_ref.is_some())
429
+ && (request.entity_ids.is_empty() || request.entity_ids.contains(&change.entity_id))
430
+ && (request.schema_keys.is_empty() || request.schema_keys.contains(&change.schema_key))
431
+ && (request.file_ids.is_empty()
432
+ || change
433
+ .file_id
434
+ .as_ref()
435
+ .is_some_and(|file_id| request.file_ids.contains(file_id)))
436
+ }
437
+
438
+ fn observe_change(
439
+ order: &mut Vec<CanonicalEntityIdentity>,
440
+ entities: &mut BTreeMap<CanonicalEntityIdentity, EntityAccumulator>,
441
+ change: MaterializedCanonicalChange,
442
+ source_commit_id: String,
443
+ depth: u32,
444
+ ) {
445
+ let identity = CanonicalEntityIdentity::from_change(&change);
446
+ if let Some(accumulator) = entities.get_mut(&identity) {
447
+ // TODO: represent unresolved parent-parent merge conflicts instead of
448
+ // collapsing them through deterministic traversal order. A head commit
449
+ // change for the same identity should remain the explicit resolution.
450
+ accumulator.entity.created_at = change.created_at.clone();
451
+ return;
452
+ }
453
+
454
+ order.push(identity.clone());
455
+ entities.insert(
456
+ identity,
457
+ EntityAccumulator {
458
+ entity: CommitGraphEntity {
459
+ created_at: change.created_at.clone(),
460
+ updated_at: change.created_at.clone(),
461
+ change,
462
+ source_commit_id,
463
+ depth,
464
+ },
465
+ },
466
+ );
467
+ }
468
+
469
+ #[derive(Debug)]
470
+ struct EntityAccumulator {
471
+ entity: CommitGraphEntity,
472
+ }
473
+
474
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
475
+ struct CanonicalEntityIdentity {
476
+ entity_id: EntityIdentity,
477
+ schema_key: String,
478
+ file_id: Option<String>,
479
+ }
480
+
481
+ impl CanonicalEntityIdentity {
482
+ fn from_change(change: &MaterializedCanonicalChange) -> Self {
483
+ Self {
484
+ entity_id: change.entity_id.clone(),
485
+ schema_key: change.schema_key.clone(),
486
+ file_id: change.file_id.clone(),
487
+ }
488
+ }
489
+ }
490
+
491
+ fn parse_commit_change(
492
+ change: crate::changelog::MaterializedCanonicalChange,
493
+ ) -> Result<CommitGraphCommit, LixError> {
494
+ let change_entity_id = change.entity_id.as_string()?;
495
+ if change.schema_key != COMMIT_SCHEMA_KEY {
496
+ return Err(LixError::new(
497
+ "LIX_ERROR_UNKNOWN",
498
+ format!(
499
+ "commit_graph expected schema_key '{COMMIT_SCHEMA_KEY}' but got '{}'",
500
+ change.schema_key
501
+ ),
502
+ ));
503
+ }
504
+
505
+ let snapshot_content = change.snapshot_content.as_deref().ok_or_else(|| {
506
+ LixError::new(
507
+ "LIX_ERROR_UNKNOWN",
508
+ format!(
509
+ "commit_graph commit '{}' is missing snapshot_content",
510
+ change_entity_id
511
+ ),
512
+ )
513
+ })?;
514
+ let snapshot =
515
+ serde_json::from_str::<serde_json::Value>(snapshot_content).map_err(|error| {
516
+ LixError::new(
517
+ "LIX_ERROR_UNKNOWN",
518
+ format!(
519
+ "commit_graph commit '{}' snapshot_content is invalid JSON: {error}",
520
+ change_entity_id
521
+ ),
522
+ )
523
+ })?;
524
+
525
+ let commit_id = required_string(&snapshot, "id", &change_entity_id)?;
526
+ if commit_id != change_entity_id {
527
+ return Err(LixError::new(
528
+ "LIX_ERROR_UNKNOWN",
529
+ format!(
530
+ "commit_graph commit change entity_id '{}' does not match snapshot id '{}'",
531
+ change_entity_id, commit_id
532
+ ),
533
+ ));
534
+ }
535
+
536
+ let change_ids = required_string_array(&snapshot, "change_ids", &change_entity_id)?;
537
+ let author_account_ids =
538
+ optional_string_array(&snapshot, "author_account_ids", &change_entity_id)?;
539
+ let parent_commit_ids =
540
+ required_string_array(&snapshot, "parent_commit_ids", &change_entity_id)?;
541
+ let change_set_id = required_string(&snapshot, "change_set_id", &change_entity_id)?;
542
+
543
+ Ok(CommitGraphCommit {
544
+ change,
545
+ commit_id,
546
+ change_set_id,
547
+ change_ids,
548
+ author_account_ids,
549
+ parent_commit_ids,
550
+ })
551
+ }
552
+
553
+ fn required_string(
554
+ snapshot: &serde_json::Value,
555
+ field: &str,
556
+ commit_id: &str,
557
+ ) -> Result<String, LixError> {
558
+ snapshot
559
+ .get(field)
560
+ .and_then(serde_json::Value::as_str)
561
+ .filter(|value| !value.is_empty())
562
+ .map(str::to_string)
563
+ .ok_or_else(|| {
564
+ LixError::new(
565
+ "LIX_ERROR_UNKNOWN",
566
+ format!("commit_graph commit '{commit_id}' requires string field '{field}'"),
567
+ )
568
+ })
569
+ }
570
+
571
+ fn required_string_array(
572
+ snapshot: &serde_json::Value,
573
+ field: &str,
574
+ commit_id: &str,
575
+ ) -> Result<Vec<String>, LixError> {
576
+ let values = snapshot
577
+ .get(field)
578
+ .and_then(serde_json::Value::as_array)
579
+ .ok_or_else(|| {
580
+ LixError::new(
581
+ "LIX_ERROR_UNKNOWN",
582
+ format!("commit_graph commit '{commit_id}' requires array field '{field}'"),
583
+ )
584
+ })?;
585
+
586
+ values
587
+ .iter()
588
+ .map(|value| {
589
+ value.as_str().filter(|value| !value.is_empty()).map(str::to_string).ok_or_else(|| {
590
+ LixError::new(
591
+ "LIX_ERROR_UNKNOWN",
592
+ format!(
593
+ "commit_graph commit '{commit_id}' field '{field}' must contain only non-empty strings"
594
+ ),
595
+ )
596
+ })
597
+ })
598
+ .collect()
599
+ }
600
+
601
+ fn optional_string_array(
602
+ snapshot: &serde_json::Value,
603
+ field: &str,
604
+ commit_id: &str,
605
+ ) -> Result<Vec<String>, LixError> {
606
+ match snapshot.get(field) {
607
+ Some(_) => required_string_array(snapshot, field, commit_id),
608
+ None => Ok(Vec::new()),
609
+ }
610
+ }
611
+
612
+ #[cfg(test)]
613
+ mod tests {
614
+ use std::sync::Arc;
615
+
616
+ use serde_json::json;
617
+
618
+ use crate::backend::testing::UnitTestBackend;
619
+ use crate::changelog::{
620
+ canonicalize_materialized_change, ChangelogContext, MaterializedCanonicalChange,
621
+ };
622
+ use crate::commit_graph::{CommitGraphChangeHistoryRequest, CommitGraphContext};
623
+ use crate::json_store::JsonStoreContext;
624
+ use crate::storage::{StorageContext, StorageWriteSet};
625
+
626
+ #[tokio::test]
627
+ async fn load_commit_parses_commit_snapshot() {
628
+ let backend = Arc::new(UnitTestBackend::new());
629
+ let storage = StorageContext::new(backend.clone());
630
+ let changelog = ChangelogContext::new();
631
+ append_changes(
632
+ storage.clone(),
633
+ &changelog,
634
+ &[commit_change(
635
+ "commit-1-change",
636
+ "commit-1",
637
+ &["change-1", "change-2"],
638
+ &["parent-1"],
639
+ )],
640
+ )
641
+ .await;
642
+
643
+ let graph = CommitGraphContext::new(changelog);
644
+ let mut reader = graph.reader(storage);
645
+ let commit = reader
646
+ .load_commit("commit-1")
647
+ .await
648
+ .expect("commit load should succeed")
649
+ .expect("commit should exist");
650
+
651
+ assert_eq!(commit.commit_id, "commit-1");
652
+ assert_eq!(commit.change_set_id, "change-set-1");
653
+ assert_eq!(commit.change_ids, vec!["change-1", "change-2"]);
654
+ assert_eq!(commit.parent_commit_ids, vec!["parent-1"]);
655
+ assert_eq!(commit.change.id, "commit-1-change");
656
+ }
657
+
658
+ #[tokio::test]
659
+ async fn load_commit_returns_none_for_missing_commit() {
660
+ let backend = Arc::new(UnitTestBackend::new());
661
+ let storage = StorageContext::new(backend.clone());
662
+ let graph = CommitGraphContext::new(ChangelogContext::new());
663
+ let mut reader = graph.reader(storage);
664
+
665
+ let commit = reader
666
+ .load_commit("missing")
667
+ .await
668
+ .expect("commit load should succeed");
669
+
670
+ assert_eq!(commit, None);
671
+ }
672
+
673
+ #[tokio::test]
674
+ async fn load_commit_rejects_malformed_snapshot() {
675
+ let backend = Arc::new(UnitTestBackend::new());
676
+ let storage = StorageContext::new(backend.clone());
677
+ let changelog = ChangelogContext::new();
678
+ append_changes(
679
+ storage.clone(),
680
+ &changelog,
681
+ &[MaterializedCanonicalChange {
682
+ id: "commit-1-change".to_string(),
683
+ entity_id: crate::entity_identity::EntityIdentity::single("commit-1"),
684
+ schema_key: super::COMMIT_SCHEMA_KEY.to_string(),
685
+ schema_version: "1".to_string(),
686
+ file_id: None,
687
+ snapshot_content: Some("{\"id\":\"commit-1\"}".to_string()),
688
+ metadata: None,
689
+ created_at: "2026-01-01T00:00:00Z".to_string(),
690
+ }],
691
+ )
692
+ .await;
693
+
694
+ let graph = CommitGraphContext::new(changelog);
695
+ let mut reader = graph.reader(storage);
696
+ let error = reader
697
+ .load_commit("commit-1")
698
+ .await
699
+ .expect_err("malformed commit should fail");
700
+
701
+ assert!(error.message.contains("change_ids"));
702
+ }
703
+
704
+ #[tokio::test]
705
+ async fn all_commits_returns_parsed_commits_sorted_by_id() {
706
+ let backend = Arc::new(UnitTestBackend::new());
707
+ let storage = StorageContext::new(backend.clone());
708
+ let changelog = ChangelogContext::new();
709
+ append_changes(
710
+ storage.clone(),
711
+ &changelog,
712
+ &[
713
+ commit_change("commit-b-change", "commit-b", &[], &[]),
714
+ entity_change("change-1", "entity-1", "example", "{}"),
715
+ commit_change("commit-a-change", "commit-a", &[], &[]),
716
+ ],
717
+ )
718
+ .await;
719
+
720
+ let graph = CommitGraphContext::new(changelog);
721
+ let mut reader = graph.reader(storage);
722
+ let commits = reader
723
+ .all_commits()
724
+ .await
725
+ .expect("commit scan should succeed");
726
+
727
+ assert_eq!(
728
+ commits
729
+ .iter()
730
+ .map(|commit| commit.commit_id.as_str())
731
+ .collect::<Vec<_>>(),
732
+ vec!["commit-a", "commit-b"]
733
+ );
734
+ }
735
+
736
+ #[tokio::test]
737
+ async fn entities_at_walks_ancestors_and_computes_nearest_depth() {
738
+ let backend = Arc::new(UnitTestBackend::new());
739
+ let storage = StorageContext::new(backend.clone());
740
+ let changelog = ChangelogContext::new();
741
+ append_changes(
742
+ storage.clone(),
743
+ &changelog,
744
+ &[
745
+ commit_change("commit-root-change", "commit-root", &[], &[]),
746
+ commit_change("commit-left-change", "commit-left", &[], &["commit-root"]),
747
+ commit_change("commit-right-change", "commit-right", &[], &["commit-root"]),
748
+ commit_change(
749
+ "commit-head-change",
750
+ "commit-head",
751
+ &[],
752
+ &["commit-left", "commit-right"],
753
+ ),
754
+ ],
755
+ )
756
+ .await;
757
+
758
+ let graph = CommitGraphContext::new(changelog);
759
+ let mut reader = graph.reader(storage);
760
+ let entities = reader
761
+ .entities_at("commit-head")
762
+ .await
763
+ .expect("ancestor traversal should succeed");
764
+
765
+ let depths = entities
766
+ .into_iter()
767
+ .map(|entity| (entity.source_commit_id, entity.depth))
768
+ .collect::<Vec<_>>();
769
+ assert_eq!(
770
+ depths,
771
+ vec![
772
+ ("commit-head".to_string(), 0),
773
+ ("commit-left".to_string(), 1),
774
+ ("commit-right".to_string(), 1),
775
+ ("commit-root".to_string(), 2),
776
+ ]
777
+ );
778
+ }
779
+
780
+ #[tokio::test]
781
+ async fn commit_edges_are_derived_from_parent_commit_ids() {
782
+ let graph = CommitGraphContext::new(ChangelogContext::new());
783
+ let reader = graph.reader(StorageContext::new(Arc::new(UnitTestBackend::new())));
784
+ let commits = vec![parsed_commit(
785
+ "commit-head",
786
+ &[],
787
+ &["commit-left", "commit-right"],
788
+ )];
789
+
790
+ let edges = reader.commit_edges(&commits);
791
+
792
+ assert_eq!(
793
+ edges
794
+ .iter()
795
+ .map(|edge| (
796
+ edge.parent_commit_id.as_str(),
797
+ edge.child_commit_id.as_str()
798
+ ))
799
+ .collect::<Vec<_>>(),
800
+ vec![
801
+ ("commit-left", "commit-head"),
802
+ ("commit-right", "commit-head")
803
+ ]
804
+ );
805
+ }
806
+
807
+ #[tokio::test]
808
+ async fn change_sets_are_derived_from_commits() {
809
+ let graph = CommitGraphContext::new(ChangelogContext::new());
810
+ let reader = graph.reader(StorageContext::new(Arc::new(UnitTestBackend::new())));
811
+ let commits = vec![parsed_commit("commit-1", &[], &[])];
812
+
813
+ let change_sets = reader.change_sets(&commits);
814
+
815
+ assert_eq!(change_sets.len(), 1);
816
+ assert_eq!(change_sets[0].id, "change-set-1");
817
+ assert_eq!(change_sets[0].commit_id, "commit-1");
818
+ }
819
+
820
+ #[tokio::test]
821
+ async fn change_set_elements_load_member_changes() {
822
+ let backend = Arc::new(UnitTestBackend::new());
823
+ let storage = StorageContext::new(backend.clone());
824
+ let changelog = ChangelogContext::new();
825
+ append_changes(
826
+ storage.clone(),
827
+ &changelog,
828
+ &[
829
+ entity_change("change-1", "entity-1", "example", "{}"),
830
+ commit_change("commit-1-change", "commit-1", &["change-1"], &[]),
831
+ ],
832
+ )
833
+ .await;
834
+
835
+ let graph = CommitGraphContext::new(changelog);
836
+ let mut reader = graph.reader(storage);
837
+ let commits = reader
838
+ .all_commits()
839
+ .await
840
+ .expect("commit scan should succeed");
841
+ let elements = reader
842
+ .change_set_elements(&commits)
843
+ .await
844
+ .expect("change-set elements should load");
845
+
846
+ assert_eq!(elements.len(), 1);
847
+ assert_eq!(elements[0].change_set_id, "change-set-1");
848
+ assert_eq!(elements[0].change.id, "change-1");
849
+ assert_eq!(
850
+ elements[0]
851
+ .change
852
+ .entity_id
853
+ .as_string()
854
+ .expect("entity id should project"),
855
+ "entity-1"
856
+ );
857
+ }
858
+
859
+ #[tokio::test]
860
+ async fn change_history_from_commit_reports_matching_canonical_changes_with_depth() {
861
+ let backend = Arc::new(UnitTestBackend::new());
862
+ let storage = StorageContext::new(backend.clone());
863
+ let changelog = ChangelogContext::new();
864
+ append_changes(
865
+ storage.clone(),
866
+ &changelog,
867
+ &[
868
+ entity_change("change-root", "entity-root", "test_schema", "{}"),
869
+ entity_change("change-head", "entity-head", "test_schema", "{}"),
870
+ commit_change("commit-root-change", "commit-root", &["change-root"], &[]),
871
+ commit_change(
872
+ "commit-head-change",
873
+ "commit-head",
874
+ &["change-head"],
875
+ &["commit-root"],
876
+ ),
877
+ ],
878
+ )
879
+ .await;
880
+
881
+ let graph = CommitGraphContext::new(changelog);
882
+ let mut reader = graph.reader(storage);
883
+ let history = reader
884
+ .change_history_from_commit(
885
+ "commit-head",
886
+ &CommitGraphChangeHistoryRequest {
887
+ schema_keys: vec!["test_schema".to_string()],
888
+ include_tombstones: true,
889
+ ..CommitGraphChangeHistoryRequest::default()
890
+ },
891
+ )
892
+ .await
893
+ .expect("history should resolve");
894
+
895
+ assert_eq!(
896
+ history
897
+ .iter()
898
+ .map(|entry| (
899
+ entry.change.id.as_str(),
900
+ entry.observed_commit_id.as_str(),
901
+ entry.start_commit_id.as_str(),
902
+ entry.depth
903
+ ))
904
+ .collect::<Vec<_>>(),
905
+ vec![
906
+ ("change-head", "commit-head", "commit-head", 0),
907
+ ("change-root", "commit-root", "commit-head", 1),
908
+ ]
909
+ );
910
+ }
911
+
912
+ #[tokio::test]
913
+ async fn change_history_from_commit_filters_depth_entity_file_and_tombstones() {
914
+ let backend = Arc::new(UnitTestBackend::new());
915
+ let storage = StorageContext::new(backend.clone());
916
+ let changelog = ChangelogContext::new();
917
+ append_changes(
918
+ storage.clone(),
919
+ &changelog,
920
+ &[
921
+ entity_change_with_file(
922
+ "change-file-a",
923
+ "entity-1",
924
+ "test_schema",
925
+ Some("file-a"),
926
+ "{}",
927
+ ),
928
+ entity_tombstone("change-tombstone", "entity-1", "test_schema"),
929
+ entity_change_with_file(
930
+ "change-file-b",
931
+ "entity-2",
932
+ "test_schema",
933
+ Some("file-b"),
934
+ "{}",
935
+ ),
936
+ commit_change("commit-root-change", "commit-root", &["change-file-a"], &[]),
937
+ commit_change(
938
+ "commit-head-change",
939
+ "commit-head",
940
+ &["change-tombstone", "change-file-b"],
941
+ &["commit-root"],
942
+ ),
943
+ ],
944
+ )
945
+ .await;
946
+
947
+ let graph = CommitGraphContext::new(changelog);
948
+ let mut reader = graph.reader(storage);
949
+ let history = reader
950
+ .change_history_from_commit(
951
+ "commit-head",
952
+ &CommitGraphChangeHistoryRequest {
953
+ entity_ids: vec![crate::entity_identity::EntityIdentity::single("entity-1")],
954
+ file_ids: vec!["file-a".to_string()],
955
+ min_depth: Some(1),
956
+ max_depth: Some(1),
957
+ include_tombstones: false,
958
+ ..CommitGraphChangeHistoryRequest::default()
959
+ },
960
+ )
961
+ .await
962
+ .expect("history should resolve");
963
+
964
+ assert_eq!(history.len(), 1);
965
+ assert_eq!(history[0].change.id, "change-file-a");
966
+ assert_eq!(history[0].depth, 1);
967
+ }
968
+
969
+ #[tokio::test]
970
+ async fn change_history_from_commit_includes_tombstones_when_requested() {
971
+ let backend = Arc::new(UnitTestBackend::new());
972
+ let storage = StorageContext::new(backend.clone());
973
+ let changelog = ChangelogContext::new();
974
+ append_changes(
975
+ storage.clone(),
976
+ &changelog,
977
+ &[
978
+ entity_tombstone("change-deleted", "entity-1", "test_schema"),
979
+ commit_change(
980
+ "commit-head-change",
981
+ "commit-head",
982
+ &["change-deleted"],
983
+ &[],
984
+ ),
985
+ ],
986
+ )
987
+ .await;
988
+
989
+ let graph = CommitGraphContext::new(changelog);
990
+ let mut reader = graph.reader(storage);
991
+ let hidden = reader
992
+ .change_history_from_commit("commit-head", &CommitGraphChangeHistoryRequest::default())
993
+ .await
994
+ .expect("history should resolve");
995
+ let visible = reader
996
+ .change_history_from_commit(
997
+ "commit-head",
998
+ &CommitGraphChangeHistoryRequest {
999
+ include_tombstones: true,
1000
+ ..CommitGraphChangeHistoryRequest::default()
1001
+ },
1002
+ )
1003
+ .await
1004
+ .expect("history should resolve");
1005
+
1006
+ assert!(hidden.is_empty());
1007
+ assert_eq!(visible.len(), 1);
1008
+ assert_eq!(visible[0].change.id, "change-deleted");
1009
+ }
1010
+
1011
+ #[tokio::test]
1012
+ async fn entities_at_selects_nearest_member_change_for_identity() {
1013
+ let backend = Arc::new(UnitTestBackend::new());
1014
+ let storage = StorageContext::new(backend.clone());
1015
+ let changelog = ChangelogContext::new();
1016
+ append_changes(
1017
+ storage.clone(),
1018
+ &changelog,
1019
+ &[
1020
+ entity_change(
1021
+ "change-old",
1022
+ "entity-1",
1023
+ "test_schema",
1024
+ "{\"value\":\"old\"}",
1025
+ ),
1026
+ entity_change(
1027
+ "change-new",
1028
+ "entity-1",
1029
+ "test_schema",
1030
+ "{\"value\":\"new\"}",
1031
+ ),
1032
+ commit_change("commit-root-change", "commit-root", &["change-old"], &[]),
1033
+ commit_change(
1034
+ "commit-head-change",
1035
+ "commit-head",
1036
+ &["change-new"],
1037
+ &["commit-root"],
1038
+ ),
1039
+ ],
1040
+ )
1041
+ .await;
1042
+
1043
+ let graph = CommitGraphContext::new(changelog);
1044
+ let mut reader = graph.reader(storage);
1045
+ let entities = reader
1046
+ .entities_at("commit-head")
1047
+ .await
1048
+ .expect("entities should resolve");
1049
+
1050
+ assert_eq!(
1051
+ entity_ids_for_schema(&entities, "test_schema"),
1052
+ vec![("change-new".to_string(), "commit-head".to_string(), 0)]
1053
+ );
1054
+ }
1055
+
1056
+ #[tokio::test]
1057
+ async fn entities_at_reports_created_at_from_oldest_reachable_change() {
1058
+ let backend = Arc::new(UnitTestBackend::new());
1059
+ let storage = StorageContext::new(backend.clone());
1060
+ let changelog = ChangelogContext::new();
1061
+ append_changes(
1062
+ storage.clone(),
1063
+ &changelog,
1064
+ &[
1065
+ entity_change_at(
1066
+ "change-created",
1067
+ "entity-1",
1068
+ "test_schema",
1069
+ "{\"value\":\"created\"}",
1070
+ "2026-01-01T00:00:00Z",
1071
+ ),
1072
+ entity_change_at(
1073
+ "change-updated",
1074
+ "entity-1",
1075
+ "test_schema",
1076
+ "{\"value\":\"updated\"}",
1077
+ "2026-01-02T00:00:00Z",
1078
+ ),
1079
+ commit_change(
1080
+ "commit-root-change",
1081
+ "commit-root",
1082
+ &["change-created"],
1083
+ &[],
1084
+ ),
1085
+ commit_change(
1086
+ "commit-head-change",
1087
+ "commit-head",
1088
+ &["change-updated"],
1089
+ &["commit-root"],
1090
+ ),
1091
+ ],
1092
+ )
1093
+ .await;
1094
+
1095
+ let graph = CommitGraphContext::new(changelog);
1096
+ let mut reader = graph.reader(storage);
1097
+ let entities = reader
1098
+ .entities_at("commit-head")
1099
+ .await
1100
+ .expect("entities should resolve");
1101
+ let entity = entities
1102
+ .iter()
1103
+ .find(|entity| entity.change.schema_key == "test_schema")
1104
+ .expect("test entity should resolve");
1105
+
1106
+ assert_eq!(entity.change.id, "change-updated");
1107
+ assert_eq!(entity.created_at, "2026-01-01T00:00:00Z");
1108
+ assert_eq!(entity.updated_at, "2026-01-02T00:00:00Z");
1109
+ }
1110
+
1111
+ #[tokio::test]
1112
+ async fn entities_at_uses_reverse_change_order_within_commit() {
1113
+ let backend = Arc::new(UnitTestBackend::new());
1114
+ let storage = StorageContext::new(backend.clone());
1115
+ let changelog = ChangelogContext::new();
1116
+ append_changes(
1117
+ storage.clone(),
1118
+ &changelog,
1119
+ &[
1120
+ entity_change(
1121
+ "change-first",
1122
+ "entity-1",
1123
+ "test_schema",
1124
+ "{\"value\":\"first\"}",
1125
+ ),
1126
+ entity_change(
1127
+ "change-last",
1128
+ "entity-1",
1129
+ "test_schema",
1130
+ "{\"value\":\"last\"}",
1131
+ ),
1132
+ commit_change(
1133
+ "commit-head-change",
1134
+ "commit-head",
1135
+ &["change-first", "change-last"],
1136
+ &[],
1137
+ ),
1138
+ ],
1139
+ )
1140
+ .await;
1141
+
1142
+ let graph = CommitGraphContext::new(changelog);
1143
+ let mut reader = graph.reader(storage);
1144
+ let entities = reader
1145
+ .entities_at("commit-head")
1146
+ .await
1147
+ .expect("entities should resolve");
1148
+
1149
+ assert_eq!(
1150
+ entity_ids_for_schema(&entities, "test_schema"),
1151
+ vec![("change-last".to_string(), "commit-head".to_string(), 0)]
1152
+ );
1153
+ }
1154
+
1155
+ #[tokio::test]
1156
+ async fn entities_at_head_change_overrides_both_merge_parents() {
1157
+ let backend = Arc::new(UnitTestBackend::new());
1158
+ let storage = StorageContext::new(backend.clone());
1159
+ let changelog = ChangelogContext::new();
1160
+ append_changes(
1161
+ storage.clone(),
1162
+ &changelog,
1163
+ &[
1164
+ entity_change(
1165
+ "change-left",
1166
+ "entity-1",
1167
+ "test_schema",
1168
+ "{\"value\":\"left\"}",
1169
+ ),
1170
+ entity_change(
1171
+ "change-right",
1172
+ "entity-1",
1173
+ "test_schema",
1174
+ "{\"value\":\"right\"}",
1175
+ ),
1176
+ entity_change(
1177
+ "change-resolved",
1178
+ "entity-1",
1179
+ "test_schema",
1180
+ "{\"value\":\"resolved\"}",
1181
+ ),
1182
+ commit_change("commit-left-change", "commit-left", &["change-left"], &[]),
1183
+ commit_change(
1184
+ "commit-right-change",
1185
+ "commit-right",
1186
+ &["change-right"],
1187
+ &[],
1188
+ ),
1189
+ commit_change(
1190
+ "commit-head-change",
1191
+ "commit-head",
1192
+ &["change-resolved"],
1193
+ &["commit-left", "commit-right"],
1194
+ ),
1195
+ ],
1196
+ )
1197
+ .await;
1198
+
1199
+ let graph = CommitGraphContext::new(changelog);
1200
+ let mut reader = graph.reader(storage);
1201
+ let entities = reader
1202
+ .entities_at("commit-head")
1203
+ .await
1204
+ .expect("entities should resolve");
1205
+
1206
+ assert_eq!(
1207
+ entity_ids_for_schema(&entities, "test_schema"),
1208
+ vec![("change-resolved".to_string(), "commit-head".to_string(), 0)]
1209
+ );
1210
+ }
1211
+
1212
+ #[tokio::test]
1213
+ async fn entities_at_distinguishes_same_entity_with_different_file_id() {
1214
+ let backend = Arc::new(UnitTestBackend::new());
1215
+ let storage = StorageContext::new(backend.clone());
1216
+ let changelog = ChangelogContext::new();
1217
+ append_changes(
1218
+ storage.clone(),
1219
+ &changelog,
1220
+ &[
1221
+ entity_change_with_file(
1222
+ "change-file-a",
1223
+ "entity-1",
1224
+ "test_schema",
1225
+ Some("file-a"),
1226
+ "{\"value\":\"file-a\"}",
1227
+ ),
1228
+ entity_change_with_file(
1229
+ "change-file-b",
1230
+ "entity-1",
1231
+ "test_schema",
1232
+ Some("file-b"),
1233
+ "{\"value\":\"file-b\"}",
1234
+ ),
1235
+ commit_change(
1236
+ "commit-head-change",
1237
+ "commit-head",
1238
+ &["change-file-a", "change-file-b"],
1239
+ &[],
1240
+ ),
1241
+ ],
1242
+ )
1243
+ .await;
1244
+
1245
+ let graph = CommitGraphContext::new(changelog);
1246
+ let mut reader = graph.reader(storage);
1247
+ let entities = reader
1248
+ .entities_at("commit-head")
1249
+ .await
1250
+ .expect("entities should resolve");
1251
+
1252
+ assert_eq!(
1253
+ entity_ids_for_schema(&entities, "test_schema"),
1254
+ vec![
1255
+ ("change-file-b".to_string(), "commit-head".to_string(), 0),
1256
+ ("change-file-a".to_string(), "commit-head".to_string(), 0),
1257
+ ]
1258
+ );
1259
+ }
1260
+
1261
+ #[tokio::test]
1262
+ async fn entities_at_uses_latest_change_for_same_entity_identity() {
1263
+ let backend = Arc::new(UnitTestBackend::new());
1264
+ let storage = StorageContext::new(backend.clone());
1265
+ let changelog = ChangelogContext::new();
1266
+ append_changes(
1267
+ storage.clone(),
1268
+ &changelog,
1269
+ &[
1270
+ entity_change_with_file(
1271
+ "change-entity-a",
1272
+ "entity-1",
1273
+ "test_schema",
1274
+ None,
1275
+ "{\"value\":\"a\"}",
1276
+ ),
1277
+ entity_change_with_file(
1278
+ "change-entity-b",
1279
+ "entity-1",
1280
+ "test_schema",
1281
+ None,
1282
+ "{\"value\":\"b\"}",
1283
+ ),
1284
+ commit_change(
1285
+ "commit-head-change",
1286
+ "commit-head",
1287
+ &["change-entity-a", "change-entity-b"],
1288
+ &[],
1289
+ ),
1290
+ ],
1291
+ )
1292
+ .await;
1293
+
1294
+ let graph = CommitGraphContext::new(changelog);
1295
+ let mut reader = graph.reader(storage);
1296
+ let entities = reader
1297
+ .entities_at("commit-head")
1298
+ .await
1299
+ .expect("entities should resolve");
1300
+ let entity = entities
1301
+ .iter()
1302
+ .find(|entity| entity.change.schema_key == "test_schema")
1303
+ .expect("entity should resolve");
1304
+
1305
+ assert_eq!(
1306
+ entity_ids_for_schema(&entities, "test_schema"),
1307
+ vec![("change-entity-b".to_string(), "commit-head".to_string(), 0)]
1308
+ );
1309
+ assert_eq!(
1310
+ entity.change.snapshot_content.as_deref(),
1311
+ Some("{\"value\":\"b\"}")
1312
+ );
1313
+ }
1314
+
1315
+ #[tokio::test]
1316
+ async fn entities_at_head_tombstone_hides_parent_entity() {
1317
+ let backend = Arc::new(UnitTestBackend::new());
1318
+ let storage = StorageContext::new(backend.clone());
1319
+ let changelog = ChangelogContext::new();
1320
+ append_changes(
1321
+ storage.clone(),
1322
+ &changelog,
1323
+ &[
1324
+ entity_change(
1325
+ "change-created",
1326
+ "entity-1",
1327
+ "test_schema",
1328
+ "{\"value\":\"created\"}",
1329
+ ),
1330
+ entity_tombstone("change-deleted", "entity-1", "test_schema"),
1331
+ commit_change(
1332
+ "commit-root-change",
1333
+ "commit-root",
1334
+ &["change-created"],
1335
+ &[],
1336
+ ),
1337
+ commit_change(
1338
+ "commit-head-change",
1339
+ "commit-head",
1340
+ &["change-deleted"],
1341
+ &["commit-root"],
1342
+ ),
1343
+ ],
1344
+ )
1345
+ .await;
1346
+
1347
+ let graph = CommitGraphContext::new(changelog);
1348
+ let mut reader = graph.reader(storage);
1349
+ let entities = reader
1350
+ .entities_at("commit-head")
1351
+ .await
1352
+ .expect("entities should resolve");
1353
+ let entity = entities
1354
+ .iter()
1355
+ .find(|entity| entity.change.schema_key == "test_schema")
1356
+ .expect("tombstone entity should resolve");
1357
+
1358
+ assert_eq!(
1359
+ entity_ids_for_schema(&entities, "test_schema"),
1360
+ vec![("change-deleted".to_string(), "commit-head".to_string(), 0)]
1361
+ );
1362
+ assert_eq!(entity.change.snapshot_content, None);
1363
+ }
1364
+
1365
+ #[tokio::test]
1366
+ async fn entities_at_includes_reachable_commit_rows() {
1367
+ let backend = Arc::new(UnitTestBackend::new());
1368
+ let storage = StorageContext::new(backend.clone());
1369
+ let changelog = ChangelogContext::new();
1370
+ append_changes(
1371
+ storage.clone(),
1372
+ &changelog,
1373
+ &[
1374
+ commit_change("commit-root-change", "commit-root", &[], &[]),
1375
+ commit_change("commit-head-change", "commit-head", &[], &["commit-root"]),
1376
+ ],
1377
+ )
1378
+ .await;
1379
+
1380
+ let graph = CommitGraphContext::new(changelog);
1381
+ let mut reader = graph.reader(storage);
1382
+ let entities = reader
1383
+ .entities_at("commit-head")
1384
+ .await
1385
+ .expect("entities should resolve");
1386
+
1387
+ assert_eq!(
1388
+ entity_ids_for_schema(&entities, super::COMMIT_SCHEMA_KEY),
1389
+ vec![
1390
+ (
1391
+ "commit-head-change".to_string(),
1392
+ "commit-head".to_string(),
1393
+ 0
1394
+ ),
1395
+ (
1396
+ "commit-root-change".to_string(),
1397
+ "commit-root".to_string(),
1398
+ 1
1399
+ ),
1400
+ ]
1401
+ );
1402
+ }
1403
+
1404
+ #[tokio::test]
1405
+ async fn entities_at_errors_on_missing_member_change() {
1406
+ let backend = Arc::new(UnitTestBackend::new());
1407
+ let storage = StorageContext::new(backend.clone());
1408
+ let changelog = ChangelogContext::new();
1409
+ append_changes(
1410
+ storage.clone(),
1411
+ &changelog,
1412
+ &[commit_change(
1413
+ "commit-head-change",
1414
+ "commit-head",
1415
+ &["missing-change"],
1416
+ &[],
1417
+ )],
1418
+ )
1419
+ .await;
1420
+
1421
+ let graph = CommitGraphContext::new(changelog);
1422
+ let mut reader = graph.reader(storage);
1423
+ let error = reader
1424
+ .entities_at("commit-head")
1425
+ .await
1426
+ .expect_err("missing member change should fail");
1427
+
1428
+ assert!(error.message.contains("missing-change"));
1429
+ }
1430
+
1431
+ async fn append_changes(
1432
+ storage: StorageContext,
1433
+ changelog: &ChangelogContext,
1434
+ changes: &[MaterializedCanonicalChange],
1435
+ ) {
1436
+ let mut tx = storage
1437
+ .begin_write_transaction()
1438
+ .await
1439
+ .expect("transaction should open");
1440
+ let mut writes = StorageWriteSet::new();
1441
+ let canonical_changes = {
1442
+ let mut json_writer = JsonStoreContext::new().writer();
1443
+ changes
1444
+ .iter()
1445
+ .map(|change| {
1446
+ canonicalize_materialized_change(&mut writes, &mut json_writer, change)
1447
+ })
1448
+ .collect::<Result<Vec<_>, _>>()
1449
+ .expect("changes should canonicalize")
1450
+ };
1451
+ changelog
1452
+ .writer(&mut writes)
1453
+ .stage_changes(&canonical_changes)
1454
+ .expect("append should succeed");
1455
+ writes
1456
+ .apply(&mut tx.as_mut())
1457
+ .await
1458
+ .expect("writes should apply");
1459
+ tx.commit().await.expect("commit should succeed");
1460
+ }
1461
+
1462
+ fn commit_change(
1463
+ change_id: &str,
1464
+ commit_id: &str,
1465
+ change_ids: &[&str],
1466
+ parent_commit_ids: &[&str],
1467
+ ) -> MaterializedCanonicalChange {
1468
+ MaterializedCanonicalChange {
1469
+ id: change_id.to_string(),
1470
+ entity_id: crate::entity_identity::EntityIdentity::single(commit_id),
1471
+ schema_key: super::COMMIT_SCHEMA_KEY.to_string(),
1472
+ schema_version: "1".to_string(),
1473
+ file_id: None,
1474
+ snapshot_content: Some(
1475
+ serde_json::to_string(&json!({
1476
+ "id": commit_id,
1477
+ "change_set_id": "change-set-1",
1478
+ "change_ids": change_ids,
1479
+ "parent_commit_ids": parent_commit_ids,
1480
+ }))
1481
+ .expect("snapshot should serialize"),
1482
+ ),
1483
+ metadata: None,
1484
+ created_at: "2026-01-01T00:00:00Z".to_string(),
1485
+ }
1486
+ }
1487
+
1488
+ fn parsed_commit(
1489
+ commit_id: &str,
1490
+ change_ids: &[&str],
1491
+ parent_commit_ids: &[&str],
1492
+ ) -> crate::commit_graph::CommitGraphCommit {
1493
+ super::parse_commit_change(commit_change(
1494
+ &format!("{commit_id}-change"),
1495
+ commit_id,
1496
+ change_ids,
1497
+ parent_commit_ids,
1498
+ ))
1499
+ .expect("commit helper should parse")
1500
+ }
1501
+
1502
+ fn entity_change(
1503
+ change_id: &str,
1504
+ entity_id: &str,
1505
+ schema_key: &str,
1506
+ snapshot_content: &str,
1507
+ ) -> MaterializedCanonicalChange {
1508
+ entity_change_at(
1509
+ change_id,
1510
+ entity_id,
1511
+ schema_key,
1512
+ snapshot_content,
1513
+ "2026-01-01T00:00:00Z",
1514
+ )
1515
+ }
1516
+
1517
+ fn entity_change_at(
1518
+ change_id: &str,
1519
+ entity_id: &str,
1520
+ schema_key: &str,
1521
+ snapshot_content: &str,
1522
+ created_at: &str,
1523
+ ) -> MaterializedCanonicalChange {
1524
+ MaterializedCanonicalChange {
1525
+ id: change_id.to_string(),
1526
+ entity_id: crate::entity_identity::EntityIdentity::single(entity_id),
1527
+ schema_key: schema_key.to_string(),
1528
+ schema_version: "1".to_string(),
1529
+ file_id: None,
1530
+ snapshot_content: Some(snapshot_content.to_string()),
1531
+ metadata: None,
1532
+ created_at: created_at.to_string(),
1533
+ }
1534
+ }
1535
+
1536
+ fn entity_change_with_file(
1537
+ change_id: &str,
1538
+ entity_id: &str,
1539
+ schema_key: &str,
1540
+ file_id: Option<&str>,
1541
+ snapshot_content: &str,
1542
+ ) -> MaterializedCanonicalChange {
1543
+ MaterializedCanonicalChange {
1544
+ id: change_id.to_string(),
1545
+ entity_id: crate::entity_identity::EntityIdentity::single(entity_id),
1546
+ schema_key: schema_key.to_string(),
1547
+ schema_version: "1".to_string(),
1548
+ file_id: file_id.map(str::to_string),
1549
+ snapshot_content: Some(snapshot_content.to_string()),
1550
+ metadata: None,
1551
+ created_at: "2026-01-01T00:00:00Z".to_string(),
1552
+ }
1553
+ }
1554
+
1555
+ fn entity_tombstone(
1556
+ change_id: &str,
1557
+ entity_id: &str,
1558
+ schema_key: &str,
1559
+ ) -> MaterializedCanonicalChange {
1560
+ MaterializedCanonicalChange {
1561
+ id: change_id.to_string(),
1562
+ entity_id: crate::entity_identity::EntityIdentity::single(entity_id),
1563
+ schema_key: schema_key.to_string(),
1564
+ schema_version: "1".to_string(),
1565
+ file_id: None,
1566
+ snapshot_content: None,
1567
+ metadata: None,
1568
+ created_at: "2026-01-02T00:00:00Z".to_string(),
1569
+ }
1570
+ }
1571
+
1572
+ fn entity_ids_for_schema(
1573
+ entities: &[crate::commit_graph::CommitGraphEntity],
1574
+ schema_key: &str,
1575
+ ) -> Vec<(String, String, u32)> {
1576
+ entities
1577
+ .iter()
1578
+ .filter(|entity| entity.change.schema_key == schema_key)
1579
+ .map(|entity| {
1580
+ (
1581
+ entity.change.id.clone(),
1582
+ entity.source_commit_id.clone(),
1583
+ entity.depth,
1584
+ )
1585
+ })
1586
+ .collect()
1587
+ }
1588
+ }