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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/SKILL.md +304 -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/generated/builtin-schemas.d.ts +87 -162
  7. package/dist/generated/builtin-schemas.js +139 -236
  8. package/dist/open-lix.d.ts +103 -14
  9. package/dist/open-lix.js +3 -0
  10. package/dist/sqlite/index.js +99 -22
  11. package/dist-engine-src/README.md +18 -0
  12. package/dist-engine-src/src/backend/kv.rs +358 -0
  13. package/dist-engine-src/src/backend/mod.rs +12 -0
  14. package/dist-engine-src/src/backend/testing.rs +658 -0
  15. package/dist-engine-src/src/backend/types.rs +96 -0
  16. package/dist-engine-src/src/binary_cas/chunking.rs +31 -0
  17. package/dist-engine-src/src/binary_cas/codec.rs +346 -0
  18. package/dist-engine-src/src/binary_cas/context.rs +139 -0
  19. package/dist-engine-src/src/binary_cas/kv.rs +1063 -0
  20. package/dist-engine-src/src/binary_cas/mod.rs +11 -0
  21. package/dist-engine-src/src/binary_cas/types.rs +121 -0
  22. package/dist-engine-src/src/catalog/context.rs +412 -0
  23. package/dist-engine-src/src/catalog/mod.rs +10 -0
  24. package/dist-engine-src/src/catalog/schema.rs +4 -0
  25. package/dist-engine-src/src/catalog/snapshot.rs +1114 -0
  26. package/dist-engine-src/src/cel/context.rs +86 -0
  27. package/dist-engine-src/src/cel/error.rs +19 -0
  28. package/dist-engine-src/src/cel/mod.rs +8 -0
  29. package/dist-engine-src/src/cel/provider.rs +9 -0
  30. package/dist-engine-src/src/cel/runtime.rs +167 -0
  31. package/dist-engine-src/src/cel/value.rs +50 -0
  32. package/dist-engine-src/src/commit_graph/context.rs +901 -0
  33. package/dist-engine-src/src/commit_graph/mod.rs +11 -0
  34. package/dist-engine-src/src/commit_graph/types.rs +109 -0
  35. package/dist-engine-src/src/commit_graph/walker.rs +756 -0
  36. package/dist-engine-src/src/commit_store/codec.rs +887 -0
  37. package/dist-engine-src/src/commit_store/context.rs +944 -0
  38. package/dist-engine-src/src/commit_store/materialization.rs +84 -0
  39. package/dist-engine-src/src/commit_store/mod.rs +16 -0
  40. package/dist-engine-src/src/commit_store/storage.rs +600 -0
  41. package/dist-engine-src/src/commit_store/types.rs +215 -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 +145 -0
  46. package/dist-engine-src/src/common/json_pointer.rs +67 -0
  47. package/dist-engine-src/src/common/metadata.rs +40 -0
  48. package/dist-engine-src/src/common/mod.rs +23 -0
  49. package/dist-engine-src/src/common/types.rs +105 -0
  50. package/dist-engine-src/src/common/wire.rs +222 -0
  51. package/dist-engine-src/src/domain.rs +324 -0
  52. package/dist-engine-src/src/engine.rs +225 -0
  53. package/dist-engine-src/src/entity_identity.rs +405 -0
  54. package/dist-engine-src/src/functions/context.rs +292 -0
  55. package/dist-engine-src/src/functions/deterministic.rs +113 -0
  56. package/dist-engine-src/src/functions/mod.rs +18 -0
  57. package/dist-engine-src/src/functions/provider.rs +130 -0
  58. package/dist-engine-src/src/functions/state.rs +336 -0
  59. package/dist-engine-src/src/functions/types.rs +37 -0
  60. package/dist-engine-src/src/init.rs +558 -0
  61. package/dist-engine-src/src/json_store/compression.rs +77 -0
  62. package/dist-engine-src/src/json_store/context.rs +423 -0
  63. package/dist-engine-src/src/json_store/encoded.rs +15 -0
  64. package/dist-engine-src/src/json_store/mod.rs +12 -0
  65. package/dist-engine-src/src/json_store/store.rs +1109 -0
  66. package/dist-engine-src/src/json_store/types.rs +217 -0
  67. package/dist-engine-src/src/lib.rs +62 -0
  68. package/dist-engine-src/src/live_state/context.rs +2019 -0
  69. package/dist-engine-src/src/live_state/mod.rs +15 -0
  70. package/dist-engine-src/src/live_state/overlay.rs +75 -0
  71. package/dist-engine-src/src/live_state/reader.rs +23 -0
  72. package/dist-engine-src/src/live_state/types.rs +222 -0
  73. package/dist-engine-src/src/live_state/visibility.rs +223 -0
  74. package/dist-engine-src/src/plugin/archive.rs +438 -0
  75. package/dist-engine-src/src/plugin/component.rs +183 -0
  76. package/dist-engine-src/src/plugin/install.rs +619 -0
  77. package/dist-engine-src/src/plugin/manifest.rs +516 -0
  78. package/dist-engine-src/src/plugin/materializer.rs +477 -0
  79. package/dist-engine-src/src/plugin/mod.rs +33 -0
  80. package/dist-engine-src/src/plugin/plugin_manifest.json +118 -0
  81. package/dist-engine-src/src/plugin/storage.rs +74 -0
  82. package/dist-engine-src/src/schema/annotations/defaults.rs +275 -0
  83. package/dist-engine-src/src/schema/annotations/mod.rs +1 -0
  84. package/dist-engine-src/src/schema/builtin/lix_account.json +21 -0
  85. package/dist-engine-src/src/schema/builtin/lix_active_account.json +29 -0
  86. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +29 -0
  87. package/dist-engine-src/src/schema/builtin/lix_change.json +63 -0
  88. package/dist-engine-src/src/schema/builtin/lix_change_author.json +45 -0
  89. package/dist-engine-src/src/schema/builtin/lix_commit.json +24 -0
  90. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +53 -0
  91. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +52 -0
  92. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +52 -0
  93. package/dist-engine-src/src/schema/builtin/lix_key_value.json +40 -0
  94. package/dist-engine-src/src/schema/builtin/lix_label.json +29 -0
  95. package/dist-engine-src/src/schema/builtin/lix_label_assignment.json +74 -0
  96. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +25 -0
  97. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +34 -0
  98. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +48 -0
  99. package/dist-engine-src/src/schema/builtin/mod.rs +222 -0
  100. package/dist-engine-src/src/schema/compatibility.rs +787 -0
  101. package/dist-engine-src/src/schema/definition.json +187 -0
  102. package/dist-engine-src/src/schema/definition.rs +742 -0
  103. package/dist-engine-src/src/schema/key.rs +138 -0
  104. package/dist-engine-src/src/schema/mod.rs +20 -0
  105. package/dist-engine-src/src/schema/seed.rs +14 -0
  106. package/dist-engine-src/src/schema/tests.rs +780 -0
  107. package/dist-engine-src/src/session/context.rs +364 -0
  108. package/dist-engine-src/src/session/create_version.rs +88 -0
  109. package/dist-engine-src/src/session/execute.rs +478 -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 +63 -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 +427 -0
  116. package/dist-engine-src/src/session/mod.rs +27 -0
  117. package/dist-engine-src/src/session/optimization9_sql2_bench.rs +100 -0
  118. package/dist-engine-src/src/session/switch_version.rs +109 -0
  119. package/dist-engine-src/src/sql2/change_provider.rs +331 -0
  120. package/dist-engine-src/src/sql2/classify.rs +182 -0
  121. package/dist-engine-src/src/sql2/context.rs +311 -0
  122. package/dist-engine-src/src/sql2/directory_history_provider.rs +631 -0
  123. package/dist-engine-src/src/sql2/directory_provider.rs +2453 -0
  124. package/dist-engine-src/src/sql2/dml.rs +148 -0
  125. package/dist-engine-src/src/sql2/entity_history_provider.rs +440 -0
  126. package/dist-engine-src/src/sql2/entity_provider.rs +3211 -0
  127. package/dist-engine-src/src/sql2/error.rs +216 -0
  128. package/dist-engine-src/src/sql2/execute.rs +3440 -0
  129. package/dist-engine-src/src/sql2/file_history_provider.rs +910 -0
  130. package/dist-engine-src/src/sql2/file_provider.rs +3679 -0
  131. package/dist-engine-src/src/sql2/filesystem_planner.rs +1490 -0
  132. package/dist-engine-src/src/sql2/filesystem_predicates.rs +159 -0
  133. package/dist-engine-src/src/sql2/filesystem_visibility.rs +383 -0
  134. package/dist-engine-src/src/sql2/history_projection.rs +56 -0
  135. package/dist-engine-src/src/sql2/history_provider.rs +412 -0
  136. package/dist-engine-src/src/sql2/history_route.rs +657 -0
  137. package/dist-engine-src/src/sql2/lix_state_provider.rs +2512 -0
  138. package/dist-engine-src/src/sql2/mod.rs +46 -0
  139. package/dist-engine-src/src/sql2/predicate_typecheck.rs +246 -0
  140. package/dist-engine-src/src/sql2/public_bind/assignment.rs +46 -0
  141. package/dist-engine-src/src/sql2/public_bind/capability.rs +41 -0
  142. package/dist-engine-src/src/sql2/public_bind/dml.rs +166 -0
  143. package/dist-engine-src/src/sql2/public_bind/mod.rs +25 -0
  144. package/dist-engine-src/src/sql2/public_bind/table.rs +168 -0
  145. package/dist-engine-src/src/sql2/read_only.rs +63 -0
  146. package/dist-engine-src/src/sql2/record_batch.rs +17 -0
  147. package/dist-engine-src/src/sql2/result_metadata.rs +29 -0
  148. package/dist-engine-src/src/sql2/runtime.rs +60 -0
  149. package/dist-engine-src/src/sql2/session.rs +132 -0
  150. package/dist-engine-src/src/sql2/udfs/common.rs +295 -0
  151. package/dist-engine-src/src/sql2/udfs/lix_active_version_commit_id.rs +53 -0
  152. package/dist-engine-src/src/sql2/udfs/lix_empty_blob.rs +47 -0
  153. package/dist-engine-src/src/sql2/udfs/lix_json.rs +100 -0
  154. package/dist-engine-src/src/sql2/udfs/lix_json_get.rs +99 -0
  155. package/dist-engine-src/src/sql2/udfs/lix_json_get_text.rs +99 -0
  156. package/dist-engine-src/src/sql2/udfs/lix_text_decode.rs +82 -0
  157. package/dist-engine-src/src/sql2/udfs/lix_text_encode.rs +85 -0
  158. package/dist-engine-src/src/sql2/udfs/lix_timestamp.rs +76 -0
  159. package/dist-engine-src/src/sql2/udfs/lix_uuid_v7.rs +76 -0
  160. package/dist-engine-src/src/sql2/udfs/mod.rs +89 -0
  161. package/dist-engine-src/src/sql2/udfs/public_call.rs +211 -0
  162. package/dist-engine-src/src/sql2/version_provider.rs +1202 -0
  163. package/dist-engine-src/src/sql2/version_scope.rs +394 -0
  164. package/dist-engine-src/src/sql2/write_normalization.rs +345 -0
  165. package/dist-engine-src/src/storage/context.rs +356 -0
  166. package/dist-engine-src/src/storage/mod.rs +14 -0
  167. package/dist-engine-src/src/storage/read_scope.rs +88 -0
  168. package/dist-engine-src/src/storage/types.rs +501 -0
  169. package/dist-engine-src/src/storage_bench.rs +4863 -0
  170. package/dist-engine-src/src/test_support.rs +228 -0
  171. package/dist-engine-src/src/tracked_state/by_file_index.rs +98 -0
  172. package/dist-engine-src/src/tracked_state/codec.rs +2085 -0
  173. package/dist-engine-src/src/tracked_state/context.rs +1867 -0
  174. package/dist-engine-src/src/tracked_state/diff.rs +686 -0
  175. package/dist-engine-src/src/tracked_state/materialization.rs +403 -0
  176. package/dist-engine-src/src/tracked_state/materializer.rs +488 -0
  177. package/dist-engine-src/src/tracked_state/merge.rs +492 -0
  178. package/dist-engine-src/src/tracked_state/mod.rs +32 -0
  179. package/dist-engine-src/src/tracked_state/storage.rs +375 -0
  180. package/dist-engine-src/src/tracked_state/tree.rs +3187 -0
  181. package/dist-engine-src/src/tracked_state/types.rs +231 -0
  182. package/dist-engine-src/src/transaction/commit.rs +1484 -0
  183. package/dist-engine-src/src/transaction/context.rs +1548 -0
  184. package/dist-engine-src/src/transaction/live_state_overlay.rs +35 -0
  185. package/dist-engine-src/src/transaction/mod.rs +13 -0
  186. package/dist-engine-src/src/transaction/normalization.rs +890 -0
  187. package/dist-engine-src/src/transaction/prep.rs +37 -0
  188. package/dist-engine-src/src/transaction/schema_resolver.rs +149 -0
  189. package/dist-engine-src/src/transaction/staging.rs +1731 -0
  190. package/dist-engine-src/src/transaction/types.rs +460 -0
  191. package/dist-engine-src/src/transaction/validation.rs +5830 -0
  192. package/dist-engine-src/src/untracked_state/codec.rs +307 -0
  193. package/dist-engine-src/src/untracked_state/context.rs +98 -0
  194. package/dist-engine-src/src/untracked_state/materialization.rs +63 -0
  195. package/dist-engine-src/src/untracked_state/mod.rs +15 -0
  196. package/dist-engine-src/src/untracked_state/storage.rs +396 -0
  197. package/dist-engine-src/src/untracked_state/types.rs +146 -0
  198. package/dist-engine-src/src/version/context.rs +40 -0
  199. package/dist-engine-src/src/version/lifecycle.rs +221 -0
  200. package/dist-engine-src/src/version/mod.rs +13 -0
  201. package/dist-engine-src/src/version/refs.rs +330 -0
  202. package/dist-engine-src/src/version/stage_rows.rs +67 -0
  203. package/dist-engine-src/src/version/types.rs +21 -0
  204. package/dist-engine-src/src/wasm/mod.rs +60 -0
  205. package/package.json +68 -64
@@ -0,0 +1,756 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+
3
+ use crate::commit_graph::{CommitGraphCommit, CommitGraphStoreReader, ReachableCommitGraphCommit};
4
+ use crate::storage::StorageReader;
5
+ use crate::LixError;
6
+
7
+ /// Walks parent links from `head_commit_id` and returns reachable commits
8
+ /// nearest-first.
9
+ ///
10
+ /// The walker is intentionally storage-free. It asks `CommitGraphReader` to
11
+ /// load parsed commit facts and owns only traversal concerns: caching, cycle
12
+ /// detection, and nearest-depth selection.
13
+ pub(crate) async fn walk_reachable_commits<S>(
14
+ reader: &mut CommitGraphStoreReader<S>,
15
+ head_commit_id: &str,
16
+ ) -> Result<Vec<ReachableCommitGraphCommit>, LixError>
17
+ where
18
+ S: StorageReader,
19
+ {
20
+ let mut loader = CommitTraversalLoader::new(reader);
21
+ let mut visiting = BTreeSet::new();
22
+ let mut nearest_depths = BTreeMap::new();
23
+ loader
24
+ .walk_commit(head_commit_id, 0, &mut visiting, &mut nearest_depths)
25
+ .await?;
26
+
27
+ let mut commits = nearest_depths
28
+ .into_iter()
29
+ .map(|(commit_id, depth)| {
30
+ let commit = loader
31
+ .loaded
32
+ .remove(&commit_id)
33
+ .expect("visited commit should be cached");
34
+ ReachableCommitGraphCommit { commit, depth }
35
+ })
36
+ .collect::<Vec<_>>();
37
+ commits.sort_by(|left, right| {
38
+ left.depth
39
+ .cmp(&right.depth)
40
+ .then_with(|| left.commit.commit_id.cmp(&right.commit.commit_id))
41
+ });
42
+ Ok(commits)
43
+ }
44
+
45
+ /// Returns the best common ancestors shared by two commit heads.
46
+ ///
47
+ /// This is graph math, not merge policy. A commit is "best" when it is a
48
+ /// common ancestor and no descendant of it is also a common ancestor.
49
+ ///
50
+ /// Simple history has one best common ancestor:
51
+ ///
52
+ /// ```text
53
+ /// A -- B -- C left
54
+ /// \
55
+ /// D right
56
+ /// ```
57
+ ///
58
+ /// `best_common_ancestors(C, D)` returns `[B]`.
59
+ ///
60
+ /// Commit history is a DAG, not a tree, so criss-cross histories can have
61
+ /// multiple equally good answers. Callers that need one merge base should wrap
62
+ /// this API with an explicit policy instead of pretending the graph always has
63
+ /// a single lowest common ancestor.
64
+ pub(crate) async fn best_common_ancestors<S>(
65
+ reader: &mut CommitGraphStoreReader<S>,
66
+ left_commit_id: &str,
67
+ right_commit_id: &str,
68
+ ) -> Result<Vec<CommitGraphCommit>, LixError>
69
+ where
70
+ S: StorageReader,
71
+ {
72
+ let left_reachable = walk_reachable_commits(reader, left_commit_id).await?;
73
+ let right_reachable = walk_reachable_commits(reader, right_commit_id).await?;
74
+ let right_ids = right_reachable
75
+ .iter()
76
+ .map(|reachable| reachable.commit.commit_id.clone())
77
+ .collect::<BTreeSet<_>>();
78
+ let common_ids = left_reachable
79
+ .iter()
80
+ .filter(|reachable| right_ids.contains(&reachable.commit.commit_id))
81
+ .map(|reachable| reachable.commit.commit_id.clone())
82
+ .collect::<BTreeSet<_>>();
83
+
84
+ let mut best = Vec::new();
85
+ for reachable in left_reachable {
86
+ let commit_id = &reachable.commit.commit_id;
87
+ if !common_ids.contains(commit_id) {
88
+ continue;
89
+ }
90
+
91
+ if has_descendant_in_set(reader, commit_id, &common_ids).await? {
92
+ continue;
93
+ }
94
+
95
+ best.push(reachable.commit);
96
+ }
97
+ best.sort_by(|left, right| left.commit_id.cmp(&right.commit_id));
98
+ Ok(best)
99
+ }
100
+
101
+ async fn has_descendant_in_set<S>(
102
+ reader: &mut CommitGraphStoreReader<S>,
103
+ commit_id: &str,
104
+ candidate_descendant_ids: &BTreeSet<String>,
105
+ ) -> Result<bool, LixError>
106
+ where
107
+ S: StorageReader,
108
+ {
109
+ for candidate_descendant_id in candidate_descendant_ids {
110
+ if candidate_descendant_id == commit_id {
111
+ continue;
112
+ }
113
+ let reachable = walk_reachable_commits(reader, candidate_descendant_id).await?;
114
+ if reachable
115
+ .iter()
116
+ .any(|reachable| reachable.commit.commit_id == commit_id)
117
+ {
118
+ return Ok(true);
119
+ }
120
+ }
121
+ Ok(false)
122
+ }
123
+
124
+ struct CommitTraversalLoader<'a, S>
125
+ where
126
+ S: StorageReader,
127
+ {
128
+ reader: &'a mut CommitGraphStoreReader<S>,
129
+ loaded: BTreeMap<String, CommitGraphCommit>,
130
+ }
131
+
132
+ impl<'a, S> CommitTraversalLoader<'a, S>
133
+ where
134
+ S: StorageReader,
135
+ {
136
+ fn new(reader: &'a mut CommitGraphStoreReader<S>) -> Self {
137
+ Self {
138
+ reader,
139
+ loaded: BTreeMap::new(),
140
+ }
141
+ }
142
+
143
+ async fn walk_commit(
144
+ &mut self,
145
+ commit_id: &str,
146
+ depth: u32,
147
+ visiting: &mut BTreeSet<String>,
148
+ nearest_depths: &mut BTreeMap<String, u32>,
149
+ ) -> Result<(), LixError> {
150
+ let mut stack = vec![TraversalFrame {
151
+ commit_id: commit_id.to_string(),
152
+ depth,
153
+ expanded: false,
154
+ }];
155
+
156
+ while let Some(frame) = stack.pop() {
157
+ if frame.expanded {
158
+ visiting.remove(&frame.commit_id);
159
+ continue;
160
+ }
161
+
162
+ if visiting.contains(&frame.commit_id) {
163
+ return Err(LixError::new(
164
+ "LIX_ERROR_UNKNOWN",
165
+ format!(
166
+ "commit_graph cycle detected at commit '{}'",
167
+ frame.commit_id
168
+ ),
169
+ ));
170
+ }
171
+
172
+ if let Some(previous_depth) = nearest_depths.get(&frame.commit_id) {
173
+ if *previous_depth <= frame.depth {
174
+ continue;
175
+ }
176
+ }
177
+
178
+ let commit = self.load_commit(&frame.commit_id).await?;
179
+ nearest_depths.insert(frame.commit_id.clone(), frame.depth);
180
+
181
+ visiting.insert(frame.commit_id.clone());
182
+ stack.push(TraversalFrame {
183
+ commit_id: frame.commit_id,
184
+ depth: frame.depth,
185
+ expanded: true,
186
+ });
187
+ for parent_commit_id in commit.parent_commit_ids.iter().rev() {
188
+ stack.push(TraversalFrame {
189
+ commit_id: parent_commit_id.clone(),
190
+ depth: frame.depth + 1,
191
+ expanded: false,
192
+ });
193
+ }
194
+ }
195
+ Ok(())
196
+ }
197
+
198
+ async fn load_commit(&mut self, commit_id: &str) -> Result<CommitGraphCommit, LixError> {
199
+ if let Some(commit) = self.loaded.get(commit_id) {
200
+ return Ok(commit.clone());
201
+ }
202
+ let Some(commit) = self.reader.load_commit(commit_id).await? else {
203
+ return Err(LixError::new(
204
+ "LIX_ERROR_UNKNOWN",
205
+ format!("commit_graph missing commit '{commit_id}'"),
206
+ ));
207
+ };
208
+ self.loaded.insert(commit_id.to_string(), commit.clone());
209
+ Ok(commit)
210
+ }
211
+ }
212
+
213
+ struct TraversalFrame {
214
+ commit_id: String,
215
+ depth: u32,
216
+ expanded: bool,
217
+ }
218
+
219
+ #[cfg(test)]
220
+ mod tests {
221
+ use std::sync::Arc;
222
+
223
+ use serde_json::json;
224
+
225
+ use crate::backend::testing::UnitTestBackend;
226
+ use crate::commit_graph::CommitGraphContext;
227
+ use crate::commit_store::{Change, CommitDraftRef, CommitStoreContext};
228
+ use crate::storage::{StorageContext, StorageWriteSet};
229
+ use crate::LixError;
230
+
231
+ #[tokio::test]
232
+ async fn reachable_commits_returns_commits_nearest_first() {
233
+ let backend = Arc::new(UnitTestBackend::new());
234
+ let storage = StorageContext::new(backend.clone());
235
+ append_changes(
236
+ storage.clone(),
237
+ &[
238
+ commit_change("commit-root-change", "commit-root", &[], &[]),
239
+ commit_change(
240
+ "commit-parent-change",
241
+ "commit-parent",
242
+ &[],
243
+ &["commit-root"],
244
+ ),
245
+ commit_change("commit-head-change", "commit-head", &[], &["commit-parent"]),
246
+ ],
247
+ )
248
+ .await;
249
+
250
+ let graph = CommitGraphContext::new();
251
+ let mut reader = graph.reader(storage);
252
+ let commits = reader
253
+ .reachable_commits("commit-head")
254
+ .await
255
+ .expect("reachable commits should load");
256
+
257
+ assert_eq!(
258
+ commits
259
+ .iter()
260
+ .map(|reachable| (reachable.commit.commit_id.as_str(), reachable.depth))
261
+ .collect::<Vec<_>>(),
262
+ vec![("commit-head", 0), ("commit-parent", 1), ("commit-root", 2)]
263
+ );
264
+ }
265
+
266
+ #[tokio::test]
267
+ async fn reachable_commits_errors_on_missing_parent_commit() {
268
+ let backend = Arc::new(UnitTestBackend::new());
269
+ let storage = StorageContext::new(backend.clone());
270
+ append_changes(
271
+ storage.clone(),
272
+ &[commit_change(
273
+ "commit-head-change",
274
+ "commit-head",
275
+ &[],
276
+ &["missing-parent"],
277
+ )],
278
+ )
279
+ .await;
280
+
281
+ let graph = CommitGraphContext::new();
282
+ let mut reader = graph.reader(storage);
283
+ let error = reader
284
+ .reachable_commits("commit-head")
285
+ .await
286
+ .expect_err("missing parent should fail");
287
+
288
+ assert!(error.message.contains("missing-parent"));
289
+ }
290
+
291
+ #[tokio::test]
292
+ async fn reachable_commits_errors_on_cycle() {
293
+ let backend = Arc::new(UnitTestBackend::new());
294
+ let storage = StorageContext::new(backend.clone());
295
+ append_changes(
296
+ storage.clone(),
297
+ &[
298
+ commit_change("commit-a-change", "commit-a", &[], &["commit-b"]),
299
+ commit_change("commit-b-change", "commit-b", &[], &["commit-a"]),
300
+ ],
301
+ )
302
+ .await;
303
+
304
+ let graph = CommitGraphContext::new();
305
+ let mut reader = graph.reader(storage);
306
+ let error = reader
307
+ .reachable_commits("commit-a")
308
+ .await
309
+ .expect_err("cycle should fail");
310
+
311
+ assert!(error.message.contains("cycle"));
312
+ }
313
+
314
+ #[tokio::test]
315
+ async fn reachable_commits_dedupes_shared_ancestors_in_diamond() {
316
+ let backend = Arc::new(UnitTestBackend::new());
317
+ let storage = StorageContext::new(backend.clone());
318
+ append_changes(
319
+ storage.clone(),
320
+ &[
321
+ commit_change("commit-root-change", "commit-root", &[], &[]),
322
+ commit_change("commit-left-change", "commit-left", &[], &["commit-root"]),
323
+ commit_change("commit-right-change", "commit-right", &[], &["commit-root"]),
324
+ commit_change(
325
+ "commit-head-change",
326
+ "commit-head",
327
+ &[],
328
+ &["commit-left", "commit-right"],
329
+ ),
330
+ ],
331
+ )
332
+ .await;
333
+
334
+ let graph = CommitGraphContext::new();
335
+ let mut reader = graph.reader(storage);
336
+ let commits = reader
337
+ .reachable_commits("commit-head")
338
+ .await
339
+ .expect("reachable commits should load");
340
+
341
+ assert_eq!(
342
+ commits
343
+ .iter()
344
+ .map(|reachable| (reachable.commit.commit_id.as_str(), reachable.depth))
345
+ .collect::<Vec<_>>(),
346
+ vec![
347
+ ("commit-head", 0),
348
+ ("commit-left", 1),
349
+ ("commit-right", 1),
350
+ ("commit-root", 2),
351
+ ]
352
+ );
353
+ }
354
+
355
+ #[tokio::test]
356
+ async fn reachable_commits_keeps_nearest_depth_for_multiple_paths() {
357
+ let backend = Arc::new(UnitTestBackend::new());
358
+ let storage = StorageContext::new(backend.clone());
359
+ append_changes(
360
+ storage.clone(),
361
+ &[
362
+ commit_change("commit-root-change", "commit-root", &[], &[]),
363
+ commit_change(
364
+ "commit-parent-change",
365
+ "commit-parent",
366
+ &[],
367
+ &["commit-root"],
368
+ ),
369
+ commit_change(
370
+ "commit-head-change",
371
+ "commit-head",
372
+ &[],
373
+ &["commit-root", "commit-parent"],
374
+ ),
375
+ ],
376
+ )
377
+ .await;
378
+
379
+ let graph = CommitGraphContext::new();
380
+ let mut reader = graph.reader(storage);
381
+ let commits = reader
382
+ .reachable_commits("commit-head")
383
+ .await
384
+ .expect("reachable commits should load");
385
+
386
+ assert_eq!(
387
+ commits
388
+ .iter()
389
+ .map(|reachable| (reachable.commit.commit_id.as_str(), reachable.depth))
390
+ .collect::<Vec<_>>(),
391
+ vec![("commit-head", 0), ("commit-parent", 1), ("commit-root", 1)]
392
+ );
393
+ }
394
+
395
+ #[tokio::test]
396
+ async fn reachable_commits_orders_same_depth_commits_by_id() {
397
+ let backend = Arc::new(UnitTestBackend::new());
398
+ let storage = StorageContext::new(backend.clone());
399
+ append_changes(
400
+ storage.clone(),
401
+ &[
402
+ commit_change("commit-z-change", "commit-z", &[], &[]),
403
+ commit_change("commit-a-change", "commit-a", &[], &[]),
404
+ commit_change(
405
+ "commit-head-change",
406
+ "commit-head",
407
+ &[],
408
+ &["commit-z", "commit-a"],
409
+ ),
410
+ ],
411
+ )
412
+ .await;
413
+
414
+ let graph = CommitGraphContext::new();
415
+ let mut reader = graph.reader(storage);
416
+ let commits = reader
417
+ .reachable_commits("commit-head")
418
+ .await
419
+ .expect("reachable commits should load");
420
+
421
+ assert_eq!(
422
+ commits
423
+ .iter()
424
+ .map(|reachable| (reachable.commit.commit_id.as_str(), reachable.depth))
425
+ .collect::<Vec<_>>(),
426
+ vec![("commit-head", 0), ("commit-a", 1), ("commit-z", 1)]
427
+ );
428
+ }
429
+
430
+ #[tokio::test]
431
+ async fn reachable_commits_errors_on_missing_head_commit() {
432
+ let backend = Arc::new(UnitTestBackend::new());
433
+ let storage = StorageContext::new(backend.clone());
434
+ let graph = CommitGraphContext::new();
435
+ let mut reader = graph.reader(storage);
436
+
437
+ let error = reader
438
+ .reachable_commits("missing-head")
439
+ .await
440
+ .expect_err("missing head should fail");
441
+
442
+ assert!(error.message.contains("missing-head"));
443
+ }
444
+
445
+ #[tokio::test]
446
+ async fn best_common_ancestors_returns_nearest_common_commit_in_simple_graph() {
447
+ let backend = Arc::new(UnitTestBackend::new());
448
+ let storage = StorageContext::new(backend.clone());
449
+ append_changes(
450
+ storage.clone(),
451
+ &[
452
+ commit_change("commit-a-change", "commit-a", &[], &[]),
453
+ commit_change("commit-b-change", "commit-b", &[], &["commit-a"]),
454
+ commit_change("commit-c-change", "commit-c", &[], &["commit-b"]),
455
+ commit_change("commit-d-change", "commit-d", &[], &["commit-b"]),
456
+ ],
457
+ )
458
+ .await;
459
+
460
+ let graph = CommitGraphContext::new();
461
+ let mut reader = graph.reader(storage);
462
+ let ancestors = reader
463
+ .best_common_ancestors("commit-c", "commit-d")
464
+ .await
465
+ .expect("best common ancestors should load");
466
+
467
+ assert_eq!(
468
+ ancestors
469
+ .iter()
470
+ .map(|commit| commit.commit_id.as_str())
471
+ .collect::<Vec<_>>(),
472
+ vec!["commit-b"]
473
+ );
474
+ }
475
+
476
+ #[tokio::test]
477
+ async fn best_common_ancestors_returns_shared_fork_in_diamond_graph() {
478
+ let backend = Arc::new(UnitTestBackend::new());
479
+ let storage = StorageContext::new(backend.clone());
480
+ append_changes(
481
+ storage.clone(),
482
+ &[
483
+ commit_change("commit-root-change", "commit-root", &[], &[]),
484
+ commit_change("commit-left-change", "commit-left", &[], &["commit-root"]),
485
+ commit_change("commit-right-change", "commit-right", &[], &["commit-root"]),
486
+ commit_change(
487
+ "commit-left-head-change",
488
+ "commit-left-head",
489
+ &[],
490
+ &["commit-left"],
491
+ ),
492
+ commit_change(
493
+ "commit-right-head-change",
494
+ "commit-right-head",
495
+ &[],
496
+ &["commit-right"],
497
+ ),
498
+ ],
499
+ )
500
+ .await;
501
+
502
+ let graph = CommitGraphContext::new();
503
+ let mut reader = graph.reader(storage);
504
+ let ancestors = reader
505
+ .best_common_ancestors("commit-left-head", "commit-right-head")
506
+ .await
507
+ .expect("best common ancestors should load");
508
+
509
+ assert_eq!(
510
+ ancestors
511
+ .iter()
512
+ .map(|commit| commit.commit_id.as_str())
513
+ .collect::<Vec<_>>(),
514
+ vec!["commit-root"]
515
+ );
516
+ }
517
+
518
+ #[tokio::test]
519
+ async fn best_common_ancestors_returns_parent_when_one_side_is_ancestor() {
520
+ let backend = Arc::new(UnitTestBackend::new());
521
+ let storage = StorageContext::new(backend.clone());
522
+ append_changes(
523
+ storage.clone(),
524
+ &[
525
+ commit_change("commit-a-change", "commit-a", &[], &[]),
526
+ commit_change("commit-b-change", "commit-b", &[], &["commit-a"]),
527
+ commit_change("commit-c-change", "commit-c", &[], &["commit-b"]),
528
+ ],
529
+ )
530
+ .await;
531
+
532
+ let graph = CommitGraphContext::new();
533
+ let mut reader = graph.reader(storage);
534
+ let ancestors = reader
535
+ .best_common_ancestors("commit-b", "commit-c")
536
+ .await
537
+ .expect("best common ancestors should load");
538
+
539
+ assert_eq!(
540
+ ancestors
541
+ .iter()
542
+ .map(|commit| commit.commit_id.as_str())
543
+ .collect::<Vec<_>>(),
544
+ vec!["commit-b"]
545
+ );
546
+ }
547
+
548
+ #[tokio::test]
549
+ async fn best_common_ancestors_returns_multiple_bases_for_criss_cross_graph() {
550
+ let backend = Arc::new(UnitTestBackend::new());
551
+ let storage = StorageContext::new(backend.clone());
552
+ append_changes(
553
+ storage.clone(),
554
+ &[
555
+ commit_change("commit-root-change", "commit-root", &[], &[]),
556
+ commit_change("commit-left-change", "commit-left", &[], &["commit-root"]),
557
+ commit_change("commit-right-change", "commit-right", &[], &["commit-root"]),
558
+ commit_change(
559
+ "commit-head-left-change",
560
+ "commit-head-left",
561
+ &[],
562
+ &["commit-left", "commit-right"],
563
+ ),
564
+ commit_change(
565
+ "commit-head-right-change",
566
+ "commit-head-right",
567
+ &[],
568
+ &["commit-right", "commit-left"],
569
+ ),
570
+ ],
571
+ )
572
+ .await;
573
+
574
+ let graph = CommitGraphContext::new();
575
+ let mut reader = graph.reader(storage);
576
+ let ancestors = reader
577
+ .best_common_ancestors("commit-head-left", "commit-head-right")
578
+ .await
579
+ .expect("best common ancestors should load");
580
+
581
+ assert_eq!(
582
+ ancestors
583
+ .iter()
584
+ .map(|commit| commit.commit_id.as_str())
585
+ .collect::<Vec<_>>(),
586
+ vec!["commit-left", "commit-right"]
587
+ );
588
+ }
589
+
590
+ #[tokio::test]
591
+ async fn merge_base_returns_single_best_common_ancestor() {
592
+ let backend = Arc::new(UnitTestBackend::new());
593
+ let storage = StorageContext::new(backend.clone());
594
+ append_changes(
595
+ storage.clone(),
596
+ &[
597
+ commit_change("commit-a-change", "commit-a", &[], &[]),
598
+ commit_change("commit-b-change", "commit-b", &[], &["commit-a"]),
599
+ commit_change("commit-c-change", "commit-c", &[], &["commit-b"]),
600
+ commit_change("commit-d-change", "commit-d", &[], &["commit-b"]),
601
+ ],
602
+ )
603
+ .await;
604
+
605
+ let graph = CommitGraphContext::new();
606
+ let mut reader = graph.reader(storage);
607
+ let base = reader
608
+ .merge_base("commit-c", "commit-d")
609
+ .await
610
+ .expect("single merge base should resolve");
611
+
612
+ assert_eq!(base.commit_id, "commit-b");
613
+ }
614
+
615
+ #[tokio::test]
616
+ async fn merge_base_errors_when_histories_have_no_common_commit() {
617
+ let backend = Arc::new(UnitTestBackend::new());
618
+ let storage = StorageContext::new(backend.clone());
619
+ append_changes(
620
+ storage.clone(),
621
+ &[
622
+ commit_change("commit-left-change", "commit-left", &[], &[]),
623
+ commit_change("commit-right-change", "commit-right", &[], &[]),
624
+ ],
625
+ )
626
+ .await;
627
+
628
+ let graph = CommitGraphContext::new();
629
+ let mut reader = graph.reader(storage);
630
+ let error = reader
631
+ .merge_base("commit-left", "commit-right")
632
+ .await
633
+ .expect_err("unrelated histories should not have a merge base");
634
+
635
+ assert!(error.message.contains("no common history"));
636
+ }
637
+
638
+ #[tokio::test]
639
+ async fn merge_base_errors_when_best_common_ancestor_is_ambiguous() {
640
+ let backend = Arc::new(UnitTestBackend::new());
641
+ let storage = StorageContext::new(backend.clone());
642
+ append_changes(
643
+ storage.clone(),
644
+ &[
645
+ commit_change("commit-root-change", "commit-root", &[], &[]),
646
+ commit_change("commit-left-change", "commit-left", &[], &["commit-root"]),
647
+ commit_change("commit-right-change", "commit-right", &[], &["commit-root"]),
648
+ commit_change(
649
+ "commit-head-left-change",
650
+ "commit-head-left",
651
+ &[],
652
+ &["commit-left", "commit-right"],
653
+ ),
654
+ commit_change(
655
+ "commit-head-right-change",
656
+ "commit-head-right",
657
+ &[],
658
+ &["commit-right", "commit-left"],
659
+ ),
660
+ ],
661
+ )
662
+ .await;
663
+
664
+ let graph = CommitGraphContext::new();
665
+ let mut reader = graph.reader(storage);
666
+ let error = reader
667
+ .merge_base("commit-head-left", "commit-head-right")
668
+ .await
669
+ .expect_err("ambiguous best common ancestors should fail");
670
+
671
+ assert_eq!(error.code, LixError::CODE_AMBIGUOUS_MERGE_BASE);
672
+ assert_eq!(
673
+ error
674
+ .details
675
+ .as_ref()
676
+ .and_then(|details| details.get("left_commit_id")),
677
+ Some(&json!("commit-head-left"))
678
+ );
679
+ assert_eq!(
680
+ error
681
+ .details
682
+ .as_ref()
683
+ .and_then(|details| details.get("right_commit_id")),
684
+ Some(&json!("commit-head-right"))
685
+ );
686
+ assert_eq!(
687
+ error
688
+ .details
689
+ .as_ref()
690
+ .and_then(|details| details.get("candidates")),
691
+ Some(&json!(["commit-left", "commit-right"]))
692
+ );
693
+ }
694
+
695
+ #[derive(Clone)]
696
+ struct TestCommitChange {
697
+ change: Change,
698
+ parent_commit_ids: Vec<String>,
699
+ }
700
+
701
+ async fn append_changes(storage: StorageContext, changes: &[TestCommitChange]) {
702
+ let mut tx = storage
703
+ .begin_write_transaction()
704
+ .await
705
+ .expect("transaction should open");
706
+ let mut writes = StorageWriteSet::new();
707
+ let commit_store = CommitStoreContext::new();
708
+ for change in changes {
709
+ let commit_id = change
710
+ .change
711
+ .entity_id
712
+ .as_single_string()
713
+ .expect("commit fixture should have single id")
714
+ .to_string();
715
+ let author_account_ids = Vec::new();
716
+ let commit = CommitDraftRef {
717
+ id: &commit_id,
718
+ change_id: &change.change.id,
719
+ parent_ids: &change.parent_commit_ids,
720
+ author_account_ids: &author_account_ids,
721
+ created_at: &change.change.created_at,
722
+ };
723
+ commit_store
724
+ .writer(tx.as_mut(), &mut writes)
725
+ .stage_commit_draft(commit, Vec::new(), Vec::new())
726
+ .await
727
+ .expect("commit-store fixture should append");
728
+ }
729
+ writes
730
+ .apply(&mut tx.as_mut())
731
+ .await
732
+ .expect("writes should apply");
733
+ tx.commit().await.expect("commit should succeed");
734
+ }
735
+
736
+ fn commit_change(
737
+ change_id: &str,
738
+ commit_id: &str,
739
+ change_ids: &[&str],
740
+ parent_commit_ids: &[&str],
741
+ ) -> TestCommitChange {
742
+ let _ = change_ids;
743
+ TestCommitChange {
744
+ change: Change {
745
+ id: change_id.to_string(),
746
+ entity_id: crate::entity_identity::EntityIdentity::single(commit_id),
747
+ schema_key: "lix_commit".to_string(),
748
+ file_id: None,
749
+ snapshot_ref: None,
750
+ metadata_ref: None,
751
+ created_at: "2026-01-01T00:00:00Z".to_string(),
752
+ },
753
+ parent_commit_ids: parent_commit_ids.iter().map(|id| id.to_string()).collect(),
754
+ }
755
+ }
756
+ }