@lix-js/sdk 0.6.0-preview.0 → 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 (196) hide show
  1. package/README.md +9 -0
  2. package/SKILL.md +468 -0
  3. package/dist/engine-wasm/index.d.ts +15 -11
  4. package/dist/engine-wasm/index.js +105 -38
  5. package/dist/engine-wasm/wasm/lix_engine.d.ts +14 -2
  6. package/dist/engine-wasm/wasm/lix_engine.js +18 -17
  7. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  8. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +2 -1
  9. package/dist/generated/builtin-schemas.d.ts +31 -41
  10. package/dist/generated/builtin-schemas.js +52 -56
  11. package/dist/open-lix.d.ts +141 -24
  12. package/dist/open-lix.js +199 -35
  13. package/dist/sqlite/index.js +99 -22
  14. package/dist-engine-src/README.md +18 -0
  15. package/dist-engine-src/src/backend/kv.rs +358 -0
  16. package/dist-engine-src/src/backend/mod.rs +12 -0
  17. package/dist-engine-src/src/backend/testing.rs +658 -0
  18. package/dist-engine-src/src/backend/types.rs +96 -0
  19. package/dist-engine-src/src/binary_cas/chunking.rs +31 -0
  20. package/dist-engine-src/src/binary_cas/codec.rs +346 -0
  21. package/dist-engine-src/src/binary_cas/context.rs +139 -0
  22. package/dist-engine-src/src/binary_cas/kv.rs +1063 -0
  23. package/dist-engine-src/src/binary_cas/mod.rs +11 -0
  24. package/dist-engine-src/src/binary_cas/types.rs +127 -0
  25. package/dist-engine-src/src/cel/context.rs +86 -0
  26. package/dist-engine-src/src/cel/error.rs +19 -0
  27. package/dist-engine-src/src/cel/mod.rs +8 -0
  28. package/dist-engine-src/src/cel/provider.rs +9 -0
  29. package/dist-engine-src/src/cel/runtime.rs +167 -0
  30. package/dist-engine-src/src/cel/value.rs +50 -0
  31. package/dist-engine-src/src/changelog/codec.rs +321 -0
  32. package/dist-engine-src/src/changelog/context.rs +92 -0
  33. package/dist-engine-src/src/changelog/materialization.rs +121 -0
  34. package/dist-engine-src/src/changelog/mod.rs +13 -0
  35. package/dist-engine-src/src/changelog/reader.rs +20 -0
  36. package/dist-engine-src/src/changelog/storage.rs +220 -0
  37. package/dist-engine-src/src/changelog/types.rs +38 -0
  38. package/dist-engine-src/src/commit_graph/context.rs +1588 -0
  39. package/dist-engine-src/src/commit_graph/mod.rs +12 -0
  40. package/dist-engine-src/src/commit_graph/types.rs +145 -0
  41. package/dist-engine-src/src/commit_graph/walker.rs +780 -0
  42. package/dist-engine-src/src/common/error.rs +313 -0
  43. package/dist-engine-src/src/common/fingerprint.rs +3 -0
  44. package/dist-engine-src/src/common/fs_path.rs +1336 -0
  45. package/dist-engine-src/src/common/identity.rs +135 -0
  46. package/dist-engine-src/src/common/metadata.rs +35 -0
  47. package/dist-engine-src/src/common/mod.rs +23 -0
  48. package/dist-engine-src/src/common/types.rs +105 -0
  49. package/dist-engine-src/src/common/wire.rs +222 -0
  50. package/dist-engine-src/src/engine.rs +239 -0
  51. package/dist-engine-src/src/entity_identity.rs +285 -0
  52. package/dist-engine-src/src/functions/context.rs +327 -0
  53. package/dist-engine-src/src/functions/deterministic.rs +113 -0
  54. package/dist-engine-src/src/functions/mod.rs +18 -0
  55. package/dist-engine-src/src/functions/provider.rs +130 -0
  56. package/dist-engine-src/src/functions/state.rs +363 -0
  57. package/dist-engine-src/src/functions/types.rs +37 -0
  58. package/dist-engine-src/src/init.rs +505 -0
  59. package/dist-engine-src/src/json_store/compression.rs +77 -0
  60. package/dist-engine-src/src/json_store/context.rs +129 -0
  61. package/dist-engine-src/src/json_store/encoded.rs +15 -0
  62. package/dist-engine-src/src/json_store/mod.rs +9 -0
  63. package/dist-engine-src/src/json_store/store.rs +236 -0
  64. package/dist-engine-src/src/json_store/types.rs +52 -0
  65. package/dist-engine-src/src/lib.rs +61 -0
  66. package/dist-engine-src/src/live_state/context.rs +2241 -0
  67. package/dist-engine-src/src/live_state/mod.rs +15 -0
  68. package/dist-engine-src/src/live_state/overlay.rs +75 -0
  69. package/dist-engine-src/src/live_state/reader.rs +23 -0
  70. package/dist-engine-src/src/live_state/types.rs +239 -0
  71. package/dist-engine-src/src/live_state/visibility.rs +218 -0
  72. package/dist-engine-src/src/plugin/archive.rs +441 -0
  73. package/dist-engine-src/src/plugin/component.rs +183 -0
  74. package/dist-engine-src/src/plugin/install.rs +637 -0
  75. package/dist-engine-src/src/plugin/manifest.rs +516 -0
  76. package/dist-engine-src/src/plugin/materializer.rs +477 -0
  77. package/dist-engine-src/src/plugin/mod.rs +33 -0
  78. package/dist-engine-src/src/plugin/plugin_manifest.json +119 -0
  79. package/dist-engine-src/src/plugin/storage.rs +74 -0
  80. package/dist-engine-src/src/schema/annotations/defaults.rs +280 -0
  81. package/dist-engine-src/src/schema/annotations/mod.rs +1 -0
  82. package/dist-engine-src/src/schema/builtin/lix_account.json +22 -0
  83. package/dist-engine-src/src/schema/builtin/lix_active_account.json +30 -0
  84. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +30 -0
  85. package/dist-engine-src/src/schema/builtin/lix_change.json +62 -0
  86. package/dist-engine-src/src/schema/builtin/lix_change_author.json +46 -0
  87. package/dist-engine-src/src/schema/builtin/lix_change_set.json +18 -0
  88. package/dist-engine-src/src/schema/builtin/lix_change_set_element.json +75 -0
  89. package/dist-engine-src/src/schema/builtin/lix_commit.json +62 -0
  90. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +46 -0
  91. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +53 -0
  92. package/dist-engine-src/src/schema/builtin/lix_entity_label.json +63 -0
  93. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +53 -0
  94. package/dist-engine-src/src/schema/builtin/lix_key_value.json +41 -0
  95. package/dist-engine-src/src/schema/builtin/lix_label.json +22 -0
  96. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +31 -0
  97. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +35 -0
  98. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +49 -0
  99. package/dist-engine-src/src/schema/builtin/mod.rs +271 -0
  100. package/dist-engine-src/src/schema/definition.json +157 -0
  101. package/dist-engine-src/src/schema/definition.rs +636 -0
  102. package/dist-engine-src/src/schema/key.rs +206 -0
  103. package/dist-engine-src/src/schema/mod.rs +20 -0
  104. package/dist-engine-src/src/schema/seed.rs +14 -0
  105. package/dist-engine-src/src/schema/tests.rs +739 -0
  106. package/dist-engine-src/src/schema_registry.rs +294 -0
  107. package/dist-engine-src/src/session/context.rs +366 -0
  108. package/dist-engine-src/src/session/create_version.rs +80 -0
  109. package/dist-engine-src/src/session/execute.rs +447 -0
  110. package/dist-engine-src/src/session/merge/analysis.rs +102 -0
  111. package/dist-engine-src/src/session/merge/apply.rs +23 -0
  112. package/dist-engine-src/src/session/merge/conflicts.rs +62 -0
  113. package/dist-engine-src/src/session/merge/mod.rs +11 -0
  114. package/dist-engine-src/src/session/merge/stats.rs +65 -0
  115. package/dist-engine-src/src/session/merge/version.rs +437 -0
  116. package/dist-engine-src/src/session/mod.rs +25 -0
  117. package/dist-engine-src/src/session/switch_version.rs +121 -0
  118. package/dist-engine-src/src/sql2/change_provider.rs +337 -0
  119. package/dist-engine-src/src/sql2/classify.rs +147 -0
  120. package/dist-engine-src/src/sql2/commit_derived_provider.rs +591 -0
  121. package/dist-engine-src/src/sql2/context.rs +307 -0
  122. package/dist-engine-src/src/sql2/directory_history_provider.rs +623 -0
  123. package/dist-engine-src/src/sql2/directory_provider.rs +2405 -0
  124. package/dist-engine-src/src/sql2/dml.rs +148 -0
  125. package/dist-engine-src/src/sql2/entity_history_provider.rs +444 -0
  126. package/dist-engine-src/src/sql2/entity_provider.rs +2700 -0
  127. package/dist-engine-src/src/sql2/error.rs +196 -0
  128. package/dist-engine-src/src/sql2/execute.rs +3379 -0
  129. package/dist-engine-src/src/sql2/file_history_provider.rs +902 -0
  130. package/dist-engine-src/src/sql2/file_provider.rs +3254 -0
  131. package/dist-engine-src/src/sql2/filesystem_planner.rs +1526 -0
  132. package/dist-engine-src/src/sql2/filesystem_predicates.rs +159 -0
  133. package/dist-engine-src/src/sql2/filesystem_visibility.rs +369 -0
  134. package/dist-engine-src/src/sql2/history_projection.rs +80 -0
  135. package/dist-engine-src/src/sql2/history_provider.rs +418 -0
  136. package/dist-engine-src/src/sql2/history_route.rs +643 -0
  137. package/dist-engine-src/src/sql2/lix_state_provider.rs +2430 -0
  138. package/dist-engine-src/src/sql2/mod.rs +43 -0
  139. package/dist-engine-src/src/sql2/read_only.rs +65 -0
  140. package/dist-engine-src/src/sql2/record_batch.rs +17 -0
  141. package/dist-engine-src/src/sql2/result_metadata.rs +29 -0
  142. package/dist-engine-src/src/sql2/runtime.rs +60 -0
  143. package/dist-engine-src/src/sql2/session.rs +135 -0
  144. package/dist-engine-src/src/sql2/udfs/common.rs +295 -0
  145. package/dist-engine-src/src/sql2/udfs/lix_active_version_commit_id.rs +53 -0
  146. package/dist-engine-src/src/sql2/udfs/lix_empty_blob.rs +47 -0
  147. package/dist-engine-src/src/sql2/udfs/lix_json.rs +100 -0
  148. package/dist-engine-src/src/sql2/udfs/lix_json_get.rs +99 -0
  149. package/dist-engine-src/src/sql2/udfs/lix_json_get_text.rs +99 -0
  150. package/dist-engine-src/src/sql2/udfs/lix_text_decode.rs +82 -0
  151. package/dist-engine-src/src/sql2/udfs/lix_text_encode.rs +85 -0
  152. package/dist-engine-src/src/sql2/udfs/lix_uuid_v7.rs +76 -0
  153. package/dist-engine-src/src/sql2/udfs/mod.rs +82 -0
  154. package/dist-engine-src/src/sql2/version_provider.rs +1187 -0
  155. package/dist-engine-src/src/sql2/version_scope.rs +394 -0
  156. package/dist-engine-src/src/sql2/write_normalization.rs +345 -0
  157. package/dist-engine-src/src/storage/context.rs +356 -0
  158. package/dist-engine-src/src/storage/mod.rs +14 -0
  159. package/dist-engine-src/src/storage/read_scope.rs +88 -0
  160. package/dist-engine-src/src/storage/types.rs +501 -0
  161. package/dist-engine-src/src/storage_bench.rs +3406 -0
  162. package/dist-engine-src/src/test_support.rs +81 -0
  163. package/dist-engine-src/src/tracked_state/by_file_index.rs +102 -0
  164. package/dist-engine-src/src/tracked_state/codec.rs +747 -0
  165. package/dist-engine-src/src/tracked_state/context.rs +983 -0
  166. package/dist-engine-src/src/tracked_state/diff.rs +494 -0
  167. package/dist-engine-src/src/tracked_state/materialization.rs +141 -0
  168. package/dist-engine-src/src/tracked_state/merge.rs +474 -0
  169. package/dist-engine-src/src/tracked_state/mod.rs +31 -0
  170. package/dist-engine-src/src/tracked_state/rebuild.rs +771 -0
  171. package/dist-engine-src/src/tracked_state/storage.rs +243 -0
  172. package/dist-engine-src/src/tracked_state/tree.rs +2744 -0
  173. package/dist-engine-src/src/tracked_state/tree_types.rs +176 -0
  174. package/dist-engine-src/src/tracked_state/types.rs +61 -0
  175. package/dist-engine-src/src/transaction/commit.rs +1224 -0
  176. package/dist-engine-src/src/transaction/context.rs +1307 -0
  177. package/dist-engine-src/src/transaction/live_state_overlay.rs +34 -0
  178. package/dist-engine-src/src/transaction/mod.rs +11 -0
  179. package/dist-engine-src/src/transaction/normalization.rs +1026 -0
  180. package/dist-engine-src/src/transaction/schema_resolver.rs +127 -0
  181. package/dist-engine-src/src/transaction/staging.rs +1436 -0
  182. package/dist-engine-src/src/transaction/types.rs +351 -0
  183. package/dist-engine-src/src/transaction/validation.rs +4811 -0
  184. package/dist-engine-src/src/untracked_state/codec.rs +363 -0
  185. package/dist-engine-src/src/untracked_state/context.rs +82 -0
  186. package/dist-engine-src/src/untracked_state/materialization.rs +157 -0
  187. package/dist-engine-src/src/untracked_state/mod.rs +17 -0
  188. package/dist-engine-src/src/untracked_state/storage.rs +348 -0
  189. package/dist-engine-src/src/untracked_state/types.rs +96 -0
  190. package/dist-engine-src/src/version/context.rs +52 -0
  191. package/dist-engine-src/src/version/mod.rs +12 -0
  192. package/dist-engine-src/src/version/refs.rs +421 -0
  193. package/dist-engine-src/src/version/stage_rows.rs +71 -0
  194. package/dist-engine-src/src/version/types.rs +21 -0
  195. package/dist-engine-src/src/wasm/mod.rs +60 -0
  196. package/package.json +68 -63
@@ -0,0 +1,1307 @@
1
+ use std::collections::BTreeSet;
2
+ use std::sync::Arc;
3
+
4
+ use async_trait::async_trait;
5
+ use serde_json::Value as JsonValue;
6
+
7
+ use crate::binary_cas::{BinaryCasContext, BlobBytesBatch, BlobHash};
8
+ use crate::changelog::ChangelogContext;
9
+ use crate::commit_graph::{CommitGraphContext, CommitGraphStoreReader};
10
+ use crate::entity_identity::EntityIdentity;
11
+ use crate::functions::{FunctionContext, FunctionProviderHandle};
12
+ use crate::json_store::JsonStoreContext;
13
+ use crate::live_state::{
14
+ LiveStateContext, LiveStateRow, LiveStateRowRequest, LiveStateScanRequest,
15
+ };
16
+ use crate::schema_registry::SchemaRegistry;
17
+ use crate::session::{SessionMode, WORKSPACE_VERSION_KEY};
18
+ use crate::sql2::SqlWriteExecutionContext;
19
+ use crate::storage::{StorageContext, StorageWriteSet, StorageWriteTransaction};
20
+ use crate::tracked_state::{TrackedStateContext, TrackedStateStoreReader};
21
+ use crate::transaction::commit;
22
+ use crate::transaction::live_state_overlay::overlay_scan_rows;
23
+ use crate::transaction::normalization::normalize_stage_row;
24
+ use crate::transaction::schema_resolver::TransactionSchemaResolver;
25
+ use crate::transaction::staging::{StagedWriteSet, TransactionStagedWrites};
26
+ use crate::transaction::types::{
27
+ StageFileData, StageRow, StageWrite, StageWriteMode, StageWriteOutcome,
28
+ };
29
+ use crate::transaction::validation::{validate_staged_writes, TransactionValidationInput};
30
+ use crate::version::{VersionContext, VersionRefReader};
31
+ use crate::GLOBAL_VERSION_ID;
32
+ use crate::{LixError, NullableKeyFilter};
33
+
34
+ #[derive(Debug, Clone, PartialEq, Eq, Default)]
35
+ pub(crate) struct TransactionCommitOutcome;
36
+
37
+ /// One execution-scoped transaction capability for engine2 write paths.
38
+ ///
39
+ /// This is intentionally not a session-wide kitchen sink. It owns the backend
40
+ /// write transaction for one `SessionContext::execute(...)` call and projects
41
+ /// staged SQL writes back into the SQL DAG through an engine2-local live-state
42
+ /// overlay.
43
+ ///
44
+ /// Transaction invariant: this is the capability for engine2 operations
45
+ /// that may write. Write-relevant reads must be exposed from this transaction,
46
+ /// after the backend write transaction has begun, rather than from session-level
47
+ /// helpers.
48
+ pub(crate) struct Transaction {
49
+ active_version_id: String,
50
+ live_state: Arc<LiveStateContext>,
51
+ tracked_state: Arc<TrackedStateContext>,
52
+ binary_cas: Arc<BinaryCasContext>,
53
+ changelog: Arc<ChangelogContext>,
54
+ version_ctx: Arc<VersionContext>,
55
+ schema_resolver: TransactionSchemaResolver,
56
+ staged_writes: Arc<TransactionStagedWrites>,
57
+ storage_transaction: Box<dyn StorageWriteTransaction + Send + Sync + 'static>,
58
+ visible_schemas: Vec<JsonValue>,
59
+ functions: FunctionProviderHandle,
60
+ }
61
+
62
+ impl Transaction {
63
+ /// Opens a backend write transaction and creates an execution-scoped
64
+ /// staging area for SQL/provider hooks.
65
+ async fn open(
66
+ mode: &SessionMode,
67
+ storage: StorageContext,
68
+ live_state: Arc<LiveStateContext>,
69
+ tracked_state: Arc<TrackedStateContext>,
70
+ binary_cas: Arc<BinaryCasContext>,
71
+ changelog: Arc<ChangelogContext>,
72
+ version_ctx: Arc<VersionContext>,
73
+ schema_registry: Arc<SchemaRegistry>,
74
+ ) -> Result<OpenTransaction, LixError> {
75
+ let mut storage_transaction = storage.begin_write_transaction().await?;
76
+ let setup_result = async {
77
+ let active_version_id = resolve_active_version_id(
78
+ mode,
79
+ live_state.as_ref(),
80
+ version_ctx.as_ref(),
81
+ storage_transaction.as_mut(),
82
+ )
83
+ .await?;
84
+ let runtime_functions = {
85
+ let runtime_live_state = live_state.reader(storage_transaction.as_mut());
86
+ FunctionContext::prepare(&runtime_live_state).await?
87
+ };
88
+ let functions = runtime_functions.provider();
89
+ let visible_schemas = {
90
+ let visible_live_state = live_state.reader(storage_transaction.as_mut());
91
+ schema_registry
92
+ .visible_schemas(&visible_live_state, &active_version_id)
93
+ .await?
94
+ };
95
+ Ok::<_, LixError>((
96
+ active_version_id,
97
+ runtime_functions,
98
+ functions,
99
+ visible_schemas,
100
+ ))
101
+ }
102
+ .await;
103
+ let (active_version_id, runtime_functions, functions, visible_schemas) = match setup_result
104
+ {
105
+ Ok(result) => result,
106
+ Err(error) => {
107
+ let _ = storage_transaction.rollback().await;
108
+ return Err(error);
109
+ }
110
+ };
111
+ let mut schema_resolver = TransactionSchemaResolver::new(schema_registry);
112
+ schema_resolver
113
+ .remember_visible_schemas(active_version_id.clone(), visible_schemas.clone())?;
114
+ let staged_writes = Arc::new(TransactionStagedWrites::new(functions.clone()));
115
+ Ok(OpenTransaction {
116
+ transaction: Self {
117
+ active_version_id,
118
+ live_state,
119
+ tracked_state,
120
+ binary_cas,
121
+ changelog,
122
+ version_ctx,
123
+ schema_resolver,
124
+ staged_writes,
125
+ storage_transaction,
126
+ visible_schemas,
127
+ functions,
128
+ },
129
+ runtime_functions,
130
+ })
131
+ }
132
+
133
+ /// Commits staged writes, runtime function state, and the backend transaction.
134
+ ///
135
+ /// Commit owns the execution boundary: provider-staged rows become
136
+ /// changelog facts, `lix_commit` rows, version-ref updates, and visible
137
+ /// live_state rows before the backend transaction is committed.
138
+ pub(crate) async fn commit(
139
+ mut self,
140
+ runtime_functions: &FunctionContext,
141
+ ) -> Result<TransactionCommitOutcome, LixError> {
142
+ let staged_writes = match self.staged_writes.drain() {
143
+ Ok(staged_writes) => staged_writes,
144
+ Err(error) => {
145
+ let _ = self.storage_transaction.rollback().await;
146
+ return Err(error);
147
+ }
148
+ };
149
+ if let Err(error) = self.validate_staged_writes_by_version(&staged_writes).await {
150
+ let _ = self.storage_transaction.rollback().await;
151
+ return Err(error);
152
+ }
153
+ if let Err(error) = commit::commit_staged_writes(
154
+ &self.binary_cas,
155
+ &self.changelog,
156
+ &self.live_state,
157
+ self.version_ctx.as_ref(),
158
+ Some(runtime_functions),
159
+ self.storage_transaction.as_mut(),
160
+ staged_writes,
161
+ )
162
+ .await
163
+ {
164
+ let _ = self.storage_transaction.rollback().await;
165
+ return Err(error);
166
+ }
167
+ self.storage_transaction.commit().await?;
168
+ Ok(TransactionCommitOutcome::default())
169
+ }
170
+
171
+ /// Rolls back the backend transaction.
172
+ ///
173
+ /// This is the explicit failure path for a write execution. Dropping the
174
+ /// buffered transaction without commit is not the API we want callers to
175
+ /// rely on.
176
+ #[allow(dead_code)]
177
+ pub(crate) async fn rollback(self) -> Result<(), LixError> {
178
+ self.storage_transaction.rollback().await
179
+ }
180
+
181
+ /// Stages one decoded write batch into this transaction.
182
+ ///
183
+ /// This is the programmatic write entrypoint used by non-SQL APIs. The
184
+ /// transaction still owns hydration from `StageRow` into `StagedStateRow`,
185
+ /// so generated timestamps, change ids, commit ids, and commit membership
186
+ /// stay in one place.
187
+ #[allow(dead_code)]
188
+ pub(crate) async fn stage_write(
189
+ &mut self,
190
+ write: StageWrite,
191
+ ) -> Result<StageWriteOutcome, LixError> {
192
+ require_valid_stage_write_storage_scopes(&write)?;
193
+ self.require_existing_stage_write_version_ids(&write)
194
+ .await?;
195
+ let write = self.normalize_stage_write(write).await?;
196
+ self.staged_writes.stage_write(write)
197
+ }
198
+
199
+ async fn normalize_stage_write(&mut self, write: StageWrite) -> Result<StageWrite, LixError> {
200
+ Ok(match write {
201
+ StageWrite::Rows { mode, rows } => StageWrite::Rows {
202
+ mode,
203
+ rows: self.normalize_stage_rows(rows).await?,
204
+ },
205
+ StageWrite::RowsWithFileData {
206
+ mode,
207
+ rows,
208
+ file_data,
209
+ count,
210
+ } => StageWrite::RowsWithFileData {
211
+ mode,
212
+ rows: self.normalize_stage_rows(rows).await?,
213
+ file_data,
214
+ count,
215
+ },
216
+ StageWrite::AdoptedChanges { changes } => StageWrite::AdoptedChanges { changes },
217
+ })
218
+ }
219
+
220
+ async fn normalize_stage_rows(
221
+ &mut self,
222
+ rows: Vec<StageRow>,
223
+ ) -> Result<Vec<StageRow>, LixError> {
224
+ let mut normalized_rows = Vec::with_capacity(rows.len());
225
+ for row in rows {
226
+ let version_id = row.schema_scope_version_id().to_string();
227
+ let staged = self.staged_writes.staging_overlay()?;
228
+ let live_state = self.live_state.reader(self.storage_transaction.as_mut());
229
+ let catalog = self
230
+ .schema_resolver
231
+ .catalog_for_row_normalization(&live_state, staged, &version_id)
232
+ .await?;
233
+ let row = normalize_stage_row(row, catalog, self.functions.clone())?;
234
+ normalized_rows.push(row);
235
+ }
236
+ Ok(normalized_rows)
237
+ }
238
+
239
+ async fn validate_staged_writes_by_version(
240
+ &mut self,
241
+ staged_writes: &StagedWriteSet,
242
+ ) -> Result<(), LixError> {
243
+ for version_id in staged_write_validation_version_ids(staged_writes) {
244
+ let version_staged_writes =
245
+ staged_write_set_for_schema_scope(staged_writes, &version_id);
246
+ let live_state = self.live_state.reader(self.storage_transaction.as_mut());
247
+ let schema_catalog = self
248
+ .schema_resolver
249
+ .catalog_for_validation(&live_state, staged_writes, &version_id)
250
+ .await?;
251
+ validate_staged_writes(TransactionValidationInput::new(
252
+ &version_staged_writes,
253
+ &schema_catalog,
254
+ &live_state,
255
+ ))
256
+ .await?;
257
+ }
258
+ Ok(())
259
+ }
260
+
261
+ /// Convenience helper for programmatic APIs that only stage state rows.
262
+ #[allow(dead_code)]
263
+ pub(crate) async fn stage_rows(
264
+ &mut self,
265
+ rows: Vec<StageRow>,
266
+ ) -> Result<StageWriteOutcome, LixError> {
267
+ self.stage_write(StageWrite::Rows {
268
+ mode: StageWriteMode::Replace,
269
+ rows,
270
+ })
271
+ .await
272
+ }
273
+
274
+ async fn require_existing_stage_write_version_ids(
275
+ &mut self,
276
+ write: &StageWrite,
277
+ ) -> Result<(), LixError> {
278
+ let version_ids = stage_write_version_ids(write);
279
+ let reader = self
280
+ .version_ctx
281
+ .ref_reader(self.storage_transaction.as_mut());
282
+ for version_id in version_ids {
283
+ if version_id == GLOBAL_VERSION_ID {
284
+ continue;
285
+ }
286
+ if reader.load_head_commit_id(&version_id).await?.is_none() {
287
+ return Err(LixError::version_not_found(
288
+ version_id,
289
+ "stage_write",
290
+ "target",
291
+ ));
292
+ }
293
+ }
294
+ Ok(())
295
+ }
296
+
297
+ /// Returns the active version resolved inside this write transaction.
298
+ pub(crate) fn active_version_id(&self) -> &str {
299
+ &self.active_version_id
300
+ }
301
+
302
+ /// Returns this transaction's prepared runtime functions.
303
+ pub(crate) fn functions(&self) -> FunctionProviderHandle {
304
+ self.functions.clone()
305
+ }
306
+
307
+ /// Adds an extra parent to the commit generated for `version_id`.
308
+ ///
309
+ /// Merge uses this to preserve source-branch ancestry. Ordinary writes do
310
+ /// not call this because commit finalization already parents to the
311
+ /// version's previous head.
312
+ pub(crate) fn add_commit_parent(
313
+ &self,
314
+ version_id: String,
315
+ parent_commit_id: String,
316
+ ) -> Result<(), LixError> {
317
+ self.staged_writes
318
+ .add_commit_parent(version_id, parent_commit_id)
319
+ }
320
+
321
+ /// Advances a version ref without staging tracked rows.
322
+ ///
323
+ /// Fast-forward merges use this path because the commit graph already
324
+ /// contains the source head; the target ref only needs to move to it.
325
+ pub(crate) async fn advance_version_ref(
326
+ &mut self,
327
+ version_id: &str,
328
+ commit_id: &str,
329
+ ) -> Result<(), LixError> {
330
+ let timestamp = self.functions.call_timestamp();
331
+ let mut writes = StorageWriteSet::new();
332
+ let canonical_row = {
333
+ let mut json_writer = JsonStoreContext::new().writer();
334
+ self.version_ctx.canonical_ref_row(
335
+ &mut writes,
336
+ &mut json_writer,
337
+ version_id,
338
+ commit_id,
339
+ &timestamp,
340
+ )?
341
+ };
342
+ self.version_ctx
343
+ .stage_canonical_ref_rows(&mut writes, &[canonical_row])?;
344
+ writes
345
+ .apply(&mut self.storage_transaction.as_mut())
346
+ .await
347
+ .map(|_| ())
348
+ }
349
+
350
+ /// Returns the commit id currently staged for `version_id`, if tracked rows
351
+ /// have been staged for that version.
352
+ pub(crate) fn staged_commit_id(&self, version_id: &str) -> Result<Option<String>, LixError> {
353
+ self.staged_writes.staged_commit_id(version_id)
354
+ }
355
+
356
+ /// Stages a commit for `version_id` even if no tracked rows changed.
357
+ pub(crate) fn stage_empty_commit(&self, version_id: String) -> Result<String, LixError> {
358
+ self.staged_writes.stage_empty_commit(version_id)
359
+ }
360
+
361
+ /// Creates a version-ref reader scoped to this write transaction.
362
+ pub(crate) fn version_ref_reader(&mut self) -> impl VersionRefReader + '_ {
363
+ self.version_ctx
364
+ .ref_reader(self.storage_transaction.as_mut())
365
+ }
366
+
367
+ /// Creates a tracked-state reader scoped to this write transaction.
368
+ pub(crate) fn tracked_state_reader(
369
+ &mut self,
370
+ ) -> TrackedStateStoreReader<&mut dyn StorageWriteTransaction> {
371
+ self.tracked_state.reader(self.storage_transaction.as_mut())
372
+ }
373
+
374
+ /// Creates a commit-graph reader scoped to this write transaction.
375
+ pub(crate) fn commit_graph_reader(
376
+ &mut self,
377
+ ) -> CommitGraphStoreReader<&mut dyn StorageWriteTransaction> {
378
+ CommitGraphContext::new(self.changelog.as_ref().clone())
379
+ .reader(self.storage_transaction.as_mut())
380
+ }
381
+ }
382
+
383
+ pub(crate) struct OpenTransaction {
384
+ pub(crate) transaction: Transaction,
385
+ pub(crate) runtime_functions: FunctionContext,
386
+ }
387
+
388
+ pub(crate) async fn open_transaction(
389
+ mode: &SessionMode,
390
+ storage: StorageContext,
391
+ live_state: Arc<LiveStateContext>,
392
+ tracked_state: Arc<TrackedStateContext>,
393
+ binary_cas: Arc<BinaryCasContext>,
394
+ changelog: Arc<ChangelogContext>,
395
+ version_ctx: Arc<VersionContext>,
396
+ schema_registry: Arc<SchemaRegistry>,
397
+ ) -> Result<OpenTransaction, LixError> {
398
+ Transaction::open(
399
+ mode,
400
+ storage,
401
+ live_state,
402
+ tracked_state,
403
+ binary_cas,
404
+ changelog,
405
+ version_ctx,
406
+ schema_registry,
407
+ )
408
+ .await
409
+ }
410
+
411
+ #[async_trait]
412
+ impl SqlWriteExecutionContext for Transaction {
413
+ fn active_version_id(&self) -> &str {
414
+ &self.active_version_id
415
+ }
416
+
417
+ fn functions(&self) -> FunctionProviderHandle {
418
+ self.functions.clone()
419
+ }
420
+
421
+ fn list_visible_schemas(&self) -> Result<Vec<JsonValue>, LixError> {
422
+ Ok(self.visible_schemas.clone())
423
+ }
424
+
425
+ async fn load_bytes_many(&mut self, hashes: &[BlobHash]) -> Result<BlobBytesBatch, LixError> {
426
+ self.binary_cas
427
+ .reader(self.storage_transaction.as_mut())
428
+ .load_bytes_many(hashes)
429
+ .await
430
+ }
431
+
432
+ async fn scan_live_state(
433
+ &mut self,
434
+ request: &LiveStateScanRequest,
435
+ ) -> Result<Vec<LiveStateRow>, LixError> {
436
+ let staged = self.staged_writes.staging_overlay()?;
437
+ let base = self.live_state.reader(self.storage_transaction.as_mut());
438
+ overlay_scan_rows(&base, &staged, request).await
439
+ }
440
+
441
+ async fn load_version_head(&mut self, version_id: &str) -> Result<Option<String>, LixError> {
442
+ self.version_ctx
443
+ .ref_reader(self.storage_transaction.as_mut())
444
+ .load_head_commit_id(version_id)
445
+ .await
446
+ }
447
+
448
+ async fn stage_write(&mut self, write: StageWrite) -> Result<StageWriteOutcome, LixError> {
449
+ Transaction::stage_write(self, write).await
450
+ }
451
+ }
452
+
453
+ fn stage_write_version_ids(write: &StageWrite) -> BTreeSet<String> {
454
+ match write {
455
+ StageWrite::Rows { rows, .. } => stage_row_version_ids(rows),
456
+ StageWrite::RowsWithFileData {
457
+ rows, file_data, ..
458
+ } => stage_row_version_ids(rows)
459
+ .into_iter()
460
+ .chain(stage_file_data_version_ids(file_data))
461
+ .collect(),
462
+ StageWrite::AdoptedChanges { changes } => changes
463
+ .iter()
464
+ .map(|change| change.version_id.clone())
465
+ .collect(),
466
+ }
467
+ }
468
+
469
+ fn staged_write_validation_version_ids(staged_writes: &StagedWriteSet) -> BTreeSet<String> {
470
+ staged_writes
471
+ .state_rows
472
+ .iter()
473
+ .map(|row| row.schema_scope_version_id().to_string())
474
+ .chain(
475
+ staged_writes
476
+ .adopted_rows
477
+ .iter()
478
+ .map(|row| row.schema_scope_version_id().to_string()),
479
+ )
480
+ .collect()
481
+ }
482
+
483
+ fn staged_write_set_for_schema_scope(
484
+ staged_writes: &StagedWriteSet,
485
+ schema_scope_version_id: &str,
486
+ ) -> StagedWriteSet {
487
+ StagedWriteSet {
488
+ state_rows: staged_writes
489
+ .state_rows
490
+ .iter()
491
+ .filter(|row| row.schema_scope_version_id() == schema_scope_version_id)
492
+ .cloned()
493
+ .collect(),
494
+ adopted_rows: staged_writes
495
+ .adopted_rows
496
+ .iter()
497
+ .filter(|row| row.schema_scope_version_id() == schema_scope_version_id)
498
+ .cloned()
499
+ .collect(),
500
+ insert_identities: staged_writes
501
+ .insert_identities
502
+ .iter()
503
+ .filter(|(identity, _)| {
504
+ let identity_schema_scope = if identity.version_id == GLOBAL_VERSION_ID {
505
+ GLOBAL_VERSION_ID
506
+ } else {
507
+ identity.version_id.as_str()
508
+ };
509
+ identity_schema_scope == schema_scope_version_id
510
+ })
511
+ .map(|(identity, origin)| (identity.clone(), origin.clone()))
512
+ .collect(),
513
+ commit_members_by_version: staged_writes
514
+ .commit_members_by_version
515
+ .iter()
516
+ .filter(|(member_version_id, _)| {
517
+ let member_schema_scope = if member_version_id.as_str() == GLOBAL_VERSION_ID {
518
+ GLOBAL_VERSION_ID
519
+ } else {
520
+ member_version_id.as_str()
521
+ };
522
+ member_schema_scope == schema_scope_version_id
523
+ })
524
+ .map(|(member_version_id, members)| (member_version_id.clone(), members.clone()))
525
+ .collect(),
526
+ extra_commit_parents_by_version: staged_writes
527
+ .extra_commit_parents_by_version
528
+ .iter()
529
+ .filter(|(parent_version_id, _)| parent_version_id.as_str() == schema_scope_version_id)
530
+ .map(|(parent_version_id, parents)| (parent_version_id.clone(), parents.clone()))
531
+ .collect(),
532
+ file_data_writes: staged_writes
533
+ .file_data_writes
534
+ .iter()
535
+ .filter(|write| {
536
+ let write_schema_scope = if write.version_id == GLOBAL_VERSION_ID {
537
+ GLOBAL_VERSION_ID
538
+ } else {
539
+ write.version_id.as_str()
540
+ };
541
+ write_schema_scope == schema_scope_version_id
542
+ })
543
+ .cloned()
544
+ .collect(),
545
+ }
546
+ }
547
+
548
+ fn require_valid_stage_write_storage_scopes(write: &StageWrite) -> Result<(), LixError> {
549
+ match write {
550
+ StageWrite::Rows { rows, .. } => require_valid_stage_row_storage_scopes(rows),
551
+ StageWrite::RowsWithFileData { rows, .. } => require_valid_stage_row_storage_scopes(rows),
552
+ StageWrite::AdoptedChanges { .. } => Ok(()),
553
+ }
554
+ }
555
+
556
+ fn require_valid_stage_row_storage_scopes(rows: &[StageRow]) -> Result<(), LixError> {
557
+ for row in rows {
558
+ require_valid_storage_scope(row.version_id.as_str(), row.global)?;
559
+ }
560
+ Ok(())
561
+ }
562
+
563
+ fn require_valid_storage_scope(version_id: &str, global: bool) -> Result<(), LixError> {
564
+ if global != (version_id == GLOBAL_VERSION_ID) {
565
+ return Err(LixError::new(
566
+ LixError::CODE_INVALID_STORAGE_SCOPE,
567
+ format!("invalid storage scope: version_id='{version_id}', global={global}"),
568
+ ));
569
+ }
570
+ Ok(())
571
+ }
572
+
573
+ fn stage_row_version_ids(rows: &[StageRow]) -> BTreeSet<String> {
574
+ rows.iter().map(|row| row.version_id.clone()).collect()
575
+ }
576
+
577
+ fn stage_file_data_version_ids(file_data: &[StageFileData]) -> BTreeSet<String> {
578
+ file_data
579
+ .iter()
580
+ .map(|write| write.version_id.clone())
581
+ .collect()
582
+ }
583
+
584
+ async fn resolve_active_version_id(
585
+ mode: &SessionMode,
586
+ live_state: &LiveStateContext,
587
+ version_ctx: &VersionContext,
588
+ transaction: &mut dyn StorageWriteTransaction,
589
+ ) -> Result<String, LixError> {
590
+ match mode {
591
+ SessionMode::Pinned { version_id } => Ok(version_id.clone()),
592
+ SessionMode::Workspace => {
593
+ load_workspace_version_id(live_state, version_ctx, transaction).await
594
+ }
595
+ }
596
+ }
597
+
598
+ async fn load_workspace_version_id(
599
+ live_state: &LiveStateContext,
600
+ version_ctx: &VersionContext,
601
+ transaction: &mut dyn StorageWriteTransaction,
602
+ ) -> Result<String, LixError> {
603
+ let row = live_state
604
+ .reader(&mut *transaction)
605
+ .load_row(&LiveStateRowRequest {
606
+ schema_key: "lix_key_value".to_string(),
607
+ version_id: GLOBAL_VERSION_ID.to_string(),
608
+ entity_id: EntityIdentity::single(WORKSPACE_VERSION_KEY),
609
+ file_id: NullableKeyFilter::Null,
610
+ })
611
+ .await?
612
+ .ok_or_else(|| {
613
+ LixError::new(
614
+ "LIX_ERROR_UNKNOWN",
615
+ "workspace version selector is missing lix_key_value:lix_workspace_version_id",
616
+ )
617
+ })?;
618
+ let snapshot_content = row.snapshot_content.as_deref().ok_or_else(|| {
619
+ LixError::new(
620
+ "LIX_ERROR_UNKNOWN",
621
+ "workspace version selector is missing snapshot_content",
622
+ )
623
+ })?;
624
+ let snapshot = serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
625
+ LixError::new(
626
+ "LIX_ERROR_UNKNOWN",
627
+ format!("workspace version selector snapshot is invalid JSON: {error}"),
628
+ )
629
+ })?;
630
+ let version_id = snapshot
631
+ .get("value")
632
+ .and_then(JsonValue::as_str)
633
+ .filter(|value| !value.is_empty())
634
+ .ok_or_else(|| {
635
+ LixError::new(
636
+ "LIX_ERROR_UNKNOWN",
637
+ "workspace version selector value must be a non-empty string",
638
+ )
639
+ })?
640
+ .to_string();
641
+
642
+ let head = version_ctx
643
+ .ref_reader(&mut *transaction)
644
+ .load_head_commit_id(&version_id)
645
+ .await?;
646
+ if head.is_none() {
647
+ return Err(LixError::version_not_found(
648
+ version_id,
649
+ "load_workspace_version_id",
650
+ "workspace_selector",
651
+ ));
652
+ }
653
+
654
+ Ok(version_id)
655
+ }
656
+
657
+ #[cfg(test)]
658
+ mod tests {
659
+ use std::sync::Arc;
660
+
661
+ use serde_json::json;
662
+
663
+ use super::*;
664
+ use crate::backend::testing::UnitTestBackend;
665
+ use crate::changelog::ChangelogScanRequest;
666
+ use crate::tracked_state::{TrackedStateRowRequest, TrackedStateScanRequest};
667
+ use crate::untracked_state::{UntrackedStateContext, UntrackedStateRowRequest};
668
+ use crate::version::VersionContext;
669
+ use crate::Backend;
670
+ use crate::NullableKeyFilter;
671
+ use crate::GLOBAL_VERSION_ID;
672
+
673
+ fn live_state_context() -> LiveStateContext {
674
+ LiveStateContext::new(
675
+ crate::tracked_state::TrackedStateContext::new(),
676
+ crate::untracked_state::UntrackedStateContext::new(),
677
+ crate::commit_graph::CommitGraphContext::new(crate::changelog::ChangelogContext::new()),
678
+ )
679
+ }
680
+
681
+ #[tokio::test]
682
+ async fn stage_rows_routes_tracked_and_untracked_rows_without_sql() {
683
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
684
+ let storage = StorageContext::new(Arc::clone(&backend));
685
+ let live_state = Arc::new(live_state_context());
686
+ seed_visible_schema_rows(storage.clone(), &live_state).await;
687
+ let binary_cas = Arc::new(BinaryCasContext::new());
688
+ let changelog = Arc::new(ChangelogContext::new());
689
+ let version_ctx = Arc::new(VersionContext::new(Arc::new(UntrackedStateContext::new())));
690
+ let schema_registry = Arc::new(SchemaRegistry::new());
691
+ let opened = open_transaction(
692
+ &SessionMode::Pinned {
693
+ version_id: GLOBAL_VERSION_ID.to_string(),
694
+ },
695
+ storage.clone(),
696
+ Arc::clone(&live_state),
697
+ Arc::new(crate::tracked_state::TrackedStateContext::new()),
698
+ Arc::clone(&binary_cas),
699
+ Arc::clone(&changelog),
700
+ Arc::clone(&version_ctx),
701
+ Arc::clone(&schema_registry),
702
+ )
703
+ .await
704
+ .expect("transaction should open");
705
+ let mut transaction = opened.transaction;
706
+ let runtime_functions = opened.runtime_functions;
707
+
708
+ transaction
709
+ .stage_rows(vec![
710
+ key_value_stage_row("tracked-programmatic", "tracked", false),
711
+ key_value_stage_row("untracked-programmatic", "untracked", true),
712
+ ])
713
+ .await
714
+ .expect("programmatic rows should stage");
715
+ transaction
716
+ .commit(&runtime_functions)
717
+ .await
718
+ .expect("transaction should commit");
719
+
720
+ let changes = changelog
721
+ .reader(storage.clone())
722
+ .scan_changes(&ChangelogScanRequest::default())
723
+ .await
724
+ .expect("changelog should scan");
725
+ assert!(
726
+ changes
727
+ .iter()
728
+ .any(|change| change.entity_id.as_string().as_deref() == Ok("tracked-programmatic")),
729
+ "tracked staged row should be appended to changelog"
730
+ );
731
+ assert!(
732
+ !changes
733
+ .iter()
734
+ .any(|change| change.entity_id.as_string().as_deref()
735
+ == Ok("untracked-programmatic")),
736
+ "untracked staged row must not be appended to changelog"
737
+ );
738
+
739
+ let head_commit_id = version_ctx
740
+ .ref_reader(storage.clone())
741
+ .load_head_commit_id(GLOBAL_VERSION_ID)
742
+ .await
743
+ .expect("version ref should load")
744
+ .expect("tracked commit should advance the global version ref");
745
+
746
+ let tracked_row = crate::tracked_state::TrackedStateContext::new()
747
+ .reader(storage.clone())
748
+ .load_row_at_commit(
749
+ &head_commit_id,
750
+ &TrackedStateRowRequest {
751
+ schema_key: "lix_key_value".to_string(),
752
+ entity_id: crate::entity_identity::EntityIdentity::single(
753
+ "tracked-programmatic",
754
+ ),
755
+ file_id: NullableKeyFilter::Null,
756
+ },
757
+ )
758
+ .await
759
+ .expect("tracked state should load")
760
+ .expect("tracked row should be present in tracked state");
761
+ assert_eq!(tracked_row.commit_id, head_commit_id);
762
+ assert_eq!(
763
+ tracked_row.snapshot_content.as_deref(),
764
+ Some(r#"{"key":"tracked-programmatic","value":"tracked"}"#)
765
+ );
766
+
767
+ let untracked_row = crate::untracked_state::UntrackedStateContext::new()
768
+ .reader(storage.clone())
769
+ .load_row(&UntrackedStateRowRequest {
770
+ schema_key: "lix_key_value".to_string(),
771
+ version_id: GLOBAL_VERSION_ID.to_string(),
772
+ entity_id: crate::entity_identity::EntityIdentity::single("untracked-programmatic"),
773
+ file_id: NullableKeyFilter::Null,
774
+ })
775
+ .await
776
+ .expect("untracked state should load")
777
+ .expect("untracked row should be present in untracked state");
778
+ assert_eq!(
779
+ untracked_row.snapshot_content.as_deref(),
780
+ Some(r#"{"key":"untracked-programmatic","value":"untracked"}"#)
781
+ );
782
+
783
+ let live_untracked_row = live_state
784
+ .reader(storage.clone())
785
+ .load_row(&crate::live_state::LiveStateRowRequest {
786
+ schema_key: "lix_key_value".to_string(),
787
+ version_id: GLOBAL_VERSION_ID.to_string(),
788
+ entity_id: crate::entity_identity::EntityIdentity::single("untracked-programmatic"),
789
+ file_id: NullableKeyFilter::Null,
790
+ })
791
+ .await
792
+ .expect("live state should load")
793
+ .expect("untracked row should be visible through live state");
794
+ assert!(live_untracked_row.untracked);
795
+ assert!(live_untracked_row.global);
796
+ assert_eq!(live_untracked_row.version_id, GLOBAL_VERSION_ID);
797
+
798
+ let tracked_rows = crate::tracked_state::TrackedStateContext::new()
799
+ .reader(storage.clone())
800
+ .scan_rows_at_commit(&head_commit_id, &TrackedStateScanRequest::default())
801
+ .await
802
+ .expect("tracked state should scan");
803
+ assert!(
804
+ tracked_rows
805
+ .iter()
806
+ .all(|row| row.entity_id.as_string().as_deref() != Ok("untracked-programmatic")),
807
+ "untracked staged rows should not be written into tracked state"
808
+ );
809
+ }
810
+
811
+ #[tokio::test]
812
+ async fn commit_validates_staged_rows_before_persistence() {
813
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
814
+ let storage = StorageContext::new(Arc::clone(&backend));
815
+ let live_state = Arc::new(live_state_context());
816
+ seed_visible_schema_rows(storage.clone(), &live_state).await;
817
+ let binary_cas = Arc::new(BinaryCasContext::new());
818
+ let changelog = Arc::new(ChangelogContext::new());
819
+ let version_ctx = Arc::new(VersionContext::new(Arc::new(UntrackedStateContext::new())));
820
+ let schema_registry = Arc::new(SchemaRegistry::new());
821
+ let opened = open_transaction(
822
+ &SessionMode::Pinned {
823
+ version_id: GLOBAL_VERSION_ID.to_string(),
824
+ },
825
+ storage.clone(),
826
+ Arc::clone(&live_state),
827
+ Arc::new(crate::tracked_state::TrackedStateContext::new()),
828
+ Arc::clone(&binary_cas),
829
+ Arc::clone(&changelog),
830
+ Arc::clone(&version_ctx),
831
+ Arc::clone(&schema_registry),
832
+ )
833
+ .await
834
+ .expect("transaction should open");
835
+ let mut transaction = opened.transaction;
836
+ let runtime_functions = opened.runtime_functions;
837
+
838
+ let mut invalid_row = key_value_stage_row("invalid-programmatic", "invalid", false);
839
+ invalid_row.snapshot_content = Some("{\"key\":\"invalid-programmatic\"}".to_string());
840
+ transaction
841
+ .stage_rows(vec![invalid_row])
842
+ .await
843
+ .expect("invalid row should still reach commit validation");
844
+
845
+ let error = transaction
846
+ .commit(&runtime_functions)
847
+ .await
848
+ .expect_err("validation should reject before persistence");
849
+ assert!(
850
+ error.message.contains("snapshot_content validation failed"),
851
+ "validation error should explain the rejected schema data: {error:?}"
852
+ );
853
+
854
+ let changes = changelog
855
+ .reader(storage.clone())
856
+ .scan_changes(&ChangelogScanRequest::default())
857
+ .await
858
+ .expect("changelog should scan after failed commit");
859
+ assert!(
860
+ changes.is_empty(),
861
+ "validation failure must happen before changelog persistence"
862
+ );
863
+ let head = version_ctx
864
+ .ref_reader(storage.clone())
865
+ .load_head_commit_id(GLOBAL_VERSION_ID)
866
+ .await
867
+ .expect("version ref should load after failed commit");
868
+ assert_eq!(
869
+ head, None,
870
+ "validation failure must happen before version-ref persistence"
871
+ );
872
+ }
873
+
874
+ #[tokio::test]
875
+ async fn commit_rejects_non_object_metadata_without_sql() {
876
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
877
+ let storage = StorageContext::new(Arc::clone(&backend));
878
+ let (live_state, _binary_cas, changelog, version_ref, runtime_functions, mut transaction) =
879
+ open_test_transaction(&backend).await;
880
+
881
+ let mut row = key_value_stage_row("invalid-metadata", "value", false);
882
+ row.metadata = Some(json!("not-an-object"));
883
+ transaction
884
+ .stage_rows(vec![row])
885
+ .await
886
+ .expect("row should stage before metadata validation");
887
+
888
+ let error = transaction
889
+ .commit(&runtime_functions)
890
+ .await
891
+ .expect_err("non-object metadata should fail commit validation");
892
+
893
+ assert_eq!(error.code, LixError::CODE_SCHEMA_VALIDATION);
894
+ assert!(
895
+ error.message.contains("metadata") && error.message.contains("JSON object"),
896
+ "error should explain metadata object validation: {error:?}"
897
+ );
898
+ assert_no_persistence_after_validation_failure(
899
+ storage.clone(),
900
+ &live_state,
901
+ &changelog,
902
+ &version_ref,
903
+ )
904
+ .await;
905
+ }
906
+
907
+ #[tokio::test]
908
+ async fn stage_rows_rejects_unknown_schema_key_without_sql() {
909
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
910
+ let (
911
+ _live_state,
912
+ _binary_cas,
913
+ _changelog,
914
+ _version_ref,
915
+ _runtime_functions,
916
+ mut transaction,
917
+ ) = open_test_transaction(&backend).await;
918
+
919
+ let mut row = key_value_stage_row("unknown-schema", "value", false);
920
+ row.schema_key = "missing_schema".to_string();
921
+
922
+ let error = transaction
923
+ .stage_rows(vec![row])
924
+ .await
925
+ .expect_err("unknown schema should be rejected while staging");
926
+
927
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
928
+ assert!(
929
+ error
930
+ .message
931
+ .contains("schema 'missing_schema' version '1' is not visible"),
932
+ "error should explain missing schema visibility: {error:?}"
933
+ );
934
+ }
935
+
936
+ #[tokio::test]
937
+ async fn stage_rows_rejects_missing_version_without_sql() {
938
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
939
+ let (
940
+ _live_state,
941
+ _binary_cas,
942
+ _changelog,
943
+ _version_ref,
944
+ _runtime_functions,
945
+ mut transaction,
946
+ ) = open_test_transaction(&backend).await;
947
+
948
+ let mut row = key_value_stage_row("ghost-version-row", "value", false);
949
+ row.version_id = "ghost-version".to_string();
950
+ row.global = false;
951
+
952
+ let error = transaction
953
+ .stage_rows(vec![row])
954
+ .await
955
+ .expect_err("missing version should be rejected before staging");
956
+
957
+ assert_eq!(error.code, LixError::CODE_VERSION_NOT_FOUND);
958
+ assert!(
959
+ error
960
+ .message
961
+ .contains("version 'ghost-version' was not found"),
962
+ "error should explain missing version: {error:?}"
963
+ );
964
+ }
965
+
966
+ #[tokio::test]
967
+ async fn stage_rows_rejects_invalid_storage_scope_without_sql() {
968
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
969
+ let (
970
+ _live_state,
971
+ _binary_cas,
972
+ _changelog,
973
+ _version_ref,
974
+ _runtime_functions,
975
+ mut transaction,
976
+ ) = open_test_transaction(&backend).await;
977
+
978
+ let mut row = key_value_stage_row("invalid-storage-scope", "value", false);
979
+ row.version_id = GLOBAL_VERSION_ID.to_string();
980
+ row.global = false;
981
+
982
+ let error = transaction
983
+ .stage_rows(vec![row])
984
+ .await
985
+ .expect_err("invalid storage scope should be rejected before staging");
986
+
987
+ assert_eq!(error.code, LixError::CODE_INVALID_STORAGE_SCOPE);
988
+ assert!(
989
+ error.message.contains("version_id='global', global=false"),
990
+ "error should explain invalid storage scope: {error:?}"
991
+ );
992
+ }
993
+
994
+ #[tokio::test]
995
+ async fn stage_rows_rejects_unknown_schema_version_without_sql() {
996
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
997
+ let (
998
+ _live_state,
999
+ _binary_cas,
1000
+ _changelog,
1001
+ _version_ref,
1002
+ _runtime_functions,
1003
+ mut transaction,
1004
+ ) = open_test_transaction(&backend).await;
1005
+
1006
+ let mut row = key_value_stage_row("unknown-version", "value", false);
1007
+ row.schema_version = "999".to_string();
1008
+
1009
+ let error = transaction
1010
+ .stage_rows(vec![row])
1011
+ .await
1012
+ .expect_err("unknown schema version should be rejected while staging");
1013
+
1014
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
1015
+ assert!(
1016
+ error
1017
+ .message
1018
+ .contains("schema 'lix_key_value' version '999' is not visible"),
1019
+ "error should explain missing schema version visibility: {error:?}"
1020
+ );
1021
+ }
1022
+
1023
+ #[tokio::test]
1024
+ async fn stage_rows_rejects_invalid_snapshot_json_without_sql() {
1025
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1026
+ let (
1027
+ _live_state,
1028
+ _binary_cas,
1029
+ _changelog,
1030
+ _version_ref,
1031
+ _runtime_functions,
1032
+ mut transaction,
1033
+ ) = open_test_transaction(&backend).await;
1034
+
1035
+ let mut row = key_value_stage_row("invalid-json", "value", false);
1036
+ row.snapshot_content = Some("{".to_string());
1037
+
1038
+ let error = transaction
1039
+ .stage_rows(vec![row])
1040
+ .await
1041
+ .expect_err("invalid JSON should be rejected while staging");
1042
+
1043
+ assert_eq!(error.code, LixError::CODE_SCHEMA_VALIDATION);
1044
+ assert!(
1045
+ error.message.contains("invalid JSON"),
1046
+ "error should explain invalid JSON: {error:?}"
1047
+ );
1048
+ }
1049
+
1050
+ #[tokio::test]
1051
+ async fn commit_rejects_snapshot_that_violates_json_schema_without_sql() {
1052
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1053
+ let storage = StorageContext::new(Arc::clone(&backend));
1054
+ let (live_state, _binary_cas, changelog, version_ref, runtime_functions, mut transaction) =
1055
+ open_test_transaction(&backend).await;
1056
+
1057
+ let mut row = key_value_stage_row("schema-mismatch", "value", false);
1058
+ row.snapshot_content = Some(r#"{"key":"schema-mismatch"}"#.to_string());
1059
+ transaction
1060
+ .stage_rows(vec![row])
1061
+ .await
1062
+ .expect("row should stage before JSON Schema validation");
1063
+
1064
+ let error = transaction
1065
+ .commit(&runtime_functions)
1066
+ .await
1067
+ .expect_err("JSON Schema mismatch should fail commit validation");
1068
+
1069
+ assert_eq!(error.code, LixError::CODE_SCHEMA_VALIDATION);
1070
+ assert!(
1071
+ error.message.contains("snapshot_content validation failed"),
1072
+ "error should explain JSON Schema validation: {error:?}"
1073
+ );
1074
+ assert_no_persistence_after_validation_failure(
1075
+ storage.clone(),
1076
+ &live_state,
1077
+ &changelog,
1078
+ &version_ref,
1079
+ )
1080
+ .await;
1081
+ }
1082
+
1083
+ #[tokio::test]
1084
+ async fn stage_rows_rejects_malformed_registered_schema_without_sql() {
1085
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1086
+ let (
1087
+ _live_state,
1088
+ _binary_cas,
1089
+ _changelog,
1090
+ _version_ref,
1091
+ _runtime_functions,
1092
+ mut transaction,
1093
+ ) = open_test_transaction(&backend).await;
1094
+
1095
+ let mut row = key_value_stage_row("malformed-registered-schema", "value", false);
1096
+ row.schema_key = "lix_registered_schema".to_string();
1097
+ row.snapshot_content = Some(
1098
+ json!({
1099
+ "value": {
1100
+ "x-lix-key": "malformed_registered_schema"
1101
+ }
1102
+ })
1103
+ .to_string(),
1104
+ );
1105
+ row.entity_id = None;
1106
+
1107
+ let error = transaction
1108
+ .stage_rows(vec![row])
1109
+ .await
1110
+ .expect_err("malformed registered schema should be rejected while staging");
1111
+
1112
+ assert_eq!(error.code, LixError::CODE_SCHEMA_VALIDATION);
1113
+ assert!(
1114
+ error.message.contains("x-lix-version")
1115
+ || error.message.contains("primary-key pointer"),
1116
+ "error should explain malformed registered schema: {error:?}"
1117
+ );
1118
+ }
1119
+
1120
+ #[tokio::test]
1121
+ async fn stage_rows_rejects_primary_key_entity_id_mismatch_without_sql() {
1122
+ let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
1123
+ let (
1124
+ _live_state,
1125
+ _binary_cas,
1126
+ _changelog,
1127
+ _version_ref,
1128
+ _runtime_functions,
1129
+ mut transaction,
1130
+ ) = open_test_transaction(&backend).await;
1131
+
1132
+ let mut row = key_value_stage_row("right-id", "value", false);
1133
+ row.entity_id = Some(crate::entity_identity::EntityIdentity::single("wrong-id"));
1134
+
1135
+ let error = transaction
1136
+ .stage_rows(vec![row])
1137
+ .await
1138
+ .expect_err("entity id mismatch should be rejected while staging");
1139
+
1140
+ assert_eq!(error.code, LixError::CODE_SCHEMA_VALIDATION);
1141
+ assert!(
1142
+ error
1143
+ .message
1144
+ .contains("does not match x-lix-primary-key derived entity_id"),
1145
+ "error should explain entity id mismatch: {error:?}"
1146
+ );
1147
+ }
1148
+
1149
+ async fn open_test_transaction(
1150
+ backend: &Arc<dyn Backend + Send + Sync>,
1151
+ ) -> (
1152
+ Arc<LiveStateContext>,
1153
+ Arc<BinaryCasContext>,
1154
+ Arc<ChangelogContext>,
1155
+ Arc<VersionContext>,
1156
+ FunctionContext,
1157
+ Transaction,
1158
+ ) {
1159
+ let storage = StorageContext::new(Arc::clone(backend));
1160
+ let live_state = Arc::new(live_state_context());
1161
+ seed_visible_schema_rows(storage.clone(), &live_state).await;
1162
+ let binary_cas = Arc::new(BinaryCasContext::new());
1163
+ let changelog = Arc::new(ChangelogContext::new());
1164
+ let version_ctx = Arc::new(VersionContext::new(Arc::new(UntrackedStateContext::new())));
1165
+ let schema_registry = Arc::new(SchemaRegistry::new());
1166
+ let opened = open_transaction(
1167
+ &SessionMode::Pinned {
1168
+ version_id: GLOBAL_VERSION_ID.to_string(),
1169
+ },
1170
+ storage,
1171
+ Arc::clone(&live_state),
1172
+ Arc::new(crate::tracked_state::TrackedStateContext::new()),
1173
+ Arc::clone(&binary_cas),
1174
+ Arc::clone(&changelog),
1175
+ Arc::clone(&version_ctx),
1176
+ schema_registry,
1177
+ )
1178
+ .await
1179
+ .expect("transaction should open");
1180
+ let transaction = opened.transaction;
1181
+ let runtime_functions = opened.runtime_functions;
1182
+
1183
+ (
1184
+ live_state,
1185
+ binary_cas,
1186
+ changelog,
1187
+ version_ctx,
1188
+ runtime_functions,
1189
+ transaction,
1190
+ )
1191
+ }
1192
+
1193
+ async fn seed_visible_schema_rows(storage: StorageContext, live_state: &LiveStateContext) {
1194
+ let rows = crate::schema::seed_schema_definitions()
1195
+ .into_iter()
1196
+ .map(|schema| {
1197
+ let key = crate::schema::schema_key_from_definition(schema)
1198
+ .expect("seed schema key should derive");
1199
+ LiveStateRow {
1200
+ entity_id: crate::schema::registered_schema_entity_id(
1201
+ &key.schema_key,
1202
+ &key.schema_version,
1203
+ )
1204
+ .expect("registered schema identity should derive"),
1205
+ schema_key: "lix_registered_schema".to_string(),
1206
+ file_id: None,
1207
+ version_id: GLOBAL_VERSION_ID.to_string(),
1208
+ schema_version: "1".to_string(),
1209
+ snapshot_content: Some(json!({ "value": schema }).to_string()),
1210
+ metadata: None,
1211
+ created_at: "1970-01-01T00:00:00.000Z".to_string(),
1212
+ updated_at: "1970-01-01T00:00:00.000Z".to_string(),
1213
+ change_id: None,
1214
+ commit_id: None,
1215
+ untracked: true,
1216
+ global: true,
1217
+ }
1218
+ })
1219
+ .collect::<Vec<_>>();
1220
+ let mut storage_transaction = storage
1221
+ .begin_write_transaction()
1222
+ .await
1223
+ .expect("schema fixture transaction should open");
1224
+ let mut writes = StorageWriteSet::new();
1225
+ let mut json_writer = JsonStoreContext::new().writer();
1226
+ {
1227
+ let mut writer = live_state.writer(storage_transaction.as_mut());
1228
+ writer
1229
+ .stage_rows(&mut writes, &mut json_writer, &rows)
1230
+ .await
1231
+ .expect("schema fixture rows should stage");
1232
+ }
1233
+ writes
1234
+ .apply(&mut storage_transaction.as_mut())
1235
+ .await
1236
+ .expect("schema fixture rows should apply");
1237
+ storage_transaction
1238
+ .commit()
1239
+ .await
1240
+ .expect("schema fixture transaction should commit");
1241
+ }
1242
+
1243
+ async fn assert_no_persistence_after_validation_failure(
1244
+ storage: StorageContext,
1245
+ live_state: &LiveStateContext,
1246
+ changelog: &ChangelogContext,
1247
+ version_ctx: &VersionContext,
1248
+ ) {
1249
+ let changes = changelog
1250
+ .reader(storage.clone())
1251
+ .scan_changes(&ChangelogScanRequest::default())
1252
+ .await
1253
+ .expect("changelog should scan after failed commit");
1254
+ assert!(
1255
+ changes.is_empty(),
1256
+ "validation failure must happen before changelog persistence"
1257
+ );
1258
+ let head = version_ctx
1259
+ .ref_reader(storage.clone())
1260
+ .load_head_commit_id(GLOBAL_VERSION_ID)
1261
+ .await
1262
+ .expect("version ref should load after failed commit");
1263
+ assert_eq!(
1264
+ head, None,
1265
+ "validation failure must happen before version-ref persistence"
1266
+ );
1267
+ let row = live_state
1268
+ .reader(storage)
1269
+ .load_row(&crate::live_state::LiveStateRowRequest {
1270
+ schema_key: "lix_key_value".to_string(),
1271
+ version_id: GLOBAL_VERSION_ID.to_string(),
1272
+ entity_id: crate::entity_identity::EntityIdentity::single("schema-mismatch"),
1273
+ file_id: NullableKeyFilter::Null,
1274
+ })
1275
+ .await
1276
+ .expect("live state should load after failed commit");
1277
+ assert_eq!(
1278
+ row, None,
1279
+ "validation failure must happen before live-state persistence"
1280
+ );
1281
+ }
1282
+
1283
+ fn key_value_stage_row(key: &str, value: &str, untracked: bool) -> StageRow {
1284
+ StageRow {
1285
+ entity_id: Some(crate::entity_identity::EntityIdentity::single(key)),
1286
+ schema_key: "lix_key_value".to_string(),
1287
+ file_id: None,
1288
+ snapshot_content: Some(
1289
+ json!({
1290
+ "key": key,
1291
+ "value": value,
1292
+ })
1293
+ .to_string(),
1294
+ ),
1295
+ metadata: None,
1296
+ origin: None,
1297
+ schema_version: "1".to_string(),
1298
+ created_at: None,
1299
+ updated_at: None,
1300
+ global: true,
1301
+ change_id: None,
1302
+ commit_id: None,
1303
+ untracked,
1304
+ version_id: GLOBAL_VERSION_ID.to_string(),
1305
+ }
1306
+ }
1307
+ }