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

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 (235) hide show
  1. package/README.md +1 -1
  2. package/SKILL.md +105 -65
  3. package/dist/engine-wasm/index.js +4 -4
  4. package/dist/engine-wasm/wasm/lix_engine.d.ts +30 -6
  5. package/dist/engine-wasm/wasm/lix_engine.js +187 -117
  6. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  7. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +14 -8
  8. package/dist/generated/builtin-schemas.d.ts +69 -69
  9. package/dist/generated/builtin-schemas.js +94 -94
  10. package/dist/open-lix.d.ts +42 -28
  11. package/dist/open-lix.js +49 -10
  12. package/dist/sqlite/index.js +86 -30
  13. package/dist-engine-src/README.md +3 -3
  14. package/dist-engine-src/src/backend/capabilities.rs +67 -0
  15. package/dist-engine-src/src/backend/conformance/baseline.rs +1127 -0
  16. package/dist-engine-src/src/backend/conformance/factory.rs +93 -0
  17. package/dist-engine-src/src/backend/conformance/failure_tests.rs +608 -0
  18. package/dist-engine-src/src/backend/conformance/fixtures.rs +26 -0
  19. package/dist-engine-src/src/backend/conformance/mod.rs +75 -0
  20. package/dist-engine-src/src/backend/conformance/model.rs +28 -0
  21. package/dist-engine-src/src/backend/conformance/model_based.rs +257 -0
  22. package/dist-engine-src/src/backend/conformance/persistence.rs +204 -0
  23. package/dist-engine-src/src/backend/conformance/projection.rs +21 -0
  24. package/dist-engine-src/src/backend/conformance/pushdown.rs +24 -0
  25. package/dist-engine-src/src/backend/conformance/runner.rs +90 -0
  26. package/dist-engine-src/src/backend/conformance/scan.rs +24 -0
  27. package/dist-engine-src/src/backend/conformance/write.rs +16 -0
  28. package/dist-engine-src/src/backend/error.rs +94 -0
  29. package/dist-engine-src/src/backend/in_memory.rs +670 -0
  30. package/dist-engine-src/src/backend/mod.rs +36 -9
  31. package/dist-engine-src/src/backend/predicate.rs +80 -0
  32. package/dist-engine-src/src/backend/traits.rs +260 -0
  33. package/dist-engine-src/src/backend/types.rs +224 -81
  34. package/dist-engine-src/src/binary_cas/context.rs +8 -8
  35. package/dist-engine-src/src/binary_cas/kv.rs +234 -259
  36. package/dist-engine-src/src/{version → branch}/context.rs +12 -12
  37. package/dist-engine-src/src/branch/lifecycle.rs +221 -0
  38. package/dist-engine-src/src/branch/mod.rs +13 -0
  39. package/dist-engine-src/src/branch/refs.rs +321 -0
  40. package/dist-engine-src/src/branch/stage_rows.rs +67 -0
  41. package/dist-engine-src/src/branch/types.rs +21 -0
  42. package/dist-engine-src/src/catalog/context.rs +18 -18
  43. package/dist-engine-src/src/catalog/snapshot.rs +8 -8
  44. package/dist-engine-src/src/changelog/bench_support.rs +785 -0
  45. package/dist-engine-src/src/changelog/change.rs +1 -0
  46. package/dist-engine-src/src/changelog/codec.rs +497 -0
  47. package/dist-engine-src/src/changelog/commit.rs +1 -0
  48. package/dist-engine-src/src/changelog/context.rs +1614 -0
  49. package/dist-engine-src/src/changelog/mod.rs +29 -0
  50. package/dist-engine-src/src/changelog/store.rs +163 -0
  51. package/dist-engine-src/src/changelog/test_support.rs +54 -0
  52. package/dist-engine-src/src/changelog/types.rs +213 -0
  53. package/dist-engine-src/src/commit_graph/context.rs +317 -274
  54. package/dist-engine-src/src/commit_graph/mod.rs +2 -4
  55. package/dist-engine-src/src/commit_graph/types.rs +22 -42
  56. package/dist-engine-src/src/commit_graph/walker.rs +133 -103
  57. package/dist-engine-src/src/common/error.rs +52 -18
  58. package/dist-engine-src/src/common/identity.rs +2 -2
  59. package/dist-engine-src/src/common/mod.rs +1 -1
  60. package/dist-engine-src/src/domain.rs +42 -46
  61. package/dist-engine-src/src/engine.rs +74 -96
  62. package/dist-engine-src/src/{entity_identity.rs → entity_pk.rs} +89 -92
  63. package/dist-engine-src/src/functions/context.rs +56 -52
  64. package/dist-engine-src/src/functions/state.rs +51 -52
  65. package/dist-engine-src/src/init.rs +288 -154
  66. package/dist-engine-src/src/json_store/context.rs +15 -266
  67. package/dist-engine-src/src/json_store/mod.rs +26 -0
  68. package/dist-engine-src/src/json_store/store.rs +103 -718
  69. package/dist-engine-src/src/json_store/types.rs +4 -9
  70. package/dist-engine-src/src/lib.rs +49 -19
  71. package/dist-engine-src/src/live_state/context.rs +654 -790
  72. package/dist-engine-src/src/live_state/mod.rs +9 -3
  73. package/dist-engine-src/src/live_state/overlay.rs +4 -4
  74. package/dist-engine-src/src/live_state/types.rs +30 -21
  75. package/dist-engine-src/src/live_state/visibility.rs +514 -71
  76. package/dist-engine-src/src/plugin/install.rs +48 -48
  77. package/dist-engine-src/src/plugin/manifest.rs +7 -7
  78. package/dist-engine-src/src/plugin/materializer.rs +0 -275
  79. package/dist-engine-src/src/plugin/plugin_manifest.json +4 -3
  80. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +2 -2
  81. package/dist-engine-src/src/schema/builtin/lix_branch_descriptor.json +34 -0
  82. package/dist-engine-src/src/schema/builtin/lix_branch_ref.json +48 -0
  83. package/dist-engine-src/src/schema/builtin/lix_change.json +3 -3
  84. package/dist-engine-src/src/schema/builtin/lix_commit.json +1 -1
  85. package/dist-engine-src/src/schema/builtin/lix_label_assignment.json +6 -6
  86. package/dist-engine-src/src/schema/builtin/mod.rs +18 -20
  87. package/dist-engine-src/src/schema/compatibility.rs +11 -11
  88. package/dist-engine-src/src/schema/definition.json +2 -2
  89. package/dist-engine-src/src/schema/definition.rs +5 -5
  90. package/dist-engine-src/src/schema/key.rs +3 -3
  91. package/dist-engine-src/src/schema/mod.rs +1 -1
  92. package/dist-engine-src/src/schema/tests.rs +18 -18
  93. package/dist-engine-src/src/session/context.rs +819 -124
  94. package/dist-engine-src/src/session/create_branch.rs +94 -0
  95. package/dist-engine-src/src/session/execute.rs +260 -57
  96. package/dist-engine-src/src/session/merge/analysis.rs +9 -3
  97. package/dist-engine-src/src/session/merge/{version.rs → branch.rs} +119 -129
  98. package/dist-engine-src/src/session/merge/conflicts.rs +2 -2
  99. package/dist-engine-src/src/session/merge/mod.rs +5 -6
  100. package/dist-engine-src/src/session/merge/stats.rs +7 -11
  101. package/dist-engine-src/src/session/mod.rs +19 -16
  102. package/dist-engine-src/src/session/switch_branch.rs +113 -0
  103. package/dist-engine-src/src/session/transaction.rs +557 -0
  104. package/dist-engine-src/src/sql2/bind/classify.rs +102 -0
  105. package/dist-engine-src/src/sql2/bind/error.rs +5 -0
  106. package/dist-engine-src/src/sql2/bind/expr.rs +29 -0
  107. package/dist-engine-src/src/sql2/bind/mod.rs +12 -0
  108. package/dist-engine-src/src/sql2/{udfs/public_call.rs → bind/public_udf.rs} +98 -3
  109. package/dist-engine-src/src/sql2/bind/read.rs +65 -0
  110. package/dist-engine-src/src/sql2/bind/statement.rs +2236 -0
  111. package/dist-engine-src/src/sql2/bind/table.rs +273 -0
  112. package/dist-engine-src/src/sql2/bind/write.rs +86 -0
  113. package/dist-engine-src/src/sql2/branch_scope.rs +436 -0
  114. package/dist-engine-src/src/sql2/catalog/capability.rs +20 -0
  115. package/dist-engine-src/src/sql2/catalog/entity_surface.rs +296 -0
  116. package/dist-engine-src/src/sql2/catalog/mod.rs +15 -0
  117. package/dist-engine-src/src/sql2/catalog/registry.rs +556 -0
  118. package/dist-engine-src/src/sql2/catalog/schema.rs +88 -0
  119. package/dist-engine-src/src/sql2/catalog/surface.rs +41 -0
  120. package/dist-engine-src/src/sql2/change_materialization.rs +122 -0
  121. package/dist-engine-src/src/sql2/context.rs +36 -30
  122. package/dist-engine-src/src/sql2/error.rs +4 -5
  123. package/dist-engine-src/src/sql2/exec/bound_public_write.rs +1593 -0
  124. package/dist-engine-src/src/sql2/exec/datafusion.rs +5266 -0
  125. package/dist-engine-src/src/sql2/exec/fast_write.rs +82 -0
  126. package/dist-engine-src/src/sql2/exec/mod.rs +24 -0
  127. package/dist-engine-src/src/sql2/exec/write.rs +661 -0
  128. package/dist-engine-src/src/sql2/filesystem_planner.rs +72 -77
  129. package/dist-engine-src/src/sql2/filesystem_visibility.rs +21 -21
  130. package/dist-engine-src/src/sql2/history_projection.rs +8 -8
  131. package/dist-engine-src/src/sql2/history_route.rs +35 -31
  132. package/dist-engine-src/src/sql2/mod.rs +30 -24
  133. package/dist-engine-src/src/sql2/optimize/datafusion.rs +1 -0
  134. package/dist-engine-src/src/sql2/optimize/mod.rs +2 -0
  135. package/dist-engine-src/src/sql2/optimize/simple_write.rs +116 -0
  136. package/dist-engine-src/src/sql2/parse/mod.rs +69 -0
  137. package/dist-engine-src/src/sql2/parse/normalize.rs +1 -0
  138. package/dist-engine-src/src/sql2/plan/branch_scope.rs +24 -0
  139. package/dist-engine-src/src/sql2/plan/mod.rs +5 -0
  140. package/dist-engine-src/src/sql2/plan/predicate.rs +22 -0
  141. package/dist-engine-src/src/sql2/plan/write.rs +147 -0
  142. package/dist-engine-src/src/sql2/predicate_typecheck.rs +258 -0
  143. package/dist-engine-src/src/sql2/{version_provider.rs → providers/branch.rs} +218 -214
  144. package/dist-engine-src/src/sql2/{change_provider.rs → providers/change.rs} +156 -42
  145. package/dist-engine-src/src/sql2/{directory_provider.rs → providers/directory.rs} +291 -322
  146. package/dist-engine-src/src/sql2/{directory_history_provider.rs → providers/directory_history.rs} +56 -42
  147. package/dist-engine-src/src/sql2/providers/entity.rs +1484 -0
  148. package/dist-engine-src/src/sql2/{entity_history_provider.rs → providers/entity_history.rs} +43 -31
  149. package/dist-engine-src/src/sql2/{file_provider.rs → providers/file.rs} +323 -316
  150. package/dist-engine-src/src/sql2/{file_history_provider.rs → providers/file_history.rs} +60 -46
  151. package/dist-engine-src/src/sql2/{history_provider.rs → providers/history.rs} +46 -32
  152. package/dist-engine-src/src/sql2/{lix_state_provider.rs → providers/lix_state.rs} +359 -329
  153. package/dist-engine-src/src/sql2/providers/mod.rs +508 -0
  154. package/dist-engine-src/src/sql2/read_only.rs +2 -2
  155. package/dist-engine-src/src/sql2/session.rs +47 -96
  156. package/dist-engine-src/src/sql2/storage/constraints.rs +1 -0
  157. package/dist-engine-src/src/sql2/storage/mod.rs +1 -0
  158. package/dist-engine-src/src/sql2/test_support/differential.rs +712 -0
  159. package/dist-engine-src/src/sql2/test_support/generators.rs +354 -0
  160. package/dist-engine-src/src/sql2/test_support/mod.rs +2 -0
  161. package/dist-engine-src/src/sql2/udfs/{lix_active_version_commit_id.rs → lix_active_branch_commit_id.rs} +7 -7
  162. package/dist-engine-src/src/sql2/udfs/mod.rs +3 -6
  163. package/dist-engine-src/src/sql2/write_normalization.rs +45 -22
  164. package/dist-engine-src/src/storage/conformance.rs +399 -0
  165. package/dist-engine-src/src/storage/context.rs +552 -288
  166. package/dist-engine-src/src/storage/mod.rs +48 -10
  167. package/dist-engine-src/src/storage/point.rs +440 -0
  168. package/dist-engine-src/src/storage/read_scope.rs +43 -64
  169. package/dist-engine-src/src/storage/reader.rs +867 -0
  170. package/dist-engine-src/src/storage/scan.rs +784 -0
  171. package/dist-engine-src/src/storage/spaces.rs +236 -0
  172. package/dist-engine-src/src/storage/stats.rs +80 -0
  173. package/dist-engine-src/src/storage/write_set.rs +962 -0
  174. package/dist-engine-src/src/storage_bench.rs +136 -4828
  175. package/dist-engine-src/src/test_support.rs +360 -138
  176. package/dist-engine-src/src/tracked_state/bench_support.rs +394 -0
  177. package/dist-engine-src/src/tracked_state/codec.rs +155 -1057
  178. package/dist-engine-src/src/tracked_state/commit_root_rebuild.rs +358 -0
  179. package/dist-engine-src/src/tracked_state/context.rs +1927 -993
  180. package/dist-engine-src/src/tracked_state/diff.rs +1715 -261
  181. package/dist-engine-src/src/tracked_state/merge.rs +74 -88
  182. package/dist-engine-src/src/tracked_state/mod.rs +19 -16
  183. package/dist-engine-src/src/tracked_state/{materialization.rs → row_materialization.rs} +50 -178
  184. package/dist-engine-src/src/tracked_state/storage.rs +243 -191
  185. package/dist-engine-src/src/tracked_state/tree.rs +247 -371
  186. package/dist-engine-src/src/tracked_state/types.rs +49 -42
  187. package/dist-engine-src/src/transaction/bench_support.rs +407 -0
  188. package/dist-engine-src/src/transaction/commit.rs +821 -713
  189. package/dist-engine-src/src/transaction/context.rs +705 -600
  190. package/dist-engine-src/src/transaction/mod.rs +13 -2
  191. package/dist-engine-src/src/transaction/normalization.rs +63 -76
  192. package/dist-engine-src/src/transaction/prep.rs +13 -13
  193. package/dist-engine-src/src/transaction/schema_resolver.rs +19 -5
  194. package/dist-engine-src/src/transaction/staging.rs +228 -434
  195. package/dist-engine-src/src/transaction/types.rs +41 -98
  196. package/dist-engine-src/src/transaction/validation.rs +382 -446
  197. package/dist-engine-src/src/untracked_state/codec.rs +337 -29
  198. package/dist-engine-src/src/untracked_state/context.rs +7 -7
  199. package/dist-engine-src/src/untracked_state/materialization.rs +2 -2
  200. package/dist-engine-src/src/untracked_state/mod.rs +1 -1
  201. package/dist-engine-src/src/untracked_state/storage.rs +659 -157
  202. package/dist-engine-src/src/untracked_state/types.rs +21 -21
  203. package/package.json +71 -68
  204. package/dist-engine-src/src/backend/kv.rs +0 -358
  205. package/dist-engine-src/src/backend/testing.rs +0 -658
  206. package/dist-engine-src/src/commit_store/codec.rs +0 -887
  207. package/dist-engine-src/src/commit_store/context.rs +0 -944
  208. package/dist-engine-src/src/commit_store/materialization.rs +0 -84
  209. package/dist-engine-src/src/commit_store/mod.rs +0 -16
  210. package/dist-engine-src/src/commit_store/storage.rs +0 -600
  211. package/dist-engine-src/src/commit_store/types.rs +0 -215
  212. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +0 -34
  213. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +0 -48
  214. package/dist-engine-src/src/session/create_version.rs +0 -88
  215. package/dist-engine-src/src/session/merge/apply.rs +0 -23
  216. package/dist-engine-src/src/session/optimization9_sql2_bench.rs +0 -100
  217. package/dist-engine-src/src/session/switch_version.rs +0 -109
  218. package/dist-engine-src/src/sql2/classify.rs +0 -182
  219. package/dist-engine-src/src/sql2/entity_provider.rs +0 -3211
  220. package/dist-engine-src/src/sql2/execute.rs +0 -3440
  221. package/dist-engine-src/src/sql2/public_bind/assignment.rs +0 -46
  222. package/dist-engine-src/src/sql2/public_bind/capability.rs +0 -41
  223. package/dist-engine-src/src/sql2/public_bind/dml.rs +0 -166
  224. package/dist-engine-src/src/sql2/public_bind/mod.rs +0 -25
  225. package/dist-engine-src/src/sql2/public_bind/table.rs +0 -168
  226. package/dist-engine-src/src/sql2/version_scope.rs +0 -394
  227. package/dist-engine-src/src/storage/types.rs +0 -501
  228. package/dist-engine-src/src/tracked_state/by_file_index.rs +0 -98
  229. package/dist-engine-src/src/tracked_state/materializer.rs +0 -488
  230. package/dist-engine-src/src/transaction/live_state_overlay.rs +0 -35
  231. package/dist-engine-src/src/version/lifecycle.rs +0 -221
  232. package/dist-engine-src/src/version/mod.rs +0 -13
  233. package/dist-engine-src/src/version/refs.rs +0 -330
  234. package/dist-engine-src/src/version/stage_rows.rs +0 -67
  235. package/dist-engine-src/src/version/types.rs +0 -21
@@ -0,0 +1,1614 @@
1
+ use std::collections::{HashMap, HashSet};
2
+
3
+ use async_trait::async_trait;
4
+ use bytes::Bytes;
5
+
6
+ use super::codec::{
7
+ decode_change_record, decode_commit_change_ref_chunk, decode_commit_record,
8
+ encode_change_record, encode_commit_change_ref_chunk, encode_commit_record,
9
+ };
10
+ use super::store::{
11
+ change_key, commit_change_ref_chunk_key, commit_change_ref_chunk_prefix, commit_key,
12
+ CHANGE_SPACE, COMMIT_CHANGE_REF_CHUNK_SPACE, COMMIT_SPACE,
13
+ };
14
+ use crate::changelog::{
15
+ ChangeLoadBatch, ChangeLoadRequest, ChangeRecord, ChangeScanBatch, ChangeScanRequest,
16
+ ChangelogAppend, ChangelogReader, ChangelogWriter, CommitChangeRef, CommitChangeRefChunk,
17
+ CommitChangeRefSet, CommitLoadBatch, CommitLoadEntry, CommitLoadRequest, CommitProjection,
18
+ CommitRecord, CommitScanBatch, CommitScanRequest, GcPlan, GcRoot,
19
+ };
20
+ use crate::storage::{
21
+ PointReadPlan, ScanPlan, StorageBackend, StorageContext, StorageCoreProjection,
22
+ StorageGetOptions, StorageKey, StoragePrefix, StorageProjectedValue, StorageRead,
23
+ StorageReadOptions, StorageScanOptions, StorageSpace, StorageWriteSet,
24
+ };
25
+ use crate::LixError;
26
+
27
+ const COMMIT_CHANGE_REF_CHUNK_FORMAT_VERSION: u32 = 1;
28
+ const COMMIT_CHANGE_REF_CHUNK_TARGET_BYTES: usize = 64 * 1024;
29
+ const COMMIT_CHANGE_REF_CHUNK_MAX_BYTES: usize = 128 * 1024;
30
+ const COMMIT_CHANGE_REF_CHUNK_MAX_ENTRIES: usize = 2048;
31
+ const SCAN_PAGE_LIMIT: usize = 1024;
32
+
33
+ #[derive(Clone, Copy, Debug, Default)]
34
+ pub(crate) struct ChangelogContext;
35
+
36
+ impl ChangelogContext {
37
+ pub(crate) fn new() -> Self {
38
+ Self
39
+ }
40
+
41
+ pub(crate) fn reader<S>(&self, store: S) -> ChangelogStoreReader<S>
42
+ where
43
+ S: ChangelogStorageRead,
44
+ {
45
+ ChangelogStoreReader { store }
46
+ }
47
+
48
+ pub(crate) fn writer<'a, S>(
49
+ &self,
50
+ store: &'a mut S,
51
+ writes: &'a mut StorageWriteSet,
52
+ ) -> ChangelogStoreWriter<'a, S>
53
+ where
54
+ S: ChangelogStorageRead + ?Sized,
55
+ {
56
+ ChangelogStoreWriter {
57
+ store,
58
+ writes,
59
+ staged_commits: HashMap::new(),
60
+ staged_changes: HashMap::new(),
61
+ staged_commit_change_ref_chunks: HashMap::new(),
62
+ }
63
+ }
64
+ }
65
+
66
+ pub(crate) struct ChangelogStoreReader<S> {
67
+ store: S,
68
+ }
69
+
70
+ pub(crate) struct ChangelogStoreWriter<'a, S: ?Sized> {
71
+ store: &'a mut S,
72
+ writes: &'a mut StorageWriteSet,
73
+ staged_commits: HashMap<String, CommitRecord>,
74
+ staged_changes: HashMap<String, ChangeRecord>,
75
+ staged_commit_change_ref_chunks: HashMap<String, Vec<CommitChangeRefChunk>>,
76
+ }
77
+
78
+ #[derive(Debug)]
79
+ pub(crate) struct ChangelogScanPage {
80
+ pub(super) keys: Vec<Vec<u8>>,
81
+ pub(super) values: Vec<Vec<u8>>,
82
+ pub(super) resume_after: Option<Vec<u8>>,
83
+ }
84
+
85
+ #[async_trait]
86
+ pub(crate) trait ChangelogStorageRead {
87
+ async fn changelog_get_many(
88
+ &mut self,
89
+ space: StorageSpace,
90
+ keys: Vec<Vec<u8>>,
91
+ ) -> Result<Vec<Option<Vec<u8>>>, LixError>;
92
+
93
+ async fn changelog_scan(
94
+ &mut self,
95
+ space: StorageSpace,
96
+ prefix: Vec<u8>,
97
+ after: Option<Vec<u8>>,
98
+ limit: usize,
99
+ projection: StorageCoreProjection,
100
+ ) -> Result<ChangelogScanPage, LixError>;
101
+ }
102
+
103
+ #[async_trait]
104
+ impl<T> ChangelogStorageRead for T
105
+ where
106
+ T: StorageRead + Send,
107
+ {
108
+ async fn changelog_get_many(
109
+ &mut self,
110
+ space: StorageSpace,
111
+ keys: Vec<Vec<u8>>,
112
+ ) -> Result<Vec<Option<Vec<u8>>>, LixError> {
113
+ native_get_many(self, space, keys)
114
+ }
115
+
116
+ async fn changelog_scan(
117
+ &mut self,
118
+ space: StorageSpace,
119
+ prefix: Vec<u8>,
120
+ after: Option<Vec<u8>>,
121
+ limit: usize,
122
+ projection: StorageCoreProjection,
123
+ ) -> Result<ChangelogScanPage, LixError> {
124
+ native_scan(self, space, prefix, after, limit, projection)
125
+ }
126
+ }
127
+
128
+ #[async_trait]
129
+ impl<B> ChangelogStorageRead for StorageContext<B>
130
+ where
131
+ B: StorageBackend + Send,
132
+ {
133
+ async fn changelog_get_many(
134
+ &mut self,
135
+ space: StorageSpace,
136
+ keys: Vec<Vec<u8>>,
137
+ ) -> Result<Vec<Option<Vec<u8>>>, LixError> {
138
+ let mut read = self.begin_read(StorageReadOptions::default())?;
139
+ native_get_many(&mut read, space, keys)
140
+ }
141
+
142
+ async fn changelog_scan(
143
+ &mut self,
144
+ space: StorageSpace,
145
+ prefix: Vec<u8>,
146
+ after: Option<Vec<u8>>,
147
+ limit: usize,
148
+ projection: StorageCoreProjection,
149
+ ) -> Result<ChangelogScanPage, LixError> {
150
+ let mut read = self.begin_read(StorageReadOptions::default())?;
151
+ native_scan(&mut read, space, prefix, after, limit, projection)
152
+ }
153
+ }
154
+
155
+ #[async_trait]
156
+ impl<S> ChangelogReader for ChangelogStoreReader<S>
157
+ where
158
+ S: ChangelogStorageRead + Send,
159
+ {
160
+ async fn plan_gc(&mut self, roots: &[GcRoot]) -> Result<GcPlan, LixError> {
161
+ Ok(empty_gc_plan(roots))
162
+ }
163
+
164
+ async fn load_commits(
165
+ &mut self,
166
+ request: CommitLoadRequest<'_>,
167
+ ) -> Result<CommitLoadBatch, LixError> {
168
+ load_commits_from_store(&mut self.store, request).await
169
+ }
170
+
171
+ async fn scan_commits(
172
+ &mut self,
173
+ request: CommitScanRequest<'_>,
174
+ ) -> Result<CommitScanBatch, LixError> {
175
+ scan_commits_from_store(&mut self.store, request).await
176
+ }
177
+
178
+ async fn load_changes(
179
+ &mut self,
180
+ request: ChangeLoadRequest<'_>,
181
+ ) -> Result<ChangeLoadBatch, LixError> {
182
+ load_changes_from_store(&mut self.store, request).await
183
+ }
184
+
185
+ async fn scan_changes(
186
+ &mut self,
187
+ request: ChangeScanRequest<'_>,
188
+ ) -> Result<ChangeScanBatch, LixError> {
189
+ scan_changes_from_store(&mut self.store, request).await
190
+ }
191
+ }
192
+
193
+ #[async_trait]
194
+ impl<S> ChangelogReader for ChangelogStoreWriter<'_, S>
195
+ where
196
+ S: ChangelogStorageRead + Send + ?Sized,
197
+ {
198
+ async fn plan_gc(&mut self, roots: &[GcRoot]) -> Result<GcPlan, LixError> {
199
+ Ok(empty_gc_plan(roots))
200
+ }
201
+
202
+ async fn load_commits(
203
+ &mut self,
204
+ request: CommitLoadRequest<'_>,
205
+ ) -> Result<CommitLoadBatch, LixError> {
206
+ let stored = load_commits_from_store(self.store, request).await?;
207
+ let entries = request
208
+ .commit_ids
209
+ .iter()
210
+ .zip(stored.entries.into_iter())
211
+ .map(|(commit_id, stored)| {
212
+ if let Some(record) = self.staged_commits.get(commit_id) {
213
+ return Some(project_commit_entry(
214
+ request.projection,
215
+ record.clone(),
216
+ self.staged_commit_change_ref_chunks
217
+ .get(commit_id)
218
+ .cloned()
219
+ .unwrap_or_default(),
220
+ ));
221
+ }
222
+ stored
223
+ })
224
+ .collect();
225
+ Ok(CommitLoadBatch { entries })
226
+ }
227
+
228
+ async fn scan_commits(
229
+ &mut self,
230
+ request: CommitScanRequest<'_>,
231
+ ) -> Result<CommitScanBatch, LixError> {
232
+ let mut batch = scan_commits_from_store(self.store, request).await?;
233
+ let mut staged = self
234
+ .staged_commits
235
+ .values()
236
+ .filter(|commit| {
237
+ request
238
+ .start_after
239
+ .map(|start_after| commit.commit_id.as_str() > start_after)
240
+ .unwrap_or(true)
241
+ })
242
+ .cloned()
243
+ .collect::<Vec<_>>();
244
+ staged.sort_by(|left, right| left.commit_id.cmp(&right.commit_id));
245
+ for commit in staged {
246
+ batch.entries.push(project_commit_entry(
247
+ request.projection,
248
+ commit.clone(),
249
+ self.staged_commit_change_ref_chunks
250
+ .get(&commit.commit_id)
251
+ .cloned()
252
+ .unwrap_or_default(),
253
+ ));
254
+ }
255
+ batch.entries.sort_by(|left, right| {
256
+ commit_entry_id(left)
257
+ .unwrap_or_default()
258
+ .cmp(commit_entry_id(right).unwrap_or_default())
259
+ });
260
+ let limit = request.limit.unwrap_or(usize::MAX);
261
+ if batch.entries.len() > limit {
262
+ batch.entries.truncate(limit);
263
+ batch.next_start_after = batch
264
+ .entries
265
+ .last()
266
+ .and_then(commit_entry_id)
267
+ .map(str::to_string);
268
+ }
269
+ Ok(batch)
270
+ }
271
+
272
+ async fn load_changes(
273
+ &mut self,
274
+ request: ChangeLoadRequest<'_>,
275
+ ) -> Result<ChangeLoadBatch, LixError> {
276
+ let stored = load_changes_from_store(self.store, request).await?;
277
+ let entries = request
278
+ .change_ids
279
+ .iter()
280
+ .zip(stored.entries.into_iter())
281
+ .map(|(change_id, stored)| self.staged_changes.get(change_id).cloned().or(stored))
282
+ .collect();
283
+ Ok(ChangeLoadBatch { entries })
284
+ }
285
+
286
+ async fn scan_changes(
287
+ &mut self,
288
+ request: ChangeScanRequest<'_>,
289
+ ) -> Result<ChangeScanBatch, LixError> {
290
+ let mut batch = scan_changes_from_store(self.store, request).await?;
291
+ let mut staged = self
292
+ .staged_changes
293
+ .values()
294
+ .filter(|change| {
295
+ request
296
+ .start_after
297
+ .map(|start_after| change.change_id.as_str() > start_after)
298
+ .unwrap_or(true)
299
+ })
300
+ .cloned()
301
+ .collect::<Vec<_>>();
302
+ staged.sort_by(|left, right| left.change_id.cmp(&right.change_id));
303
+ batch.entries.extend(staged);
304
+ batch
305
+ .entries
306
+ .sort_by(|left, right| left.change_id.cmp(&right.change_id));
307
+ batch
308
+ .entries
309
+ .dedup_by(|left, right| left.change_id == right.change_id);
310
+ let limit = request.limit.unwrap_or(usize::MAX);
311
+ if batch.entries.len() > limit {
312
+ batch.entries.truncate(limit);
313
+ batch.next_start_after = batch.entries.last().map(|change| change.change_id.clone());
314
+ }
315
+ Ok(batch)
316
+ }
317
+ }
318
+
319
+ #[async_trait]
320
+ impl<S> ChangelogWriter for ChangelogStoreWriter<'_, S>
321
+ where
322
+ S: ChangelogStorageRead + Send + ?Sized,
323
+ {
324
+ async fn stage_append(&mut self, append: ChangelogAppend) -> Result<(), LixError> {
325
+ self.validate_append(&append).await?;
326
+
327
+ for change in append.changes {
328
+ self.writes.put(
329
+ CHANGE_SPACE,
330
+ change_key(&change.change_id),
331
+ encode_change_record(&change)?,
332
+ );
333
+ self.staged_changes.insert(change.change_id.clone(), change);
334
+ }
335
+
336
+ let chunks = chunk_commit_change_refs(append.commit_change_refs)?;
337
+ for commit in append.commits {
338
+ self.writes.put(
339
+ COMMIT_SPACE,
340
+ commit_key(&commit.commit_id),
341
+ encode_commit_record(&commit)?,
342
+ );
343
+ self.staged_commits.insert(commit.commit_id.clone(), commit);
344
+ }
345
+
346
+ for (commit_id, commit_chunks) in chunks {
347
+ for (chunk_no, chunk) in commit_chunks.iter().enumerate() {
348
+ self.writes.put(
349
+ COMMIT_CHANGE_REF_CHUNK_SPACE,
350
+ commit_change_ref_chunk_key(&commit_id, chunk_no as u32),
351
+ encode_commit_change_ref_chunk(chunk)?,
352
+ );
353
+ }
354
+ self.staged_commit_change_ref_chunks
355
+ .insert(commit_id, commit_chunks);
356
+ }
357
+
358
+ Ok(())
359
+ }
360
+
361
+ async fn collect_garbage(&mut self, roots: &[GcRoot]) -> Result<GcPlan, LixError> {
362
+ Ok(empty_gc_plan(roots))
363
+ }
364
+ }
365
+
366
+ impl<S> ChangelogStoreWriter<'_, S>
367
+ where
368
+ S: ChangelogStorageRead + Send + ?Sized,
369
+ {
370
+ async fn validate_append(&mut self, append: &ChangelogAppend) -> Result<(), LixError> {
371
+ validate_unique(
372
+ append
373
+ .commits
374
+ .iter()
375
+ .map(|commit| commit.commit_id.as_str()),
376
+ "commit_id",
377
+ )?;
378
+ validate_unique(
379
+ append
380
+ .changes
381
+ .iter()
382
+ .map(|change| change.change_id.as_str()),
383
+ "change_id",
384
+ )?;
385
+ validate_unique(
386
+ append
387
+ .commits
388
+ .iter()
389
+ .map(|commit| commit.change_id.as_str()),
390
+ "commit change_id",
391
+ )?;
392
+ validate_unique(
393
+ append
394
+ .commit_change_refs
395
+ .iter()
396
+ .map(|refs| refs.commit_id.as_str()),
397
+ "commit change ref commit_id",
398
+ )?;
399
+
400
+ let append_commit_ids = append
401
+ .commits
402
+ .iter()
403
+ .map(|commit| commit.commit_id.as_str())
404
+ .collect::<HashSet<_>>();
405
+ let append_changes = append
406
+ .changes
407
+ .iter()
408
+ .map(|change| (change.change_id.as_str(), change))
409
+ .collect::<HashMap<_, _>>();
410
+
411
+ self.reject_existing_commits(&append_commit_ids).await?;
412
+ self.reject_existing_changes(append_changes.keys().copied())
413
+ .await?;
414
+ self.reject_commit_change_id_collisions(append, &append_changes)
415
+ .await?;
416
+ self.validate_parent_commits(append, &append_commit_ids)
417
+ .await?;
418
+
419
+ for commit in &append.commits {
420
+ if !append
421
+ .commit_change_refs
422
+ .iter()
423
+ .any(|refs| refs.commit_id == commit.commit_id)
424
+ {
425
+ return Err(LixError::unknown(format!(
426
+ "changelog commit '{}' is missing commit change refs",
427
+ commit.commit_id
428
+ )));
429
+ }
430
+ }
431
+
432
+ for refs in &append.commit_change_refs {
433
+ if !append_commit_ids.contains(refs.commit_id.as_str()) {
434
+ return Err(LixError::unknown(format!(
435
+ "changelog commit change refs target missing staged commit '{}'",
436
+ refs.commit_id
437
+ )));
438
+ }
439
+ validate_unique_ref_keys(&refs.entries, &refs.commit_id)?;
440
+ self.validate_change_refs(refs, &append_changes).await?;
441
+ }
442
+
443
+ Ok(())
444
+ }
445
+
446
+ async fn reject_commit_change_id_collisions(
447
+ &mut self,
448
+ append: &ChangelogAppend,
449
+ append_changes: &HashMap<&str, &ChangeRecord>,
450
+ ) -> Result<(), LixError> {
451
+ for commit in &append.commits {
452
+ if append_changes.contains_key(commit.change_id.as_str())
453
+ || self.change_exists(&commit.change_id).await?
454
+ || self
455
+ .staged_commits
456
+ .values()
457
+ .any(|staged| staged.change_id == commit.change_id)
458
+ {
459
+ return Err(LixError::unknown(format!(
460
+ "changelog commit '{}' derived change_id '{}' collides with an existing change id",
461
+ commit.commit_id, commit.change_id
462
+ )));
463
+ }
464
+ }
465
+ let mut start_after = None::<String>;
466
+ loop {
467
+ let batch = scan_commits_from_store(
468
+ self.store,
469
+ CommitScanRequest {
470
+ start_after: start_after.as_deref(),
471
+ limit: Some(SCAN_PAGE_LIMIT),
472
+ projection: CommitProjection::Record,
473
+ },
474
+ )
475
+ .await?;
476
+ for entry in batch.entries {
477
+ let CommitLoadEntry::Record(record) = entry else {
478
+ continue;
479
+ };
480
+ if append
481
+ .commits
482
+ .iter()
483
+ .any(|commit| commit.change_id == record.change_id)
484
+ {
485
+ return Err(LixError::unknown(format!(
486
+ "changelog commit derived change_id '{}' already exists",
487
+ record.change_id
488
+ )));
489
+ }
490
+ }
491
+ let Some(next) = batch.next_start_after else {
492
+ break;
493
+ };
494
+ start_after = Some(next);
495
+ }
496
+ Ok(())
497
+ }
498
+
499
+ async fn reject_existing_commits<'a>(
500
+ &mut self,
501
+ commit_ids: &HashSet<&'a str>,
502
+ ) -> Result<(), LixError> {
503
+ let keys = commit_ids
504
+ .iter()
505
+ .map(|commit_id| commit_key(commit_id))
506
+ .collect::<Vec<_>>();
507
+ for (commit_id, found) in commit_ids
508
+ .iter()
509
+ .zip(get_many(self.store, COMMIT_SPACE, keys).await?)
510
+ {
511
+ if found.is_some() || self.staged_commits.contains_key(*commit_id) {
512
+ return Err(LixError::unknown(format!(
513
+ "changelog commit '{}' already exists",
514
+ commit_id
515
+ )));
516
+ }
517
+ }
518
+ Ok(())
519
+ }
520
+
521
+ async fn reject_existing_changes<'a>(
522
+ &mut self,
523
+ change_ids: impl IntoIterator<Item = &'a str>,
524
+ ) -> Result<(), LixError> {
525
+ let change_ids = change_ids.into_iter().collect::<Vec<_>>();
526
+ let keys = change_ids
527
+ .iter()
528
+ .map(|change_id| change_key(change_id))
529
+ .collect::<Vec<_>>();
530
+ for (change_id, found) in change_ids
531
+ .iter()
532
+ .zip(get_many(self.store, CHANGE_SPACE, keys).await?)
533
+ {
534
+ if found.is_some() || self.staged_changes.contains_key(*change_id) {
535
+ return Err(LixError::unknown(format!(
536
+ "changelog change '{}' already exists",
537
+ change_id
538
+ )));
539
+ }
540
+ }
541
+ Ok(())
542
+ }
543
+
544
+ async fn validate_parent_commits(
545
+ &mut self,
546
+ append: &ChangelogAppend,
547
+ append_commit_ids: &HashSet<&str>,
548
+ ) -> Result<(), LixError> {
549
+ let parent_ids = append
550
+ .commits
551
+ .iter()
552
+ .flat_map(|commit| commit.parent_commit_ids.iter().map(String::as_str))
553
+ .filter(|parent_id| !append_commit_ids.contains(parent_id))
554
+ .collect::<HashSet<_>>();
555
+ let keys = parent_ids
556
+ .iter()
557
+ .map(|parent_id| commit_key(parent_id))
558
+ .collect::<Vec<_>>();
559
+ for (parent_id, found) in parent_ids
560
+ .iter()
561
+ .zip(get_many(self.store, COMMIT_SPACE, keys).await?)
562
+ {
563
+ if found.is_none() && !self.staged_commits.contains_key(*parent_id) {
564
+ return Err(LixError::unknown(format!(
565
+ "changelog parent commit '{}' does not exist",
566
+ parent_id
567
+ )));
568
+ }
569
+ }
570
+ Ok(())
571
+ }
572
+
573
+ async fn validate_change_refs(
574
+ &mut self,
575
+ refs: &CommitChangeRefSet,
576
+ append_changes: &HashMap<&str, &ChangeRecord>,
577
+ ) -> Result<(), LixError> {
578
+ let missing_from_append = refs
579
+ .entries
580
+ .iter()
581
+ .filter(|entry| !append_changes.contains_key(entry.change_id.as_str()))
582
+ .map(|entry| entry.change_id.as_str())
583
+ .collect::<HashSet<_>>();
584
+ let stored = self
585
+ .load_stored_changes(missing_from_append.iter().copied())
586
+ .await?;
587
+
588
+ for entry in &refs.entries {
589
+ let change = append_changes
590
+ .get(entry.change_id.as_str())
591
+ .copied()
592
+ .or_else(|| self.staged_changes.get(&entry.change_id))
593
+ .or_else(|| stored.get(entry.change_id.as_str()))
594
+ .ok_or_else(|| {
595
+ LixError::unknown(format!(
596
+ "changelog commit '{}' references missing change '{}'",
597
+ refs.commit_id, entry.change_id
598
+ ))
599
+ })?;
600
+ validate_ref_matches_change(&refs.commit_id, entry, change)?;
601
+ }
602
+ Ok(())
603
+ }
604
+
605
+ async fn load_stored_changes<'a>(
606
+ &mut self,
607
+ change_ids: impl IntoIterator<Item = &'a str>,
608
+ ) -> Result<HashMap<String, ChangeRecord>, LixError> {
609
+ let change_ids = change_ids.into_iter().collect::<Vec<_>>();
610
+ let keys = change_ids
611
+ .iter()
612
+ .map(|change_id| change_key(change_id))
613
+ .collect::<Vec<_>>();
614
+ let values = get_many(self.store, CHANGE_SPACE, keys).await?;
615
+ let mut out = HashMap::new();
616
+ for (change_id, value) in change_ids.into_iter().zip(values) {
617
+ if let Some(value) = value {
618
+ out.insert(change_id.to_string(), decode_change_record(&value)?);
619
+ }
620
+ }
621
+ Ok(out)
622
+ }
623
+
624
+ async fn change_exists(&mut self, change_id: &str) -> Result<bool, LixError> {
625
+ if self.staged_changes.contains_key(change_id) {
626
+ return Ok(true);
627
+ }
628
+ Ok(get_one(self.store, CHANGE_SPACE, change_key(change_id))
629
+ .await?
630
+ .is_some())
631
+ }
632
+ }
633
+
634
+ async fn load_commits_from_store(
635
+ store: &mut (impl ChangelogStorageRead + ?Sized),
636
+ request: CommitLoadRequest<'_>,
637
+ ) -> Result<CommitLoadBatch, LixError> {
638
+ let keys = request
639
+ .commit_ids
640
+ .iter()
641
+ .map(|commit_id| commit_key(commit_id))
642
+ .collect::<Vec<_>>();
643
+ let commit_values = get_many(store, COMMIT_SPACE, keys).await?;
644
+ let mut entries = Vec::with_capacity(request.commit_ids.len());
645
+ for (commit_id, value) in request.commit_ids.iter().zip(commit_values) {
646
+ let Some(value) = value else {
647
+ entries.push(None);
648
+ continue;
649
+ };
650
+ let record = decode_commit_record(&value)?;
651
+ let chunks = match request.projection {
652
+ CommitProjection::Record => Vec::new(),
653
+ CommitProjection::ChangeRefs | CommitProjection::Full => {
654
+ load_commit_change_ref_chunks(store, commit_id).await?
655
+ }
656
+ };
657
+ entries.push(Some(project_commit_entry(
658
+ request.projection,
659
+ record,
660
+ chunks,
661
+ )));
662
+ }
663
+ Ok(CommitLoadBatch { entries })
664
+ }
665
+
666
+ async fn scan_commits_from_store(
667
+ store: &mut (impl ChangelogStorageRead + ?Sized),
668
+ request: CommitScanRequest<'_>,
669
+ ) -> Result<CommitScanBatch, LixError> {
670
+ if request.projection != CommitProjection::Record {
671
+ return Err(LixError::new(
672
+ LixError::CODE_INTERNAL_ERROR,
673
+ "changelog scan_commits currently supports CommitProjection::Record only",
674
+ ));
675
+ }
676
+ let limit = request.limit.unwrap_or(SCAN_PAGE_LIMIT);
677
+ if limit == 0 {
678
+ return Ok(CommitScanBatch {
679
+ entries: Vec::new(),
680
+ next_start_after: request.start_after.map(str::to_string),
681
+ });
682
+ }
683
+ let page = store
684
+ .changelog_scan(
685
+ COMMIT_SPACE,
686
+ Vec::new(),
687
+ request.start_after.map(commit_key),
688
+ limit,
689
+ StorageCoreProjection::FullValue,
690
+ )
691
+ .await?;
692
+ let mut entries = Vec::with_capacity(page.values.len());
693
+ for (key, value) in page.keys.iter().zip(page.values.iter()) {
694
+ let record = decode_commit_record(value)?;
695
+ if key.as_slice() != commit_key(&record.commit_id).as_slice() {
696
+ return Err(LixError::new(
697
+ LixError::CODE_INTERNAL_ERROR,
698
+ format!(
699
+ "changelog commit scan key does not match decoded commit_id '{}'",
700
+ record.commit_id
701
+ ),
702
+ ));
703
+ }
704
+ entries.push(CommitLoadEntry::Record(record));
705
+ }
706
+ let next_start_after = page
707
+ .resume_after
708
+ .map(|key| {
709
+ String::from_utf8(key).map_err(|error| {
710
+ LixError::new(
711
+ LixError::CODE_INTERNAL_ERROR,
712
+ format!("changelog commit scan resume key is not UTF-8: {error}"),
713
+ )
714
+ })
715
+ })
716
+ .transpose()?;
717
+ Ok(CommitScanBatch {
718
+ entries,
719
+ next_start_after,
720
+ })
721
+ }
722
+
723
+ async fn load_changes_from_store(
724
+ store: &mut (impl ChangelogStorageRead + ?Sized),
725
+ request: ChangeLoadRequest<'_>,
726
+ ) -> Result<ChangeLoadBatch, LixError> {
727
+ let keys = request
728
+ .change_ids
729
+ .iter()
730
+ .map(|change_id| change_key(change_id))
731
+ .collect::<Vec<_>>();
732
+ let entries = get_many(store, CHANGE_SPACE, keys)
733
+ .await?
734
+ .into_iter()
735
+ .map(|value| value.as_deref().map(decode_change_record).transpose())
736
+ .collect::<Result<Vec<_>, LixError>>()?;
737
+ Ok(ChangeLoadBatch { entries })
738
+ }
739
+
740
+ async fn scan_changes_from_store(
741
+ store: &mut (impl ChangelogStorageRead + ?Sized),
742
+ request: ChangeScanRequest<'_>,
743
+ ) -> Result<ChangeScanBatch, LixError> {
744
+ let limit = request.limit.unwrap_or(SCAN_PAGE_LIMIT);
745
+ if limit == 0 {
746
+ return Ok(ChangeScanBatch {
747
+ entries: Vec::new(),
748
+ next_start_after: request.start_after.map(str::to_string),
749
+ });
750
+ }
751
+ let page = store
752
+ .changelog_scan(
753
+ CHANGE_SPACE,
754
+ Vec::new(),
755
+ request.start_after.map(change_key),
756
+ limit,
757
+ StorageCoreProjection::FullValue,
758
+ )
759
+ .await?;
760
+ let mut entries = Vec::with_capacity(page.values.len());
761
+ for (key, value) in page.keys.iter().zip(page.values.iter()) {
762
+ let record = decode_change_record(value)?;
763
+ if key.as_slice() != change_key(&record.change_id).as_slice() {
764
+ return Err(LixError::new(
765
+ LixError::CODE_INTERNAL_ERROR,
766
+ format!(
767
+ "changelog change scan key does not match decoded change_id '{}'",
768
+ record.change_id
769
+ ),
770
+ ));
771
+ }
772
+ entries.push(record);
773
+ }
774
+ let next_start_after = page
775
+ .resume_after
776
+ .map(|key| {
777
+ String::from_utf8(key).map_err(|error| {
778
+ LixError::new(
779
+ LixError::CODE_INTERNAL_ERROR,
780
+ format!("changelog change scan resume key is not UTF-8: {error}"),
781
+ )
782
+ })
783
+ })
784
+ .transpose()?;
785
+ Ok(ChangeScanBatch {
786
+ entries,
787
+ next_start_after,
788
+ })
789
+ }
790
+
791
+ async fn load_commit_change_ref_chunks(
792
+ store: &mut (impl ChangelogStorageRead + ?Sized),
793
+ commit_id: &str,
794
+ ) -> Result<Vec<CommitChangeRefChunk>, LixError> {
795
+ let prefix = commit_change_ref_chunk_prefix(commit_id);
796
+ let mut after = None;
797
+ let mut chunks = Vec::new();
798
+ loop {
799
+ let page = store
800
+ .changelog_scan(
801
+ COMMIT_CHANGE_REF_CHUNK_SPACE,
802
+ prefix.clone(),
803
+ after,
804
+ SCAN_PAGE_LIMIT,
805
+ StorageCoreProjection::FullValue,
806
+ )
807
+ .await?;
808
+ for value in page.values {
809
+ chunks.push(decode_commit_change_ref_chunk(&value, commit_id)?);
810
+ }
811
+ let Some(resume_after) = page.resume_after else {
812
+ break;
813
+ };
814
+ after = Some(resume_after);
815
+ }
816
+ Ok(chunks)
817
+ }
818
+
819
+ fn project_commit_entry(
820
+ projection: CommitProjection,
821
+ record: CommitRecord,
822
+ change_ref_chunks: Vec<CommitChangeRefChunk>,
823
+ ) -> CommitLoadEntry {
824
+ match projection {
825
+ CommitProjection::Record => CommitLoadEntry::Record(record),
826
+ CommitProjection::ChangeRefs => CommitLoadEntry::ChangeRefs(change_ref_chunks),
827
+ CommitProjection::Full => CommitLoadEntry::Full {
828
+ record,
829
+ change_ref_chunks,
830
+ },
831
+ }
832
+ }
833
+
834
+ fn commit_entry_id(entry: &CommitLoadEntry) -> Option<&str> {
835
+ match entry {
836
+ CommitLoadEntry::Record(record) => Some(&record.commit_id),
837
+ CommitLoadEntry::Full { record, .. } => Some(&record.commit_id),
838
+ CommitLoadEntry::ChangeRefs(chunks) => chunks.first().map(|chunk| chunk.commit_id.as_str()),
839
+ }
840
+ }
841
+
842
+ fn chunk_commit_change_refs(
843
+ refs: Vec<CommitChangeRefSet>,
844
+ ) -> Result<HashMap<String, Vec<CommitChangeRefChunk>>, LixError> {
845
+ refs.into_iter()
846
+ .map(|refs| {
847
+ let commit_id = refs.commit_id.clone();
848
+ Ok((
849
+ commit_id,
850
+ chunk_one_commit_change_refs(
851
+ refs,
852
+ COMMIT_CHANGE_REF_CHUNK_TARGET_BYTES,
853
+ COMMIT_CHANGE_REF_CHUNK_MAX_BYTES,
854
+ COMMIT_CHANGE_REF_CHUNK_MAX_ENTRIES,
855
+ )?,
856
+ ))
857
+ })
858
+ .collect()
859
+ }
860
+
861
+ fn chunk_one_commit_change_refs(
862
+ mut refs: CommitChangeRefSet,
863
+ target_bytes: usize,
864
+ max_bytes: usize,
865
+ max_entries: usize,
866
+ ) -> Result<Vec<CommitChangeRefChunk>, LixError> {
867
+ refs.entries.sort_by(|left, right| {
868
+ (
869
+ left.schema_key.as_str(),
870
+ left.file_id.as_deref(),
871
+ &left.entity_pk,
872
+ left.change_id.as_str(),
873
+ )
874
+ .cmp(&(
875
+ right.schema_key.as_str(),
876
+ right.file_id.as_deref(),
877
+ &right.entity_pk,
878
+ right.change_id.as_str(),
879
+ ))
880
+ });
881
+
882
+ let mut chunks = Vec::new();
883
+ let mut builder = CommitChangeRefChunkBuilder::new(refs.commit_id.clone());
884
+ for entry in refs.entries {
885
+ let candidate_size = builder.estimated_size_after(&entry);
886
+ if !builder.is_empty()
887
+ && (builder.len() >= max_entries
888
+ || builder.estimated_size() >= target_bytes
889
+ || candidate_size > max_bytes)
890
+ {
891
+ chunks.push(builder.finish()?);
892
+ builder = CommitChangeRefChunkBuilder::new(refs.commit_id.clone());
893
+ }
894
+
895
+ builder.push(entry);
896
+ validate_commit_change_ref_chunk_size(&builder, max_bytes)?;
897
+ }
898
+
899
+ if !builder.is_empty() {
900
+ chunks.push(builder.finish()?);
901
+ }
902
+ Ok(chunks)
903
+ }
904
+
905
+ fn commit_change_ref_chunk(commit_id: &str, entries: Vec<CommitChangeRef>) -> CommitChangeRefChunk {
906
+ CommitChangeRefChunk {
907
+ format_version: COMMIT_CHANGE_REF_CHUNK_FORMAT_VERSION,
908
+ commit_id: commit_id.to_string(),
909
+ entries,
910
+ }
911
+ }
912
+
913
+ fn validate_commit_change_ref_chunk_size(
914
+ builder: &CommitChangeRefChunkBuilder,
915
+ max_bytes: usize,
916
+ ) -> Result<(), LixError> {
917
+ let size = builder.estimated_size();
918
+ if size > max_bytes {
919
+ return Err(LixError::new(
920
+ LixError::CODE_INTERNAL_ERROR,
921
+ format!(
922
+ "single changelog commit_change_ref_chunk entry for commit '{}' exceeds {max_bytes} bytes",
923
+ builder.commit_id
924
+ ),
925
+ ));
926
+ }
927
+ Ok(())
928
+ }
929
+
930
+ struct CommitChangeRefChunkBuilder {
931
+ commit_id: String,
932
+ entries: Vec<CommitChangeRef>,
933
+ schema_keys: HashSet<String>,
934
+ file_ids: HashSet<Option<String>>,
935
+ estimated_size: usize,
936
+ }
937
+
938
+ impl CommitChangeRefChunkBuilder {
939
+ fn new(commit_id: String) -> Self {
940
+ Self {
941
+ commit_id,
942
+ entries: Vec::new(),
943
+ schema_keys: HashSet::new(),
944
+ file_ids: HashSet::new(),
945
+ estimated_size: commit_change_ref_chunk_fixed_size(),
946
+ }
947
+ }
948
+
949
+ fn is_empty(&self) -> bool {
950
+ self.entries.is_empty()
951
+ }
952
+
953
+ fn len(&self) -> usize {
954
+ self.entries.len()
955
+ }
956
+
957
+ fn estimated_size(&self) -> usize {
958
+ self.estimated_size
959
+ }
960
+
961
+ fn estimated_size_after(&self, entry: &CommitChangeRef) -> usize {
962
+ self.estimated_size + self.incremental_size(entry)
963
+ }
964
+
965
+ fn push(&mut self, entry: CommitChangeRef) {
966
+ self.estimated_size += self.incremental_size(&entry);
967
+ self.schema_keys.insert(entry.schema_key.clone());
968
+ self.file_ids.insert(entry.file_id.clone());
969
+ self.entries.push(entry);
970
+ }
971
+
972
+ fn incremental_size(&self, entry: &CommitChangeRef) -> usize {
973
+ let schema_dictionary_bytes = if self.schema_keys.contains(&entry.schema_key) {
974
+ 0
975
+ } else {
976
+ encoded_str_size(&entry.schema_key)
977
+ };
978
+ let file_dictionary_bytes = if self.file_ids.contains(&entry.file_id) {
979
+ 0
980
+ } else {
981
+ encoded_optional_str_size(entry.file_id.as_deref())
982
+ };
983
+ schema_dictionary_bytes
984
+ + file_dictionary_bytes
985
+ + encoded_commit_change_ref_entry_size(entry)
986
+ }
987
+
988
+ fn finish(self) -> Result<CommitChangeRefChunk, LixError> {
989
+ let chunk = commit_change_ref_chunk(&self.commit_id, self.entries);
990
+ debug_assert_eq!(
991
+ self.estimated_size,
992
+ encode_commit_change_ref_chunk(&chunk)?.len()
993
+ );
994
+ Ok(chunk)
995
+ }
996
+ }
997
+
998
+ fn commit_change_ref_chunk_fixed_size() -> usize {
999
+ 5 // magic
1000
+ + 4 // format_version
1001
+ + 4 // schema dictionary length
1002
+ + 4 // file dictionary length
1003
+ + 4 // entry count
1004
+ }
1005
+
1006
+ fn encoded_commit_change_ref_entry_size(entry: &CommitChangeRef) -> usize {
1007
+ 2 // schema index
1008
+ + 2 // file index
1009
+ + encoded_entity_pk_compact_size(&entry.entity_pk)
1010
+ + encoded_str_size(&entry.change_id)
1011
+ }
1012
+
1013
+ fn encoded_entity_pk_compact_size(identity: &crate::entity_pk::EntityPk) -> usize {
1014
+ if identity.parts.len() == 1 {
1015
+ 1 + encoded_str_size(&identity.parts[0])
1016
+ } else {
1017
+ 1 + 4
1018
+ + identity
1019
+ .parts
1020
+ .iter()
1021
+ .map(|part| encoded_str_size(part))
1022
+ .sum::<usize>()
1023
+ }
1024
+ }
1025
+
1026
+ fn encoded_optional_str_size(value: Option<&str>) -> usize {
1027
+ 1 + value.map(encoded_str_size).unwrap_or(0)
1028
+ }
1029
+
1030
+ fn encoded_str_size(value: &str) -> usize {
1031
+ 4 + value.len()
1032
+ }
1033
+
1034
+ fn validate_unique<'a>(
1035
+ values: impl IntoIterator<Item = &'a str>,
1036
+ label: &str,
1037
+ ) -> Result<(), LixError> {
1038
+ let mut seen = HashSet::new();
1039
+ for value in values {
1040
+ if !seen.insert(value) {
1041
+ return Err(LixError::unknown(format!(
1042
+ "changelog append contains duplicate {label} '{value}'"
1043
+ )));
1044
+ }
1045
+ }
1046
+ Ok(())
1047
+ }
1048
+
1049
+ fn validate_unique_ref_keys(entries: &[CommitChangeRef], commit_id: &str) -> Result<(), LixError> {
1050
+ let mut seen = HashSet::new();
1051
+ for entry in entries {
1052
+ let key = (
1053
+ entry.schema_key.as_str(),
1054
+ entry.file_id.as_deref(),
1055
+ &entry.entity_pk,
1056
+ );
1057
+ if !seen.insert(key) {
1058
+ return Err(LixError::unknown(format!(
1059
+ "changelog commit '{commit_id}' has duplicate change ref key"
1060
+ )));
1061
+ }
1062
+ }
1063
+ Ok(())
1064
+ }
1065
+
1066
+ fn validate_ref_matches_change(
1067
+ commit_id: &str,
1068
+ entry: &CommitChangeRef,
1069
+ change: &ChangeRecord,
1070
+ ) -> Result<(), LixError> {
1071
+ if entry.schema_key != change.schema_key
1072
+ || entry.file_id != change.file_id
1073
+ || entry.entity_pk != change.entity_pk
1074
+ {
1075
+ return Err(LixError::unknown(format!(
1076
+ "changelog commit '{}' change ref '{}' does not match referenced ChangeRecord key",
1077
+ commit_id, entry.change_id
1078
+ )));
1079
+ }
1080
+ Ok(())
1081
+ }
1082
+
1083
+ fn empty_gc_plan(roots: &[GcRoot]) -> GcPlan {
1084
+ GcPlan {
1085
+ roots: roots.to_vec(),
1086
+ ..GcPlan::default()
1087
+ }
1088
+ }
1089
+
1090
+ async fn get_one(
1091
+ store: &mut (impl ChangelogStorageRead + ?Sized),
1092
+ space: StorageSpace,
1093
+ key: Vec<u8>,
1094
+ ) -> Result<Option<Vec<u8>>, LixError> {
1095
+ Ok(get_many(store, space, vec![key])
1096
+ .await?
1097
+ .into_iter()
1098
+ .next()
1099
+ .flatten())
1100
+ }
1101
+
1102
+ async fn get_many(
1103
+ store: &mut (impl ChangelogStorageRead + ?Sized),
1104
+ space: StorageSpace,
1105
+ keys: Vec<Vec<u8>>,
1106
+ ) -> Result<Vec<Option<Vec<u8>>>, LixError> {
1107
+ if keys.is_empty() {
1108
+ return Ok(Vec::new());
1109
+ }
1110
+ store.changelog_get_many(space, keys).await
1111
+ }
1112
+
1113
+ fn native_get_many<R>(
1114
+ read: &mut R,
1115
+ space: StorageSpace,
1116
+ keys: Vec<Vec<u8>>,
1117
+ ) -> Result<Vec<Option<Vec<u8>>>, LixError>
1118
+ where
1119
+ R: StorageRead + ?Sized,
1120
+ {
1121
+ let keys = keys
1122
+ .into_iter()
1123
+ .map(|key| StorageKey(Bytes::from(key)))
1124
+ .collect::<Vec<_>>();
1125
+ let result =
1126
+ PointReadPlan::new(space, &keys).materialize(read, StorageGetOptions::default())?;
1127
+ Ok(result
1128
+ .value
1129
+ .into_iter()
1130
+ .map(|value| match value {
1131
+ Some(StorageProjectedValue::FullValue(bytes)) => Some(bytes.to_vec()),
1132
+ Some(StorageProjectedValue::KeyOnly) => Some(Vec::new()),
1133
+ None => None,
1134
+ })
1135
+ .collect())
1136
+ }
1137
+
1138
+ fn native_scan<R>(
1139
+ read: &mut R,
1140
+ space: StorageSpace,
1141
+ prefix: Vec<u8>,
1142
+ after: Option<Vec<u8>>,
1143
+ limit: usize,
1144
+ projection: StorageCoreProjection,
1145
+ ) -> Result<ChangelogScanPage, LixError>
1146
+ where
1147
+ R: StorageRead + ?Sized,
1148
+ {
1149
+ let after_key = after.map(|key| StorageKey(Bytes::from(key)));
1150
+ let opts = StorageScanOptions {
1151
+ projection,
1152
+ limit_rows: limit,
1153
+ resume_after: after_key.as_ref(),
1154
+ };
1155
+ let chunk = ScanPlan::prefix(
1156
+ space,
1157
+ StoragePrefix {
1158
+ bytes: Bytes::from(prefix),
1159
+ },
1160
+ )
1161
+ .collect(read, opts)?
1162
+ .value;
1163
+ let has_more = chunk.has_more;
1164
+ let mut keys = Vec::with_capacity(chunk.entries.len());
1165
+ let mut values = Vec::with_capacity(chunk.entries.len());
1166
+ for entry in chunk.entries {
1167
+ keys.push(entry.key.0.to_vec());
1168
+ if let StorageProjectedValue::FullValue(bytes) = entry.value {
1169
+ values.push(bytes.to_vec());
1170
+ }
1171
+ }
1172
+ let resume_after = has_more.then(|| keys.last().cloned()).flatten();
1173
+ Ok(ChangelogScanPage {
1174
+ keys,
1175
+ values,
1176
+ resume_after,
1177
+ })
1178
+ }
1179
+
1180
+ #[cfg(test)]
1181
+ mod tests {
1182
+ use crate::changelog::test_support::{changelog_test_context, test_append};
1183
+ use crate::changelog::{
1184
+ ChangeLoadRequest, ChangeRecord, ChangeScanRequest, ChangelogAppend, ChangelogReader,
1185
+ ChangelogWriter, CommitLoadEntry, CommitLoadRequest, CommitProjection, CommitScanRequest,
1186
+ };
1187
+ use crate::entity_pk::EntityPk;
1188
+
1189
+ use super::*;
1190
+
1191
+ fn test_change_ref(entity: &str, change_id: &str) -> CommitChangeRef {
1192
+ CommitChangeRef {
1193
+ schema_key: "message".to_string(),
1194
+ file_id: None,
1195
+ entity_pk: EntityPk::single(entity.to_string()),
1196
+ change_id: change_id.to_string(),
1197
+ }
1198
+ }
1199
+
1200
+ #[test]
1201
+ fn chunk_one_commit_change_refs_splits_by_encoded_size() {
1202
+ let refs = CommitChangeRefSet {
1203
+ commit_id: "commit-1".to_string(),
1204
+ entries: (0..8)
1205
+ .map(|index| {
1206
+ test_change_ref(
1207
+ &format!("entity-{index:04}-{}", "x".repeat(24)),
1208
+ &format!("change-{index:04}-{}", "y".repeat(24)),
1209
+ )
1210
+ })
1211
+ .collect(),
1212
+ };
1213
+
1214
+ let chunks = chunk_one_commit_change_refs(refs, 180, 260, 2048)
1215
+ .expect("refs should chunk under small test limit");
1216
+
1217
+ assert!(chunks.len() > 1);
1218
+ assert!(chunks
1219
+ .iter()
1220
+ .all(|chunk| encode_commit_change_ref_chunk(chunk).unwrap().len() <= 260));
1221
+ assert_eq!(
1222
+ chunks
1223
+ .iter()
1224
+ .flat_map(|chunk| chunk.entries.iter())
1225
+ .map(|entry| entry.change_id.as_str())
1226
+ .collect::<Vec<_>>(),
1227
+ vec![
1228
+ "change-0000-yyyyyyyyyyyyyyyyyyyyyyyy",
1229
+ "change-0001-yyyyyyyyyyyyyyyyyyyyyyyy",
1230
+ "change-0002-yyyyyyyyyyyyyyyyyyyyyyyy",
1231
+ "change-0003-yyyyyyyyyyyyyyyyyyyyyyyy",
1232
+ "change-0004-yyyyyyyyyyyyyyyyyyyyyyyy",
1233
+ "change-0005-yyyyyyyyyyyyyyyyyyyyyyyy",
1234
+ "change-0006-yyyyyyyyyyyyyyyyyyyyyyyy",
1235
+ "change-0007-yyyyyyyyyyyyyyyyyyyyyyyy",
1236
+ ]
1237
+ );
1238
+ }
1239
+
1240
+ #[test]
1241
+ fn chunk_one_commit_change_refs_splits_by_entry_count() {
1242
+ let refs = CommitChangeRefSet {
1243
+ commit_id: "commit-1".to_string(),
1244
+ entries: (0..5)
1245
+ .map(|index| {
1246
+ test_change_ref(&format!("entity-{index}"), &format!("change-{index}"))
1247
+ })
1248
+ .collect(),
1249
+ };
1250
+
1251
+ let chunks = chunk_one_commit_change_refs(refs, usize::MAX, usize::MAX, 2)
1252
+ .expect("refs should chunk by entry cap");
1253
+
1254
+ assert_eq!(
1255
+ chunks
1256
+ .iter()
1257
+ .map(|chunk| chunk.entries.len())
1258
+ .collect::<Vec<_>>(),
1259
+ vec![2, 2, 1]
1260
+ );
1261
+ }
1262
+
1263
+ #[tokio::test]
1264
+ async fn stage_append_writes_direct_records_and_change_ref_chunks() {
1265
+ let (context, storage) = changelog_test_context();
1266
+ let append = test_append();
1267
+
1268
+ let mut transaction = storage.begin_write_transaction().await.unwrap();
1269
+ let mut writes = StorageWriteSet::new();
1270
+ {
1271
+ let mut writer = context.writer(&mut *transaction, &mut writes);
1272
+ writer.stage_append(append).await.unwrap();
1273
+ }
1274
+ let stats = writes.apply(&mut *transaction).await.unwrap();
1275
+ assert_eq!(stats.staged_puts, 3);
1276
+ transaction.commit().await.unwrap();
1277
+
1278
+ let mut read = storage.begin_read_transaction().await.unwrap();
1279
+ let mut reader = context.reader(&mut *read);
1280
+ let commits = reader
1281
+ .load_commits(CommitLoadRequest {
1282
+ commit_ids: &["commit-1".to_string()],
1283
+ projection: CommitProjection::Full,
1284
+ })
1285
+ .await
1286
+ .unwrap();
1287
+ let Some(CommitLoadEntry::Full {
1288
+ record,
1289
+ change_ref_chunks,
1290
+ }) = commits.entries.into_iter().next().flatten()
1291
+ else {
1292
+ panic!("expected full commit entry");
1293
+ };
1294
+ assert_eq!(record.commit_id, "commit-1");
1295
+ assert_eq!(record.change_id, "commit-row-change-1");
1296
+ assert_eq!(change_ref_chunks.len(), 1);
1297
+ assert_eq!(
1298
+ change_ref_chunks[0]
1299
+ .entries
1300
+ .iter()
1301
+ .map(|entry| entry.change_id.as_str())
1302
+ .collect::<Vec<_>>(),
1303
+ vec!["change-1"]
1304
+ );
1305
+
1306
+ let changes = reader
1307
+ .load_changes(ChangeLoadRequest {
1308
+ change_ids: &["change-1".to_string(), "missing".to_string()],
1309
+ })
1310
+ .await
1311
+ .unwrap();
1312
+ assert_eq!(changes.entries[0].as_ref().unwrap().schema_key, "message");
1313
+ assert!(changes.entries[1].is_none());
1314
+ }
1315
+
1316
+ #[tokio::test]
1317
+ async fn stage_append_rejects_ref_key_mismatch() {
1318
+ let (context, storage) = changelog_test_context();
1319
+ let mut append = test_append();
1320
+ append.commit_change_refs[0].entries[0].schema_key = "wrong".to_string();
1321
+
1322
+ let mut transaction = storage.begin_write_transaction().await.unwrap();
1323
+ let mut writes = StorageWriteSet::new();
1324
+ let error = {
1325
+ let mut writer = context.writer(&mut *transaction, &mut writes);
1326
+ writer.stage_append(append).await.unwrap_err()
1327
+ };
1328
+ assert!(
1329
+ error
1330
+ .message
1331
+ .contains("does not match referenced ChangeRecord key"),
1332
+ "{error:?}"
1333
+ );
1334
+ }
1335
+
1336
+ #[tokio::test]
1337
+ async fn stage_append_rejects_commit_missing_change_refs() {
1338
+ let (context, storage) = changelog_test_context();
1339
+ let mut append = test_append();
1340
+ append.commit_change_refs.clear();
1341
+
1342
+ let mut transaction = storage.begin_write_transaction().await.unwrap();
1343
+ let mut writes = StorageWriteSet::new();
1344
+ let error = {
1345
+ let mut writer = context.writer(&mut *transaction, &mut writes);
1346
+ writer.stage_append(append).await.unwrap_err()
1347
+ };
1348
+ assert!(
1349
+ error.message.contains("is missing commit change refs"),
1350
+ "{error:?}"
1351
+ );
1352
+ }
1353
+
1354
+ #[tokio::test]
1355
+ async fn stage_append_rejects_commit_change_id_colliding_with_change_record() {
1356
+ let (context, storage) = changelog_test_context();
1357
+ let mut append = test_append();
1358
+ append.changes[0].change_id = append.commits[0].change_id.clone();
1359
+ append.commit_change_refs[0].entries[0].change_id = append.commits[0].change_id.clone();
1360
+
1361
+ let mut transaction = storage.begin_write_transaction().await.unwrap();
1362
+ let mut writes = StorageWriteSet::new();
1363
+ let error = {
1364
+ let mut writer = context.writer(&mut *transaction, &mut writes);
1365
+ writer.stage_append(append).await.unwrap_err()
1366
+ };
1367
+ assert!(
1368
+ error
1369
+ .message
1370
+ .contains("collides with an existing change id"),
1371
+ "{error:?}"
1372
+ );
1373
+ }
1374
+
1375
+ #[tokio::test]
1376
+ async fn stage_append_sorts_commit_change_refs_by_canonical_key() {
1377
+ let (context, storage) = changelog_test_context();
1378
+ let mut append = test_append();
1379
+ append.changes.push(ChangeRecord {
1380
+ format_version: 1,
1381
+ change_id: "change-0".to_string(),
1382
+ schema_key: "alpha".to_string(),
1383
+ entity_pk: EntityPk::single("entity-0"),
1384
+ file_id: None,
1385
+ snapshot_ref: None,
1386
+ metadata_ref: None,
1387
+ created_at: "2026-05-12T00:00:00Z".to_string(),
1388
+ });
1389
+ append.commit_change_refs[0].entries.insert(
1390
+ 0,
1391
+ crate::changelog::CommitChangeRef {
1392
+ schema_key: "alpha".to_string(),
1393
+ file_id: None,
1394
+ entity_pk: EntityPk::single("entity-0"),
1395
+ change_id: "change-0".to_string(),
1396
+ },
1397
+ );
1398
+ append.commit_change_refs[0].entries.swap(0, 1);
1399
+
1400
+ let mut transaction = storage.begin_write_transaction().await.unwrap();
1401
+ let mut writes = StorageWriteSet::new();
1402
+ {
1403
+ let mut writer = context.writer(&mut *transaction, &mut writes);
1404
+ writer.stage_append(append).await.unwrap();
1405
+ }
1406
+ writes.apply(&mut *transaction).await.unwrap();
1407
+ transaction.commit().await.unwrap();
1408
+
1409
+ let mut read = storage.begin_read_transaction().await.unwrap();
1410
+ let mut reader = context.reader(&mut *read);
1411
+ let commits = reader
1412
+ .load_commits(CommitLoadRequest {
1413
+ commit_ids: &["commit-1".to_string()],
1414
+ projection: CommitProjection::Full,
1415
+ })
1416
+ .await
1417
+ .unwrap();
1418
+ let Some(CommitLoadEntry::Full {
1419
+ change_ref_chunks, ..
1420
+ }) = commits.entries.into_iter().next().flatten()
1421
+ else {
1422
+ panic!("expected full commit entry");
1423
+ };
1424
+ assert_eq!(
1425
+ change_ref_chunks[0]
1426
+ .entries
1427
+ .iter()
1428
+ .map(|entry| entry.change_id.as_str())
1429
+ .collect::<Vec<_>>(),
1430
+ vec!["change-0", "change-1"]
1431
+ );
1432
+ }
1433
+
1434
+ #[tokio::test]
1435
+ async fn scan_commits_reads_direct_commit_records_in_key_order() {
1436
+ let (context, storage) = changelog_test_context();
1437
+ let mut first = test_append();
1438
+ first.commits[0].commit_id = "commit-b".to_string();
1439
+ first.commits[0].change_id = "commit-b-row-change".to_string();
1440
+ first.commit_change_refs[0].commit_id = "commit-b".to_string();
1441
+
1442
+ let mut second = test_append();
1443
+ second.commits[0].commit_id = "commit-a".to_string();
1444
+ second.commits[0].change_id = "commit-a-row-change".to_string();
1445
+ second.changes[0].change_id = "change-a".to_string();
1446
+ second.commit_change_refs[0].commit_id = "commit-a".to_string();
1447
+ second.commit_change_refs[0].entries[0].change_id = "change-a".to_string();
1448
+
1449
+ let mut transaction = storage.begin_write_transaction().await.unwrap();
1450
+ let mut writes = StorageWriteSet::new();
1451
+ {
1452
+ let mut writer = context.writer(&mut *transaction, &mut writes);
1453
+ writer.stage_append(first).await.unwrap();
1454
+ writer.stage_append(second).await.unwrap();
1455
+ }
1456
+ writes.apply(&mut *transaction).await.unwrap();
1457
+ transaction.commit().await.unwrap();
1458
+
1459
+ let mut read = storage.begin_read_transaction().await.unwrap();
1460
+ let mut reader = context.reader(&mut *read);
1461
+ let scan = reader
1462
+ .scan_commits(CommitScanRequest {
1463
+ start_after: None,
1464
+ limit: Some(1),
1465
+ projection: CommitProjection::Record,
1466
+ })
1467
+ .await
1468
+ .unwrap();
1469
+ assert_eq!(scan.entries.len(), 1);
1470
+ assert_eq!(scan.next_start_after.as_deref(), Some("commit-a"));
1471
+ let CommitLoadEntry::Record(record) = &scan.entries[0] else {
1472
+ panic!("expected record projection");
1473
+ };
1474
+ assert_eq!(record.commit_id, "commit-a");
1475
+
1476
+ let next = reader
1477
+ .scan_commits(CommitScanRequest {
1478
+ start_after: scan.next_start_after.as_deref(),
1479
+ limit: Some(10),
1480
+ projection: CommitProjection::Record,
1481
+ })
1482
+ .await
1483
+ .unwrap();
1484
+ let ids = next
1485
+ .entries
1486
+ .iter()
1487
+ .map(|entry| {
1488
+ let CommitLoadEntry::Record(record) = entry else {
1489
+ panic!("expected record projection");
1490
+ };
1491
+ record.commit_id.as_str()
1492
+ })
1493
+ .collect::<Vec<_>>();
1494
+ assert_eq!(ids, vec!["commit-b"]);
1495
+ assert_eq!(next.next_start_after, None);
1496
+ }
1497
+
1498
+ #[tokio::test]
1499
+ async fn scan_changes_reads_direct_change_records_in_key_order() {
1500
+ let (context, storage) = changelog_test_context();
1501
+ let mut first = test_append();
1502
+ first.commits[0].commit_id = "commit-b".to_string();
1503
+ first.commits[0].change_id = "commit-b-row-change".to_string();
1504
+ first.changes[0].change_id = "change-b".to_string();
1505
+ first.commit_change_refs[0].commit_id = "commit-b".to_string();
1506
+ first.commit_change_refs[0].entries[0].change_id = "change-b".to_string();
1507
+
1508
+ let mut second = test_append();
1509
+ second.commits[0].commit_id = "commit-a".to_string();
1510
+ second.commits[0].change_id = "commit-a-row-change".to_string();
1511
+ second.changes[0].change_id = "change-a".to_string();
1512
+ second.commit_change_refs[0].commit_id = "commit-a".to_string();
1513
+ second.commit_change_refs[0].entries[0].change_id = "change-a".to_string();
1514
+
1515
+ let mut transaction = storage.begin_write_transaction().await.unwrap();
1516
+ let mut writes = StorageWriteSet::new();
1517
+ {
1518
+ let mut writer = context.writer(&mut *transaction, &mut writes);
1519
+ writer.stage_append(first).await.unwrap();
1520
+ writer.stage_append(second).await.unwrap();
1521
+ }
1522
+ writes.apply(&mut *transaction).await.unwrap();
1523
+ transaction.commit().await.unwrap();
1524
+
1525
+ let mut read = storage.begin_read_transaction().await.unwrap();
1526
+ let mut reader = context.reader(&mut *read);
1527
+ let scan = reader
1528
+ .scan_changes(ChangeScanRequest {
1529
+ start_after: None,
1530
+ limit: Some(1),
1531
+ })
1532
+ .await
1533
+ .unwrap();
1534
+ assert_eq!(scan.entries.len(), 1);
1535
+ assert_eq!(scan.entries[0].change_id, "change-a");
1536
+ assert_eq!(scan.next_start_after.as_deref(), Some("change-a"));
1537
+
1538
+ let next = reader
1539
+ .scan_changes(ChangeScanRequest {
1540
+ start_after: scan.next_start_after.as_deref(),
1541
+ limit: Some(10),
1542
+ })
1543
+ .await
1544
+ .unwrap();
1545
+ let ids = next
1546
+ .entries
1547
+ .iter()
1548
+ .map(|change| change.change_id.as_str())
1549
+ .collect::<Vec<_>>();
1550
+ assert_eq!(ids, vec!["change-b"]);
1551
+ assert_eq!(next.next_start_after, None);
1552
+ }
1553
+
1554
+ #[tokio::test]
1555
+ async fn scan_changes_pages_all_direct_change_records_without_gaps() {
1556
+ let (context, storage) = changelog_test_context();
1557
+ let changes = (0..2_500)
1558
+ .map(|index| ChangeRecord {
1559
+ format_version: 1,
1560
+ change_id: format!("change-{index:04}"),
1561
+ schema_key: "message".to_string(),
1562
+ entity_pk: EntityPk::single(format!("entity-{index:04}")),
1563
+ file_id: None,
1564
+ snapshot_ref: None,
1565
+ metadata_ref: None,
1566
+ created_at: "2026-05-20T00:00:00Z".to_string(),
1567
+ })
1568
+ .collect::<Vec<_>>();
1569
+ let expected_ids = changes
1570
+ .iter()
1571
+ .map(|change| change.change_id.clone())
1572
+ .collect::<Vec<_>>();
1573
+
1574
+ let mut transaction = storage.begin_write_transaction().await.unwrap();
1575
+ let mut writes = StorageWriteSet::new();
1576
+ {
1577
+ let mut writer = context.writer(&mut *transaction, &mut writes);
1578
+ writer
1579
+ .stage_append(ChangelogAppend {
1580
+ commits: Vec::new(),
1581
+ changes,
1582
+ commit_change_refs: Vec::new(),
1583
+ })
1584
+ .await
1585
+ .unwrap();
1586
+ }
1587
+ writes.apply(&mut *transaction).await.unwrap();
1588
+ transaction.commit().await.unwrap();
1589
+
1590
+ let mut read = storage.begin_read_transaction().await.unwrap();
1591
+ let mut reader = context.reader(&mut *read);
1592
+ let mut start_after = None::<String>;
1593
+ let mut scanned_ids = Vec::new();
1594
+ let mut page_sizes = Vec::new();
1595
+ loop {
1596
+ let page = reader
1597
+ .scan_changes(ChangeScanRequest {
1598
+ start_after: start_after.as_deref(),
1599
+ limit: Some(1_024),
1600
+ })
1601
+ .await
1602
+ .unwrap();
1603
+ page_sizes.push(page.entries.len());
1604
+ scanned_ids.extend(page.entries.into_iter().map(|change| change.change_id));
1605
+ let Some(next_start_after) = page.next_start_after else {
1606
+ break;
1607
+ };
1608
+ start_after = Some(next_start_after);
1609
+ }
1610
+
1611
+ assert_eq!(page_sizes, vec![1_024, 1_024, 452]);
1612
+ assert_eq!(scanned_ids, expected_ids);
1613
+ }
1614
+ }