@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,901 @@
1
+ use std::collections::BTreeSet;
2
+
3
+ use crate::commit_graph::walker::{best_common_ancestors, walk_reachable_commits};
4
+ use crate::commit_graph::{
5
+ CommitGraphChangeHistoryEntry, CommitGraphChangeHistoryRequest, CommitGraphCommit,
6
+ CommitGraphEdge, CommitGraphReader, ReachableCommitGraphCommit,
7
+ };
8
+ use crate::commit_store::{Change, Commit, CommitStoreContext, CommitStoreReader, LocatedChange};
9
+ use crate::entity_identity::EntityIdentity;
10
+ use crate::storage::StorageReader;
11
+ use crate::storage::{ScopedStorageReader, StorageReadScope};
12
+ use crate::LixError;
13
+
14
+ const COMMIT_SCHEMA_KEY: &str = "lix_commit";
15
+
16
+ /// Read model for resolving commit-store commits into entity state at a head.
17
+ ///
18
+ /// This module does not own durable storage. It reads immutable commit-store
19
+ /// facts through a caller-provided KV store and applies commit graph rules on
20
+ /// top.
21
+ #[derive(Clone)]
22
+ pub(crate) struct CommitGraphContext {
23
+ commit_store: CommitStoreContext,
24
+ }
25
+
26
+ impl CommitGraphContext {
27
+ pub(crate) fn new() -> Self {
28
+ Self {
29
+ commit_store: CommitStoreContext::new(),
30
+ }
31
+ }
32
+
33
+ /// Creates a graph reader over a caller-provided KV store.
34
+ pub(crate) fn reader<S>(&self, store: S) -> CommitGraphStoreReader<S>
35
+ where
36
+ S: StorageReader,
37
+ {
38
+ let read_scope = StorageReadScope::new(store);
39
+ CommitGraphStoreReader {
40
+ commit_store_reader: self.commit_store.reader(read_scope.store()),
41
+ }
42
+ }
43
+ }
44
+
45
+ /// Commit-graph reader that resolves commit-store entities at a commit head.
46
+ pub(crate) struct CommitGraphStoreReader<S>
47
+ where
48
+ S: StorageReader,
49
+ {
50
+ commit_store_reader: CommitStoreReader<ScopedStorageReader<S>>,
51
+ }
52
+
53
+ impl<S> CommitGraphStoreReader<S>
54
+ where
55
+ S: StorageReader,
56
+ {
57
+ /// Loads and parses a `lix_commit` canonical change by commit id.
58
+ pub(crate) async fn load_commit(
59
+ &mut self,
60
+ commit_id: &str,
61
+ ) -> Result<Option<CommitGraphCommit>, LixError> {
62
+ let Some(commit) = self.commit_store_reader.load_commit(commit_id).await? else {
63
+ return Ok(None);
64
+ };
65
+ self.graph_commit_from_store_commit(commit).await.map(Some)
66
+ }
67
+
68
+ /// Loads every commit fact from the commit store.
69
+ ///
70
+ /// This is used by global commit surfaces where the caller wants the durable
71
+ /// graph facts themselves, not reachability from a particular version head.
72
+ pub(crate) async fn all_commits(&mut self) -> Result<Vec<CommitGraphCommit>, LixError> {
73
+ let stored_commits = self.commit_store_reader.scan_commits().await?;
74
+ let mut commits = Vec::new();
75
+ for commit in stored_commits {
76
+ commits.push(self.graph_commit_from_store_commit(commit).await?);
77
+ }
78
+ commits.sort_by(|left, right| left.commit_id.cmp(&right.commit_id));
79
+ Ok(commits)
80
+ }
81
+
82
+ /// Walks from `head_commit_id` through parent commits and records nearest depth.
83
+ pub(crate) async fn reachable_commits(
84
+ &mut self,
85
+ head_commit_id: &str,
86
+ ) -> Result<Vec<ReachableCommitGraphCommit>, LixError> {
87
+ walk_reachable_commits(self, head_commit_id).await
88
+ }
89
+
90
+ /// Returns the best common ancestors shared by two commit heads.
91
+ ///
92
+ /// This is the commit-DAG primitive. It can return more than one commit in
93
+ /// criss-cross histories. Merge code should layer an explicit merge-base
94
+ /// policy on top when it needs exactly one base for a three-way merge.
95
+ pub(crate) async fn best_common_ancestors(
96
+ &mut self,
97
+ left_commit_id: &str,
98
+ right_commit_id: &str,
99
+ ) -> Result<Vec<CommitGraphCommit>, LixError> {
100
+ best_common_ancestors(self, left_commit_id, right_commit_id).await
101
+ }
102
+
103
+ /// Resolves the single commit base to use for a three-way merge.
104
+ ///
105
+ /// This is merge policy layered over `best_common_ancestors(...)`. Histories
106
+ /// with no shared base or multiple equally good bases are rejected for now
107
+ /// so merge code cannot accidentally hide unsupported graph semantics.
108
+ pub(crate) async fn merge_base(
109
+ &mut self,
110
+ left_commit_id: &str,
111
+ right_commit_id: &str,
112
+ ) -> Result<CommitGraphCommit, LixError> {
113
+ let ancestors = self
114
+ .best_common_ancestors(left_commit_id, right_commit_id)
115
+ .await?;
116
+ match ancestors.as_slice() {
117
+ [] => Err(LixError::new(
118
+ "LIX_ERROR_UNKNOWN",
119
+ format!(
120
+ "commit_graph found no common history between '{left_commit_id}' and '{right_commit_id}'"
121
+ ),
122
+ )),
123
+ [base] => Ok(base.clone()),
124
+ _ => Err(LixError::ambiguous_merge_base(
125
+ left_commit_id,
126
+ right_commit_id,
127
+ ancestors
128
+ .iter()
129
+ .map(|ancestor| ancestor.commit_id.clone())
130
+ .collect(),
131
+ )),
132
+ }
133
+ }
134
+
135
+ /// Derives parent/child edges from parsed commits.
136
+ pub(crate) fn commit_edges(&self, commits: &[CommitGraphCommit]) -> Vec<CommitGraphEdge> {
137
+ commits
138
+ .iter()
139
+ .flat_map(|commit| {
140
+ commit.parent_commit_ids.iter().enumerate().map(
141
+ |(parent_order, parent_commit_id)| CommitGraphEdge {
142
+ parent_commit_id: parent_commit_id.clone(),
143
+ child_commit_id: commit.commit_id.clone(),
144
+ parent_order: parent_order as u32,
145
+ },
146
+ )
147
+ })
148
+ .collect()
149
+ }
150
+
151
+ /// Returns canonical changes reachable from `start_commit_id`.
152
+ ///
153
+ /// This is the primitive history API. It reports the commit/depth where
154
+ /// each matching canonical change was introduced or adopted during graph
155
+ /// traversal and leaves row shaping to callers such as SQL providers.
156
+ pub(crate) async fn change_history_from_commit(
157
+ &mut self,
158
+ start_commit_id: &str,
159
+ request: &CommitGraphChangeHistoryRequest,
160
+ ) -> Result<Vec<CommitGraphChangeHistoryEntry>, LixError> {
161
+ let commits = self.reachable_commits(start_commit_id).await?;
162
+ let mut entries = Vec::new();
163
+ let mut seen_change_ids = BTreeSet::new();
164
+
165
+ for reachable in commits {
166
+ if !depth_matches(reachable.depth, request) {
167
+ continue;
168
+ }
169
+
170
+ let commit_id = reachable.commit.commit_id;
171
+ for change_id in reachable.commit.change_ids {
172
+ if !seen_change_ids.insert(change_id.clone()) {
173
+ continue;
174
+ }
175
+ let change = self
176
+ .load_member_canonical_change(&change_id, &commit_id)
177
+ .await?;
178
+ if change_matches_history_request(&change.record, request) {
179
+ entries.push(CommitGraphChangeHistoryEntry {
180
+ located_change: change,
181
+ observed_commit_id: commit_id.clone(),
182
+ start_commit_id: start_commit_id.to_string(),
183
+ depth: reachable.depth,
184
+ });
185
+ }
186
+ }
187
+ }
188
+
189
+ Ok(entries)
190
+ }
191
+
192
+ async fn load_member_canonical_change(
193
+ &mut self,
194
+ change_id: &str,
195
+ source_commit_id: &str,
196
+ ) -> Result<LocatedChange, LixError> {
197
+ let change_ids = vec![change_id.to_string()];
198
+ self.load_canonical_changes(&change_ids)
199
+ .await?
200
+ .into_iter()
201
+ .next()
202
+ .flatten()
203
+ .ok_or_else(|| {
204
+ LixError::new(
205
+ "LIX_ERROR_UNKNOWN",
206
+ format!(
207
+ "commit_graph commit '{source_commit_id}' references missing change '{change_id}'"
208
+ ),
209
+ )
210
+ })
211
+ }
212
+
213
+ async fn graph_commit_from_store_commit(
214
+ &mut self,
215
+ commit: Commit,
216
+ ) -> Result<CommitGraphCommit, LixError> {
217
+ let change_ids = self.load_commit_change_ids(&commit).await?;
218
+ Ok(commit_graph_commit_from_store_commit(commit, change_ids)?)
219
+ }
220
+
221
+ async fn load_commit_change_ids(&self, commit: &Commit) -> Result<Vec<String>, LixError> {
222
+ let mut change_ids = Vec::new();
223
+ for pack_id in 0..commit.change_pack_count {
224
+ let Some(changes) = self
225
+ .commit_store_reader
226
+ .load_change_pack(&commit.id, pack_id)
227
+ .await?
228
+ else {
229
+ return Err(missing_pack_error("change", &commit.id, pack_id));
230
+ };
231
+ change_ids.extend(changes.into_iter().map(|change| change.id));
232
+ }
233
+ for pack_id in 0..commit.membership_pack_count {
234
+ let Some(members) = self
235
+ .commit_store_reader
236
+ .load_membership_pack(&commit.id, pack_id)
237
+ .await?
238
+ else {
239
+ return Err(missing_pack_error("membership", &commit.id, pack_id));
240
+ };
241
+ change_ids.extend(members.into_iter().map(|locator| locator.change_id));
242
+ }
243
+ Ok(change_ids)
244
+ }
245
+
246
+ async fn load_canonical_changes(
247
+ &self,
248
+ change_ids: &[String],
249
+ ) -> Result<Vec<Option<LocatedChange>>, LixError> {
250
+ self.commit_store_reader
251
+ .load_located_changes(change_ids)
252
+ .await
253
+ .map(|changes| {
254
+ changes
255
+ .into_iter()
256
+ .map(|located| {
257
+ located.map(|located| LocatedChange {
258
+ record: canonical_change_from_store_change(located.record),
259
+ source_commit_id: located.source_commit_id,
260
+ source_pack_id: located.source_pack_id,
261
+ })
262
+ })
263
+ .collect()
264
+ })
265
+ }
266
+ }
267
+
268
+ #[async_trait::async_trait]
269
+ impl<S> CommitGraphReader for CommitGraphStoreReader<S>
270
+ where
271
+ S: StorageReader,
272
+ {
273
+ async fn load_commit(
274
+ &mut self,
275
+ commit_id: &str,
276
+ ) -> Result<Option<CommitGraphCommit>, LixError> {
277
+ CommitGraphStoreReader::load_commit(self, commit_id).await
278
+ }
279
+
280
+ async fn all_commits(&mut self) -> Result<Vec<CommitGraphCommit>, LixError> {
281
+ CommitGraphStoreReader::all_commits(self).await
282
+ }
283
+
284
+ async fn reachable_commits(
285
+ &mut self,
286
+ head_commit_id: &str,
287
+ ) -> Result<Vec<ReachableCommitGraphCommit>, LixError> {
288
+ CommitGraphStoreReader::reachable_commits(self, head_commit_id).await
289
+ }
290
+
291
+ async fn best_common_ancestors(
292
+ &mut self,
293
+ left_commit_id: &str,
294
+ right_commit_id: &str,
295
+ ) -> Result<Vec<CommitGraphCommit>, LixError> {
296
+ CommitGraphStoreReader::best_common_ancestors(self, left_commit_id, right_commit_id).await
297
+ }
298
+
299
+ async fn merge_base(
300
+ &mut self,
301
+ left_commit_id: &str,
302
+ right_commit_id: &str,
303
+ ) -> Result<CommitGraphCommit, LixError> {
304
+ CommitGraphStoreReader::merge_base(self, left_commit_id, right_commit_id).await
305
+ }
306
+
307
+ fn commit_edges(&self, commits: &[CommitGraphCommit]) -> Vec<CommitGraphEdge> {
308
+ CommitGraphStoreReader::commit_edges(self, commits)
309
+ }
310
+
311
+ async fn change_history_from_commit(
312
+ &mut self,
313
+ start_commit_id: &str,
314
+ request: &CommitGraphChangeHistoryRequest,
315
+ ) -> Result<Vec<CommitGraphChangeHistoryEntry>, LixError> {
316
+ CommitGraphStoreReader::change_history_from_commit(self, start_commit_id, request).await
317
+ }
318
+ }
319
+
320
+ fn depth_matches(depth: u32, request: &CommitGraphChangeHistoryRequest) -> bool {
321
+ request.min_depth.map_or(true, |min| depth >= min)
322
+ && request.max_depth.map_or(true, |max| depth <= max)
323
+ }
324
+
325
+ fn change_matches_history_request(
326
+ change: &Change,
327
+ request: &CommitGraphChangeHistoryRequest,
328
+ ) -> bool {
329
+ (request.include_tombstones || change.snapshot_ref.is_some())
330
+ && (request.entity_ids.is_empty() || request.entity_ids.contains(&change.entity_id))
331
+ && (request.schema_keys.is_empty() || request.schema_keys.contains(&change.schema_key))
332
+ && (request.file_ids.is_empty()
333
+ || change
334
+ .file_id
335
+ .as_ref()
336
+ .is_some_and(|file_id| request.file_ids.contains(file_id)))
337
+ }
338
+
339
+ fn commit_graph_commit_from_store_commit(
340
+ commit: Commit,
341
+ change_ids: Vec<String>,
342
+ ) -> Result<CommitGraphCommit, LixError> {
343
+ let change = commit_header_canonical_change(commit.clone());
344
+ Ok(CommitGraphCommit {
345
+ canonical_change: change.clone(),
346
+ change,
347
+ commit_id: commit.id,
348
+ change_ids,
349
+ author_account_ids: commit.author_account_ids,
350
+ parent_commit_ids: commit.parent_ids,
351
+ })
352
+ }
353
+
354
+ fn commit_header_canonical_change(commit: Commit) -> Change {
355
+ Change {
356
+ id: commit.change_id,
357
+ entity_id: EntityIdentity::single(&commit.id),
358
+ schema_key: COMMIT_SCHEMA_KEY.to_string(),
359
+ file_id: None,
360
+ snapshot_ref: None,
361
+ metadata_ref: None,
362
+ created_at: commit.created_at,
363
+ }
364
+ }
365
+
366
+ fn canonical_change_from_store_change(change: Change) -> Change {
367
+ Change {
368
+ id: change.id,
369
+ entity_id: change.entity_id,
370
+ schema_key: change.schema_key,
371
+ file_id: change.file_id,
372
+ snapshot_ref: change.snapshot_ref,
373
+ metadata_ref: change.metadata_ref,
374
+ created_at: change.created_at,
375
+ }
376
+ }
377
+
378
+ fn missing_pack_error(label: &str, commit_id: &str, pack_id: u32) -> LixError {
379
+ LixError::new(
380
+ LixError::CODE_INTERNAL_ERROR,
381
+ format!("commit_graph missing {label} pack ({commit_id}, {pack_id})"),
382
+ )
383
+ }
384
+
385
+ #[cfg(test)]
386
+ mod tests {
387
+ use std::collections::{BTreeMap, BTreeSet};
388
+ use std::sync::Arc;
389
+
390
+ use crate::backend::testing::UnitTestBackend;
391
+ use crate::commit_graph::{CommitGraphChangeHistoryRequest, CommitGraphContext};
392
+ use crate::commit_store::{
393
+ Change, ChangeLocator, ChangeRef, CommitDraftRef, CommitStoreContext,
394
+ };
395
+ use crate::storage::{StorageContext, StorageWriteSet};
396
+
397
+ #[tokio::test]
398
+ async fn load_commit_parses_commit_snapshot() {
399
+ let backend = Arc::new(UnitTestBackend::new());
400
+ let storage = StorageContext::new(backend.clone());
401
+ append_changes(
402
+ storage.clone(),
403
+ &[commit_change(
404
+ "commit-1-change",
405
+ "commit-1",
406
+ &["change-1", "change-2"],
407
+ &["parent-1"],
408
+ )],
409
+ )
410
+ .await;
411
+
412
+ let graph = CommitGraphContext::new();
413
+ let mut reader = graph.reader(storage);
414
+ let commit = reader
415
+ .load_commit("commit-1")
416
+ .await
417
+ .expect("commit load should succeed")
418
+ .expect("commit should exist");
419
+
420
+ assert_eq!(commit.commit_id, "commit-1");
421
+ assert_eq!(commit.change_ids, vec!["change-1", "change-2"]);
422
+ assert_eq!(commit.parent_commit_ids, vec!["parent-1"]);
423
+ assert_eq!(commit.change.id, "commit-1-change");
424
+ }
425
+
426
+ #[tokio::test]
427
+ async fn load_commit_returns_none_for_missing_commit() {
428
+ let backend = Arc::new(UnitTestBackend::new());
429
+ let storage = StorageContext::new(backend.clone());
430
+ let graph = CommitGraphContext::new();
431
+ let mut reader = graph.reader(storage);
432
+
433
+ let commit = reader
434
+ .load_commit("missing")
435
+ .await
436
+ .expect("commit load should succeed");
437
+
438
+ assert_eq!(commit, None);
439
+ }
440
+
441
+ #[tokio::test]
442
+ async fn all_commits_returns_parsed_commits_sorted_by_id() {
443
+ let backend = Arc::new(UnitTestBackend::new());
444
+ let storage = StorageContext::new(backend.clone());
445
+ append_changes(
446
+ storage.clone(),
447
+ &[
448
+ commit_change("commit-b-change", "commit-b", &[], &[]),
449
+ entity_change("change-1", "entity-1", "example", "{}"),
450
+ commit_change("commit-a-change", "commit-a", &[], &[]),
451
+ ],
452
+ )
453
+ .await;
454
+
455
+ let graph = CommitGraphContext::new();
456
+ let mut reader = graph.reader(storage);
457
+ let commits = reader
458
+ .all_commits()
459
+ .await
460
+ .expect("commit scan should succeed");
461
+
462
+ assert_eq!(
463
+ commits
464
+ .iter()
465
+ .map(|commit| commit.commit_id.as_str())
466
+ .collect::<Vec<_>>(),
467
+ vec!["commit-a", "commit-b"]
468
+ );
469
+ }
470
+
471
+ #[tokio::test]
472
+ async fn commit_edges_are_derived_from_parent_commit_ids() {
473
+ let graph = CommitGraphContext::new();
474
+ let reader = graph.reader(StorageContext::new(Arc::new(UnitTestBackend::new())));
475
+ let commits = vec![parsed_commit(
476
+ "commit-head",
477
+ &[],
478
+ &["commit-left", "commit-right"],
479
+ )];
480
+
481
+ let edges = reader.commit_edges(&commits);
482
+
483
+ assert_eq!(
484
+ edges
485
+ .iter()
486
+ .map(|edge| (
487
+ edge.parent_commit_id.as_str(),
488
+ edge.child_commit_id.as_str(),
489
+ edge.parent_order,
490
+ ))
491
+ .collect::<Vec<_>>(),
492
+ vec![
493
+ ("commit-left", "commit-head", 0),
494
+ ("commit-right", "commit-head", 1)
495
+ ]
496
+ );
497
+ }
498
+
499
+ #[tokio::test]
500
+ async fn change_history_from_commit_reports_matching_canonical_changes_with_depth() {
501
+ let backend = Arc::new(UnitTestBackend::new());
502
+ let storage = StorageContext::new(backend.clone());
503
+ append_changes(
504
+ storage.clone(),
505
+ &[
506
+ entity_change("change-root", "entity-root", "test_schema", "{}"),
507
+ entity_change("change-head", "entity-head", "test_schema", "{}"),
508
+ commit_change("commit-root-change", "commit-root", &["change-root"], &[]),
509
+ commit_change(
510
+ "commit-head-change",
511
+ "commit-head",
512
+ &["change-head"],
513
+ &["commit-root"],
514
+ ),
515
+ ],
516
+ )
517
+ .await;
518
+
519
+ let graph = CommitGraphContext::new();
520
+ let mut reader = graph.reader(storage);
521
+ let history = reader
522
+ .change_history_from_commit(
523
+ "commit-head",
524
+ &CommitGraphChangeHistoryRequest {
525
+ schema_keys: vec!["test_schema".to_string()],
526
+ include_tombstones: true,
527
+ ..CommitGraphChangeHistoryRequest::default()
528
+ },
529
+ )
530
+ .await
531
+ .expect("history should resolve");
532
+
533
+ assert_eq!(
534
+ history
535
+ .iter()
536
+ .map(|entry| (
537
+ entry.located_change.record.id.as_str(),
538
+ entry.observed_commit_id.as_str(),
539
+ entry.start_commit_id.as_str(),
540
+ entry.depth
541
+ ))
542
+ .collect::<Vec<_>>(),
543
+ vec![
544
+ ("change-head", "commit-head", "commit-head", 0),
545
+ ("change-root", "commit-root", "commit-head", 1),
546
+ ]
547
+ );
548
+ }
549
+
550
+ #[tokio::test]
551
+ async fn change_history_from_commit_filters_depth_entity_file_and_tombstones() {
552
+ let backend = Arc::new(UnitTestBackend::new());
553
+ let storage = StorageContext::new(backend.clone());
554
+ append_changes(
555
+ storage.clone(),
556
+ &[
557
+ entity_change_with_file(
558
+ "change-file-a",
559
+ "entity-1",
560
+ "test_schema",
561
+ Some("file-a"),
562
+ "{}",
563
+ ),
564
+ entity_tombstone("change-tombstone", "entity-1", "test_schema"),
565
+ entity_change_with_file(
566
+ "change-file-b",
567
+ "entity-2",
568
+ "test_schema",
569
+ Some("file-b"),
570
+ "{}",
571
+ ),
572
+ commit_change("commit-root-change", "commit-root", &["change-file-a"], &[]),
573
+ commit_change(
574
+ "commit-head-change",
575
+ "commit-head",
576
+ &["change-tombstone", "change-file-b"],
577
+ &["commit-root"],
578
+ ),
579
+ ],
580
+ )
581
+ .await;
582
+
583
+ let graph = CommitGraphContext::new();
584
+ let mut reader = graph.reader(storage);
585
+ let history = reader
586
+ .change_history_from_commit(
587
+ "commit-head",
588
+ &CommitGraphChangeHistoryRequest {
589
+ entity_ids: vec![crate::entity_identity::EntityIdentity::single("entity-1")],
590
+ file_ids: vec!["file-a".to_string()],
591
+ min_depth: Some(1),
592
+ max_depth: Some(1),
593
+ include_tombstones: false,
594
+ ..CommitGraphChangeHistoryRequest::default()
595
+ },
596
+ )
597
+ .await
598
+ .expect("history should resolve");
599
+
600
+ assert_eq!(history.len(), 1);
601
+ assert_eq!(history[0].located_change.record.id, "change-file-a");
602
+ assert_eq!(history[0].depth, 1);
603
+ }
604
+
605
+ #[tokio::test]
606
+ async fn change_history_from_commit_includes_tombstones_when_requested() {
607
+ let backend = Arc::new(UnitTestBackend::new());
608
+ let storage = StorageContext::new(backend.clone());
609
+ append_changes(
610
+ storage.clone(),
611
+ &[
612
+ entity_tombstone("change-deleted", "entity-1", "test_schema"),
613
+ commit_change(
614
+ "commit-head-change",
615
+ "commit-head",
616
+ &["change-deleted"],
617
+ &[],
618
+ ),
619
+ ],
620
+ )
621
+ .await;
622
+
623
+ let graph = CommitGraphContext::new();
624
+ let mut reader = graph.reader(storage);
625
+ let hidden = reader
626
+ .change_history_from_commit("commit-head", &CommitGraphChangeHistoryRequest::default())
627
+ .await
628
+ .expect("history should resolve");
629
+ let visible = reader
630
+ .change_history_from_commit(
631
+ "commit-head",
632
+ &CommitGraphChangeHistoryRequest {
633
+ include_tombstones: true,
634
+ ..CommitGraphChangeHistoryRequest::default()
635
+ },
636
+ )
637
+ .await
638
+ .expect("history should resolve");
639
+
640
+ assert!(hidden.is_empty());
641
+ assert_eq!(visible.len(), 1);
642
+ assert_eq!(visible[0].located_change.record.id, "change-deleted");
643
+ }
644
+
645
+ #[derive(Clone)]
646
+ struct TestChange {
647
+ change: Change,
648
+ commit_change_ids: Vec<String>,
649
+ parent_commit_ids: Vec<String>,
650
+ author_account_ids: Vec<String>,
651
+ }
652
+
653
+ impl TestChange {
654
+ fn commit(
655
+ change_id: &str,
656
+ commit_id: &str,
657
+ change_ids: &[&str],
658
+ parent_commit_ids: &[&str],
659
+ ) -> Self {
660
+ Self {
661
+ change: Change {
662
+ id: change_id.to_string(),
663
+ entity_id: crate::entity_identity::EntityIdentity::single(commit_id),
664
+ schema_key: super::COMMIT_SCHEMA_KEY.to_string(),
665
+ file_id: None,
666
+ snapshot_ref: None,
667
+ metadata_ref: None,
668
+ created_at: "2026-01-01T00:00:00Z".to_string(),
669
+ },
670
+ commit_change_ids: change_ids.iter().map(|id| id.to_string()).collect(),
671
+ parent_commit_ids: parent_commit_ids.iter().map(|id| id.to_string()).collect(),
672
+ author_account_ids: Vec::new(),
673
+ }
674
+ }
675
+
676
+ fn entity(
677
+ change_id: &str,
678
+ entity_id: &str,
679
+ schema_key: &str,
680
+ file_id: Option<&str>,
681
+ snapshot_content: Option<&str>,
682
+ created_at: &str,
683
+ ) -> Self {
684
+ Self {
685
+ change: Change {
686
+ id: change_id.to_string(),
687
+ entity_id: crate::entity_identity::EntityIdentity::single(entity_id),
688
+ schema_key: schema_key.to_string(),
689
+ file_id: file_id.map(str::to_string),
690
+ snapshot_ref: snapshot_content.map(|content| {
691
+ crate::json_store::JsonRef::from_hash(blake3::hash(content.as_bytes()))
692
+ }),
693
+ metadata_ref: None,
694
+ created_at: created_at.to_string(),
695
+ },
696
+ commit_change_ids: Vec::new(),
697
+ parent_commit_ids: Vec::new(),
698
+ author_account_ids: Vec::new(),
699
+ }
700
+ }
701
+
702
+ fn is_commit(&self) -> bool {
703
+ self.change.schema_key == super::COMMIT_SCHEMA_KEY
704
+ }
705
+ }
706
+
707
+ async fn append_changes(storage: StorageContext, changes: &[TestChange]) {
708
+ let mut tx = storage
709
+ .begin_write_transaction()
710
+ .await
711
+ .expect("transaction should open");
712
+ let mut writes = StorageWriteSet::new();
713
+ let canonical_changes = changes
714
+ .iter()
715
+ .filter(|change| !change.is_commit())
716
+ .map(|change| change.change.clone())
717
+ .collect::<Vec<_>>();
718
+ let changes_by_id: BTreeMap<&str, &Change> = canonical_changes
719
+ .iter()
720
+ .map(|change| (change.id.as_str(), change))
721
+ .collect::<BTreeMap<_, _>>();
722
+ let mut authored_change_ids = BTreeSet::new();
723
+ let commit_store = CommitStoreContext::new();
724
+ for change in changes.iter().filter(|change| change.is_commit()) {
725
+ let commit = crate::commit_graph::CommitGraphCommit {
726
+ canonical_change: change.change.clone(),
727
+ change: change.change.clone(),
728
+ commit_id: change
729
+ .change
730
+ .entity_id
731
+ .as_single_string()
732
+ .expect("commit fixture should use single entity id")
733
+ .to_string(),
734
+ change_ids: change.commit_change_ids.clone(),
735
+ author_account_ids: change.author_account_ids.clone(),
736
+ parent_commit_ids: change.parent_commit_ids.clone(),
737
+ };
738
+ let parent_commit_ids = commit.parent_commit_ids.clone();
739
+ let author_account_ids = commit.author_account_ids.clone();
740
+ let commit_draft = CommitDraftRef {
741
+ id: &commit.commit_id,
742
+ change_id: &commit.canonical_change.id,
743
+ parent_ids: &parent_commit_ids,
744
+ author_account_ids: &author_account_ids,
745
+ created_at: &commit.canonical_change.created_at,
746
+ };
747
+
748
+ let mut authored_changes = Vec::new();
749
+ let mut adopted_changes = Vec::new();
750
+ let mut corrupt_missing_members = Vec::new();
751
+ for change_id in &commit.change_ids {
752
+ if let Some(change) = changes_by_id.get(change_id.as_str()) {
753
+ if authored_change_ids.insert(change_id.clone()) {
754
+ authored_changes.push(change_ref_from_canonical(change.as_ref()));
755
+ } else {
756
+ adopted_changes.push(change_ref_from_canonical(change.as_ref()));
757
+ }
758
+ } else {
759
+ corrupt_missing_members.push(change_id.clone());
760
+ }
761
+ }
762
+
763
+ if corrupt_missing_members.is_empty() {
764
+ commit_store
765
+ .writer(tx.as_mut(), &mut writes)
766
+ .stage_commit_draft(commit_draft, authored_changes, adopted_changes)
767
+ .await
768
+ .expect("commit-store append should succeed");
769
+ } else {
770
+ crate::commit_store::storage::stage_commit(
771
+ &mut writes,
772
+ commit_draft,
773
+ authored_changes,
774
+ corrupt_missing_members
775
+ .into_iter()
776
+ .map(|change_id| ChangeLocator {
777
+ source_commit_id: "missing-source-commit".to_string(),
778
+ source_pack_id: 0,
779
+ source_ordinal: 0,
780
+ change_id,
781
+ })
782
+ .collect(),
783
+ )
784
+ .expect("corrupt commit-store fixture should stage");
785
+ }
786
+ }
787
+ writes
788
+ .apply(&mut tx.as_mut())
789
+ .await
790
+ .expect("writes should apply");
791
+ tx.commit().await.expect("commit should succeed");
792
+ }
793
+
794
+ fn change_ref_from_canonical<'a>(change: crate::commit_store::ChangeRef<'a>) -> ChangeRef<'a> {
795
+ ChangeRef {
796
+ id: change.id,
797
+ entity_id: change.entity_id,
798
+ schema_key: change.schema_key,
799
+ file_id: change.file_id,
800
+ snapshot_ref: change.snapshot_ref,
801
+ metadata_ref: change.metadata_ref,
802
+ created_at: change.created_at,
803
+ }
804
+ }
805
+
806
+ fn commit_change(
807
+ change_id: &str,
808
+ commit_id: &str,
809
+ change_ids: &[&str],
810
+ parent_commit_ids: &[&str],
811
+ ) -> TestChange {
812
+ TestChange::commit(change_id, commit_id, change_ids, parent_commit_ids)
813
+ }
814
+
815
+ fn parsed_commit(
816
+ commit_id: &str,
817
+ change_ids: &[&str],
818
+ parent_commit_ids: &[&str],
819
+ ) -> crate::commit_graph::CommitGraphCommit {
820
+ let fixture = commit_change(
821
+ &format!("{commit_id}-change"),
822
+ commit_id,
823
+ change_ids,
824
+ parent_commit_ids,
825
+ );
826
+ crate::commit_graph::CommitGraphCommit {
827
+ canonical_change: fixture.change.clone(),
828
+ change: fixture.change,
829
+ commit_id: commit_id.to_string(),
830
+ change_ids: change_ids
831
+ .iter()
832
+ .map(|change_id| change_id.to_string())
833
+ .collect(),
834
+ author_account_ids: Vec::new(),
835
+ parent_commit_ids: parent_commit_ids
836
+ .iter()
837
+ .map(|parent_id| parent_id.to_string())
838
+ .collect(),
839
+ }
840
+ }
841
+
842
+ fn entity_change(
843
+ change_id: &str,
844
+ entity_id: &str,
845
+ schema_key: &str,
846
+ snapshot_content: &str,
847
+ ) -> TestChange {
848
+ entity_change_at(
849
+ change_id,
850
+ entity_id,
851
+ schema_key,
852
+ snapshot_content,
853
+ "2026-01-01T00:00:00Z",
854
+ )
855
+ }
856
+
857
+ fn entity_change_at(
858
+ change_id: &str,
859
+ entity_id: &str,
860
+ schema_key: &str,
861
+ snapshot_content: &str,
862
+ created_at: &str,
863
+ ) -> TestChange {
864
+ TestChange::entity(
865
+ change_id,
866
+ entity_id,
867
+ schema_key,
868
+ None,
869
+ Some(snapshot_content),
870
+ created_at,
871
+ )
872
+ }
873
+
874
+ fn entity_change_with_file(
875
+ change_id: &str,
876
+ entity_id: &str,
877
+ schema_key: &str,
878
+ file_id: Option<&str>,
879
+ snapshot_content: &str,
880
+ ) -> TestChange {
881
+ TestChange::entity(
882
+ change_id,
883
+ entity_id,
884
+ schema_key,
885
+ file_id,
886
+ Some(snapshot_content),
887
+ "2026-01-01T00:00:00Z",
888
+ )
889
+ }
890
+
891
+ fn entity_tombstone(change_id: &str, entity_id: &str, schema_key: &str) -> TestChange {
892
+ TestChange::entity(
893
+ change_id,
894
+ entity_id,
895
+ schema_key,
896
+ None,
897
+ None,
898
+ "2026-01-02T00:00:00Z",
899
+ )
900
+ }
901
+ }