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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/SKILL.md +305 -320
  2. package/dist/engine-wasm/wasm/lix_engine.d.ts +5 -0
  3. package/dist/engine-wasm/wasm/lix_engine.js +9 -13
  4. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  5. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +1 -0
  6. package/dist/open-lix.d.ts +103 -14
  7. package/dist/open-lix.js +3 -0
  8. package/dist/sqlite/index.js +99 -22
  9. package/dist-engine-src/README.md +18 -0
  10. package/dist-engine-src/src/backend/kv.rs +358 -0
  11. package/dist-engine-src/src/backend/mod.rs +12 -0
  12. package/dist-engine-src/src/backend/testing.rs +658 -0
  13. package/dist-engine-src/src/backend/types.rs +96 -0
  14. package/dist-engine-src/src/binary_cas/chunking.rs +31 -0
  15. package/dist-engine-src/src/binary_cas/codec.rs +346 -0
  16. package/dist-engine-src/src/binary_cas/context.rs +139 -0
  17. package/dist-engine-src/src/binary_cas/kv.rs +1063 -0
  18. package/dist-engine-src/src/binary_cas/mod.rs +11 -0
  19. package/dist-engine-src/src/binary_cas/types.rs +127 -0
  20. package/dist-engine-src/src/cel/context.rs +86 -0
  21. package/dist-engine-src/src/cel/error.rs +19 -0
  22. package/dist-engine-src/src/cel/mod.rs +8 -0
  23. package/dist-engine-src/src/cel/provider.rs +9 -0
  24. package/dist-engine-src/src/cel/runtime.rs +167 -0
  25. package/dist-engine-src/src/cel/value.rs +50 -0
  26. package/dist-engine-src/src/changelog/codec.rs +321 -0
  27. package/dist-engine-src/src/changelog/context.rs +92 -0
  28. package/dist-engine-src/src/changelog/materialization.rs +121 -0
  29. package/dist-engine-src/src/changelog/mod.rs +13 -0
  30. package/dist-engine-src/src/changelog/reader.rs +20 -0
  31. package/dist-engine-src/src/changelog/storage.rs +220 -0
  32. package/dist-engine-src/src/changelog/types.rs +38 -0
  33. package/dist-engine-src/src/commit_graph/context.rs +1588 -0
  34. package/dist-engine-src/src/commit_graph/mod.rs +12 -0
  35. package/dist-engine-src/src/commit_graph/types.rs +145 -0
  36. package/dist-engine-src/src/commit_graph/walker.rs +780 -0
  37. package/dist-engine-src/src/common/error.rs +313 -0
  38. package/dist-engine-src/src/common/fingerprint.rs +3 -0
  39. package/dist-engine-src/src/common/fs_path.rs +1336 -0
  40. package/dist-engine-src/src/common/identity.rs +135 -0
  41. package/dist-engine-src/src/common/metadata.rs +35 -0
  42. package/dist-engine-src/src/common/mod.rs +23 -0
  43. package/dist-engine-src/src/common/types.rs +105 -0
  44. package/dist-engine-src/src/common/wire.rs +222 -0
  45. package/dist-engine-src/src/engine.rs +239 -0
  46. package/dist-engine-src/src/entity_identity.rs +285 -0
  47. package/dist-engine-src/src/functions/context.rs +327 -0
  48. package/dist-engine-src/src/functions/deterministic.rs +113 -0
  49. package/dist-engine-src/src/functions/mod.rs +18 -0
  50. package/dist-engine-src/src/functions/provider.rs +130 -0
  51. package/dist-engine-src/src/functions/state.rs +363 -0
  52. package/dist-engine-src/src/functions/types.rs +37 -0
  53. package/dist-engine-src/src/init.rs +505 -0
  54. package/dist-engine-src/src/json_store/compression.rs +77 -0
  55. package/dist-engine-src/src/json_store/context.rs +129 -0
  56. package/dist-engine-src/src/json_store/encoded.rs +15 -0
  57. package/dist-engine-src/src/json_store/mod.rs +9 -0
  58. package/dist-engine-src/src/json_store/store.rs +236 -0
  59. package/dist-engine-src/src/json_store/types.rs +52 -0
  60. package/dist-engine-src/src/lib.rs +61 -0
  61. package/dist-engine-src/src/live_state/context.rs +2241 -0
  62. package/dist-engine-src/src/live_state/mod.rs +15 -0
  63. package/dist-engine-src/src/live_state/overlay.rs +75 -0
  64. package/dist-engine-src/src/live_state/reader.rs +23 -0
  65. package/dist-engine-src/src/live_state/types.rs +239 -0
  66. package/dist-engine-src/src/live_state/visibility.rs +218 -0
  67. package/dist-engine-src/src/plugin/archive.rs +441 -0
  68. package/dist-engine-src/src/plugin/component.rs +183 -0
  69. package/dist-engine-src/src/plugin/install.rs +637 -0
  70. package/dist-engine-src/src/plugin/manifest.rs +516 -0
  71. package/dist-engine-src/src/plugin/materializer.rs +477 -0
  72. package/dist-engine-src/src/plugin/mod.rs +33 -0
  73. package/dist-engine-src/src/plugin/plugin_manifest.json +119 -0
  74. package/dist-engine-src/src/plugin/storage.rs +74 -0
  75. package/dist-engine-src/src/schema/annotations/defaults.rs +280 -0
  76. package/dist-engine-src/src/schema/annotations/mod.rs +1 -0
  77. package/dist-engine-src/src/schema/builtin/lix_account.json +22 -0
  78. package/dist-engine-src/src/schema/builtin/lix_active_account.json +30 -0
  79. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +30 -0
  80. package/dist-engine-src/src/schema/builtin/lix_change.json +62 -0
  81. package/dist-engine-src/src/schema/builtin/lix_change_author.json +46 -0
  82. package/dist-engine-src/src/schema/builtin/lix_change_set.json +18 -0
  83. package/dist-engine-src/src/schema/builtin/lix_change_set_element.json +75 -0
  84. package/dist-engine-src/src/schema/builtin/lix_commit.json +62 -0
  85. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +46 -0
  86. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +53 -0
  87. package/dist-engine-src/src/schema/builtin/lix_entity_label.json +63 -0
  88. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +53 -0
  89. package/dist-engine-src/src/schema/builtin/lix_key_value.json +41 -0
  90. package/dist-engine-src/src/schema/builtin/lix_label.json +22 -0
  91. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +31 -0
  92. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +35 -0
  93. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +49 -0
  94. package/dist-engine-src/src/schema/builtin/mod.rs +271 -0
  95. package/dist-engine-src/src/schema/definition.json +157 -0
  96. package/dist-engine-src/src/schema/definition.rs +636 -0
  97. package/dist-engine-src/src/schema/key.rs +206 -0
  98. package/dist-engine-src/src/schema/mod.rs +20 -0
  99. package/dist-engine-src/src/schema/seed.rs +14 -0
  100. package/dist-engine-src/src/schema/tests.rs +739 -0
  101. package/dist-engine-src/src/schema_registry.rs +294 -0
  102. package/dist-engine-src/src/session/context.rs +366 -0
  103. package/dist-engine-src/src/session/create_version.rs +80 -0
  104. package/dist-engine-src/src/session/execute.rs +447 -0
  105. package/dist-engine-src/src/session/merge/analysis.rs +102 -0
  106. package/dist-engine-src/src/session/merge/apply.rs +23 -0
  107. package/dist-engine-src/src/session/merge/conflicts.rs +62 -0
  108. package/dist-engine-src/src/session/merge/mod.rs +11 -0
  109. package/dist-engine-src/src/session/merge/stats.rs +65 -0
  110. package/dist-engine-src/src/session/merge/version.rs +437 -0
  111. package/dist-engine-src/src/session/mod.rs +25 -0
  112. package/dist-engine-src/src/session/switch_version.rs +121 -0
  113. package/dist-engine-src/src/sql2/change_provider.rs +337 -0
  114. package/dist-engine-src/src/sql2/classify.rs +147 -0
  115. package/dist-engine-src/src/sql2/commit_derived_provider.rs +591 -0
  116. package/dist-engine-src/src/sql2/context.rs +307 -0
  117. package/dist-engine-src/src/sql2/directory_history_provider.rs +623 -0
  118. package/dist-engine-src/src/sql2/directory_provider.rs +2405 -0
  119. package/dist-engine-src/src/sql2/dml.rs +148 -0
  120. package/dist-engine-src/src/sql2/entity_history_provider.rs +444 -0
  121. package/dist-engine-src/src/sql2/entity_provider.rs +2700 -0
  122. package/dist-engine-src/src/sql2/error.rs +196 -0
  123. package/dist-engine-src/src/sql2/execute.rs +3379 -0
  124. package/dist-engine-src/src/sql2/file_history_provider.rs +902 -0
  125. package/dist-engine-src/src/sql2/file_provider.rs +3254 -0
  126. package/dist-engine-src/src/sql2/filesystem_planner.rs +1526 -0
  127. package/dist-engine-src/src/sql2/filesystem_predicates.rs +159 -0
  128. package/dist-engine-src/src/sql2/filesystem_visibility.rs +369 -0
  129. package/dist-engine-src/src/sql2/history_projection.rs +80 -0
  130. package/dist-engine-src/src/sql2/history_provider.rs +418 -0
  131. package/dist-engine-src/src/sql2/history_route.rs +643 -0
  132. package/dist-engine-src/src/sql2/lix_state_provider.rs +2430 -0
  133. package/dist-engine-src/src/sql2/mod.rs +43 -0
  134. package/dist-engine-src/src/sql2/read_only.rs +65 -0
  135. package/dist-engine-src/src/sql2/record_batch.rs +17 -0
  136. package/dist-engine-src/src/sql2/result_metadata.rs +29 -0
  137. package/dist-engine-src/src/sql2/runtime.rs +60 -0
  138. package/dist-engine-src/src/sql2/session.rs +135 -0
  139. package/dist-engine-src/src/sql2/udfs/common.rs +295 -0
  140. package/dist-engine-src/src/sql2/udfs/lix_active_version_commit_id.rs +53 -0
  141. package/dist-engine-src/src/sql2/udfs/lix_empty_blob.rs +47 -0
  142. package/dist-engine-src/src/sql2/udfs/lix_json.rs +100 -0
  143. package/dist-engine-src/src/sql2/udfs/lix_json_get.rs +99 -0
  144. package/dist-engine-src/src/sql2/udfs/lix_json_get_text.rs +99 -0
  145. package/dist-engine-src/src/sql2/udfs/lix_text_decode.rs +82 -0
  146. package/dist-engine-src/src/sql2/udfs/lix_text_encode.rs +85 -0
  147. package/dist-engine-src/src/sql2/udfs/lix_uuid_v7.rs +76 -0
  148. package/dist-engine-src/src/sql2/udfs/mod.rs +82 -0
  149. package/dist-engine-src/src/sql2/version_provider.rs +1187 -0
  150. package/dist-engine-src/src/sql2/version_scope.rs +394 -0
  151. package/dist-engine-src/src/sql2/write_normalization.rs +345 -0
  152. package/dist-engine-src/src/storage/context.rs +356 -0
  153. package/dist-engine-src/src/storage/mod.rs +14 -0
  154. package/dist-engine-src/src/storage/read_scope.rs +88 -0
  155. package/dist-engine-src/src/storage/types.rs +501 -0
  156. package/dist-engine-src/src/storage_bench.rs +3406 -0
  157. package/dist-engine-src/src/test_support.rs +81 -0
  158. package/dist-engine-src/src/tracked_state/by_file_index.rs +102 -0
  159. package/dist-engine-src/src/tracked_state/codec.rs +747 -0
  160. package/dist-engine-src/src/tracked_state/context.rs +983 -0
  161. package/dist-engine-src/src/tracked_state/diff.rs +494 -0
  162. package/dist-engine-src/src/tracked_state/materialization.rs +141 -0
  163. package/dist-engine-src/src/tracked_state/merge.rs +474 -0
  164. package/dist-engine-src/src/tracked_state/mod.rs +31 -0
  165. package/dist-engine-src/src/tracked_state/rebuild.rs +771 -0
  166. package/dist-engine-src/src/tracked_state/storage.rs +243 -0
  167. package/dist-engine-src/src/tracked_state/tree.rs +2744 -0
  168. package/dist-engine-src/src/tracked_state/tree_types.rs +176 -0
  169. package/dist-engine-src/src/tracked_state/types.rs +61 -0
  170. package/dist-engine-src/src/transaction/commit.rs +1224 -0
  171. package/dist-engine-src/src/transaction/context.rs +1307 -0
  172. package/dist-engine-src/src/transaction/live_state_overlay.rs +34 -0
  173. package/dist-engine-src/src/transaction/mod.rs +11 -0
  174. package/dist-engine-src/src/transaction/normalization.rs +1026 -0
  175. package/dist-engine-src/src/transaction/schema_resolver.rs +127 -0
  176. package/dist-engine-src/src/transaction/staging.rs +1436 -0
  177. package/dist-engine-src/src/transaction/types.rs +351 -0
  178. package/dist-engine-src/src/transaction/validation.rs +4811 -0
  179. package/dist-engine-src/src/untracked_state/codec.rs +363 -0
  180. package/dist-engine-src/src/untracked_state/context.rs +82 -0
  181. package/dist-engine-src/src/untracked_state/materialization.rs +157 -0
  182. package/dist-engine-src/src/untracked_state/mod.rs +17 -0
  183. package/dist-engine-src/src/untracked_state/storage.rs +348 -0
  184. package/dist-engine-src/src/untracked_state/types.rs +96 -0
  185. package/dist-engine-src/src/version/context.rs +52 -0
  186. package/dist-engine-src/src/version/mod.rs +12 -0
  187. package/dist-engine-src/src/version/refs.rs +421 -0
  188. package/dist-engine-src/src/version/stage_rows.rs +71 -0
  189. package/dist-engine-src/src/version/types.rs +21 -0
  190. package/dist-engine-src/src/wasm/mod.rs +60 -0
  191. package/package.json +68 -64
@@ -0,0 +1,1224 @@
1
+ use std::collections::BTreeMap;
2
+
3
+ use crate::binary_cas::BinaryCasContext;
4
+ use crate::changelog::{CanonicalChange, ChangelogContext};
5
+ use crate::functions::FunctionContext;
6
+ use crate::json_store::{JsonRef, JsonStoreContext, JsonStoreWriter};
7
+ use crate::live_state::{LiveStateContext, LiveStateRow};
8
+ use crate::storage::{StorageReader, StorageWriteSet, StorageWriteTransaction};
9
+ use crate::transaction::staging::StagedWriteSet;
10
+ use crate::transaction::types::{StagedAdoptedStateRow, StagedCommitMembers, StagedStateRow};
11
+ use crate::version::{VersionContext, VersionRefReader};
12
+ use crate::GLOBAL_VERSION_ID;
13
+ use crate::{serialize_row_metadata, LixError, RowMetadata};
14
+
15
+ /// Commits transaction-staged rows into durable tracked and untracked stores.
16
+ ///
17
+ /// Providers decode DataFusion DML into hydrated `StagedStateRow`s. Untracked
18
+ /// rows are durable local overlay state and bypass changelog/commit rows.
19
+ /// Tracked rows receive normal `lix_commit` rows, append canonical changelog
20
+ /// facts, then update the live-state serving projection. The tracked side of
21
+ /// that projection is a prolly root keyed by the new commit id.
22
+ pub(crate) async fn commit_staged_writes(
23
+ binary_cas: &BinaryCasContext,
24
+ changelog: &ChangelogContext,
25
+ live_state: &LiveStateContext,
26
+ version_ctx: &VersionContext,
27
+ runtime_functions: Option<&FunctionContext>,
28
+ transaction: &mut (impl StorageWriteTransaction + ?Sized),
29
+ staged_writes: StagedWriteSet,
30
+ ) -> Result<(), LixError> {
31
+ let mut writes = StorageWriteSet::new();
32
+ let mut json_writer = JsonStoreContext::new().writer();
33
+
34
+ if !staged_writes.file_data_writes.is_empty() {
35
+ let mut blob_writer = binary_cas.writer(&mut writes);
36
+ for write in &staged_writes.file_data_writes {
37
+ blob_writer.stage_bytes(&write.data)?;
38
+ }
39
+ }
40
+
41
+ let (mut changelog_rows, untracked_rows): (Vec<_>, Vec<_>) = staged_writes
42
+ .state_rows
43
+ .into_iter()
44
+ .partition(|row| !row.untracked);
45
+ let adopted_rows = staged_writes.adopted_rows;
46
+ let finalized = finalize_commit_rows(
47
+ staged_writes.commit_members_by_version,
48
+ staged_writes.extra_commit_parents_by_version,
49
+ version_ctx,
50
+ transaction,
51
+ )
52
+ .await?;
53
+ changelog_rows.extend(finalized.commit_rows);
54
+ let version_heads = finalized.version_heads;
55
+
56
+ if let Some(runtime_functions) = runtime_functions {
57
+ let mut writer = live_state.writer(&mut *transaction);
58
+ runtime_functions
59
+ .stage_persist_if_needed(&mut writer, &mut writes, &mut json_writer)
60
+ .await?;
61
+ }
62
+
63
+ if changelog_rows.is_empty()
64
+ && adopted_rows.is_empty()
65
+ && untracked_rows.is_empty()
66
+ && version_heads.is_empty()
67
+ && writes.is_empty()
68
+ {
69
+ return Ok(());
70
+ }
71
+
72
+ if !changelog_rows.is_empty() {
73
+ let canonical_changes = new_canonical_changes(
74
+ changelog,
75
+ transaction,
76
+ &mut writes,
77
+ &mut json_writer,
78
+ &changelog_rows,
79
+ )
80
+ .await?;
81
+ {
82
+ let mut writer = changelog.writer(&mut writes);
83
+ writer.stage_changes(&canonical_changes)?;
84
+ }
85
+ }
86
+ if !adopted_rows.is_empty() {
87
+ validate_adopted_canonical_changes(changelog, transaction, &adopted_rows).await?;
88
+ }
89
+
90
+ // The serving projection is updated in the same backend transaction as the
91
+ // changelog append. Tracked rows become prolly mutations under their owning
92
+ // commit root; untracked rows remain in the separate local overlay store.
93
+ let live_state_rows = changelog_rows
94
+ .into_iter()
95
+ .map(LiveStateRow::from)
96
+ .chain(adopted_rows.into_iter().map(LiveStateRow::from))
97
+ .chain(untracked_rows.into_iter().map(LiveStateRow::from))
98
+ .collect::<Vec<_>>();
99
+
100
+ {
101
+ let mut writer = live_state.writer(&mut *transaction);
102
+ writer
103
+ .stage_rows(&mut writes, &mut json_writer, &live_state_rows)
104
+ .await?;
105
+ }
106
+
107
+ for version_head in version_heads {
108
+ let canonical_row = version_ctx.canonical_ref_row(
109
+ &mut writes,
110
+ &mut json_writer,
111
+ &version_head.version_id,
112
+ &version_head.commit_id,
113
+ &version_head.timestamp,
114
+ )?;
115
+ version_ctx.stage_canonical_ref_rows(&mut writes, &[canonical_row])?;
116
+ }
117
+
118
+ writes.apply(transaction).await?;
119
+ Ok(())
120
+ }
121
+
122
+ async fn new_canonical_changes(
123
+ changelog: &ChangelogContext,
124
+ transaction: &mut (impl StorageReader + ?Sized),
125
+ writes: &mut StorageWriteSet,
126
+ json_writer: &mut JsonStoreWriter,
127
+ rows: &[StagedStateRow],
128
+ ) -> Result<Vec<CanonicalChange>, LixError> {
129
+ let reader = changelog.reader(&mut *transaction);
130
+ let mut changes = Vec::new();
131
+ for row in rows {
132
+ let change = canonical_change_from_staged_row(writes, json_writer, row)?;
133
+ match reader.load_change(&change.id).await? {
134
+ Some(existing) => {
135
+ let entity_id = existing
136
+ .entity_id
137
+ .as_string()
138
+ .unwrap_or_else(|_| "<invalid entity_id>".to_string());
139
+ return Err(LixError::new(
140
+ LixError::CODE_UNIQUE,
141
+ format!(
142
+ "canonical change id '{}' already exists with different content for schema '{}' entity '{}'",
143
+ change.id,
144
+ existing.schema_key,
145
+ entity_id
146
+ ),
147
+ ));
148
+ }
149
+ None => changes.push(change),
150
+ }
151
+ }
152
+ Ok(changes)
153
+ }
154
+
155
+ async fn validate_adopted_canonical_changes(
156
+ changelog: &ChangelogContext,
157
+ transaction: &mut (impl StorageReader + ?Sized),
158
+ rows: &[StagedAdoptedStateRow],
159
+ ) -> Result<(), LixError> {
160
+ let mut writes = StorageWriteSet::new();
161
+ let mut json_writer = JsonStoreContext::new().writer();
162
+ let reader = changelog.reader(&mut *transaction);
163
+ for row in rows {
164
+ let expected = canonical_change_from_adopted_row(&mut writes, &mut json_writer, row)?;
165
+ match reader.load_change(&expected.id).await? {
166
+ Some(existing) if existing == expected => {}
167
+ Some(existing) => {
168
+ let entity_id = existing
169
+ .entity_id
170
+ .as_string()
171
+ .unwrap_or_else(|_| "<invalid entity_id>".to_string());
172
+ return Err(LixError::new(
173
+ LixError::CODE_UNIQUE,
174
+ format!(
175
+ "adopted canonical change id '{}' exists with different content for schema '{}' entity '{}'",
176
+ expected.id, existing.schema_key, entity_id
177
+ ),
178
+ ));
179
+ }
180
+ None => {
181
+ return Err(LixError::new(
182
+ LixError::CODE_INTERNAL_ERROR,
183
+ format!(
184
+ "adopted canonical change id '{}' does not exist in the changelog",
185
+ expected.id
186
+ ),
187
+ ));
188
+ }
189
+ }
190
+ }
191
+ Ok(())
192
+ }
193
+
194
+ fn canonical_change_from_staged_row(
195
+ writes: &mut StorageWriteSet,
196
+ json_writer: &mut JsonStoreWriter,
197
+ row: &StagedStateRow,
198
+ ) -> Result<CanonicalChange, LixError> {
199
+ let Some(change_id) = row.change_id.as_ref() else {
200
+ return Err(LixError::new(
201
+ "LIX_ERROR_UNKNOWN",
202
+ "tracked staged row is missing change_id before changelog append",
203
+ ));
204
+ };
205
+
206
+ Ok(CanonicalChange {
207
+ id: change_id.clone(),
208
+ entity_id: row.entity_id.clone(),
209
+ schema_key: row.schema_key.clone(),
210
+ schema_version: row.schema_version.clone(),
211
+ file_id: row.file_id.clone(),
212
+ snapshot_ref: stage_optional_json(writes, json_writer, row.snapshot_content.as_deref())?,
213
+ metadata_ref: stage_optional_metadata(writes, json_writer, row.metadata.as_ref())?,
214
+ created_at: row.created_at.clone(),
215
+ })
216
+ }
217
+
218
+ fn stage_optional_json(
219
+ writes: &mut StorageWriteSet,
220
+ json_writer: &mut JsonStoreWriter,
221
+ value: Option<&str>,
222
+ ) -> Result<Option<JsonRef>, LixError> {
223
+ let Some(value) = value else {
224
+ return Ok(None);
225
+ };
226
+ json_writer.stage_bytes(writes, value.as_bytes()).map(Some)
227
+ }
228
+
229
+ fn stage_optional_metadata(
230
+ writes: &mut StorageWriteSet,
231
+ json_writer: &mut JsonStoreWriter,
232
+ value: Option<&RowMetadata>,
233
+ ) -> Result<Option<JsonRef>, LixError> {
234
+ let Some(value) = value else {
235
+ return Ok(None);
236
+ };
237
+ let serialized = serialize_row_metadata(value);
238
+ json_writer
239
+ .stage_bytes(writes, serialized.as_bytes())
240
+ .map(Some)
241
+ }
242
+
243
+ fn canonical_change_from_adopted_row(
244
+ writes: &mut StorageWriteSet,
245
+ json_writer: &mut JsonStoreWriter,
246
+ row: &StagedAdoptedStateRow,
247
+ ) -> Result<CanonicalChange, LixError> {
248
+ Ok(CanonicalChange {
249
+ id: row.change_id.clone(),
250
+ entity_id: row.entity_id.clone(),
251
+ schema_key: row.schema_key.clone(),
252
+ schema_version: row.schema_version.clone(),
253
+ file_id: row.file_id.clone(),
254
+ snapshot_ref: stage_optional_json(writes, json_writer, row.snapshot_content.as_deref())?,
255
+ metadata_ref: stage_optional_metadata(writes, json_writer, row.metadata.as_ref())?,
256
+ created_at: row.created_at.clone(),
257
+ })
258
+ }
259
+
260
+ /// Materializes tracked staged membership into `lix_commit` rows.
261
+ ///
262
+ /// Staging only accumulates `version_id -> change_ids` because commit ids,
263
+ /// parent heads, and commit-row timestamps belong to transaction finalization.
264
+ /// The `change_ids` list is the ordered set of canonical changes whose effects
265
+ /// the commit introduces relative to its first parent; merge commits may later
266
+ /// populate this list with existing source-parent changes instead of copied
267
+ /// changelog facts.
268
+ /// This function turns those membership sets into normal `StagedStateRow`s with
269
+ /// `schema_key = "lix_commit"`, so the changelog/live_state flush can treat
270
+ /// commit rows exactly like any other staged state row.
271
+ ///
272
+ /// Commit finalization output split by durability target.
273
+ ///
274
+ /// `commit_rows` are ordinary changelog facts. live_state later projects them
275
+ /// from commit_graph; tracked_state roots do not store commit graph facts.
276
+ ///
277
+ /// `version_heads` are moving refs. They are written through `VersionContext`
278
+ /// and must never be appended to changelog.
279
+ struct FinalizedCommitRows {
280
+ commit_rows: Vec<StagedStateRow>,
281
+ version_heads: Vec<PendingVersionHead>,
282
+ }
283
+
284
+ struct PendingVersionHead {
285
+ version_id: String,
286
+ commit_id: String,
287
+ timestamp: String,
288
+ }
289
+
290
+ async fn finalize_commit_rows(
291
+ commit_members_by_version: BTreeMap<String, StagedCommitMembers>,
292
+ extra_commit_parents_by_version: BTreeMap<String, Vec<String>>,
293
+ version_ctx: &VersionContext,
294
+ transaction: &mut (impl StorageReader + ?Sized),
295
+ ) -> Result<FinalizedCommitRows, LixError> {
296
+ let mut commit_rows = Vec::new();
297
+ let mut version_heads = Vec::new();
298
+
299
+ for (version_id, members) in commit_members_by_version {
300
+ if members.is_empty() && !members.allow_empty {
301
+ continue;
302
+ }
303
+
304
+ let commit_id = members.commit_id;
305
+ let commit_change_id = members.commit_change_id;
306
+ let change_set_id = members.change_set_id;
307
+ let timestamp = members.created_at;
308
+ let change_ids = members.change_ids.into_iter().collect::<Vec<_>>();
309
+ let parent_commit_ids = version_ctx
310
+ .ref_reader(&mut *transaction)
311
+ .load_head_commit_id(&version_id)
312
+ .await?
313
+ .into_iter()
314
+ .collect::<Vec<_>>();
315
+ let parent_commit_ids = merge_parent_commit_ids(
316
+ parent_commit_ids,
317
+ extra_commit_parents_by_version
318
+ .get(&version_id)
319
+ .cloned()
320
+ .unwrap_or_default(),
321
+ );
322
+ let snapshot_content = serde_json::to_string(&serde_json::json!({
323
+ "id": commit_id,
324
+ "change_set_id": change_set_id,
325
+ "change_ids": change_ids,
326
+ "author_account_ids": [],
327
+ "parent_commit_ids": parent_commit_ids,
328
+ }))
329
+ .map_err(|error| {
330
+ LixError::new(
331
+ "LIX_ERROR_UNKNOWN",
332
+ format!("engine2 commit row snapshot serialization failed: {error}"),
333
+ )
334
+ })?;
335
+
336
+ commit_rows.push(StagedStateRow {
337
+ entity_id: crate::entity_identity::EntityIdentity::single(&commit_id),
338
+ schema_key: "lix_commit".to_string(),
339
+ file_id: None,
340
+ snapshot_content: Some(snapshot_content),
341
+ metadata: None,
342
+ origin: None,
343
+ schema_version: "1".to_string(),
344
+ created_at: timestamp.clone(),
345
+ updated_at: timestamp.clone(),
346
+ global: true,
347
+ change_id: Some(commit_change_id),
348
+ commit_id: Some(commit_id.clone()),
349
+ untracked: false,
350
+ version_id: GLOBAL_VERSION_ID.to_string(),
351
+ });
352
+ version_heads.push(PendingVersionHead {
353
+ version_id,
354
+ commit_id,
355
+ timestamp,
356
+ });
357
+ }
358
+
359
+ Ok(FinalizedCommitRows {
360
+ commit_rows,
361
+ version_heads,
362
+ })
363
+ }
364
+
365
+ fn merge_parent_commit_ids(mut base: Vec<String>, extra: Vec<String>) -> Vec<String> {
366
+ for parent in extra {
367
+ if !base.contains(&parent) {
368
+ base.push(parent);
369
+ }
370
+ }
371
+ base
372
+ }
373
+
374
+ #[cfg(test)]
375
+ mod tests {
376
+ use std::collections::BTreeMap;
377
+ use std::sync::{
378
+ atomic::{AtomicUsize, Ordering},
379
+ Arc,
380
+ };
381
+
382
+ use async_trait::async_trait;
383
+ use serde_json::Value as JsonValue;
384
+
385
+ use super::*;
386
+ use crate::backend::{
387
+ testing::UnitTestBackend, Backend, BackendKvEntryPage, BackendKvExistsBatch,
388
+ BackendKvGetRequest, BackendKvKeyPage, BackendKvScanRequest, BackendKvValueBatch,
389
+ BackendKvValuePage, BackendKvWriteBatch, BackendKvWriteStats, BackendReadTransaction,
390
+ BackendWriteTransaction,
391
+ };
392
+ use crate::changelog::ChangelogContext;
393
+ use crate::live_state::{LiveStateContext, LiveStateRowRequest};
394
+ use crate::storage::StorageContext;
395
+ use crate::untracked_state::{
396
+ canonicalize_materialized_row, MaterializedUntrackedStateRow, UntrackedStateContext,
397
+ UntrackedStateRowRequest,
398
+ };
399
+ use crate::version::VersionContext;
400
+ use crate::NullableKeyFilter;
401
+
402
+ const DETERMINISTIC_MODE_KEY: &str = "lix_deterministic_mode";
403
+ const DETERMINISTIC_SEQUENCE_KEY: &str = "lix_deterministic_sequence_number";
404
+
405
+ fn live_state_context() -> LiveStateContext {
406
+ LiveStateContext::new(
407
+ crate::tracked_state::TrackedStateContext::new(),
408
+ crate::untracked_state::UntrackedStateContext::new(),
409
+ crate::commit_graph::CommitGraphContext::new(crate::changelog::ChangelogContext::new()),
410
+ )
411
+ }
412
+
413
+ #[tokio::test]
414
+ async fn commit_staged_writes_appends_changelog_and_updates_serving_projection() {
415
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
416
+ let storage = StorageContext::new(Arc::clone(&backend));
417
+ let binary_cas = BinaryCasContext::new();
418
+ let changelog = ChangelogContext::new();
419
+ let live_state = Arc::new(live_state_context());
420
+ let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
421
+ let mut transaction = storage
422
+ .begin_write_transaction()
423
+ .await
424
+ .expect("transaction should open");
425
+
426
+ commit_staged_writes(
427
+ &binary_cas,
428
+ &changelog,
429
+ live_state.as_ref(),
430
+ &version_ctx,
431
+ None,
432
+ transaction.as_mut(),
433
+ StagedWriteSet {
434
+ insert_identities: BTreeMap::new(),
435
+ state_rows: vec![tracked_global_row("change-1")],
436
+ adopted_rows: Vec::new(),
437
+ commit_members_by_version: BTreeMap::from([(
438
+ GLOBAL_VERSION_ID.to_string(),
439
+ members(["change-1"]),
440
+ )]),
441
+ extra_commit_parents_by_version: BTreeMap::new(),
442
+ file_data_writes: Vec::new(),
443
+ },
444
+ )
445
+ .await
446
+ .expect("commit should flush staged rows");
447
+ transaction
448
+ .commit()
449
+ .await
450
+ .expect("commit should persist kv");
451
+
452
+ let changes = {
453
+ let reader = changelog.reader(storage.clone());
454
+ reader
455
+ .scan_changes(&crate::changelog::ChangelogScanRequest::default())
456
+ .await
457
+ }
458
+ .expect("changelog scan should succeed");
459
+ let change_ids = changes
460
+ .iter()
461
+ .map(|change| change.id.as_str())
462
+ .collect::<Vec<_>>();
463
+ assert_eq!(change_ids, vec!["change-1", "test-uuid-2"]);
464
+ assert!(changes
465
+ .iter()
466
+ .any(|change| change.schema_key == "lix_commit"));
467
+ assert!(!changes
468
+ .iter()
469
+ .any(|change| change.schema_key == "lix_version_ref"));
470
+
471
+ let loaded_head = version_ctx
472
+ .ref_reader(storage.clone())
473
+ .load_head_commit_id(GLOBAL_VERSION_ID)
474
+ .await
475
+ .expect("version ref load should succeed");
476
+ assert_eq!(loaded_head.as_deref(), Some("test-uuid-1"));
477
+ }
478
+
479
+ #[tokio::test]
480
+ async fn commit_with_only_untracked_writes_does_not_create_lix_commit() {
481
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
482
+ let storage = StorageContext::new(Arc::clone(&backend));
483
+ let binary_cas = BinaryCasContext::new();
484
+ let changelog = ChangelogContext::new();
485
+ let live_state = Arc::new(live_state_context());
486
+ let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
487
+ let untracked_state = UntrackedStateContext::new();
488
+ let mut transaction = storage
489
+ .begin_write_transaction()
490
+ .await
491
+ .expect("transaction should open");
492
+
493
+ commit_staged_writes(
494
+ &binary_cas,
495
+ &changelog,
496
+ live_state.as_ref(),
497
+ &version_ctx,
498
+ None,
499
+ transaction.as_mut(),
500
+ StagedWriteSet {
501
+ insert_identities: BTreeMap::new(),
502
+ state_rows: vec![untracked_global_row("change-untracked")],
503
+ adopted_rows: Vec::new(),
504
+ commit_members_by_version: BTreeMap::new(),
505
+ extra_commit_parents_by_version: BTreeMap::new(),
506
+ file_data_writes: Vec::new(),
507
+ },
508
+ )
509
+ .await
510
+ .expect("commit should flush untracked row");
511
+ transaction
512
+ .commit()
513
+ .await
514
+ .expect("commit should persist kv");
515
+
516
+ let changes = {
517
+ let reader = changelog.reader(storage.clone());
518
+ reader
519
+ .scan_changes(&crate::changelog::ChangelogScanRequest::default())
520
+ .await
521
+ }
522
+ .expect("changelog scan should succeed");
523
+ assert!(changes.is_empty());
524
+
525
+ let loaded = {
526
+ let mut untracked_reader = untracked_state.reader(storage.clone());
527
+ untracked_reader
528
+ .load_row(&UntrackedStateRowRequest {
529
+ schema_key: "test_schema".to_string(),
530
+ version_id: GLOBAL_VERSION_ID.to_string(),
531
+ entity_id: crate::entity_identity::EntityIdentity::single("entity-1"),
532
+ file_id: NullableKeyFilter::Null,
533
+ })
534
+ .await
535
+ }
536
+ .expect("untracked row load should succeed")
537
+ .expect("untracked row should be persisted");
538
+ assert_eq!(
539
+ loaded.snapshot_content.as_deref(),
540
+ Some("{\"value\":\"untracked\"}")
541
+ );
542
+ }
543
+
544
+ #[tokio::test]
545
+ async fn tracked_write_deletes_matching_untracked_overlay() {
546
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
547
+ let storage = StorageContext::new(Arc::clone(&backend));
548
+ let binary_cas = BinaryCasContext::new();
549
+ let changelog = ChangelogContext::new();
550
+ let untracked_state = UntrackedStateContext::new();
551
+ let live_state = Arc::new(live_state_context());
552
+ let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
553
+
554
+ let mut seed_transaction = storage
555
+ .begin_write_transaction()
556
+ .await
557
+ .expect("seed transaction should open");
558
+ let mut writes = StorageWriteSet::new();
559
+ let canonical_row = {
560
+ let mut json_writer = JsonStoreContext::new().writer();
561
+ canonicalize_materialized_row(
562
+ &mut writes,
563
+ &mut json_writer,
564
+ &MaterializedUntrackedStateRow::from(untracked_global_row("change-untracked")),
565
+ )
566
+ .expect("untracked seed should canonicalize")
567
+ };
568
+ untracked_state
569
+ .writer(&mut writes)
570
+ .stage_rows(&[canonical_row])
571
+ .expect("untracked seed should write");
572
+ writes
573
+ .apply(&mut seed_transaction.as_mut())
574
+ .await
575
+ .expect("untracked seed should apply");
576
+ seed_transaction
577
+ .commit()
578
+ .await
579
+ .expect("seed transaction should persist");
580
+
581
+ let mut transaction = storage
582
+ .begin_write_transaction()
583
+ .await
584
+ .expect("transaction should open");
585
+ commit_staged_writes(
586
+ &binary_cas,
587
+ &changelog,
588
+ live_state.as_ref(),
589
+ &version_ctx,
590
+ None,
591
+ transaction.as_mut(),
592
+ StagedWriteSet {
593
+ insert_identities: BTreeMap::new(),
594
+ state_rows: vec![tracked_global_row("change-tracked")],
595
+ adopted_rows: Vec::new(),
596
+ commit_members_by_version: BTreeMap::from([(
597
+ GLOBAL_VERSION_ID.to_string(),
598
+ members(["change-tracked"]),
599
+ )]),
600
+ extra_commit_parents_by_version: BTreeMap::new(),
601
+ file_data_writes: Vec::new(),
602
+ },
603
+ )
604
+ .await
605
+ .expect("tracked commit should flush");
606
+ transaction
607
+ .commit()
608
+ .await
609
+ .expect("commit should persist kv");
610
+
611
+ let untracked = {
612
+ let mut untracked_reader = untracked_state.reader(storage.clone());
613
+ untracked_reader.load_row(&untracked_request()).await
614
+ }
615
+ .expect("untracked load should succeed");
616
+ assert_eq!(untracked, None);
617
+
618
+ let visible = live_state
619
+ .reader(storage.clone())
620
+ .load_row(&live_state_request())
621
+ .await
622
+ .expect("live-state load should succeed")
623
+ .expect("tracked row should be visible");
624
+ assert!(!visible.untracked);
625
+ assert_eq!(visible.change_id.as_deref(), Some("change-tracked"));
626
+ assert_eq!(visible.snapshot_content.as_deref(), Some("{\"value\":1}"));
627
+ }
628
+
629
+ #[tokio::test]
630
+ async fn commit_staged_writes_applies_cross_subsystem_rows_as_one_backend_batch() {
631
+ let counting_backend = Arc::new(CountingBackend::new());
632
+ let write_batches = counting_backend.write_batches();
633
+ let backend: Arc<dyn Backend + Send + Sync> = counting_backend;
634
+ let storage = StorageContext::new(backend);
635
+ let binary_cas = BinaryCasContext::new();
636
+ let changelog = ChangelogContext::new();
637
+ let live_state = Arc::new(live_state_context());
638
+ let untracked_state = UntrackedStateContext::new();
639
+ let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
640
+ crate::test_support::seed_global_version_head(storage.clone()).await;
641
+ {
642
+ let mut seed_transaction = storage
643
+ .begin_write_transaction()
644
+ .await
645
+ .expect("seed transaction should open");
646
+ let mut writes = StorageWriteSet::new();
647
+ let mut json_writer = JsonStoreContext::new().writer();
648
+ let mode_snapshot = serde_json::to_string(&serde_json::json!({
649
+ "key": DETERMINISTIC_MODE_KEY,
650
+ "value": { "enabled": true },
651
+ }))
652
+ .expect("mode snapshot should serialize");
653
+ {
654
+ let mut writer = live_state.writer(seed_transaction.as_mut());
655
+ writer
656
+ .stage_rows(
657
+ &mut writes,
658
+ &mut json_writer,
659
+ &[LiveStateRow {
660
+ entity_id: crate::entity_identity::EntityIdentity::single(
661
+ DETERMINISTIC_MODE_KEY,
662
+ ),
663
+ schema_key: "lix_key_value".to_string(),
664
+ file_id: None,
665
+ snapshot_content: Some(mode_snapshot),
666
+ metadata: None,
667
+ schema_version: "1".to_string(),
668
+ created_at: "2026-01-01T00:00:00Z".to_string(),
669
+ updated_at: "2026-01-01T00:00:00Z".to_string(),
670
+ global: true,
671
+ change_id: None,
672
+ commit_id: None,
673
+ untracked: true,
674
+ version_id: GLOBAL_VERSION_ID.to_string(),
675
+ }],
676
+ )
677
+ .await
678
+ .expect("deterministic mode should stage");
679
+ }
680
+ writes
681
+ .apply(&mut seed_transaction.as_mut())
682
+ .await
683
+ .expect("deterministic mode should apply");
684
+ seed_transaction
685
+ .commit()
686
+ .await
687
+ .expect("seed transaction should persist");
688
+ }
689
+ write_batches.store(0, Ordering::SeqCst);
690
+ let runtime_functions = {
691
+ let reader = live_state.reader(storage.clone());
692
+ FunctionContext::prepare(&reader)
693
+ .await
694
+ .expect("runtime context should prepare")
695
+ };
696
+ runtime_functions.provider().call_uuid_v7();
697
+ let mut transaction = storage
698
+ .begin_write_transaction()
699
+ .await
700
+ .expect("transaction should open");
701
+
702
+ let mut untracked_row = untracked_global_row("change-untracked");
703
+ untracked_row.entity_id = crate::entity_identity::EntityIdentity::single("entity-2");
704
+
705
+ commit_staged_writes(
706
+ &binary_cas,
707
+ &changelog,
708
+ live_state.as_ref(),
709
+ &version_ctx,
710
+ Some(&runtime_functions),
711
+ transaction.as_mut(),
712
+ StagedWriteSet {
713
+ insert_identities: BTreeMap::new(),
714
+ state_rows: vec![tracked_global_row("change-tracked"), untracked_row],
715
+ adopted_rows: Vec::new(),
716
+ commit_members_by_version: BTreeMap::from([(
717
+ GLOBAL_VERSION_ID.to_string(),
718
+ members(["change-tracked"]),
719
+ )]),
720
+ extra_commit_parents_by_version: BTreeMap::new(),
721
+ file_data_writes: Vec::new(),
722
+ },
723
+ )
724
+ .await
725
+ .expect("cross-subsystem commit should stage and apply");
726
+
727
+ assert_eq!(
728
+ write_batches.load(Ordering::SeqCst),
729
+ 1,
730
+ "tracked, json, untracked, changelog, and version refs must apply as one backend write batch"
731
+ );
732
+
733
+ transaction
734
+ .commit()
735
+ .await
736
+ .expect("commit should persist kv");
737
+ assert_eq!(write_batches.load(Ordering::SeqCst), 1);
738
+
739
+ let changes = changelog
740
+ .reader(storage.clone())
741
+ .scan_changes(&crate::changelog::ChangelogScanRequest::default())
742
+ .await
743
+ .expect("changelog scan should succeed");
744
+ assert!(changes.iter().any(|change| change.id == "change-tracked"));
745
+ assert!(changes
746
+ .iter()
747
+ .any(|change| change.schema_key == "lix_commit"));
748
+
749
+ let loaded_head = version_ctx
750
+ .ref_reader(storage.clone())
751
+ .load_head_commit_id(GLOBAL_VERSION_ID)
752
+ .await
753
+ .expect("version ref load should succeed");
754
+ assert_eq!(loaded_head.as_deref(), Some("test-uuid-1"));
755
+
756
+ let untracked = {
757
+ let mut untracked_reader = untracked_state.reader(storage.clone());
758
+ untracked_reader
759
+ .load_row(&UntrackedStateRowRequest {
760
+ schema_key: "test_schema".to_string(),
761
+ version_id: GLOBAL_VERSION_ID.to_string(),
762
+ entity_id: crate::entity_identity::EntityIdentity::single("entity-2"),
763
+ file_id: NullableKeyFilter::Null,
764
+ })
765
+ .await
766
+ }
767
+ .expect("untracked row load should succeed")
768
+ .expect("untracked row should persist");
769
+ assert_eq!(
770
+ untracked.snapshot_content.as_deref(),
771
+ Some("{\"value\":\"untracked\"}")
772
+ );
773
+
774
+ let sequence_row = live_state
775
+ .reader(storage.clone())
776
+ .load_row(&LiveStateRowRequest {
777
+ schema_key: "lix_key_value".to_string(),
778
+ version_id: GLOBAL_VERSION_ID.to_string(),
779
+ entity_id: crate::entity_identity::EntityIdentity::single(
780
+ DETERMINISTIC_SEQUENCE_KEY,
781
+ ),
782
+ file_id: NullableKeyFilter::Null,
783
+ })
784
+ .await
785
+ .expect("deterministic sequence should load")
786
+ .expect("deterministic sequence should persist");
787
+ assert_eq!(
788
+ sequence_row.snapshot_content.as_deref(),
789
+ Some("{\"key\":\"lix_deterministic_sequence_number\",\"value\":0}")
790
+ );
791
+ }
792
+
793
+ #[tokio::test]
794
+ async fn non_global_tracked_write_creates_one_commit_and_advances_only_touched_version() {
795
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
796
+ let storage = StorageContext::new(Arc::clone(&backend));
797
+ let binary_cas = BinaryCasContext::new();
798
+ let changelog = ChangelogContext::new();
799
+ let live_state = Arc::new(live_state_context());
800
+ let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
801
+ crate::test_support::seed_version_head(storage.clone(), GLOBAL_VERSION_ID, "global-before")
802
+ .await;
803
+ crate::test_support::seed_version_head(storage.clone(), "version-a", "version-a-before")
804
+ .await;
805
+
806
+ let mut transaction = storage
807
+ .begin_write_transaction()
808
+ .await
809
+ .expect("transaction should open");
810
+ commit_staged_writes(
811
+ &binary_cas,
812
+ &changelog,
813
+ live_state.as_ref(),
814
+ &version_ctx,
815
+ None,
816
+ transaction.as_mut(),
817
+ StagedWriteSet {
818
+ insert_identities: BTreeMap::new(),
819
+ state_rows: vec![tracked_version_row("version-a", "change-version-a")],
820
+ adopted_rows: Vec::new(),
821
+ commit_members_by_version: BTreeMap::from([(
822
+ "version-a".to_string(),
823
+ members(["change-version-a"]),
824
+ )]),
825
+ extra_commit_parents_by_version: BTreeMap::new(),
826
+ file_data_writes: Vec::new(),
827
+ },
828
+ )
829
+ .await
830
+ .expect("version commit should flush");
831
+ transaction
832
+ .commit()
833
+ .await
834
+ .expect("commit should persist kv");
835
+
836
+ let changes = changelog
837
+ .reader(storage.clone())
838
+ .scan_changes(&crate::changelog::ChangelogScanRequest::default())
839
+ .await
840
+ .expect("changelog scan should succeed");
841
+ let commit_changes = changes
842
+ .iter()
843
+ .filter(|change| change.schema_key == "lix_commit")
844
+ .collect::<Vec<_>>();
845
+ assert_eq!(
846
+ commit_changes.len(),
847
+ 1,
848
+ "a write to one non-global version must create exactly one commit"
849
+ );
850
+ assert_eq!(
851
+ commit_changes[0]
852
+ .entity_id
853
+ .as_string()
854
+ .expect("commit entity id should project"),
855
+ "test-uuid-1"
856
+ );
857
+ assert!(changes.iter().any(|change| change.id == "change-version-a"));
858
+ assert!(!changes
859
+ .iter()
860
+ .any(|change| change.schema_key == "lix_version_ref"));
861
+
862
+ let global_head = version_ctx
863
+ .ref_reader(storage.clone())
864
+ .load_head_commit_id(GLOBAL_VERSION_ID)
865
+ .await
866
+ .expect("global head should load");
867
+ let version_head = version_ctx
868
+ .ref_reader(storage.clone())
869
+ .load_head_commit_id("version-a")
870
+ .await
871
+ .expect("version head should load");
872
+ assert_eq!(global_head.as_deref(), Some("global-before"));
873
+ assert_eq!(version_head.as_deref(), Some("test-uuid-1"));
874
+ }
875
+
876
+ #[tokio::test]
877
+ async fn finalize_commit_rows_parents_global_commit_to_existing_version_ref() {
878
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
879
+ let storage = StorageContext::new(Arc::clone(&backend));
880
+ let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
881
+ crate::test_support::seed_version_head(
882
+ storage.clone(),
883
+ GLOBAL_VERSION_ID,
884
+ "initial-commit",
885
+ )
886
+ .await;
887
+
888
+ let mut transaction = storage
889
+ .begin_write_transaction()
890
+ .await
891
+ .expect("transaction should open");
892
+ let rows = finalize_commit_rows(
893
+ BTreeMap::from([(
894
+ GLOBAL_VERSION_ID.to_string(),
895
+ members(["change-a", "change-b"]),
896
+ )]),
897
+ BTreeMap::new(),
898
+ &version_ctx,
899
+ transaction.as_mut(),
900
+ )
901
+ .await
902
+ .expect("global commit row should finalize");
903
+
904
+ assert_eq!(rows.commit_rows.len(), 1);
905
+ assert_eq!(rows.version_heads.len(), 1);
906
+ let row = &rows.commit_rows[0];
907
+ assert_eq!(row.entity_id.as_string().as_deref(), Ok("test-uuid-1"));
908
+ assert_eq!(row.schema_key, "lix_commit");
909
+ assert_eq!(row.schema_version, "1");
910
+ assert_eq!(row.change_id.as_deref(), Some("test-uuid-2"));
911
+ assert_eq!(row.commit_id.as_deref(), Some("test-uuid-1"));
912
+ assert!(row.global);
913
+ assert!(!row.untracked);
914
+ assert_eq!(row.version_id, GLOBAL_VERSION_ID);
915
+ assert_eq!(row.created_at, "test-timestamp-1");
916
+ assert_eq!(row.updated_at, "test-timestamp-1");
917
+
918
+ let snapshot = serde_json::from_str::<JsonValue>(
919
+ row.snapshot_content
920
+ .as_deref()
921
+ .expect("commit row should have snapshot"),
922
+ )
923
+ .expect("commit snapshot should be JSON");
924
+ assert_eq!(
925
+ snapshot.get("id").and_then(JsonValue::as_str),
926
+ Some("test-uuid-1")
927
+ );
928
+ assert_eq!(
929
+ snapshot
930
+ .get("change_ids")
931
+ .and_then(JsonValue::as_array)
932
+ .expect("change_ids should be array")
933
+ .iter()
934
+ .map(|value| value.as_str().expect("change id should be string"))
935
+ .collect::<Vec<_>>(),
936
+ vec!["change-a", "change-b"]
937
+ );
938
+ assert_eq!(
939
+ snapshot
940
+ .get("parent_commit_ids")
941
+ .and_then(JsonValue::as_array)
942
+ .expect("parent_commit_ids should be array")
943
+ .iter()
944
+ .map(|value| value.as_str().expect("parent id should be string"))
945
+ .collect::<Vec<_>>(),
946
+ vec!["initial-commit"]
947
+ );
948
+
949
+ let version_head = &rows.version_heads[0];
950
+ assert_eq!(version_head.version_id, GLOBAL_VERSION_ID);
951
+ assert_eq!(version_head.commit_id, "test-uuid-1");
952
+ }
953
+
954
+ #[tokio::test]
955
+ async fn finalize_commit_rows_skips_empty_members() {
956
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
957
+ let storage = StorageContext::new(Arc::clone(&backend));
958
+ let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
959
+ let mut transaction = storage
960
+ .begin_write_transaction()
961
+ .await
962
+ .expect("transaction should open");
963
+ let rows = finalize_commit_rows(
964
+ BTreeMap::from([(
965
+ GLOBAL_VERSION_ID.to_string(),
966
+ StagedCommitMembers::default(),
967
+ )]),
968
+ BTreeMap::new(),
969
+ &version_ctx,
970
+ transaction.as_mut(),
971
+ )
972
+ .await
973
+ .expect("empty members should be ignored");
974
+
975
+ assert!(rows.commit_rows.is_empty());
976
+ assert!(rows.version_heads.is_empty());
977
+ }
978
+
979
+ #[tokio::test]
980
+ async fn finalize_commit_rows_uses_existing_version_ref_as_parent() {
981
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
982
+ let storage = StorageContext::new(Arc::clone(&backend));
983
+ let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
984
+ crate::test_support::seed_version_head(storage.clone(), GLOBAL_VERSION_ID, "global-before")
985
+ .await;
986
+ crate::test_support::seed_version_head(storage.clone(), "version-a", "previous-commit")
987
+ .await;
988
+
989
+ let mut transaction = storage
990
+ .begin_write_transaction()
991
+ .await
992
+ .expect("transaction should open");
993
+ let rows = finalize_commit_rows(
994
+ BTreeMap::from([("version-a".to_string(), members(["change-a"]))]),
995
+ BTreeMap::new(),
996
+ &version_ctx,
997
+ transaction.as_mut(),
998
+ )
999
+ .await
1000
+ .expect("active-version commit finalization should resolve parent");
1001
+
1002
+ let snapshot = serde_json::from_str::<JsonValue>(
1003
+ rows.commit_rows[0]
1004
+ .snapshot_content
1005
+ .as_deref()
1006
+ .expect("commit row should have snapshot"),
1007
+ )
1008
+ .expect("commit snapshot should be JSON");
1009
+ assert_eq!(
1010
+ snapshot
1011
+ .get("parent_commit_ids")
1012
+ .and_then(JsonValue::as_array)
1013
+ .expect("parent_commit_ids should be array")
1014
+ .iter()
1015
+ .map(|value| value.as_str().expect("parent id should be text"))
1016
+ .collect::<Vec<_>>(),
1017
+ vec!["previous-commit"]
1018
+ );
1019
+ assert_eq!(rows.version_heads[0].version_id, "version-a");
1020
+ }
1021
+
1022
+ #[tokio::test]
1023
+ async fn finalize_commit_rows_appends_extra_merge_parent_after_target_head() {
1024
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1025
+ let storage = StorageContext::new(Arc::clone(&backend));
1026
+ let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
1027
+ crate::test_support::seed_version_head(storage.clone(), "version-a", "target-head").await;
1028
+
1029
+ let mut transaction = storage
1030
+ .begin_write_transaction()
1031
+ .await
1032
+ .expect("transaction should open");
1033
+ let rows = finalize_commit_rows(
1034
+ BTreeMap::from([("version-a".to_string(), members(["change-a"]))]),
1035
+ BTreeMap::from([("version-a".to_string(), vec!["source-head".to_string()])]),
1036
+ &version_ctx,
1037
+ transaction.as_mut(),
1038
+ )
1039
+ .await
1040
+ .expect("merge commit finalization should resolve parents");
1041
+
1042
+ let snapshot = serde_json::from_str::<JsonValue>(
1043
+ rows.commit_rows[0]
1044
+ .snapshot_content
1045
+ .as_deref()
1046
+ .expect("commit row should have snapshot"),
1047
+ )
1048
+ .expect("commit snapshot should be JSON");
1049
+ assert_eq!(
1050
+ snapshot
1051
+ .get("parent_commit_ids")
1052
+ .and_then(JsonValue::as_array)
1053
+ .expect("parent_commit_ids should be array")
1054
+ .iter()
1055
+ .map(|value| value.as_str().expect("parent id should be text"))
1056
+ .collect::<Vec<_>>(),
1057
+ vec!["target-head", "source-head"]
1058
+ );
1059
+ }
1060
+
1061
+ fn members<const N: usize>(change_ids: [&str; N]) -> StagedCommitMembers {
1062
+ let mut members = StagedCommitMembers::new(
1063
+ "test-uuid-1".to_string(),
1064
+ "test-uuid-2".to_string(),
1065
+ "test-uuid-3".to_string(),
1066
+ "test-timestamp-1".to_string(),
1067
+ );
1068
+ for change_id in change_ids {
1069
+ members.add_change_id(change_id.to_string());
1070
+ }
1071
+ members
1072
+ }
1073
+
1074
+ fn tracked_global_row(change_id: &str) -> StagedStateRow {
1075
+ tracked_version_row(GLOBAL_VERSION_ID, change_id)
1076
+ }
1077
+
1078
+ fn tracked_version_row(version_id: &str, change_id: &str) -> StagedStateRow {
1079
+ StagedStateRow {
1080
+ entity_id: crate::entity_identity::EntityIdentity::single("entity-1"),
1081
+ schema_key: "test_schema".to_string(),
1082
+ file_id: None,
1083
+ snapshot_content: Some("{\"value\":1}".to_string()),
1084
+ metadata: None,
1085
+ origin: None,
1086
+ schema_version: "1".to_string(),
1087
+ created_at: "2026-01-01T00:00:00Z".to_string(),
1088
+ updated_at: "2026-01-01T00:00:00Z".to_string(),
1089
+ global: version_id == GLOBAL_VERSION_ID,
1090
+ change_id: Some(change_id.to_string()),
1091
+ commit_id: Some("test-uuid-1".to_string()),
1092
+ untracked: false,
1093
+ version_id: version_id.to_string(),
1094
+ }
1095
+ }
1096
+
1097
+ fn untracked_global_row(change_id: &str) -> StagedStateRow {
1098
+ StagedStateRow {
1099
+ snapshot_content: Some("{\"value\":\"untracked\"}".to_string()),
1100
+ change_id: None,
1101
+ commit_id: None,
1102
+ untracked: true,
1103
+ ..tracked_global_row(change_id)
1104
+ }
1105
+ }
1106
+
1107
+ fn untracked_request() -> UntrackedStateRowRequest {
1108
+ UntrackedStateRowRequest {
1109
+ schema_key: "test_schema".to_string(),
1110
+ version_id: GLOBAL_VERSION_ID.to_string(),
1111
+ entity_id: crate::entity_identity::EntityIdentity::single("entity-1"),
1112
+ file_id: NullableKeyFilter::Null,
1113
+ }
1114
+ }
1115
+
1116
+ fn live_state_request() -> LiveStateRowRequest {
1117
+ LiveStateRowRequest {
1118
+ schema_key: "test_schema".to_string(),
1119
+ version_id: GLOBAL_VERSION_ID.to_string(),
1120
+ entity_id: crate::entity_identity::EntityIdentity::single("entity-1"),
1121
+ file_id: NullableKeyFilter::Null,
1122
+ }
1123
+ }
1124
+
1125
+ struct CountingBackend {
1126
+ inner: UnitTestBackend,
1127
+ write_batches: Arc<AtomicUsize>,
1128
+ }
1129
+
1130
+ impl CountingBackend {
1131
+ fn new() -> Self {
1132
+ Self {
1133
+ inner: UnitTestBackend::new(),
1134
+ write_batches: Arc::new(AtomicUsize::new(0)),
1135
+ }
1136
+ }
1137
+
1138
+ fn write_batches(&self) -> Arc<AtomicUsize> {
1139
+ Arc::clone(&self.write_batches)
1140
+ }
1141
+ }
1142
+
1143
+ #[async_trait]
1144
+ impl Backend for CountingBackend {
1145
+ async fn begin_read_transaction(
1146
+ &self,
1147
+ ) -> Result<Box<dyn BackendReadTransaction + Send + Sync + 'static>, LixError> {
1148
+ self.inner.begin_read_transaction().await
1149
+ }
1150
+
1151
+ async fn begin_write_transaction(
1152
+ &self,
1153
+ ) -> Result<Box<dyn BackendWriteTransaction + Send + Sync + 'static>, LixError> {
1154
+ Ok(Box::new(CountingWriteTransaction {
1155
+ inner: self.inner.begin_write_transaction().await?,
1156
+ write_batches: Arc::clone(&self.write_batches),
1157
+ }))
1158
+ }
1159
+ }
1160
+
1161
+ struct CountingWriteTransaction {
1162
+ inner: Box<dyn BackendWriteTransaction + Send + Sync + 'static>,
1163
+ write_batches: Arc<AtomicUsize>,
1164
+ }
1165
+
1166
+ #[async_trait]
1167
+ impl BackendReadTransaction for CountingWriteTransaction {
1168
+ async fn get_values(
1169
+ &mut self,
1170
+ request: BackendKvGetRequest,
1171
+ ) -> Result<BackendKvValueBatch, LixError> {
1172
+ self.inner.get_values(request).await
1173
+ }
1174
+
1175
+ async fn exists_many(
1176
+ &mut self,
1177
+ request: BackendKvGetRequest,
1178
+ ) -> Result<BackendKvExistsBatch, LixError> {
1179
+ self.inner.exists_many(request).await
1180
+ }
1181
+
1182
+ async fn scan_keys(
1183
+ &mut self,
1184
+ request: BackendKvScanRequest,
1185
+ ) -> Result<BackendKvKeyPage, LixError> {
1186
+ self.inner.scan_keys(request).await
1187
+ }
1188
+
1189
+ async fn scan_values(
1190
+ &mut self,
1191
+ request: BackendKvScanRequest,
1192
+ ) -> Result<BackendKvValuePage, LixError> {
1193
+ self.inner.scan_values(request).await
1194
+ }
1195
+
1196
+ async fn scan_entries(
1197
+ &mut self,
1198
+ request: BackendKvScanRequest,
1199
+ ) -> Result<BackendKvEntryPage, LixError> {
1200
+ self.inner.scan_entries(request).await
1201
+ }
1202
+
1203
+ async fn rollback(self: Box<Self>) -> Result<(), LixError> {
1204
+ let Self { inner, .. } = *self;
1205
+ inner.rollback().await
1206
+ }
1207
+ }
1208
+
1209
+ #[async_trait]
1210
+ impl BackendWriteTransaction for CountingWriteTransaction {
1211
+ async fn write_kv_batch(
1212
+ &mut self,
1213
+ batch: BackendKvWriteBatch,
1214
+ ) -> Result<BackendKvWriteStats, LixError> {
1215
+ self.write_batches.fetch_add(1, Ordering::SeqCst);
1216
+ self.inner.write_kv_batch(batch).await
1217
+ }
1218
+
1219
+ async fn commit(self: Box<Self>) -> Result<(), LixError> {
1220
+ let Self { inner, .. } = *self;
1221
+ inner.commit().await
1222
+ }
1223
+ }
1224
+ }