@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,3379 @@
1
+ use datafusion::arrow::datatypes::Field;
2
+ use datafusion::arrow::record_batch::RecordBatch;
3
+ use datafusion::common::ScalarValue;
4
+ use datafusion::logical_expr::{Expr, LogicalPlan, WriteOp};
5
+ use datafusion::prelude::SessionContext;
6
+ use serde_json::{json, Value as JsonValue};
7
+ use std::collections::{BTreeSet, HashSet};
8
+
9
+ use crate::schema::schema_key_from_definition;
10
+ use crate::{LixError, LixNotice, SqlQueryResult, Value};
11
+
12
+ use super::result_metadata::field_is_json;
13
+ use super::session::{build_read_session, build_write_session};
14
+ use super::write_normalization::{
15
+ is_binary_type, lix_file_data_type_lix_error, logical_expr_is_binary_or_null,
16
+ };
17
+ use super::{SqlExecutionContext, SqlStatementKind, SqlWriteExecutionContext};
18
+
19
+ #[allow(dead_code)]
20
+ pub(crate) struct SqlLogicalPlan {
21
+ session: SessionContext,
22
+ plan: LogicalPlan,
23
+ kind: SqlStatementKind,
24
+ notices: Vec<LixNotice>,
25
+ strict_binary_params: BTreeSet<usize>,
26
+ }
27
+
28
+ impl SqlLogicalPlan {
29
+ #[allow(dead_code)]
30
+ pub(crate) fn kind(&self) -> SqlStatementKind {
31
+ self.kind
32
+ }
33
+
34
+ #[allow(dead_code)]
35
+ pub(crate) fn is_write(&self) -> bool {
36
+ self.kind == SqlStatementKind::Write
37
+ }
38
+ }
39
+
40
+ /// Minimal top-level sql2 entrypoint.
41
+ ///
42
+ /// The final implementation will build the DataFusion session from the
43
+ /// execution context and source rows from `live_state()`.
44
+ ///
45
+ /// `catalog()` is intentionally omitted from the MVP boundary for now.
46
+ #[allow(dead_code)]
47
+ pub(crate) async fn execute_sql(
48
+ ctx: &dyn SqlExecutionContext,
49
+ sql: &str,
50
+ params: &[Value],
51
+ ) -> Result<SqlQueryResult, LixError> {
52
+ let plan = create_logical_plan(ctx, sql).await?;
53
+ execute_logical_plan(plan, params).await
54
+ }
55
+
56
+ pub(crate) async fn create_logical_plan(
57
+ ctx: &dyn SqlExecutionContext,
58
+ sql: &str,
59
+ ) -> Result<SqlLogicalPlan, LixError> {
60
+ super::validate_supported_statement_ast(sql)?;
61
+ let session = build_read_session(ctx).await?;
62
+ let plan = session
63
+ .state()
64
+ .create_logical_plan(sql)
65
+ .await
66
+ .map_err(datafusion_error_to_lix_error)?;
67
+ validate_supported_logical_plan(&plan)?;
68
+ let kind = classify_logical_plan(&plan);
69
+ let notices = history_filter_notices(&plan);
70
+
71
+ Ok(SqlLogicalPlan {
72
+ session,
73
+ plan,
74
+ kind,
75
+ notices,
76
+ strict_binary_params: BTreeSet::new(),
77
+ })
78
+ }
79
+
80
+ #[allow(dead_code)]
81
+ pub(crate) async fn create_write_logical_plan(
82
+ ctx: &mut dyn SqlWriteExecutionContext,
83
+ sql: &str,
84
+ ) -> Result<SqlLogicalPlan, LixError> {
85
+ super::validate_supported_statement_ast(sql)?;
86
+ reject_read_only_history_view_dml(sql, &ctx.list_visible_schemas()?)?;
87
+ let session = build_write_session(ctx).await?;
88
+ let plan = session
89
+ .state()
90
+ .create_logical_plan(sql)
91
+ .await
92
+ .map_err(datafusion_error_to_lix_error)?;
93
+ validate_supported_logical_plan(&plan)?;
94
+ let strict_binary_params = validate_strict_lix_file_data_writes(&plan)?;
95
+ let kind = classify_logical_plan(&plan);
96
+
97
+ Ok(SqlLogicalPlan {
98
+ session,
99
+ plan,
100
+ kind,
101
+ notices: Vec::new(),
102
+ strict_binary_params,
103
+ })
104
+ }
105
+
106
+ fn validate_strict_lix_file_data_writes(plan: &LogicalPlan) -> Result<BTreeSet<usize>, LixError> {
107
+ let mut strict_binary_params = BTreeSet::new();
108
+ let LogicalPlan::Dml(dml) = plan else {
109
+ return Ok(strict_binary_params);
110
+ };
111
+ if dml.table_name.table() != "lix_file"
112
+ || !matches!(dml.op, WriteOp::Insert(_) | WriteOp::Update)
113
+ {
114
+ return Ok(strict_binary_params);
115
+ }
116
+
117
+ reject_non_binary_lix_file_data_write(&dml.input, &mut strict_binary_params)?;
118
+ Ok(strict_binary_params)
119
+ }
120
+
121
+ fn reject_non_binary_lix_file_data_write(
122
+ input: &LogicalPlan,
123
+ strict_binary_params: &mut BTreeSet<usize>,
124
+ ) -> Result<(), LixError> {
125
+ let LogicalPlan::Projection(projection) = input else {
126
+ return Ok(());
127
+ };
128
+
129
+ let Some(data_expr) = projection.expr.iter().find_map(|expr| match expr {
130
+ Expr::Alias(alias) if alias.name == "data" => Some(alias.expr.as_ref()),
131
+ _ => None,
132
+ }) else {
133
+ return Ok(());
134
+ };
135
+
136
+ validate_lix_file_data_expr(data_expr, strict_binary_params)?;
137
+
138
+ let Expr::Column(column) = data_expr else {
139
+ return Ok(());
140
+ };
141
+ let LogicalPlan::Values(values) = projection.input.as_ref() else {
142
+ return Ok(());
143
+ };
144
+ let Ok(column_index) = values.schema.index_of_column(column) else {
145
+ return Ok(());
146
+ };
147
+
148
+ for row in &values.values {
149
+ if let Some(value_expr) = row.get(column_index) {
150
+ validate_lix_file_data_expr(value_expr, strict_binary_params)?;
151
+ }
152
+ }
153
+
154
+ Ok(())
155
+ }
156
+
157
+ fn validate_lix_file_data_expr(
158
+ expr: &Expr,
159
+ strict_binary_params: &mut BTreeSet<usize>,
160
+ ) -> Result<(), LixError> {
161
+ match expr {
162
+ Expr::Cast(cast) if is_binary_type(&cast.data_type) => {
163
+ if collect_placeholder_param(&cast.expr, strict_binary_params)? {
164
+ return Ok(());
165
+ }
166
+ if !logical_expr_is_binary_or_null(&cast.expr) {
167
+ return Err(lix_file_data_type_lix_error());
168
+ }
169
+ }
170
+ Expr::Placeholder(_) => {
171
+ collect_placeholder_param(expr, strict_binary_params)?;
172
+ }
173
+ Expr::Alias(alias) => validate_lix_file_data_expr(&alias.expr, strict_binary_params)?,
174
+ _ => {}
175
+ }
176
+ Ok(())
177
+ }
178
+
179
+ fn collect_placeholder_param(
180
+ expr: &Expr,
181
+ strict_binary_params: &mut BTreeSet<usize>,
182
+ ) -> Result<bool, LixError> {
183
+ match expr {
184
+ Expr::Placeholder(placeholder) => {
185
+ let index = placeholder_index(&placeholder.id)?;
186
+ strict_binary_params.insert(index);
187
+ Ok(true)
188
+ }
189
+ Expr::Alias(alias) => collect_placeholder_param(&alias.expr, strict_binary_params),
190
+ _ => Ok(false),
191
+ }
192
+ }
193
+
194
+ fn placeholder_index(id: &str) -> Result<usize, LixError> {
195
+ id.strip_prefix('$')
196
+ .and_then(|raw| raw.parse::<usize>().ok())
197
+ .filter(|index| *index > 0)
198
+ .ok_or_else(|| {
199
+ LixError::new(
200
+ LixError::CODE_PARSE_ERROR,
201
+ format!("unsupported SQL parameter placeholder '{id}'"),
202
+ )
203
+ .with_hint("Use numbered placeholders like $1, $2, ...")
204
+ })
205
+ }
206
+
207
+ pub(crate) async fn execute_logical_plan(
208
+ plan: SqlLogicalPlan,
209
+ params: &[Value],
210
+ ) -> Result<SqlQueryResult, LixError> {
211
+ let SqlLogicalPlan {
212
+ session,
213
+ plan,
214
+ kind: _,
215
+ notices,
216
+ strict_binary_params,
217
+ } = plan;
218
+ validate_parameter_count(&plan, params.len())?;
219
+ validate_strict_binary_params(&strict_binary_params, params)?;
220
+
221
+ let mut dataframe = session
222
+ .execute_logical_plan(plan)
223
+ .await
224
+ .map_err(datafusion_error_to_lix_error)?;
225
+ if !params.is_empty() {
226
+ dataframe = dataframe
227
+ .with_param_values(
228
+ params
229
+ .iter()
230
+ .map(scalar_value_from_lix_value)
231
+ .collect::<Vec<_>>(),
232
+ )
233
+ .map_err(datafusion_error_to_lix_error)?;
234
+ }
235
+
236
+ let result_fields = dataframe
237
+ .schema()
238
+ .fields()
239
+ .iter()
240
+ .map(|field| field.as_ref().clone())
241
+ .collect::<Vec<_>>();
242
+ let batches = super::runtime::collect_dataframe(dataframe)
243
+ .await
244
+ .map_err(datafusion_error_to_lix_error)?;
245
+ let mut result = query_result_from_batches(&result_fields, &batches)?;
246
+ result.notices = notices;
247
+ Ok(result)
248
+ }
249
+
250
+ fn validate_strict_binary_params(
251
+ strict_binary_params: &BTreeSet<usize>,
252
+ params: &[Value],
253
+ ) -> Result<(), LixError> {
254
+ for index in strict_binary_params {
255
+ let Some(value) = params.get(index - 1) else {
256
+ continue;
257
+ };
258
+ if !matches!(value, Value::Blob(_)) {
259
+ return Err(lix_file_data_type_lix_error());
260
+ }
261
+ }
262
+ Ok(())
263
+ }
264
+
265
+ fn validate_parameter_count(plan: &LogicalPlan, param_count: usize) -> Result<(), LixError> {
266
+ let parameter_names = plan
267
+ .get_parameter_names()
268
+ .map_err(datafusion_error_to_lix_error)?;
269
+ let expected_count = expected_positional_parameter_count(&parameter_names)?;
270
+ if param_count == expected_count {
271
+ return Ok(());
272
+ }
273
+
274
+ Err(LixError::new(
275
+ LixError::CODE_INVALID_PARAM,
276
+ format!(
277
+ "SQL expected {expected_count} parameter(s), but {param_count} parameter(s) were provided"
278
+ ),
279
+ )
280
+ .with_details(json!({
281
+ "operation": "execute",
282
+ "expected_param_count": expected_count,
283
+ "provided_param_count": param_count,
284
+ "placeholders": sorted_parameter_names(&parameter_names),
285
+ })))
286
+ }
287
+
288
+ fn expected_positional_parameter_count(
289
+ parameter_names: &HashSet<String>,
290
+ ) -> Result<usize, LixError> {
291
+ let mut max_index = 0usize;
292
+ for name in parameter_names {
293
+ let Some(index) = name
294
+ .strip_prefix('$')
295
+ .and_then(|raw| raw.parse::<usize>().ok())
296
+ else {
297
+ return Err(LixError::new(
298
+ LixError::CODE_PARSE_ERROR,
299
+ format!("unsupported SQL parameter placeholder '{name}'"),
300
+ )
301
+ .with_hint("Use numbered placeholders like $1, $2, ...")
302
+ .with_details(json!({
303
+ "operation": "execute",
304
+ "placeholder": name,
305
+ })));
306
+ };
307
+ if index == 0 {
308
+ return Err(LixError::new(
309
+ LixError::CODE_PARSE_ERROR,
310
+ "SQL parameter placeholders are 1-indexed",
311
+ )
312
+ .with_hint("Use numbered placeholders like $1, $2, ...")
313
+ .with_details(json!({
314
+ "operation": "execute",
315
+ "placeholder": name,
316
+ })));
317
+ }
318
+ max_index = max_index.max(index);
319
+ }
320
+ Ok(max_index)
321
+ }
322
+
323
+ fn sorted_parameter_names(parameter_names: &HashSet<String>) -> Vec<String> {
324
+ let mut names = parameter_names.iter().cloned().collect::<Vec<_>>();
325
+ names.sort();
326
+ names
327
+ }
328
+
329
+ fn reject_read_only_history_view_dml(
330
+ sql: &str,
331
+ visible_schemas: &[JsonValue],
332
+ ) -> Result<(), LixError> {
333
+ let target_names = super::dml_target_table_names(sql)?;
334
+ for target_name in target_names {
335
+ if is_history_view_name(&target_name, visible_schemas)? {
336
+ return Err(read_only_history_view_error(&target_name));
337
+ }
338
+ }
339
+ Ok(())
340
+ }
341
+
342
+ fn is_history_view_name(table_name: &str, visible_schemas: &[JsonValue]) -> Result<bool, LixError> {
343
+ if matches!(
344
+ table_name,
345
+ "lix_state_history" | "lix_file_history" | "lix_directory_history"
346
+ ) {
347
+ return Ok(true);
348
+ }
349
+
350
+ for schema in visible_schemas {
351
+ let schema_key = schema_key_from_definition(schema)?;
352
+ if table_name == format!("{}_history", schema_key.schema_key) {
353
+ return Ok(true);
354
+ }
355
+ }
356
+
357
+ Ok(false)
358
+ }
359
+
360
+ fn read_only_history_view_error(view_name: &str) -> LixError {
361
+ LixError::new(
362
+ LixError::CODE_READ_ONLY,
363
+ format!("DML cannot write read-only history view '{view_name}'"),
364
+ )
365
+ .with_hint(
366
+ "History views are query-only; write to the live surface such as lix_state, lix_file, lix_directory, or the typed entity table.",
367
+ )
368
+ }
369
+
370
+ fn classify_logical_plan(plan: &LogicalPlan) -> SqlStatementKind {
371
+ match plan {
372
+ LogicalPlan::Dml(_) => SqlStatementKind::Write,
373
+ LogicalPlan::Ddl(_) | LogicalPlan::Statement(_) | LogicalPlan::Copy(_) => {
374
+ SqlStatementKind::Other
375
+ }
376
+ _ => SqlStatementKind::Read,
377
+ }
378
+ }
379
+
380
+ fn validate_supported_logical_plan(plan: &LogicalPlan) -> Result<(), LixError> {
381
+ match plan {
382
+ LogicalPlan::Ddl(_) => {
383
+ return Err(LixError::new(
384
+ LixError::CODE_UNSUPPORTED_SQL,
385
+ "DDL statements are not supported by Lix SQL",
386
+ )
387
+ .with_hint(
388
+ "Use Lix entity surfaces such as lix_registered_schema, lix_version, lix_file, and lix_key_value instead of CREATE/DROP statements.",
389
+ ));
390
+ }
391
+ LogicalPlan::Statement(_) => {
392
+ return Err(LixError::new(
393
+ LixError::CODE_UNSUPPORTED_SQL,
394
+ "SQL utility statements are not supported by Lix SQL",
395
+ ));
396
+ }
397
+ LogicalPlan::Copy(_) => {
398
+ return Err(LixError::new(
399
+ LixError::CODE_UNSUPPORTED_SQL,
400
+ "COPY statements are not supported by Lix SQL",
401
+ ));
402
+ }
403
+ LogicalPlan::RecursiveQuery(_) => {
404
+ return Err(LixError::new(
405
+ LixError::CODE_UNSUPPORTED_SQL,
406
+ "recursive CTEs are not supported by Lix SQL",
407
+ )
408
+ .with_hint(
409
+ "Use explicit commit graph surfaces such as lix_commit, lix_commit_edge, and lix_state_history instead of WITH RECURSIVE.",
410
+ ));
411
+ }
412
+ _ => {}
413
+ }
414
+
415
+ for input in plan.inputs() {
416
+ validate_supported_logical_plan(input)?;
417
+ }
418
+
419
+ Ok(())
420
+ }
421
+
422
+ fn scalar_value_from_lix_value(value: &Value) -> ScalarValue {
423
+ match value {
424
+ Value::Null => ScalarValue::Null,
425
+ Value::Boolean(value) => ScalarValue::Boolean(Some(*value)),
426
+ Value::Integer(value) => ScalarValue::Int64(Some(*value)),
427
+ Value::Real(value) => ScalarValue::Float64(Some(*value)),
428
+ Value::Text(value) => ScalarValue::Utf8(Some(value.clone())),
429
+ Value::Json(value) => ScalarValue::Utf8(Some(value.to_string())),
430
+ Value::Blob(value) => ScalarValue::Binary(Some(value.clone())),
431
+ }
432
+ }
433
+
434
+ fn datafusion_error_to_lix_error(error: datafusion::error::DataFusionError) -> LixError {
435
+ super::error::datafusion_error_to_lix_error(error)
436
+ }
437
+
438
+ fn query_result_from_batches(
439
+ result_fields: &[Field],
440
+ batches: &[RecordBatch],
441
+ ) -> Result<SqlQueryResult, LixError> {
442
+ let result_columns = result_fields
443
+ .iter()
444
+ .map(|field| field.name().to_string())
445
+ .collect::<Vec<_>>();
446
+ let mut rows = Vec::<Vec<Value>>::new();
447
+ for batch in batches {
448
+ for row_index in 0..batch.num_rows() {
449
+ let mut row = Vec::<Value>::with_capacity(batch.num_columns());
450
+ for (column_index, array) in batch.columns().iter().enumerate() {
451
+ let scalar = ScalarValue::try_from_array(array.as_ref(), row_index)
452
+ .map_err(datafusion_error_to_lix_error)?;
453
+ let field = result_fields.get(column_index);
454
+ row.push(scalar_value_to_lix_value(&scalar, field)?);
455
+ }
456
+ rows.push(row);
457
+ }
458
+ }
459
+
460
+ Ok(SqlQueryResult {
461
+ rows,
462
+ columns: result_columns.to_vec(),
463
+ notices: Vec::new(),
464
+ })
465
+ }
466
+
467
+ fn history_filter_notices(plan: &LogicalPlan) -> Vec<LixNotice> {
468
+ let mut observations = Vec::new();
469
+ collect_notice_observations(plan, &Vec::new(), &mut observations);
470
+
471
+ let mut notices = Vec::new();
472
+ let mut emitted_codes = HashSet::<String>::new();
473
+ for observation in observations {
474
+ for rule in HISTORY_NOTICE_RULES {
475
+ if observation.table_name != rule.table_name {
476
+ continue;
477
+ }
478
+ if !observation.references_any(rule.payload_columns)
479
+ || observation.references_any(rule.identity_columns)
480
+ {
481
+ continue;
482
+ }
483
+
484
+ let code = format!("LIX_HISTORY_NON_IDENTITY_FILTER:{}", rule.table_name);
485
+ if emitted_codes.insert(code) {
486
+ notices.push(history_non_identity_filter_notice(rule.table_name));
487
+ }
488
+ }
489
+ }
490
+ notices
491
+ }
492
+
493
+ #[derive(Debug)]
494
+ struct NoticeObservation {
495
+ table_name: String,
496
+ filter_columns: HashSet<String>,
497
+ }
498
+
499
+ impl NoticeObservation {
500
+ fn references_any(&self, columns: &[&str]) -> bool {
501
+ columns
502
+ .iter()
503
+ .any(|column| self.filter_columns.contains(*column))
504
+ }
505
+ }
506
+
507
+ struct HistoryNoticeRule {
508
+ table_name: &'static str,
509
+ payload_columns: &'static [&'static str],
510
+ identity_columns: &'static [&'static str],
511
+ }
512
+
513
+ const HISTORY_NOTICE_RULES: &[HistoryNoticeRule] = &[
514
+ HistoryNoticeRule {
515
+ table_name: "lix_file_history",
516
+ payload_columns: &["path", "directory_id", "name", "hidden", "data"],
517
+ identity_columns: &["id", "lixcol_entity_id"],
518
+ },
519
+ HistoryNoticeRule {
520
+ table_name: "lix_directory_history",
521
+ payload_columns: &["path", "parent_id", "name", "hidden"],
522
+ identity_columns: &["id", "lixcol_entity_id"],
523
+ },
524
+ ];
525
+
526
+ fn collect_notice_observations(
527
+ plan: &LogicalPlan,
528
+ active_filter_columns: &Vec<HashSet<String>>,
529
+ observations: &mut Vec<NoticeObservation>,
530
+ ) {
531
+ match plan {
532
+ LogicalPlan::Filter(filter) => {
533
+ let mut next_filters = active_filter_columns.clone();
534
+ next_filters.push(expr_column_names(&filter.predicate));
535
+ collect_notice_observations(&filter.input, &next_filters, observations);
536
+ }
537
+ LogicalPlan::TableScan(scan) => {
538
+ let mut filter_columns = HashSet::new();
539
+ for columns in active_filter_columns {
540
+ filter_columns.extend(columns.iter().cloned());
541
+ }
542
+ for filter in &scan.filters {
543
+ filter_columns.extend(expr_column_names(filter));
544
+ }
545
+ if !filter_columns.is_empty() {
546
+ observations.push(NoticeObservation {
547
+ table_name: table_reference_name(&scan.table_name),
548
+ filter_columns,
549
+ });
550
+ }
551
+ }
552
+ other => {
553
+ for input in other.inputs() {
554
+ collect_notice_observations(input, active_filter_columns, observations);
555
+ }
556
+ }
557
+ }
558
+ }
559
+
560
+ fn expr_column_names(expr: &Expr) -> HashSet<String> {
561
+ expr.column_refs()
562
+ .iter()
563
+ .map(|column| column.name.clone())
564
+ .collect()
565
+ }
566
+
567
+ fn table_reference_name(table: &datafusion::common::TableReference) -> String {
568
+ match table {
569
+ datafusion::common::TableReference::Bare { table } => table.to_string(),
570
+ datafusion::common::TableReference::Partial { table, .. } => table.to_string(),
571
+ datafusion::common::TableReference::Full { table, .. } => table.to_string(),
572
+ }
573
+ }
574
+
575
+ fn history_non_identity_filter_notice(view_name: &str) -> LixNotice {
576
+ LixNotice {
577
+ code: "LIX_HISTORY_NON_IDENTITY_FILTER".to_string(),
578
+ message: format!("{view_name} was filtered without an identity predicate."),
579
+ hint: Some(
580
+ "Filter by id or lixcol_entity_id to include tombstones and renamed history."
581
+ .to_string(),
582
+ ),
583
+ }
584
+ }
585
+
586
+ fn scalar_value_to_lix_value(
587
+ value: &ScalarValue,
588
+ field: Option<&Field>,
589
+ ) -> Result<Value, LixError> {
590
+ match value {
591
+ ScalarValue::Null => Ok(Value::Null),
592
+ ScalarValue::Boolean(Some(value)) => Ok(Value::Boolean(*value)),
593
+ ScalarValue::Boolean(None) => Ok(Value::Null),
594
+ ScalarValue::Int8(Some(value)) => Ok(Value::Integer(i64::from(*value))),
595
+ ScalarValue::Int8(None) => Ok(Value::Null),
596
+ ScalarValue::Int16(Some(value)) => Ok(Value::Integer(i64::from(*value))),
597
+ ScalarValue::Int16(None) => Ok(Value::Null),
598
+ ScalarValue::Int32(Some(value)) => Ok(Value::Integer(i64::from(*value))),
599
+ ScalarValue::Int32(None) => Ok(Value::Null),
600
+ ScalarValue::Int64(Some(value)) => Ok(Value::Integer(*value)),
601
+ ScalarValue::Int64(None) => Ok(Value::Null),
602
+ ScalarValue::UInt8(Some(value)) => Ok(Value::Integer(i64::from(*value))),
603
+ ScalarValue::UInt8(None) => Ok(Value::Null),
604
+ ScalarValue::UInt16(Some(value)) => Ok(Value::Integer(i64::from(*value))),
605
+ ScalarValue::UInt16(None) => Ok(Value::Null),
606
+ ScalarValue::UInt32(Some(value)) => Ok(Value::Integer(i64::from(*value))),
607
+ ScalarValue::UInt32(None) => Ok(Value::Null),
608
+ ScalarValue::UInt64(Some(value)) => match i64::try_from(*value) {
609
+ Ok(value) => Ok(Value::Integer(value)),
610
+ Err(_) => Ok(Value::Text(value.to_string())),
611
+ },
612
+ ScalarValue::UInt64(None) => Ok(Value::Null),
613
+ ScalarValue::Float32(Some(value)) => Ok(Value::Real(f64::from(*value))),
614
+ ScalarValue::Float32(None) => Ok(Value::Null),
615
+ ScalarValue::Float64(Some(value)) => Ok(Value::Real(*value)),
616
+ ScalarValue::Float64(None) => Ok(Value::Null),
617
+ ScalarValue::Utf8(Some(value))
618
+ | ScalarValue::Utf8View(Some(value))
619
+ | ScalarValue::LargeUtf8(Some(value)) => string_scalar_to_lix_value(value, field),
620
+ ScalarValue::Utf8(None) | ScalarValue::Utf8View(None) | ScalarValue::LargeUtf8(None) => {
621
+ Ok(Value::Null)
622
+ }
623
+ ScalarValue::Binary(Some(value)) | ScalarValue::LargeBinary(Some(value)) => {
624
+ Ok(Value::Blob(value.clone()))
625
+ }
626
+ ScalarValue::Binary(None) | ScalarValue::LargeBinary(None) => Ok(Value::Null),
627
+ other => Ok(Value::Text(other.to_string())),
628
+ }
629
+ }
630
+
631
+ fn string_scalar_to_lix_value(value: &str, field: Option<&Field>) -> Result<Value, LixError> {
632
+ if field.is_some_and(field_is_json) {
633
+ return serde_json::from_str::<serde_json::Value>(value)
634
+ .map(Value::Json)
635
+ .map_err(|error| {
636
+ LixError::new(
637
+ "LIX_ERROR_INVALID_JSON",
638
+ format!(
639
+ "column '{}' is marked as JSON but contains invalid JSON: {error}",
640
+ field
641
+ .map(|field| field.name().as_str())
642
+ .unwrap_or("<unknown>")
643
+ ),
644
+ )
645
+ });
646
+ }
647
+ Ok(Value::Text(value.to_string()))
648
+ }
649
+
650
+ #[cfg(test)]
651
+ mod tests {
652
+ use std::sync::{Arc, Mutex};
653
+
654
+ use async_trait::async_trait;
655
+ use serde_json::json;
656
+ use serde_json::Value as JsonValue;
657
+
658
+ use super::{
659
+ create_write_logical_plan, execute_logical_plan, execute_sql, SqlExecutionContext,
660
+ SqlWriteExecutionContext,
661
+ };
662
+ use crate::binary_cas::BlobDataReader;
663
+ use crate::changelog::{CanonicalChange, ChangelogReader, ChangelogScanRequest};
664
+ use crate::commit_graph::{
665
+ CommitGraphChangeHistoryEntry, CommitGraphChangeHistoryRequest, CommitGraphChangeSet,
666
+ CommitGraphChangeSetElement, CommitGraphCommit, CommitGraphEdge, CommitGraphReader,
667
+ ReachableCommitGraphCommit,
668
+ };
669
+ use crate::functions::{
670
+ FunctionProvider, FunctionProviderHandle, SharedFunctionProvider, SystemFunctionProvider,
671
+ };
672
+ use crate::json_store::JsonStoreContext;
673
+ use crate::live_state::{
674
+ LiveStateContext, LiveStateReader, LiveStateRow, LiveStateRowRequest, LiveStateScanRequest,
675
+ };
676
+ use crate::sql2::{ChangelogQuerySource, SqlChangelogQuerySource};
677
+ use crate::storage::{
678
+ KvEntryPage, KvExistsBatch, KvGetRequest, KvKeyPage, KvScanRequest, KvValueBatch,
679
+ KvValuePage, StorageContext, StorageReadScope, StorageReadTransaction, StorageReader,
680
+ StorageWriteSet,
681
+ };
682
+ use crate::tracked_state::TrackedStateContext;
683
+ use crate::transaction::types::{StageRow, StageWrite, StageWriteOutcome};
684
+ use crate::untracked_state::UntrackedStateContext;
685
+ use crate::version::VersionRefReader;
686
+ use crate::{Engine, ExecuteResult, SessionContext};
687
+ use crate::{LixError, Value};
688
+
689
+ struct DummyBlobReader;
690
+ struct DummyLiveStateReader;
691
+ struct RowsLiveStateReader {
692
+ rows: Vec<LiveStateRow>,
693
+ }
694
+ struct BackendBlobReader(StorageContext);
695
+ struct DummyChangelogReader;
696
+ struct DummyCommitGraphReader;
697
+ struct DummyVersionRefReader;
698
+ struct TestReadTransaction(StorageContext);
699
+
700
+ fn test_read_scope(
701
+ storage: StorageContext,
702
+ ) -> StorageReadScope<Box<dyn StorageReadTransaction + Send + Sync + 'static>> {
703
+ StorageReadScope::new(Box::new(TestReadTransaction(storage)))
704
+ }
705
+
706
+ #[async_trait]
707
+ impl StorageReader for TestReadTransaction {
708
+ async fn get_values(&mut self, request: KvGetRequest) -> Result<KvValueBatch, LixError> {
709
+ self.0.get_values(request).await
710
+ }
711
+
712
+ async fn exists_many(&mut self, request: KvGetRequest) -> Result<KvExistsBatch, LixError> {
713
+ self.0.exists_many(request).await
714
+ }
715
+
716
+ async fn scan_keys(&mut self, request: KvScanRequest) -> Result<KvKeyPage, LixError> {
717
+ self.0.scan_keys(request).await
718
+ }
719
+
720
+ async fn scan_values(&mut self, request: KvScanRequest) -> Result<KvValuePage, LixError> {
721
+ self.0.scan_values(request).await
722
+ }
723
+
724
+ async fn scan_entries(&mut self, request: KvScanRequest) -> Result<KvEntryPage, LixError> {
725
+ self.0.scan_entries(request).await
726
+ }
727
+ }
728
+
729
+ #[async_trait]
730
+ impl StorageReadTransaction for TestReadTransaction {
731
+ async fn rollback(self: Box<Self>) -> Result<(), LixError> {
732
+ Ok(())
733
+ }
734
+ }
735
+
736
+ #[allow(dead_code)]
737
+ fn test_functions() -> FunctionProviderHandle {
738
+ SharedFunctionProvider::new(
739
+ Box::new(SystemFunctionProvider) as Box<dyn FunctionProvider + Send>
740
+ )
741
+ }
742
+
743
+ #[derive(Default)]
744
+ struct CapturingStagedWrites {
745
+ deltas: Vec<CapturedStageWrite>,
746
+ }
747
+
748
+ #[derive(Clone)]
749
+ struct CapturedStageWrite {
750
+ rows: Vec<StageRow>,
751
+ }
752
+
753
+ impl CapturedStageWrite {
754
+ fn pending_write_overlay(&self) -> Result<CapturedStageOverlay, LixError> {
755
+ Ok(CapturedStageOverlay {
756
+ rows: self.rows.clone(),
757
+ })
758
+ }
759
+ }
760
+
761
+ struct CapturedStageOverlay {
762
+ rows: Vec<StageRow>,
763
+ }
764
+
765
+ impl CapturedStageOverlay {
766
+ fn visible_semantic_rows(
767
+ &self,
768
+ include_tombstones: bool,
769
+ schema_key: &str,
770
+ ) -> Vec<CapturedStageRow> {
771
+ self.visible_all_semantic_rows()
772
+ .into_iter()
773
+ .filter(|row| row.schema_key == schema_key)
774
+ .filter(|row| include_tombstones || !row.tombstone)
775
+ .collect()
776
+ }
777
+
778
+ fn visible_all_semantic_rows(&self) -> Vec<CapturedStageRow> {
779
+ self.rows
780
+ .iter()
781
+ .cloned()
782
+ .map(CapturedStageRow::from)
783
+ .collect()
784
+ }
785
+ }
786
+
787
+ struct CapturedStageRow {
788
+ entity_id: String,
789
+ schema_key: String,
790
+ schema_version: String,
791
+ version_id: String,
792
+ file_id: Option<String>,
793
+ snapshot_content: Option<String>,
794
+ metadata: Option<JsonValue>,
795
+ global: bool,
796
+ untracked: bool,
797
+ tombstone: bool,
798
+ }
799
+
800
+ impl From<StageRow> for CapturedStageRow {
801
+ fn from(row: StageRow) -> Self {
802
+ Self {
803
+ entity_id: row
804
+ .entity_id
805
+ .expect("captured staged row should carry entity_id")
806
+ .as_string()
807
+ .expect("captured staged row should project entity_id"),
808
+ schema_key: row.schema_key,
809
+ schema_version: row.schema_version,
810
+ version_id: row.version_id,
811
+ file_id: row.file_id,
812
+ global: row.global,
813
+ untracked: row.untracked,
814
+ tombstone: row.snapshot_content.is_none(),
815
+ snapshot_content: row.snapshot_content,
816
+ metadata: row.metadata,
817
+ }
818
+ }
819
+ }
820
+
821
+ struct DummySqlExecutionContext<'a> {
822
+ active_version_id: &'a str,
823
+ blob_reader: Arc<dyn BlobDataReader>,
824
+ live_state: Arc<dyn LiveStateReader>,
825
+ schema_definitions: Vec<JsonValue>,
826
+ }
827
+
828
+ impl<'a> SqlExecutionContext for DummySqlExecutionContext<'a> {
829
+ fn active_version_id(&self) -> &str {
830
+ self.active_version_id
831
+ }
832
+
833
+ fn live_state(&self) -> Arc<dyn LiveStateReader> {
834
+ Arc::clone(&self.live_state)
835
+ }
836
+
837
+ fn functions(&self) -> FunctionProviderHandle {
838
+ test_functions()
839
+ }
840
+
841
+ fn blob_reader(&self) -> Arc<dyn BlobDataReader> {
842
+ Arc::clone(&self.blob_reader)
843
+ }
844
+
845
+ fn changelog_query_source(&self) -> SqlChangelogQuerySource {
846
+ let base_scope = test_read_scope(StorageContext::new(Arc::new(
847
+ crate::backend::testing::UnitTestBackend::new(),
848
+ )));
849
+ let read_scope = StorageReadScope::new(base_scope.store());
850
+ ChangelogQuerySource {
851
+ changelog_reader: Arc::new(DummyChangelogReader),
852
+ json_reader: JsonStoreContext::new().reader(read_scope.store()),
853
+ }
854
+ }
855
+
856
+ fn commit_graph(&self) -> Box<dyn CommitGraphReader> {
857
+ Box::new(DummyCommitGraphReader)
858
+ }
859
+
860
+ fn version_ref(&self) -> Arc<dyn VersionRefReader> {
861
+ Arc::new(DummyVersionRefReader)
862
+ }
863
+
864
+ fn list_visible_schemas(&self) -> Result<Vec<JsonValue>, LixError> {
865
+ Ok(self.schema_definitions.clone())
866
+ }
867
+ }
868
+
869
+ struct DummySqlWriteExecutionContext<'a> {
870
+ active_version_id: &'a str,
871
+ blob_reader: Arc<dyn BlobDataReader>,
872
+ live_state: Arc<dyn LiveStateReader>,
873
+ staged_writes: Arc<Mutex<CapturingStagedWrites>>,
874
+ schema_definitions: Vec<JsonValue>,
875
+ }
876
+
877
+ #[async_trait]
878
+ impl SqlWriteExecutionContext for DummySqlWriteExecutionContext<'_> {
879
+ fn active_version_id(&self) -> &str {
880
+ self.active_version_id
881
+ }
882
+
883
+ fn functions(&self) -> FunctionProviderHandle {
884
+ test_functions()
885
+ }
886
+
887
+ fn list_visible_schemas(&self) -> Result<Vec<JsonValue>, LixError> {
888
+ Ok(self.schema_definitions.clone())
889
+ }
890
+
891
+ async fn load_bytes_many(
892
+ &mut self,
893
+ hashes: &[crate::binary_cas::BlobHash],
894
+ ) -> Result<crate::binary_cas::BlobBytesBatch, LixError> {
895
+ self.blob_reader.load_bytes_many(hashes).await
896
+ }
897
+
898
+ async fn scan_live_state(
899
+ &mut self,
900
+ request: &LiveStateScanRequest,
901
+ ) -> Result<Vec<LiveStateRow>, LixError> {
902
+ self.live_state.scan_rows(request).await
903
+ }
904
+
905
+ async fn load_version_head(
906
+ &mut self,
907
+ version_id: &str,
908
+ ) -> Result<Option<String>, LixError> {
909
+ Ok(Some(format!("commit-{version_id}")))
910
+ }
911
+
912
+ async fn stage_write(&mut self, write: StageWrite) -> Result<StageWriteOutcome, LixError> {
913
+ let count = match &write {
914
+ StageWrite::Rows { rows, .. } => rows.len() as u64,
915
+ StageWrite::RowsWithFileData { count, .. } => *count,
916
+ StageWrite::AdoptedChanges { changes } => changes.len() as u64,
917
+ };
918
+ let rows = match write {
919
+ StageWrite::Rows { rows, .. } => rows,
920
+ StageWrite::RowsWithFileData { rows, .. } => rows,
921
+ StageWrite::AdoptedChanges { .. } => Vec::new(),
922
+ };
923
+ self.staged_writes
924
+ .lock()
925
+ .expect("staged writes lock")
926
+ .deltas
927
+ .push(CapturedStageWrite { rows });
928
+ Ok(StageWriteOutcome { count })
929
+ }
930
+ }
931
+
932
+ async fn execute_write_sql(
933
+ ctx: &mut dyn SqlWriteExecutionContext,
934
+ sql: &str,
935
+ params: &[Value],
936
+ ) -> Result<crate::SqlQueryResult, LixError> {
937
+ let plan = create_write_logical_plan(ctx, sql).await?;
938
+ execute_logical_plan(plan, params).await
939
+ }
940
+
941
+ #[async_trait]
942
+ impl ChangelogReader for DummyChangelogReader {
943
+ async fn load_change(&self, _change_id: &str) -> Result<Option<CanonicalChange>, LixError> {
944
+ Ok(None)
945
+ }
946
+
947
+ async fn scan_changes(
948
+ &self,
949
+ _request: &ChangelogScanRequest,
950
+ ) -> Result<Vec<CanonicalChange>, LixError> {
951
+ Ok(Vec::new())
952
+ }
953
+ }
954
+
955
+ #[async_trait]
956
+ impl VersionRefReader for DummyVersionRefReader {
957
+ async fn load_head(
958
+ &self,
959
+ _version_id: &str,
960
+ ) -> Result<Option<crate::version::VersionHead>, LixError> {
961
+ Ok(None)
962
+ }
963
+
964
+ async fn scan_heads(&self) -> Result<Vec<crate::version::VersionHead>, LixError> {
965
+ Ok(Vec::new())
966
+ }
967
+ }
968
+
969
+ #[async_trait]
970
+ impl CommitGraphReader for DummyCommitGraphReader {
971
+ async fn load_commit(
972
+ &mut self,
973
+ _commit_id: &str,
974
+ ) -> Result<Option<CommitGraphCommit>, LixError> {
975
+ Ok(None)
976
+ }
977
+
978
+ async fn all_commits(&mut self) -> Result<Vec<CommitGraphCommit>, LixError> {
979
+ Ok(Vec::new())
980
+ }
981
+
982
+ async fn reachable_commits(
983
+ &mut self,
984
+ _head_commit_id: &str,
985
+ ) -> Result<Vec<ReachableCommitGraphCommit>, LixError> {
986
+ Ok(Vec::new())
987
+ }
988
+
989
+ async fn best_common_ancestors(
990
+ &mut self,
991
+ _left_commit_id: &str,
992
+ _right_commit_id: &str,
993
+ ) -> Result<Vec<CommitGraphCommit>, LixError> {
994
+ Ok(Vec::new())
995
+ }
996
+
997
+ async fn merge_base(
998
+ &mut self,
999
+ _left_commit_id: &str,
1000
+ _right_commit_id: &str,
1001
+ ) -> Result<CommitGraphCommit, LixError> {
1002
+ Err(LixError::new(
1003
+ "LIX_ERROR_UNKNOWN",
1004
+ "dummy commit graph reader cannot resolve merge base",
1005
+ ))
1006
+ }
1007
+
1008
+ fn commit_edges(&self, _commits: &[CommitGraphCommit]) -> Vec<CommitGraphEdge> {
1009
+ Vec::new()
1010
+ }
1011
+
1012
+ fn change_sets(&self, _commits: &[CommitGraphCommit]) -> Vec<CommitGraphChangeSet> {
1013
+ Vec::new()
1014
+ }
1015
+
1016
+ async fn change_set_elements(
1017
+ &mut self,
1018
+ _commits: &[CommitGraphCommit],
1019
+ ) -> Result<Vec<CommitGraphChangeSetElement>, LixError> {
1020
+ Ok(Vec::new())
1021
+ }
1022
+
1023
+ async fn change_history_from_commit(
1024
+ &mut self,
1025
+ _start_commit_id: &str,
1026
+ _request: &CommitGraphChangeHistoryRequest,
1027
+ ) -> Result<Vec<CommitGraphChangeHistoryEntry>, LixError> {
1028
+ Ok(Vec::new())
1029
+ }
1030
+ }
1031
+
1032
+ #[async_trait]
1033
+ impl LiveStateReader for DummyLiveStateReader {
1034
+ async fn scan_rows(
1035
+ &self,
1036
+ _request: &LiveStateScanRequest,
1037
+ ) -> Result<Vec<LiveStateRow>, LixError> {
1038
+ Ok(vec![])
1039
+ }
1040
+
1041
+ async fn load_row(
1042
+ &self,
1043
+ _request: &LiveStateRowRequest,
1044
+ ) -> Result<Option<LiveStateRow>, LixError> {
1045
+ Ok(None)
1046
+ }
1047
+ }
1048
+
1049
+ #[async_trait]
1050
+ impl LiveStateReader for RowsLiveStateReader {
1051
+ async fn scan_rows(
1052
+ &self,
1053
+ _request: &LiveStateScanRequest,
1054
+ ) -> Result<Vec<LiveStateRow>, LixError> {
1055
+ Ok(self.rows.clone())
1056
+ }
1057
+
1058
+ async fn load_row(
1059
+ &self,
1060
+ _request: &LiveStateRowRequest,
1061
+ ) -> Result<Option<LiveStateRow>, LixError> {
1062
+ Ok(None)
1063
+ }
1064
+ }
1065
+
1066
+ #[async_trait]
1067
+ impl BlobDataReader for DummyBlobReader {
1068
+ async fn load_bytes_many(
1069
+ &self,
1070
+ hashes: &[crate::binary_cas::BlobHash],
1071
+ ) -> Result<crate::binary_cas::BlobBytesBatch, LixError> {
1072
+ Ok(crate::binary_cas::BlobBytesBatch::missing(hashes.len()))
1073
+ }
1074
+ }
1075
+
1076
+ #[async_trait]
1077
+ impl BlobDataReader for BackendBlobReader {
1078
+ async fn load_bytes_many(
1079
+ &self,
1080
+ hashes: &[crate::binary_cas::BlobHash],
1081
+ ) -> Result<crate::binary_cas::BlobBytesBatch, LixError> {
1082
+ let binary_cas = crate::binary_cas::BinaryCasContext::new();
1083
+ let reader = binary_cas.reader(self.0.clone());
1084
+ reader.load_bytes_many(hashes).await
1085
+ }
1086
+ }
1087
+
1088
+ fn live_lix_state_row(entity_id: &str, metadata: Option<&str>) -> LiveStateRow {
1089
+ LiveStateRow {
1090
+ entity_id: crate::entity_identity::EntityIdentity::from_string(entity_id)
1091
+ .expect("entity id should decode"),
1092
+ schema_key: "lix_key_value".to_string(),
1093
+ file_id: None,
1094
+ snapshot_content: Some("{\"key\":\"hello\",\"value\":\"world\"}".to_string()),
1095
+ metadata: metadata.map(|value| {
1096
+ serde_json::from_str(value).expect("test metadata should be valid JSON")
1097
+ }),
1098
+ schema_version: "1".to_string(),
1099
+ version_id: "version-a".to_string(),
1100
+ change_id: Some(format!("change-{entity_id}")),
1101
+ commit_id: Some(format!("commit-{entity_id}")),
1102
+ global: false,
1103
+ untracked: false,
1104
+ created_at: "2026-04-23T00:00:00Z".to_string(),
1105
+ updated_at: "2026-04-23T01:00:00Z".to_string(),
1106
+ }
1107
+ }
1108
+
1109
+ fn live_entity_row(entity_id: &str, version_id: &str, value: &str) -> LiveStateRow {
1110
+ LiveStateRow {
1111
+ entity_id: crate::entity_identity::EntityIdentity::from_string(entity_id)
1112
+ .expect("entity id should decode"),
1113
+ schema_key: "test_state_schema".to_string(),
1114
+ file_id: None,
1115
+ snapshot_content: Some(format!("{{\"value\":\"{value}\"}}")),
1116
+ metadata: Some(json!({ "source": entity_id })),
1117
+ schema_version: "1".to_string(),
1118
+ version_id: version_id.to_string(),
1119
+ change_id: Some(format!("change-{entity_id}")),
1120
+ commit_id: Some(format!("commit-{entity_id}")),
1121
+ global: false,
1122
+ untracked: false,
1123
+ created_at: "2026-04-23T00:00:00Z".to_string(),
1124
+ updated_at: "2026-04-23T01:00:00Z".to_string(),
1125
+ }
1126
+ }
1127
+
1128
+ fn live_directory_row(
1129
+ entity_id: &str,
1130
+ version_id: &str,
1131
+ parent_id: Option<&str>,
1132
+ name: &str,
1133
+ hidden: bool,
1134
+ ) -> LiveStateRow {
1135
+ LiveStateRow {
1136
+ entity_id: crate::entity_identity::EntityIdentity::from_string(entity_id)
1137
+ .expect("entity id should decode"),
1138
+ schema_key: "lix_directory_descriptor".to_string(),
1139
+ file_id: None,
1140
+ snapshot_content: Some(
1141
+ json!({
1142
+ "id": entity_id,
1143
+ "parent_id": parent_id,
1144
+ "name": name,
1145
+ "hidden": hidden
1146
+ })
1147
+ .to_string(),
1148
+ ),
1149
+ metadata: Some(json!({ "source": entity_id })),
1150
+ schema_version: "1".to_string(),
1151
+ version_id: version_id.to_string(),
1152
+ change_id: Some(format!("change-{entity_id}")),
1153
+ commit_id: Some(format!("commit-{entity_id}")),
1154
+ global: false,
1155
+ untracked: false,
1156
+ created_at: "2026-04-23T00:00:00Z".to_string(),
1157
+ updated_at: "2026-04-23T01:00:00Z".to_string(),
1158
+ }
1159
+ }
1160
+
1161
+ fn live_file_row(
1162
+ entity_id: &str,
1163
+ version_id: &str,
1164
+ directory_id: Option<&str>,
1165
+ name: &str,
1166
+ hidden: bool,
1167
+ ) -> LiveStateRow {
1168
+ LiveStateRow {
1169
+ entity_id: crate::entity_identity::EntityIdentity::from_string(entity_id)
1170
+ .expect("entity id should decode"),
1171
+ schema_key: "lix_file_descriptor".to_string(),
1172
+ file_id: None,
1173
+ snapshot_content: Some(
1174
+ json!({
1175
+ "id": entity_id,
1176
+ "directory_id": directory_id,
1177
+ "name": name,
1178
+ "hidden": hidden
1179
+ })
1180
+ .to_string(),
1181
+ ),
1182
+ metadata: Some(json!({ "source": entity_id })),
1183
+ schema_version: "1".to_string(),
1184
+ version_id: version_id.to_string(),
1185
+ change_id: Some(format!("change-{entity_id}")),
1186
+ commit_id: Some(format!("commit-{entity_id}")),
1187
+ global: false,
1188
+ untracked: false,
1189
+ created_at: "2026-04-23T00:00:00Z".to_string(),
1190
+ updated_at: "2026-04-23T01:00:00Z".to_string(),
1191
+ }
1192
+ }
1193
+
1194
+ #[tokio::test]
1195
+ async fn sql_execution_context_exposes_live_state_and_blob_reader() {
1196
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
1197
+ let live_state = Arc::new(DummyLiveStateReader);
1198
+ let ctx = DummySqlExecutionContext {
1199
+ active_version_id: "version-a",
1200
+ blob_reader: Arc::clone(&blob_reader),
1201
+ live_state: Arc::clone(&live_state) as Arc<dyn LiveStateReader>,
1202
+ schema_definitions: vec![],
1203
+ };
1204
+
1205
+ let actual = ctx.live_state();
1206
+ let expected = live_state as Arc<dyn LiveStateReader>;
1207
+ assert_eq!(ctx.active_version_id(), "version-a");
1208
+ assert!(Arc::ptr_eq(&actual, &expected));
1209
+ assert!(Arc::ptr_eq(&ctx.blob_reader(), &blob_reader));
1210
+ }
1211
+
1212
+ #[tokio::test]
1213
+ async fn execute_sql_uses_execution_context_boundary() {
1214
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
1215
+ let live_state = Arc::new(DummyLiveStateReader);
1216
+ let ctx = DummySqlExecutionContext {
1217
+ active_version_id: "version-a",
1218
+ blob_reader,
1219
+ live_state,
1220
+ schema_definitions: vec![],
1221
+ };
1222
+
1223
+ let result = execute_sql(&ctx, "SELECT 1", &[])
1224
+ .await
1225
+ .expect("sql2 execute should support literal-only queries");
1226
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
1227
+ }
1228
+
1229
+ #[tokio::test]
1230
+ async fn execute_sql_collects_union_all_partitions() {
1231
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
1232
+ let live_state = Arc::new(DummyLiveStateReader);
1233
+ let ctx = DummySqlExecutionContext {
1234
+ active_version_id: "version-a",
1235
+ blob_reader,
1236
+ live_state,
1237
+ schema_definitions: vec![],
1238
+ };
1239
+
1240
+ let result = execute_sql(&ctx, "SELECT 1 UNION ALL SELECT 2", &[])
1241
+ .await
1242
+ .expect("sql2 execute should collect UNION ALL partitions");
1243
+ assert_eq!(
1244
+ result.rows,
1245
+ vec![vec![Value::Integer(1)], vec![Value::Integer(2)]]
1246
+ );
1247
+ }
1248
+
1249
+ #[tokio::test]
1250
+ async fn execute_sql_rejects_extra_parameters() {
1251
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
1252
+ let live_state = Arc::new(DummyLiveStateReader);
1253
+ let ctx = DummySqlExecutionContext {
1254
+ active_version_id: "version-a",
1255
+ blob_reader,
1256
+ live_state,
1257
+ schema_definitions: vec![],
1258
+ };
1259
+
1260
+ let error = execute_sql(
1261
+ &ctx,
1262
+ "SELECT $1 AS value",
1263
+ &[Value::Integer(1), Value::Integer(2)],
1264
+ )
1265
+ .await
1266
+ .expect_err("extra params should fail instead of being ignored");
1267
+
1268
+ assert_eq!(error.code, LixError::CODE_INVALID_PARAM);
1269
+ assert_eq!(
1270
+ error.message,
1271
+ "SQL expected 1 parameter(s), but 2 parameter(s) were provided"
1272
+ );
1273
+ assert_eq!(
1274
+ error.details,
1275
+ Some(json!({
1276
+ "operation": "execute",
1277
+ "expected_param_count": 1,
1278
+ "provided_param_count": 2,
1279
+ "placeholders": ["$1"],
1280
+ }))
1281
+ );
1282
+ }
1283
+
1284
+ #[tokio::test]
1285
+ async fn execute_sql_exposes_datafusion_information_schema() {
1286
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
1287
+ let live_state = Arc::new(DummyLiveStateReader);
1288
+ let ctx = DummySqlExecutionContext {
1289
+ active_version_id: "version-a",
1290
+ blob_reader,
1291
+ live_state,
1292
+ schema_definitions: vec![],
1293
+ };
1294
+
1295
+ let information_schema_result = execute_sql(
1296
+ &ctx,
1297
+ "SELECT table_name FROM information_schema.tables WHERE table_name = 'lix_state'",
1298
+ &[],
1299
+ )
1300
+ .await
1301
+ .expect("information_schema.tables should be enabled");
1302
+ assert_eq!(
1303
+ information_schema_result.rows,
1304
+ vec![vec![Value::Text("lix_state".to_string())]]
1305
+ );
1306
+
1307
+ let tables_result = execute_sql(
1308
+ &ctx,
1309
+ "SELECT table_name FROM information_schema.tables",
1310
+ &[],
1311
+ )
1312
+ .await
1313
+ .expect("information_schema.tables should list registered tables");
1314
+ assert!(tables_result.rows.iter().any(|row| {
1315
+ row.iter()
1316
+ .any(|value| matches!(value, Value::Text(value) if value == "lix_state"))
1317
+ }));
1318
+ }
1319
+
1320
+ async fn setup_engine2_history_fixture() -> Result<(SessionContext, String), LixError> {
1321
+ let backend = crate::backend::testing::UnitTestBackend::new();
1322
+ let init_receipt = Engine::initialize(Box::new(backend.clone())).await?;
1323
+ let engine = Engine::new(Box::new(backend)).await?;
1324
+ let session = engine.open_session(init_receipt.main_version_id).await?;
1325
+
1326
+ session
1327
+ .execute(
1328
+ "INSERT INTO lix_registered_schema (value, lixcol_global, lixcol_untracked) \
1329
+ VALUES (\
1330
+ lix_json('{\"x-lix-key\":\"test_state_schema\",\"x-lix-version\":\"1\",\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"},\"count\":{\"type\":\"integer\"}},\"required\":[\"value\",\"count\"],\"additionalProperties\":false}'),\
1331
+ false,\
1332
+ true\
1333
+ )",
1334
+ &[],
1335
+ )
1336
+ .await?;
1337
+ session
1338
+ .execute(
1339
+ "INSERT INTO test_state_schema \
1340
+ (lixcol_entity_id, value, count, lixcol_metadata, lixcol_untracked) \
1341
+ VALUES ('entity-history', 'A', 7, '{\"source\":\"history\"}', false)",
1342
+ &[],
1343
+ )
1344
+ .await?;
1345
+ session
1346
+ .execute(
1347
+ "INSERT INTO lix_directory (id, path, hidden) \
1348
+ VALUES ('dir-docs', '/docs/', false)",
1349
+ &[],
1350
+ )
1351
+ .await?;
1352
+ session
1353
+ .execute(
1354
+ "INSERT INTO lix_file (id, path, data, hidden) \
1355
+ VALUES ('file-a', '/docs/readme.md', X'68656C6C6F', false)",
1356
+ &[],
1357
+ )
1358
+ .await?;
1359
+
1360
+ let active_version_id = session.active_version_id().await?;
1361
+ let head_commit_id = engine
1362
+ .load_version_head_commit_id(&active_version_id)
1363
+ .await?
1364
+ .ok_or_else(|| {
1365
+ LixError::new(
1366
+ "LIX_ERROR_UNKNOWN",
1367
+ "history fixture expected the session version to have a head commit",
1368
+ )
1369
+ })?;
1370
+ Ok((session, head_commit_id))
1371
+ }
1372
+
1373
+ #[tokio::test]
1374
+ async fn lix_file_path_predicates_canonicalize_bound_values_like_writes() {
1375
+ let backend = crate::backend::testing::UnitTestBackend::new();
1376
+ let init_receipt = Engine::initialize(Box::new(backend.clone()))
1377
+ .await
1378
+ .expect("engine should initialize");
1379
+ let engine = Engine::new(Box::new(backend))
1380
+ .await
1381
+ .expect("engine should open");
1382
+ let session = engine
1383
+ .open_session(init_receipt.main_version_id)
1384
+ .await
1385
+ .expect("session should open");
1386
+
1387
+ session
1388
+ .execute(
1389
+ "INSERT INTO lix_file (id, path, data) VALUES ('file-nfc', $1, X'41')",
1390
+ &[Value::Text("/Cafe\u{301}.txt".to_string())],
1391
+ )
1392
+ .await
1393
+ .expect("NFD path insert should canonicalize");
1394
+
1395
+ let nfd_result = session
1396
+ .execute(
1397
+ "SELECT id FROM lix_file WHERE path = $1",
1398
+ &[Value::Text("/Cafe\u{301}.txt".to_string())],
1399
+ )
1400
+ .await
1401
+ .expect("NFD path predicate should canonicalize");
1402
+ assert_eq!(
1403
+ rows_from_execute_result(nfd_result).1,
1404
+ vec![vec![Value::Text("file-nfc".to_string())]]
1405
+ );
1406
+
1407
+ let percent_result = session
1408
+ .execute(
1409
+ "SELECT id FROM lix_file WHERE path = '/%43afe%CC%81.txt'",
1410
+ &[],
1411
+ )
1412
+ .await
1413
+ .expect("percent-encoded path predicate should canonicalize");
1414
+ assert_eq!(
1415
+ rows_from_execute_result(percent_result).1,
1416
+ vec![vec![Value::Text("file-nfc".to_string())]]
1417
+ );
1418
+
1419
+ let reversed_result = session
1420
+ .execute(
1421
+ "SELECT id FROM lix_file WHERE $1 = path",
1422
+ &[Value::Text("/Cafe\u{301}.txt".to_string())],
1423
+ )
1424
+ .await
1425
+ .expect("reversed path predicate should canonicalize");
1426
+ assert_eq!(
1427
+ rows_from_execute_result(reversed_result).1,
1428
+ vec![vec![Value::Text("file-nfc".to_string())]]
1429
+ );
1430
+
1431
+ let or_result = session
1432
+ .execute(
1433
+ "SELECT id FROM lix_file WHERE path = $1 OR id = 'missing'",
1434
+ &[Value::Text("/Cafe\u{301}.txt".to_string())],
1435
+ )
1436
+ .await
1437
+ .expect("OR path predicate should canonicalize");
1438
+ assert_eq!(
1439
+ rows_from_execute_result(or_result).1,
1440
+ vec![vec![Value::Text("file-nfc".to_string())]]
1441
+ );
1442
+
1443
+ let not_result = session
1444
+ .execute(
1445
+ "SELECT id FROM lix_file WHERE NOT (path = $1)",
1446
+ &[Value::Text("/Cafe\u{301}.txt".to_string())],
1447
+ )
1448
+ .await
1449
+ .expect("NOT path predicate should canonicalize");
1450
+ assert!(rows_from_execute_result(not_result).1.is_empty());
1451
+
1452
+ let not_in_result = session
1453
+ .execute(
1454
+ "SELECT id FROM lix_file WHERE path NOT IN ($1)",
1455
+ &[Value::Text("/%43afe%CC%81.txt".to_string())],
1456
+ )
1457
+ .await
1458
+ .expect("NOT IN path predicate should canonicalize");
1459
+ assert!(rows_from_execute_result(not_in_result).1.is_empty());
1460
+
1461
+ let update_result = session
1462
+ .execute(
1463
+ "UPDATE lix_file SET hidden = true WHERE path = $1 OR id = 'missing'",
1464
+ &[Value::Text("/Cafe\u{301}.txt".to_string())],
1465
+ )
1466
+ .await
1467
+ .expect("update predicate should canonicalize through OR");
1468
+ assert_eq!(update_result.rows_affected(), 1);
1469
+
1470
+ let delete_result = session
1471
+ .execute(
1472
+ "DELETE FROM lix_file WHERE path = $1",
1473
+ &[Value::Text("/%43afe%CC%81.txt".to_string())],
1474
+ )
1475
+ .await
1476
+ .expect("delete predicate should canonicalize");
1477
+ assert_eq!(delete_result.rows_affected(), 1);
1478
+ }
1479
+
1480
+ #[tokio::test]
1481
+ async fn lix_file_path_predicates_reject_non_literal_path_values() {
1482
+ let backend = crate::backend::testing::UnitTestBackend::new();
1483
+ let init_receipt = Engine::initialize(Box::new(backend.clone()))
1484
+ .await
1485
+ .expect("engine should initialize");
1486
+ let engine = Engine::new(Box::new(backend))
1487
+ .await
1488
+ .expect("engine should open");
1489
+ let session = engine
1490
+ .open_session(init_receipt.main_version_id)
1491
+ .await
1492
+ .expect("session should open");
1493
+
1494
+ session
1495
+ .execute(
1496
+ "INSERT INTO lix_file (id, path, data) VALUES ('file-nfc', $1, X'41')",
1497
+ &[Value::Text("/Cafe\u{301}.txt".to_string())],
1498
+ )
1499
+ .await
1500
+ .expect("NFD path insert should canonicalize");
1501
+
1502
+ let error = session
1503
+ .execute("SELECT id FROM lix_file WHERE path = id", &[])
1504
+ .await
1505
+ .expect_err("computed path predicate values should be rejected");
1506
+ assert_eq!(error.code, LixError::CODE_UNSUPPORTED_SQL);
1507
+ assert!(
1508
+ error
1509
+ .message
1510
+ .contains("filesystem path predicates only support literal path values"),
1511
+ "{error:?}"
1512
+ );
1513
+ }
1514
+
1515
+ #[tokio::test]
1516
+ async fn lix_directory_path_predicates_canonicalize_bound_values_like_writes() {
1517
+ let backend = crate::backend::testing::UnitTestBackend::new();
1518
+ let init_receipt = Engine::initialize(Box::new(backend.clone()))
1519
+ .await
1520
+ .expect("engine should initialize");
1521
+ let engine = Engine::new(Box::new(backend))
1522
+ .await
1523
+ .expect("engine should open");
1524
+ let session = engine
1525
+ .open_session(init_receipt.main_version_id)
1526
+ .await
1527
+ .expect("session should open");
1528
+
1529
+ session
1530
+ .execute(
1531
+ "INSERT INTO lix_directory (id, path) VALUES ('dir-nfc', $1)",
1532
+ &[Value::Text("/Cafe\u{301}/".to_string())],
1533
+ )
1534
+ .await
1535
+ .expect("NFD directory path insert should canonicalize");
1536
+
1537
+ let result = session
1538
+ .execute(
1539
+ "SELECT id FROM lix_directory WHERE path IN ($1)",
1540
+ &[Value::Text("/%43afe%CC%81/".to_string())],
1541
+ )
1542
+ .await
1543
+ .expect("directory path predicate should canonicalize");
1544
+ assert_eq!(
1545
+ rows_from_execute_result(result).1,
1546
+ vec![vec![Value::Text("dir-nfc".to_string())]]
1547
+ );
1548
+
1549
+ let or_result = session
1550
+ .execute(
1551
+ "SELECT id FROM lix_directory WHERE id = 'missing' OR path = $1",
1552
+ &[Value::Text("/Cafe\u{301}/".to_string())],
1553
+ )
1554
+ .await
1555
+ .expect("directory OR path predicate should canonicalize");
1556
+ assert_eq!(
1557
+ rows_from_execute_result(or_result).1,
1558
+ vec![vec![Value::Text("dir-nfc".to_string())]]
1559
+ );
1560
+
1561
+ let not_in_result = session
1562
+ .execute(
1563
+ "SELECT id FROM lix_directory WHERE path NOT IN ($1)",
1564
+ &[Value::Text("/%43afe%CC%81/".to_string())],
1565
+ )
1566
+ .await
1567
+ .expect("directory NOT IN path predicate should canonicalize");
1568
+ assert!(rows_from_execute_result(not_in_result).1.is_empty());
1569
+ }
1570
+
1571
+ #[tokio::test]
1572
+ async fn lix_directory_path_predicates_reject_non_literal_path_values() {
1573
+ let backend = crate::backend::testing::UnitTestBackend::new();
1574
+ let init_receipt = Engine::initialize(Box::new(backend.clone()))
1575
+ .await
1576
+ .expect("engine should initialize");
1577
+ let engine = Engine::new(Box::new(backend))
1578
+ .await
1579
+ .expect("engine should open");
1580
+ let session = engine
1581
+ .open_session(init_receipt.main_version_id)
1582
+ .await
1583
+ .expect("session should open");
1584
+
1585
+ session
1586
+ .execute(
1587
+ "INSERT INTO lix_directory (id, path) VALUES ('dir-nfc', $1)",
1588
+ &[Value::Text("/Cafe\u{301}/".to_string())],
1589
+ )
1590
+ .await
1591
+ .expect("NFD directory path insert should canonicalize");
1592
+
1593
+ let error = session
1594
+ .execute("SELECT id FROM lix_directory WHERE path IN (id)", &[])
1595
+ .await
1596
+ .expect_err("computed directory path predicate values should be rejected");
1597
+ assert_eq!(error.code, LixError::CODE_UNSUPPORTED_SQL);
1598
+ assert!(
1599
+ error
1600
+ .message
1601
+ .contains("filesystem path predicates only support literal path values"),
1602
+ "{error:?}"
1603
+ );
1604
+ }
1605
+
1606
+ fn rows_from_execute_result(result: ExecuteResult) -> (Vec<String>, Vec<Vec<Value>>) {
1607
+ let rows = result;
1608
+ (
1609
+ rows.columns().to_vec(),
1610
+ rows.rows()
1611
+ .iter()
1612
+ .map(|row| row.values().to_vec())
1613
+ .collect(),
1614
+ )
1615
+ }
1616
+
1617
+ #[tokio::test]
1618
+ async fn execute_sql_reads_lix_state_history_from_history_context() {
1619
+ let (session, head_commit_id) = setup_engine2_history_fixture()
1620
+ .await
1621
+ .expect("history fixture should initialize");
1622
+ let result = session
1623
+ .execute(
1624
+ &format!(
1625
+ "SELECT entity_id, snapshot_content, metadata, depth, start_commit_id \
1626
+ FROM lix_state_history \
1627
+ WHERE schema_key = 'test_state_schema' \
1628
+ AND entity_id = 'entity-history' \
1629
+ AND start_commit_id = '{head_commit_id}' \
1630
+ AND depth >= 0"
1631
+ ),
1632
+ &[],
1633
+ )
1634
+ .await
1635
+ .expect("sql2 execute should read lix_state_history through real engine2 context");
1636
+ let (columns, rows) = rows_from_execute_result(result);
1637
+
1638
+ assert_eq!(
1639
+ columns,
1640
+ vec![
1641
+ "entity_id",
1642
+ "snapshot_content",
1643
+ "metadata",
1644
+ "depth",
1645
+ "start_commit_id"
1646
+ ]
1647
+ );
1648
+ assert_eq!(rows.len(), 1);
1649
+ assert_eq!(rows[0][0], Value::Text("entity-history".to_string()));
1650
+ assert_eq!(rows[0][1], Value::Json(json!({"count": 7, "value": "A"})));
1651
+ assert_eq!(rows[0][2], Value::Json(json!({"source": "history"})));
1652
+ assert!(matches!(rows[0][3], Value::Integer(_)));
1653
+ assert_eq!(rows[0][4], Value::Text(head_commit_id.clone()));
1654
+ }
1655
+
1656
+ #[tokio::test]
1657
+ async fn execute_sql_reads_entity_history_view_from_history_context() {
1658
+ let (session, head_commit_id) = setup_engine2_history_fixture()
1659
+ .await
1660
+ .expect("history fixture should initialize");
1661
+ let result = session
1662
+ .execute(
1663
+ &format!(
1664
+ "SELECT value, count, lixcol_entity_id, lixcol_start_commit_id, lixcol_depth \
1665
+ FROM test_state_schema_history \
1666
+ WHERE lixcol_start_commit_id = '{head_commit_id}' \
1667
+ AND lixcol_entity_id = 'entity-history'"
1668
+ ),
1669
+ &[],
1670
+ )
1671
+ .await
1672
+ .expect("sql2 execute should read entity history through real engine2 context");
1673
+ let (columns, rows) = rows_from_execute_result(result);
1674
+
1675
+ assert_eq!(
1676
+ columns,
1677
+ vec![
1678
+ "value",
1679
+ "count",
1680
+ "lixcol_entity_id",
1681
+ "lixcol_start_commit_id",
1682
+ "lixcol_depth",
1683
+ ]
1684
+ );
1685
+ assert_eq!(rows.len(), 1);
1686
+ assert_eq!(rows[0][0], Value::Text("A".to_string()));
1687
+ assert_eq!(rows[0][1], Value::Integer(7));
1688
+ assert_eq!(rows[0][2], Value::Text("entity-history".to_string()));
1689
+ assert_eq!(rows[0][3], Value::Text(head_commit_id));
1690
+ assert!(matches!(rows[0][4], Value::Integer(_)));
1691
+ }
1692
+
1693
+ #[tokio::test]
1694
+ async fn execute_sql_reads_directory_history_view_from_history_context() {
1695
+ let (session, head_commit_id) = setup_engine2_history_fixture()
1696
+ .await
1697
+ .expect("history fixture should initialize");
1698
+ let result = session
1699
+ .execute(
1700
+ &format!(
1701
+ "SELECT id, parent_id, name, path, hidden, lixcol_start_commit_id, lixcol_depth \
1702
+ FROM lix_directory_history \
1703
+ WHERE id = 'dir-docs' AND lixcol_start_commit_id = '{head_commit_id}'"
1704
+ ),
1705
+ &[],
1706
+ )
1707
+ .await
1708
+ .expect("sql2 execute should read directory history through real engine2 context");
1709
+ assert!(
1710
+ result.notices().is_empty(),
1711
+ "identity-filtered directory history should not emit soft notices"
1712
+ );
1713
+ let (columns, rows) = rows_from_execute_result(result);
1714
+
1715
+ assert_eq!(
1716
+ columns,
1717
+ vec![
1718
+ "id",
1719
+ "parent_id",
1720
+ "name",
1721
+ "path",
1722
+ "hidden",
1723
+ "lixcol_start_commit_id",
1724
+ "lixcol_depth",
1725
+ ]
1726
+ );
1727
+ assert_eq!(rows.len(), 1);
1728
+ assert_eq!(rows[0][0], Value::Text("dir-docs".to_string()));
1729
+ assert_eq!(rows[0][1], Value::Null);
1730
+ assert_eq!(rows[0][2], Value::Text("docs".to_string()));
1731
+ assert_eq!(rows[0][3], Value::Text("/docs/".to_string()));
1732
+ assert_eq!(rows[0][4], Value::Boolean(false));
1733
+ assert_eq!(rows[0][5], Value::Text(head_commit_id.clone()));
1734
+ assert!(matches!(rows[0][6], Value::Integer(_)));
1735
+
1736
+ let name_filtered_result = session
1737
+ .execute(
1738
+ &format!(
1739
+ "SELECT id \
1740
+ FROM lix_directory_history \
1741
+ WHERE name = 'docs' \
1742
+ AND lixcol_start_commit_id = '{head_commit_id}'"
1743
+ ),
1744
+ &[],
1745
+ )
1746
+ .await
1747
+ .expect("sql2 execute should attach notices to name-filtered directory history reads");
1748
+ assert_eq!(name_filtered_result.notices().len(), 1);
1749
+ assert_eq!(
1750
+ name_filtered_result.notices()[0].code,
1751
+ "LIX_HISTORY_NON_IDENTITY_FILTER"
1752
+ );
1753
+ }
1754
+
1755
+ #[tokio::test]
1756
+ async fn execute_sql_reads_file_history_view_from_history_context() {
1757
+ let (session, head_commit_id) = setup_engine2_history_fixture()
1758
+ .await
1759
+ .expect("history fixture should initialize");
1760
+ let result = session
1761
+ .execute(
1762
+ &format!(
1763
+ "SELECT id, path, data, hidden, lixcol_start_commit_id, lixcol_depth \
1764
+ FROM lix_file_history \
1765
+ WHERE id = 'file-a' \
1766
+ AND lixcol_start_commit_id = '{head_commit_id}' \
1767
+ AND data IS NOT NULL \
1768
+ ORDER BY lixcol_depth",
1769
+ ),
1770
+ &[],
1771
+ )
1772
+ .await
1773
+ .expect("sql2 execute should read file history through real engine2 context");
1774
+ assert!(
1775
+ result.notices().is_empty(),
1776
+ "identity-filtered file history should not emit soft notices"
1777
+ );
1778
+ let (columns, rows) = rows_from_execute_result(result);
1779
+
1780
+ assert_eq!(
1781
+ columns,
1782
+ vec![
1783
+ "id",
1784
+ "path",
1785
+ "data",
1786
+ "hidden",
1787
+ "lixcol_start_commit_id",
1788
+ "lixcol_depth",
1789
+ ]
1790
+ );
1791
+ assert_eq!(rows.len(), 1);
1792
+ assert_eq!(rows[0][0], Value::Text("file-a".to_string()));
1793
+ assert_eq!(rows[0][1], Value::Text("/docs/readme.md".to_string()));
1794
+ assert_eq!(rows[0][2], Value::Blob(b"hello".to_vec()));
1795
+ assert_eq!(rows[0][3], Value::Boolean(false));
1796
+ assert_eq!(rows[0][4], Value::Text(head_commit_id.clone()));
1797
+ assert!(matches!(rows[0][5], Value::Integer(_)));
1798
+
1799
+ let path_filtered_result = session
1800
+ .execute(
1801
+ &format!(
1802
+ "SELECT id \
1803
+ FROM lix_file_history \
1804
+ WHERE path = '/docs/readme.md' \
1805
+ AND lixcol_start_commit_id = '{head_commit_id}'"
1806
+ ),
1807
+ &[],
1808
+ )
1809
+ .await
1810
+ .expect("sql2 execute should attach notices to path-filtered file history reads");
1811
+ assert_eq!(path_filtered_result.notices().len(), 1);
1812
+ assert_eq!(
1813
+ path_filtered_result.notices()[0].code,
1814
+ "LIX_HISTORY_NON_IDENTITY_FILTER"
1815
+ );
1816
+ }
1817
+
1818
+ #[tokio::test]
1819
+ async fn execute_sql_insert_into_lix_state_values_stages_write() {
1820
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
1821
+ let live_state = Arc::new(DummyLiveStateReader);
1822
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
1823
+ let mut ctx = DummySqlWriteExecutionContext {
1824
+ active_version_id: "version-a",
1825
+ blob_reader,
1826
+ live_state,
1827
+ staged_writes: Arc::clone(&staged_writes),
1828
+ schema_definitions: vec![],
1829
+ };
1830
+
1831
+ let result = execute_write_sql(
1832
+ &mut ctx,
1833
+ "INSERT INTO lix_state (\
1834
+ entity_id, schema_key, file_id, snapshot_content, metadata, schema_version, global, untracked\
1835
+ ) VALUES (\
1836
+ 'entity-1', 'lix_key_value', NULL, '{\"key\":\"hello\",\"value\":\"world\"}', '{\"source\":\"sql\"}', '1', false, false\
1837
+ )",
1838
+ &[],
1839
+ )
1840
+ .await
1841
+ .expect("INSERT INTO lix_state VALUES should stage write");
1842
+
1843
+ assert_eq!(result.columns, vec!["count"]);
1844
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
1845
+
1846
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
1847
+ assert_eq!(staged_writes.deltas.len(), 1);
1848
+ let overlay = staged_writes.deltas[0]
1849
+ .pending_write_overlay()
1850
+ .expect("staged delta should expose pending overlay");
1851
+ let rows = overlay.visible_semantic_rows(false, "lix_key_value");
1852
+ assert_eq!(rows.len(), 1);
1853
+ assert_eq!(rows[0].entity_id, "entity-1");
1854
+ assert_eq!(rows[0].schema_version, "1");
1855
+ assert_eq!(rows[0].version_id, "version-a");
1856
+ assert!(!rows[0].global);
1857
+ assert!(!rows[0].untracked);
1858
+ assert_eq!(
1859
+ rows[0].snapshot_content.as_deref(),
1860
+ Some("{\"key\":\"hello\",\"value\":\"world\"}")
1861
+ );
1862
+ assert_eq!(rows[0].metadata.as_ref(), Some(&json!({"source": "sql"})));
1863
+ }
1864
+
1865
+ #[tokio::test]
1866
+ async fn execute_sql_insert_into_lix_state_defaults_global_and_untracked_to_false() {
1867
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
1868
+ let live_state = Arc::new(DummyLiveStateReader);
1869
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
1870
+ let mut ctx = DummySqlWriteExecutionContext {
1871
+ active_version_id: "version-a",
1872
+ blob_reader,
1873
+ live_state,
1874
+ staged_writes: Arc::clone(&staged_writes),
1875
+ schema_definitions: vec![],
1876
+ };
1877
+
1878
+ let result = execute_write_sql(
1879
+ &mut ctx,
1880
+ "INSERT INTO lix_state (\
1881
+ entity_id, schema_key, file_id, snapshot_content, metadata, schema_version\
1882
+ ) VALUES (\
1883
+ 'entity-defaults', 'lix_key_value', NULL, '{\"key\":\"hello\",\"value\":\"defaults\"}', NULL, '1'\
1884
+ )",
1885
+ &[],
1886
+ )
1887
+ .await
1888
+ .expect("INSERT INTO lix_state should default bookkeeping flags");
1889
+
1890
+ assert_eq!(result.columns, vec!["count"]);
1891
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
1892
+
1893
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
1894
+ assert_eq!(staged_writes.deltas.len(), 1);
1895
+ let overlay = staged_writes.deltas[0]
1896
+ .pending_write_overlay()
1897
+ .expect("staged delta should expose pending overlay");
1898
+ let rows = overlay.visible_semantic_rows(false, "lix_key_value");
1899
+ assert_eq!(rows.len(), 1);
1900
+ assert_eq!(rows[0].entity_id, "entity-defaults");
1901
+ assert_eq!(rows[0].version_id, "version-a");
1902
+ assert!(!rows[0].global);
1903
+ assert!(!rows[0].untracked);
1904
+ }
1905
+
1906
+ #[tokio::test]
1907
+ async fn execute_sql_insert_into_lix_state_select_stages_write() {
1908
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
1909
+ let live_state = Arc::new(DummyLiveStateReader);
1910
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
1911
+ let mut ctx = DummySqlWriteExecutionContext {
1912
+ active_version_id: "version-a",
1913
+ blob_reader,
1914
+ live_state,
1915
+ staged_writes: Arc::clone(&staged_writes),
1916
+ schema_definitions: vec![],
1917
+ };
1918
+
1919
+ let result = execute_write_sql(
1920
+ &mut ctx,
1921
+ "INSERT INTO lix_state (\
1922
+ entity_id, schema_key, file_id, snapshot_content, metadata, schema_version, global, untracked\
1923
+ ) \
1924
+ SELECT \
1925
+ 'entity-from-select' AS entity_id, \
1926
+ 'lix_key_value' AS schema_key, \
1927
+ NULL AS file_id, \
1928
+ '{\"key\":\"hello\",\"value\":\"from-select\"}' AS snapshot_content, \
1929
+ '{\"source\":\"select\"}' AS metadata, \
1930
+ '1' AS schema_version, \
1931
+ false AS global, \
1932
+ false AS untracked",
1933
+ &[],
1934
+ )
1935
+ .await
1936
+ .expect("INSERT INTO lix_state SELECT should stage write");
1937
+
1938
+ assert_eq!(result.columns, vec!["count"]);
1939
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
1940
+
1941
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
1942
+ assert_eq!(staged_writes.deltas.len(), 1);
1943
+ let overlay = staged_writes.deltas[0]
1944
+ .pending_write_overlay()
1945
+ .expect("staged delta should expose pending overlay");
1946
+ let rows = overlay.visible_semantic_rows(false, "lix_key_value");
1947
+ assert_eq!(rows.len(), 1);
1948
+ assert_eq!(rows[0].entity_id, "entity-from-select");
1949
+ assert_eq!(rows[0].schema_version, "1");
1950
+ assert_eq!(rows[0].version_id, "version-a");
1951
+ assert_eq!(
1952
+ rows[0].snapshot_content.as_deref(),
1953
+ Some("{\"key\":\"hello\",\"value\":\"from-select\"}")
1954
+ );
1955
+ assert_eq!(
1956
+ rows[0].metadata.as_ref(),
1957
+ Some(&json!({"source": "select"}))
1958
+ );
1959
+ }
1960
+
1961
+ #[tokio::test]
1962
+ async fn execute_sql_insert_into_entity_by_version_stages_write() {
1963
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
1964
+ let live_state = Arc::new(DummyLiveStateReader);
1965
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
1966
+ let mut ctx = DummySqlWriteExecutionContext {
1967
+ active_version_id: "version-a",
1968
+ blob_reader,
1969
+ live_state,
1970
+ staged_writes: Arc::clone(&staged_writes),
1971
+ schema_definitions: vec![json!({
1972
+ "x-lix-key": "test_state_schema",
1973
+ "x-lix-version": "1",
1974
+ "type": "object",
1975
+ "properties": {
1976
+ "value": { "type": "string" }
1977
+ }
1978
+ })],
1979
+ };
1980
+
1981
+ let result = execute_write_sql(
1982
+ &mut ctx,
1983
+ "INSERT INTO test_state_schema_by_version (\
1984
+ lixcol_entity_id, lixcol_version_id, value\
1985
+ ) VALUES ('entity-c', 'version-b', 'C')",
1986
+ &[],
1987
+ )
1988
+ .await
1989
+ .expect("INSERT INTO entity by-version surface should stage write");
1990
+
1991
+ assert_eq!(result.columns, vec!["count"]);
1992
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
1993
+
1994
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
1995
+ assert_eq!(staged_writes.deltas.len(), 1);
1996
+ let overlay = staged_writes.deltas[0]
1997
+ .pending_write_overlay()
1998
+ .expect("staged delta should expose pending overlay");
1999
+ let rows = overlay.visible_semantic_rows(false, "test_state_schema");
2000
+ assert_eq!(rows.len(), 1);
2001
+ assert_eq!(rows[0].entity_id, "entity-c");
2002
+ assert_eq!(rows[0].schema_version, "1");
2003
+ assert_eq!(rows[0].version_id, "version-b");
2004
+ assert!(!rows[0].global);
2005
+ assert!(!rows[0].untracked);
2006
+ assert_eq!(
2007
+ rows[0].snapshot_content.as_deref(),
2008
+ Some("{\"value\":\"C\"}")
2009
+ );
2010
+ }
2011
+
2012
+ #[tokio::test]
2013
+ async fn execute_sql_insert_into_active_entity_defaults_active_version() {
2014
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2015
+ let live_state = Arc::new(DummyLiveStateReader);
2016
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2017
+ let mut ctx = DummySqlWriteExecutionContext {
2018
+ active_version_id: "version-a",
2019
+ blob_reader,
2020
+ live_state,
2021
+ staged_writes: Arc::clone(&staged_writes),
2022
+ schema_definitions: vec![json!({
2023
+ "x-lix-key": "test_state_schema",
2024
+ "x-lix-version": "1",
2025
+ "type": "object",
2026
+ "properties": {
2027
+ "value": { "type": "string" }
2028
+ }
2029
+ })],
2030
+ };
2031
+
2032
+ let result = execute_write_sql(
2033
+ &mut ctx,
2034
+ "INSERT INTO test_state_schema (lixcol_entity_id, value) \
2035
+ VALUES ('entity-c', 'C')",
2036
+ &[],
2037
+ )
2038
+ .await
2039
+ .expect("INSERT INTO active entity surface should stage write");
2040
+
2041
+ assert_eq!(result.columns, vec!["count"]);
2042
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2043
+
2044
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2045
+ assert_eq!(staged_writes.deltas.len(), 1);
2046
+ let overlay = staged_writes.deltas[0]
2047
+ .pending_write_overlay()
2048
+ .expect("staged delta should expose pending overlay");
2049
+ let rows = overlay.visible_semantic_rows(false, "test_state_schema");
2050
+ assert_eq!(rows.len(), 1);
2051
+ assert_eq!(rows[0].entity_id, "entity-c");
2052
+ assert_eq!(rows[0].version_id, "version-a");
2053
+ assert!(!rows[0].global);
2054
+ assert!(!rows[0].untracked);
2055
+ assert_eq!(
2056
+ rows[0].snapshot_content.as_deref(),
2057
+ Some("{\"value\":\"C\"}")
2058
+ );
2059
+ }
2060
+
2061
+ #[tokio::test]
2062
+ async fn execute_sql_insert_into_directory_by_version_stages_write() {
2063
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2064
+ let live_state = Arc::new(DummyLiveStateReader);
2065
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2066
+ let mut ctx = DummySqlWriteExecutionContext {
2067
+ active_version_id: "version-a",
2068
+ blob_reader,
2069
+ live_state,
2070
+ staged_writes: Arc::clone(&staged_writes),
2071
+ schema_definitions: vec![],
2072
+ };
2073
+
2074
+ let result = execute_write_sql(
2075
+ &mut ctx,
2076
+ "INSERT INTO lix_directory_by_version (\
2077
+ id, parent_id, name, hidden, lixcol_version_id\
2078
+ ) VALUES ('dir-docs', NULL, 'docs', false, 'version-b')",
2079
+ &[],
2080
+ )
2081
+ .await
2082
+ .expect("INSERT INTO lix_directory_by_version should stage write");
2083
+
2084
+ assert_eq!(result.columns, vec!["count"]);
2085
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2086
+
2087
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2088
+ assert_eq!(staged_writes.deltas.len(), 1);
2089
+ let overlay = staged_writes.deltas[0]
2090
+ .pending_write_overlay()
2091
+ .expect("staged delta should expose pending overlay");
2092
+ let rows = overlay.visible_semantic_rows(false, "lix_directory_descriptor");
2093
+ assert_eq!(rows.len(), 1);
2094
+ assert_eq!(rows[0].entity_id, "dir-docs");
2095
+ assert_eq!(rows[0].schema_version, "1");
2096
+ assert_eq!(rows[0].version_id, "version-b");
2097
+ assert!(!rows[0].global);
2098
+ assert!(!rows[0].untracked);
2099
+ assert_eq!(
2100
+ rows[0].snapshot_content.as_deref(),
2101
+ Some("{\"hidden\":false,\"id\":\"dir-docs\",\"name\":\"docs\",\"parent_id\":null}")
2102
+ );
2103
+ }
2104
+
2105
+ #[tokio::test]
2106
+ async fn execute_sql_insert_into_active_directory_defaults_active_version() {
2107
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2108
+ let live_state = Arc::new(DummyLiveStateReader);
2109
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2110
+ let mut ctx = DummySqlWriteExecutionContext {
2111
+ active_version_id: "version-a",
2112
+ blob_reader,
2113
+ live_state,
2114
+ staged_writes: Arc::clone(&staged_writes),
2115
+ schema_definitions: vec![],
2116
+ };
2117
+
2118
+ let result = execute_write_sql(
2119
+ &mut ctx,
2120
+ "INSERT INTO lix_directory (id, parent_id, name, hidden) \
2121
+ VALUES ('dir-docs', NULL, 'docs', false)",
2122
+ &[],
2123
+ )
2124
+ .await
2125
+ .expect("INSERT INTO lix_directory should stage write");
2126
+
2127
+ assert_eq!(result.columns, vec!["count"]);
2128
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2129
+
2130
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2131
+ assert_eq!(staged_writes.deltas.len(), 1);
2132
+ let overlay = staged_writes.deltas[0]
2133
+ .pending_write_overlay()
2134
+ .expect("staged delta should expose pending overlay");
2135
+ let rows = overlay.visible_semantic_rows(false, "lix_directory_descriptor");
2136
+ assert_eq!(rows.len(), 1);
2137
+ assert_eq!(rows[0].entity_id, "dir-docs");
2138
+ assert_eq!(rows[0].version_id, "version-a");
2139
+ assert!(!rows[0].global);
2140
+ assert!(!rows[0].untracked);
2141
+ }
2142
+
2143
+ #[tokio::test]
2144
+ async fn execute_sql_update_directory_stages_rewritten_descriptor() {
2145
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2146
+ let live_state = Arc::new(RowsLiveStateReader {
2147
+ rows: vec![
2148
+ live_directory_row("dir-docs", "version-a", None, "docs", false),
2149
+ live_directory_row("dir-guides", "version-a", Some("dir-docs"), "guides", false),
2150
+ ],
2151
+ });
2152
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2153
+ let mut ctx = DummySqlWriteExecutionContext {
2154
+ active_version_id: "version-a",
2155
+ blob_reader,
2156
+ live_state,
2157
+ staged_writes: Arc::clone(&staged_writes),
2158
+ schema_definitions: vec![],
2159
+ };
2160
+
2161
+ let result = execute_write_sql(
2162
+ &mut ctx,
2163
+ "UPDATE lix_directory \
2164
+ SET hidden = true, lixcol_metadata = '{\"source\":\"directory-update\"}' \
2165
+ WHERE id = 'dir-docs'",
2166
+ &[],
2167
+ )
2168
+ .await
2169
+ .expect("UPDATE lix_directory should stage rewritten descriptor");
2170
+
2171
+ assert_eq!(result.columns, vec!["count"]);
2172
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2173
+
2174
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2175
+ assert_eq!(staged_writes.deltas.len(), 1);
2176
+ let overlay = staged_writes.deltas[0]
2177
+ .pending_write_overlay()
2178
+ .expect("staged delta should expose pending overlay");
2179
+ let rows = overlay.visible_semantic_rows(false, "lix_directory_descriptor");
2180
+ assert_eq!(rows.len(), 1);
2181
+ assert_eq!(rows[0].entity_id, "dir-docs");
2182
+ assert_eq!(rows[0].version_id, "version-a");
2183
+ assert_eq!(
2184
+ rows[0].snapshot_content.as_deref(),
2185
+ Some("{\"hidden\":true,\"id\":\"dir-docs\",\"name\":\"docs\",\"parent_id\":null}")
2186
+ );
2187
+ assert_eq!(
2188
+ rows[0].metadata.as_ref(),
2189
+ Some(&json!({"source": "directory-update"}))
2190
+ );
2191
+ }
2192
+
2193
+ #[tokio::test]
2194
+ async fn execute_sql_update_directory_rejects_path_assignment() {
2195
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2196
+ let live_state = Arc::new(RowsLiveStateReader {
2197
+ rows: vec![live_directory_row(
2198
+ "dir-docs",
2199
+ "version-a",
2200
+ None,
2201
+ "docs",
2202
+ false,
2203
+ )],
2204
+ });
2205
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2206
+ let mut ctx = DummySqlWriteExecutionContext {
2207
+ active_version_id: "version-a",
2208
+ blob_reader,
2209
+ live_state,
2210
+ staged_writes: Arc::clone(&staged_writes),
2211
+ schema_definitions: vec![],
2212
+ };
2213
+
2214
+ let error = execute_write_sql(
2215
+ &mut ctx,
2216
+ "UPDATE lix_directory SET path = '/renamed/' WHERE id = 'dir-docs'",
2217
+ &[],
2218
+ )
2219
+ .await
2220
+ .expect_err("path should remain read-only");
2221
+
2222
+ assert!(
2223
+ error.message.contains("read-only column 'path'"),
2224
+ "unexpected error: {error:?}"
2225
+ );
2226
+ assert!(staged_writes
2227
+ .lock()
2228
+ .expect("staged writes lock")
2229
+ .deltas
2230
+ .is_empty());
2231
+ }
2232
+
2233
+ #[tokio::test]
2234
+ async fn execute_sql_delete_directory_by_version_stages_tombstone() {
2235
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2236
+ let live_state = Arc::new(RowsLiveStateReader {
2237
+ rows: vec![
2238
+ live_directory_row("dir-docs", "version-a", None, "docs", false),
2239
+ live_directory_row("dir-guides", "version-b", Some("dir-docs"), "guides", false),
2240
+ ],
2241
+ });
2242
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2243
+ let mut ctx = DummySqlWriteExecutionContext {
2244
+ active_version_id: "version-a",
2245
+ blob_reader,
2246
+ live_state,
2247
+ staged_writes: Arc::clone(&staged_writes),
2248
+ schema_definitions: vec![],
2249
+ };
2250
+
2251
+ let result = execute_write_sql(
2252
+ &mut ctx,
2253
+ "DELETE FROM lix_directory_by_version \
2254
+ WHERE id = 'dir-guides' AND lixcol_version_id = 'version-b'",
2255
+ &[],
2256
+ )
2257
+ .await
2258
+ .expect("DELETE lix_directory_by_version should stage tombstone");
2259
+
2260
+ assert_eq!(result.columns, vec!["count"]);
2261
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2262
+
2263
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2264
+ assert_eq!(staged_writes.deltas.len(), 1);
2265
+ let overlay = staged_writes.deltas[0]
2266
+ .pending_write_overlay()
2267
+ .expect("staged delta should expose pending overlay");
2268
+ let rows = overlay.visible_all_semantic_rows();
2269
+ assert_eq!(rows.len(), 1);
2270
+ assert_eq!(rows[0].entity_id, "dir-guides");
2271
+ assert_eq!(rows[0].version_id, "version-b");
2272
+ assert!(rows[0].tombstone);
2273
+ assert_eq!(rows[0].snapshot_content, None);
2274
+ }
2275
+
2276
+ #[tokio::test]
2277
+ async fn execute_sql_insert_into_file_by_version_stages_descriptor_write() {
2278
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2279
+ let live_state = Arc::new(DummyLiveStateReader);
2280
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2281
+ let mut ctx = DummySqlWriteExecutionContext {
2282
+ active_version_id: "version-a",
2283
+ blob_reader,
2284
+ live_state,
2285
+ staged_writes: Arc::clone(&staged_writes),
2286
+ schema_definitions: vec![],
2287
+ };
2288
+
2289
+ let result = execute_write_sql(
2290
+ &mut ctx,
2291
+ "INSERT INTO lix_file_by_version (\
2292
+ id, directory_id, name, hidden, lixcol_version_id\
2293
+ ) VALUES ('file-readme', 'dir-docs', 'readme.md', false, 'version-b')",
2294
+ &[],
2295
+ )
2296
+ .await
2297
+ .expect("INSERT INTO lix_file_by_version should stage descriptor write");
2298
+
2299
+ assert_eq!(result.columns, vec!["count"]);
2300
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2301
+
2302
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2303
+ assert_eq!(staged_writes.deltas.len(), 1);
2304
+ let overlay = staged_writes.deltas[0]
2305
+ .pending_write_overlay()
2306
+ .expect("staged delta should expose pending overlay");
2307
+ let rows = overlay.visible_semantic_rows(false, "lix_file_descriptor");
2308
+ assert_eq!(rows.len(), 1);
2309
+ assert_eq!(rows[0].entity_id, "file-readme");
2310
+ assert_eq!(rows[0].schema_version, "1");
2311
+ assert_eq!(rows[0].version_id, "version-b");
2312
+ assert!(!rows[0].global);
2313
+ assert!(!rows[0].untracked);
2314
+ let snapshot: JsonValue =
2315
+ serde_json::from_str(rows[0].snapshot_content.as_deref().unwrap())
2316
+ .expect("descriptor snapshot JSON");
2317
+ assert_eq!(snapshot["id"], "file-readme");
2318
+ assert_eq!(snapshot["directory_id"], "dir-docs");
2319
+ assert_eq!(snapshot["name"], "readme.md");
2320
+ assert_eq!(snapshot["hidden"], false);
2321
+ }
2322
+
2323
+ #[tokio::test]
2324
+ async fn execute_sql_insert_into_active_file_defaults_active_version() {
2325
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2326
+ let live_state = Arc::new(DummyLiveStateReader);
2327
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2328
+ let mut ctx = DummySqlWriteExecutionContext {
2329
+ active_version_id: "version-a",
2330
+ blob_reader,
2331
+ live_state,
2332
+ staged_writes: Arc::clone(&staged_writes),
2333
+ schema_definitions: vec![],
2334
+ };
2335
+
2336
+ let result = execute_write_sql(
2337
+ &mut ctx,
2338
+ "INSERT INTO lix_file (id, directory_id, name, hidden) \
2339
+ VALUES ('file-readme', 'dir-docs', 'readme.md', false)",
2340
+ &[],
2341
+ )
2342
+ .await
2343
+ .expect("INSERT INTO lix_file should stage descriptor write");
2344
+
2345
+ assert_eq!(result.columns, vec!["count"]);
2346
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2347
+
2348
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2349
+ assert_eq!(staged_writes.deltas.len(), 1);
2350
+ let overlay = staged_writes.deltas[0]
2351
+ .pending_write_overlay()
2352
+ .expect("staged delta should expose pending overlay");
2353
+ let rows = overlay.visible_semantic_rows(false, "lix_file_descriptor");
2354
+ assert_eq!(rows.len(), 1);
2355
+ assert_eq!(rows[0].entity_id, "file-readme");
2356
+ assert_eq!(rows[0].version_id, "version-a");
2357
+ assert!(!rows[0].global);
2358
+ assert!(!rows[0].untracked);
2359
+ }
2360
+
2361
+ #[tokio::test]
2362
+ async fn execute_sql_insert_into_file_with_data_stages_blob_ref() {
2363
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2364
+ let live_state = Arc::new(DummyLiveStateReader);
2365
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2366
+ let mut ctx = DummySqlWriteExecutionContext {
2367
+ active_version_id: "version-a",
2368
+ blob_reader,
2369
+ live_state,
2370
+ staged_writes: Arc::clone(&staged_writes),
2371
+ schema_definitions: vec![],
2372
+ };
2373
+
2374
+ let result = execute_write_sql(
2375
+ &mut ctx,
2376
+ "INSERT INTO lix_file_by_version (\
2377
+ id, directory_id, name, hidden, data, lixcol_version_id\
2378
+ ) VALUES ('file-readme', 'dir-docs', 'readme.md', false, X'4142', 'version-b')",
2379
+ &[],
2380
+ )
2381
+ .await
2382
+ .expect("INSERT INTO lix_file_by_version should stage descriptor and data writes");
2383
+
2384
+ assert_eq!(result.columns, vec!["count"]);
2385
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2386
+
2387
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2388
+ assert_eq!(staged_writes.deltas.len(), 1);
2389
+ let overlay = staged_writes.deltas[0]
2390
+ .pending_write_overlay()
2391
+ .expect("staged delta should expose pending overlay");
2392
+ let descriptor_rows = overlay.visible_semantic_rows(false, "lix_file_descriptor");
2393
+ assert_eq!(descriptor_rows.len(), 1);
2394
+ assert_eq!(descriptor_rows[0].entity_id, "file-readme");
2395
+ let blob_ref_rows = overlay.visible_semantic_rows(false, "lix_binary_blob_ref");
2396
+ assert_eq!(blob_ref_rows.len(), 1);
2397
+ assert_eq!(blob_ref_rows[0].entity_id, "file-readme");
2398
+ assert_eq!(blob_ref_rows[0].file_id.as_deref(), Some("file-readme"));
2399
+ assert_eq!(blob_ref_rows[0].version_id, "version-b");
2400
+ let snapshot: JsonValue =
2401
+ serde_json::from_str(blob_ref_rows[0].snapshot_content.as_deref().unwrap())
2402
+ .expect("blob ref snapshot JSON");
2403
+ assert_eq!(snapshot["id"], "file-readme");
2404
+ assert_eq!(snapshot["size_bytes"], 2);
2405
+ assert!(snapshot["blob_hash"]
2406
+ .as_str()
2407
+ .is_some_and(|value| !value.is_empty()));
2408
+ }
2409
+
2410
+ #[tokio::test]
2411
+ async fn execute_sql_update_file_stages_rewritten_descriptor() {
2412
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2413
+ let live_state = Arc::new(RowsLiveStateReader {
2414
+ rows: vec![
2415
+ live_directory_row("dir-docs", "version-a", None, "docs", false),
2416
+ live_file_row(
2417
+ "file-readme",
2418
+ "version-a",
2419
+ Some("dir-docs"),
2420
+ "readme.md",
2421
+ false,
2422
+ ),
2423
+ live_file_row(
2424
+ "file-guide",
2425
+ "version-a",
2426
+ Some("dir-docs"),
2427
+ "guide.md",
2428
+ false,
2429
+ ),
2430
+ ],
2431
+ });
2432
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2433
+ let mut ctx = DummySqlWriteExecutionContext {
2434
+ active_version_id: "version-a",
2435
+ blob_reader,
2436
+ live_state,
2437
+ staged_writes: Arc::clone(&staged_writes),
2438
+ schema_definitions: vec![],
2439
+ };
2440
+
2441
+ let result = execute_write_sql(
2442
+ &mut ctx,
2443
+ "UPDATE lix_file \
2444
+ SET name = 'readme-updated.txt', hidden = true, lixcol_metadata = '{\"source\":\"file-update\"}' \
2445
+ WHERE id = 'file-readme'",
2446
+ &[],
2447
+ )
2448
+ .await
2449
+ .expect("UPDATE lix_file should stage rewritten descriptor");
2450
+
2451
+ assert_eq!(result.columns, vec!["count"]);
2452
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2453
+
2454
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2455
+ assert_eq!(staged_writes.deltas.len(), 1);
2456
+ let overlay = staged_writes.deltas[0]
2457
+ .pending_write_overlay()
2458
+ .expect("staged delta should expose pending overlay");
2459
+ let rows = overlay.visible_semantic_rows(false, "lix_file_descriptor");
2460
+ assert_eq!(rows.len(), 1);
2461
+ assert_eq!(rows[0].entity_id, "file-readme");
2462
+ assert_eq!(rows[0].version_id, "version-a");
2463
+ let snapshot: JsonValue =
2464
+ serde_json::from_str(rows[0].snapshot_content.as_deref().unwrap())
2465
+ .expect("descriptor snapshot JSON");
2466
+ assert_eq!(snapshot["id"], "file-readme");
2467
+ assert_eq!(snapshot["directory_id"], "dir-docs");
2468
+ assert_eq!(snapshot["name"], "readme-updated.txt");
2469
+ assert_eq!(snapshot["hidden"], true);
2470
+ assert_eq!(
2471
+ rows[0].metadata.as_ref(),
2472
+ Some(&json!({"source": "file-update"}))
2473
+ );
2474
+ }
2475
+
2476
+ #[tokio::test]
2477
+ async fn execute_sql_update_file_stages_data_blob_ref() {
2478
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2479
+ let live_state = Arc::new(RowsLiveStateReader {
2480
+ rows: vec![
2481
+ live_directory_row("dir-docs", "version-a", None, "docs", false),
2482
+ live_file_row(
2483
+ "file-readme",
2484
+ "version-a",
2485
+ Some("dir-docs"),
2486
+ "readme.md",
2487
+ false,
2488
+ ),
2489
+ ],
2490
+ });
2491
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2492
+ let mut ctx = DummySqlWriteExecutionContext {
2493
+ active_version_id: "version-a",
2494
+ blob_reader,
2495
+ live_state,
2496
+ staged_writes: Arc::clone(&staged_writes),
2497
+ schema_definitions: vec![],
2498
+ };
2499
+
2500
+ let result = execute_write_sql(
2501
+ &mut ctx,
2502
+ "UPDATE lix_file SET data = X'4142' WHERE id = 'file-readme'",
2503
+ &[],
2504
+ )
2505
+ .await
2506
+ .expect("UPDATE lix_file should stage data write");
2507
+
2508
+ assert_eq!(result.columns, vec!["count"]);
2509
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2510
+
2511
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2512
+ assert_eq!(staged_writes.deltas.len(), 1);
2513
+ let overlay = staged_writes.deltas[0]
2514
+ .pending_write_overlay()
2515
+ .expect("staged delta should expose pending overlay");
2516
+ assert!(overlay
2517
+ .visible_semantic_rows(false, "lix_file_descriptor")
2518
+ .is_empty());
2519
+ let blob_ref_rows = overlay.visible_semantic_rows(false, "lix_binary_blob_ref");
2520
+ assert_eq!(blob_ref_rows.len(), 1);
2521
+ assert_eq!(blob_ref_rows[0].entity_id, "file-readme");
2522
+ let snapshot: JsonValue =
2523
+ serde_json::from_str(blob_ref_rows[0].snapshot_content.as_deref().unwrap())
2524
+ .expect("blob ref snapshot JSON");
2525
+ assert_eq!(snapshot["id"], "file-readme");
2526
+ assert_eq!(snapshot["size_bytes"], 2);
2527
+ }
2528
+
2529
+ #[tokio::test]
2530
+ async fn execute_sql_update_file_stages_path_assignment() {
2531
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2532
+ let live_state = Arc::new(RowsLiveStateReader {
2533
+ rows: vec![
2534
+ live_directory_row("dir-docs", "version-a", None, "docs", false),
2535
+ live_file_row(
2536
+ "file-readme",
2537
+ "version-a",
2538
+ Some("dir-docs"),
2539
+ "readme.md",
2540
+ false,
2541
+ ),
2542
+ ],
2543
+ });
2544
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2545
+ let mut ctx = DummySqlWriteExecutionContext {
2546
+ active_version_id: "version-a",
2547
+ blob_reader,
2548
+ live_state,
2549
+ staged_writes: Arc::clone(&staged_writes),
2550
+ schema_definitions: vec![],
2551
+ };
2552
+
2553
+ let result = execute_write_sql(
2554
+ &mut ctx,
2555
+ "UPDATE lix_file SET path = '/docs/renamed.md' WHERE id = 'file-readme'",
2556
+ &[],
2557
+ )
2558
+ .await
2559
+ .expect("path update should stage descriptor rewrite");
2560
+
2561
+ assert_eq!(result.columns, vec!["count"]);
2562
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2563
+
2564
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2565
+ assert_eq!(staged_writes.deltas.len(), 1);
2566
+ let overlay = staged_writes.deltas[0]
2567
+ .pending_write_overlay()
2568
+ .expect("staged delta should expose pending overlay");
2569
+ let rows = overlay.visible_semantic_rows(false, "lix_file_descriptor");
2570
+ assert_eq!(rows.len(), 1);
2571
+ let snapshot: JsonValue =
2572
+ serde_json::from_str(rows[0].snapshot_content.as_deref().unwrap())
2573
+ .expect("descriptor snapshot JSON");
2574
+ assert_eq!(snapshot["directory_id"], "dir-docs");
2575
+ assert_eq!(snapshot["name"], "renamed.md");
2576
+ }
2577
+
2578
+ #[tokio::test]
2579
+ async fn execute_sql_delete_file_by_version_stages_descriptor_tombstone() {
2580
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2581
+ let live_state = Arc::new(RowsLiveStateReader {
2582
+ rows: vec![
2583
+ live_directory_row("dir-docs", "version-a", None, "docs", false),
2584
+ live_directory_row("dir-docs", "version-b", None, "docs", false),
2585
+ live_file_row(
2586
+ "file-readme",
2587
+ "version-a",
2588
+ Some("dir-docs"),
2589
+ "readme.md",
2590
+ false,
2591
+ ),
2592
+ live_file_row(
2593
+ "file-guide",
2594
+ "version-b",
2595
+ Some("dir-docs"),
2596
+ "guide.md",
2597
+ false,
2598
+ ),
2599
+ ],
2600
+ });
2601
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2602
+ let mut ctx = DummySqlWriteExecutionContext {
2603
+ active_version_id: "version-a",
2604
+ blob_reader,
2605
+ live_state,
2606
+ staged_writes: Arc::clone(&staged_writes),
2607
+ schema_definitions: vec![],
2608
+ };
2609
+
2610
+ let result = execute_write_sql(
2611
+ &mut ctx,
2612
+ "DELETE FROM lix_file_by_version \
2613
+ WHERE id = 'file-guide' AND lixcol_version_id = 'version-b'",
2614
+ &[],
2615
+ )
2616
+ .await
2617
+ .expect("DELETE lix_file_by_version should stage descriptor tombstone");
2618
+
2619
+ assert_eq!(result.columns, vec!["count"]);
2620
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2621
+
2622
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2623
+ assert_eq!(staged_writes.deltas.len(), 1);
2624
+ let overlay = staged_writes.deltas[0]
2625
+ .pending_write_overlay()
2626
+ .expect("staged delta should expose pending overlay");
2627
+ let rows = overlay.visible_all_semantic_rows();
2628
+ assert_eq!(rows.len(), 1);
2629
+ assert_eq!(rows[0].entity_id, "file-guide");
2630
+ assert_eq!(rows[0].version_id, "version-b");
2631
+ assert!(rows[0].tombstone);
2632
+ assert_eq!(rows[0].snapshot_content, None);
2633
+ }
2634
+
2635
+ #[tokio::test]
2636
+ async fn execute_sql_update_entity_surface_stages_rewritten_snapshot() {
2637
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2638
+ let live_state = Arc::new(RowsLiveStateReader {
2639
+ rows: vec![
2640
+ live_entity_row("entity-a", "version-a", "A"),
2641
+ live_entity_row("entity-b", "version-a", "B"),
2642
+ ],
2643
+ });
2644
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2645
+ let mut ctx = DummySqlWriteExecutionContext {
2646
+ active_version_id: "version-a",
2647
+ blob_reader,
2648
+ live_state,
2649
+ staged_writes: Arc::clone(&staged_writes),
2650
+ schema_definitions: vec![json!({
2651
+ "x-lix-key": "test_state_schema",
2652
+ "x-lix-version": "1",
2653
+ "type": "object",
2654
+ "properties": {
2655
+ "value": { "type": "string" }
2656
+ }
2657
+ })],
2658
+ };
2659
+
2660
+ let result = execute_write_sql(
2661
+ &mut ctx,
2662
+ "UPDATE test_state_schema \
2663
+ SET value = 'updated', lixcol_metadata = '{\"source\":\"entity-update\"}' \
2664
+ WHERE value = 'A'",
2665
+ &[],
2666
+ )
2667
+ .await
2668
+ .expect("UPDATE entity surface should stage rewritten row");
2669
+
2670
+ assert_eq!(result.columns, vec!["count"]);
2671
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2672
+
2673
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2674
+ assert_eq!(staged_writes.deltas.len(), 1);
2675
+ let overlay = staged_writes.deltas[0]
2676
+ .pending_write_overlay()
2677
+ .expect("staged delta should expose pending overlay");
2678
+ let rows = overlay.visible_semantic_rows(false, "test_state_schema");
2679
+ assert_eq!(rows.len(), 1);
2680
+ assert_eq!(rows[0].entity_id, "entity-a");
2681
+ assert_eq!(rows[0].version_id, "version-a");
2682
+ assert_eq!(
2683
+ rows[0].snapshot_content.as_deref(),
2684
+ Some("{\"value\":\"updated\"}")
2685
+ );
2686
+ assert_eq!(
2687
+ rows[0].metadata.as_ref(),
2688
+ Some(&json!({"source": "entity-update"}))
2689
+ );
2690
+ }
2691
+
2692
+ #[tokio::test]
2693
+ async fn execute_sql_delete_entity_by_version_stages_tombstone() {
2694
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2695
+ let live_state = Arc::new(RowsLiveStateReader {
2696
+ rows: vec![
2697
+ live_entity_row("entity-a", "version-a", "A"),
2698
+ live_entity_row("entity-b", "version-b", "B"),
2699
+ ],
2700
+ });
2701
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2702
+ let mut ctx = DummySqlWriteExecutionContext {
2703
+ active_version_id: "version-a",
2704
+ blob_reader,
2705
+ live_state,
2706
+ staged_writes: Arc::clone(&staged_writes),
2707
+ schema_definitions: vec![json!({
2708
+ "x-lix-key": "test_state_schema",
2709
+ "x-lix-version": "1",
2710
+ "type": "object",
2711
+ "properties": {
2712
+ "value": { "type": "string" }
2713
+ }
2714
+ })],
2715
+ };
2716
+
2717
+ let result = execute_write_sql(
2718
+ &mut ctx,
2719
+ "DELETE FROM test_state_schema_by_version \
2720
+ WHERE lixcol_version_id = 'version-b'",
2721
+ &[],
2722
+ )
2723
+ .await
2724
+ .expect("DELETE entity by-version surface should stage tombstone");
2725
+
2726
+ assert_eq!(result.columns, vec!["count"]);
2727
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2728
+
2729
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2730
+ assert_eq!(staged_writes.deltas.len(), 1);
2731
+ let overlay = staged_writes.deltas[0]
2732
+ .pending_write_overlay()
2733
+ .expect("staged delta should expose pending overlay");
2734
+ let rows = overlay.visible_all_semantic_rows();
2735
+ assert_eq!(rows.len(), 1);
2736
+ assert_eq!(rows[0].entity_id, "entity-b");
2737
+ assert_eq!(rows[0].version_id, "version-b");
2738
+ assert!(rows[0].tombstone);
2739
+ assert_eq!(rows[0].snapshot_content, None);
2740
+ }
2741
+
2742
+ #[tokio::test]
2743
+ async fn execute_sql_update_lix_state_stages_rewritten_rows() {
2744
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2745
+ let live_state = Arc::new(RowsLiveStateReader {
2746
+ rows: vec![
2747
+ live_lix_state_row("entity-1", Some("{\"source\":\"match\"}")),
2748
+ live_lix_state_row("entity-2", Some("{\"source\":\"skip\"}")),
2749
+ ],
2750
+ });
2751
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2752
+ let mut ctx = DummySqlWriteExecutionContext {
2753
+ active_version_id: "version-a",
2754
+ blob_reader,
2755
+ live_state,
2756
+ staged_writes: Arc::clone(&staged_writes),
2757
+ schema_definitions: vec![],
2758
+ };
2759
+
2760
+ let result = execute_write_sql(
2761
+ &mut ctx,
2762
+ "UPDATE lix_state \
2763
+ SET snapshot_content = '{\"key\":\"hello\",\"value\":\"updated\"}', \
2764
+ metadata = '{\"schema_key\":\"lix_key_value\"}' \
2765
+ WHERE metadata = '{\"source\":\"match\"}'",
2766
+ &[],
2767
+ )
2768
+ .await
2769
+ .expect("UPDATE lix_state should stage rewritten rows");
2770
+
2771
+ assert_eq!(result.columns, vec!["count"]);
2772
+ assert_eq!(result.rows, vec![vec![Value::Integer(1)]]);
2773
+
2774
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2775
+ assert_eq!(staged_writes.deltas.len(), 1);
2776
+ let overlay = staged_writes.deltas[0]
2777
+ .pending_write_overlay()
2778
+ .expect("staged delta should expose pending overlay");
2779
+ let rows = overlay.visible_semantic_rows(false, "lix_key_value");
2780
+ assert_eq!(rows.len(), 1);
2781
+ assert_eq!(rows[0].entity_id, "entity-1");
2782
+ assert_eq!(rows[0].version_id, "version-a");
2783
+ assert_eq!(
2784
+ rows[0].snapshot_content.as_deref(),
2785
+ Some("{\"key\":\"hello\",\"value\":\"updated\"}")
2786
+ );
2787
+ assert_eq!(
2788
+ rows[0].metadata.as_ref(),
2789
+ Some(&json!({"schema_key": "lix_key_value"}))
2790
+ );
2791
+ }
2792
+
2793
+ #[tokio::test]
2794
+ async fn execute_sql_delete_lix_state_without_where_stages_all_rows() {
2795
+ let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
2796
+ let live_state = Arc::new(RowsLiveStateReader {
2797
+ rows: vec![
2798
+ live_lix_state_row("entity-1", Some("{\"source\":\"one\"}")),
2799
+ live_lix_state_row("entity-2", Some("{\"source\":\"two\"}")),
2800
+ ],
2801
+ });
2802
+ let staged_writes = Arc::new(Mutex::new(CapturingStagedWrites::default()));
2803
+ let mut ctx = DummySqlWriteExecutionContext {
2804
+ active_version_id: "version-a",
2805
+ blob_reader,
2806
+ live_state,
2807
+ staged_writes: Arc::clone(&staged_writes),
2808
+ schema_definitions: vec![],
2809
+ };
2810
+
2811
+ let result = execute_write_sql(&mut ctx, "DELETE FROM lix_state", &[])
2812
+ .await
2813
+ .expect("DELETE FROM lix_state should follow DataFusion delete-all semantics");
2814
+
2815
+ assert_eq!(result.columns, vec!["count"]);
2816
+ assert_eq!(result.rows, vec![vec![Value::Integer(2)]]);
2817
+
2818
+ let staged_writes = staged_writes.lock().expect("staged writes lock");
2819
+ assert_eq!(staged_writes.deltas.len(), 1);
2820
+ let overlay = staged_writes.deltas[0]
2821
+ .pending_write_overlay()
2822
+ .expect("staged delta should expose pending overlay");
2823
+ let rows = overlay.visible_all_semantic_rows();
2824
+ assert_eq!(rows.len(), 2);
2825
+ assert!(rows.iter().all(|row| row.tombstone));
2826
+ assert!(rows.iter().all(|row| row.snapshot_content.is_none()));
2827
+ assert!(rows.iter().any(|row| row.entity_id == "entity-1"));
2828
+ assert!(rows.iter().any(|row| row.entity_id == "entity-2"));
2829
+ }
2830
+
2831
+ struct BackendSqlExecutionContext<'a> {
2832
+ active_version_id: &'a str,
2833
+ storage: StorageContext,
2834
+ blob_reader: Arc<dyn BlobDataReader>,
2835
+ live_state: Arc<dyn LiveStateReader>,
2836
+ schema_definitions: Vec<JsonValue>,
2837
+ }
2838
+
2839
+ impl SqlExecutionContext for BackendSqlExecutionContext<'_> {
2840
+ fn active_version_id(&self) -> &str {
2841
+ self.active_version_id
2842
+ }
2843
+
2844
+ fn live_state(&self) -> Arc<dyn LiveStateReader> {
2845
+ Arc::clone(&self.live_state)
2846
+ }
2847
+
2848
+ fn functions(&self) -> FunctionProviderHandle {
2849
+ test_functions()
2850
+ }
2851
+
2852
+ fn blob_reader(&self) -> Arc<dyn BlobDataReader> {
2853
+ Arc::clone(&self.blob_reader)
2854
+ }
2855
+
2856
+ fn changelog_query_source(&self) -> SqlChangelogQuerySource {
2857
+ let base_scope = test_read_scope(self.storage.clone());
2858
+ let read_scope = StorageReadScope::new(base_scope.store());
2859
+ ChangelogQuerySource {
2860
+ changelog_reader: Arc::new(
2861
+ crate::changelog::ChangelogContext::new().reader(read_scope.store()),
2862
+ ),
2863
+ json_reader: JsonStoreContext::new().reader(read_scope.store()),
2864
+ }
2865
+ }
2866
+
2867
+ fn commit_graph(&self) -> Box<dyn CommitGraphReader> {
2868
+ Box::new(DummyCommitGraphReader)
2869
+ }
2870
+
2871
+ fn version_ref(&self) -> Arc<dyn VersionRefReader> {
2872
+ Arc::new(
2873
+ crate::version::VersionContext::new(Arc::new(UntrackedStateContext::new()))
2874
+ .ref_reader(self.storage.clone()),
2875
+ )
2876
+ }
2877
+
2878
+ fn list_visible_schemas(&self) -> Result<Vec<JsonValue>, LixError> {
2879
+ Ok(self.schema_definitions.clone())
2880
+ }
2881
+ }
2882
+
2883
+ async fn setup_sql2_state_fixture(
2884
+ ) -> Result<(crate::backend::testing::UnitTestBackend, JsonValue), crate::LixError> {
2885
+ let backend = crate::backend::testing::UnitTestBackend::new();
2886
+ let init_receipt = Engine::initialize(Box::new(backend.clone())).await?;
2887
+ let storage = crate::storage::StorageContext::new(std::sync::Arc::new(backend.clone()));
2888
+ {
2889
+ let mut transaction = storage.begin_write_transaction().await?;
2890
+ let version_ctx = crate::version::VersionContext::new(Arc::new(
2891
+ crate::untracked_state::UntrackedStateContext::new(),
2892
+ ));
2893
+ let mut writes = StorageWriteSet::new();
2894
+ let canonical_rows = {
2895
+ let mut json_writer = JsonStoreContext::new().writer();
2896
+ vec![
2897
+ version_ctx.canonical_ref_row(
2898
+ &mut writes,
2899
+ &mut json_writer,
2900
+ "version-a",
2901
+ &init_receipt.initial_commit_id,
2902
+ "1970-01-01T00:00:00.000Z",
2903
+ )?,
2904
+ version_ctx.canonical_ref_row(
2905
+ &mut writes,
2906
+ &mut json_writer,
2907
+ "version-b",
2908
+ &init_receipt.initial_commit_id,
2909
+ "1970-01-01T00:00:00.000Z",
2910
+ )?,
2911
+ ]
2912
+ };
2913
+ version_ctx.stage_canonical_ref_rows(&mut writes, &canonical_rows)?;
2914
+ writes.apply(&mut transaction.as_mut()).await?;
2915
+ transaction.commit().await?;
2916
+ }
2917
+ let engine = Engine::new(Box::new(backend.clone())).await?;
2918
+ let session_a = engine.open_session("version-a").await?;
2919
+ let session_b = engine.open_session("version-b").await?;
2920
+ let schema_definition = json!({
2921
+ "x-lix-key": "test_state_schema",
2922
+ "x-lix-version": "1",
2923
+ "type": "object",
2924
+ "properties": {
2925
+ "value": { "type": "string" }
2926
+ },
2927
+ "required": ["value"],
2928
+ "additionalProperties": false
2929
+ });
2930
+ session_a
2931
+ .execute(
2932
+ "INSERT INTO lix_registered_schema (value, lixcol_global, lixcol_untracked) \
2933
+ VALUES (\
2934
+ lix_json('{\"x-lix-key\":\"test_state_schema\",\"x-lix-version\":\"1\",\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"],\"additionalProperties\":false}'),\
2935
+ false,\
2936
+ true\
2937
+ )",
2938
+ &[],
2939
+ )
2940
+ .await?;
2941
+ session_b
2942
+ .execute(
2943
+ "INSERT INTO lix_registered_schema (value, lixcol_global, lixcol_untracked) \
2944
+ VALUES (\
2945
+ lix_json('{\"x-lix-key\":\"test_state_schema\",\"x-lix-version\":\"1\",\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"],\"additionalProperties\":false}'),\
2946
+ false,\
2947
+ true\
2948
+ )",
2949
+ &[],
2950
+ )
2951
+ .await?;
2952
+ session_a
2953
+ .execute(
2954
+ "INSERT INTO lix_state (\
2955
+ entity_id, schema_key, file_id, snapshot_content, schema_version, global, untracked\
2956
+ ) VALUES (\
2957
+ 'entity-a', 'test_state_schema', NULL, '{\"value\":\"A\"}', '1', false, false\
2958
+ )",
2959
+ &[],
2960
+ )
2961
+ .await?;
2962
+ session_b
2963
+ .execute(
2964
+ "INSERT INTO lix_state (\
2965
+ entity_id, schema_key, file_id, snapshot_content, schema_version, global, untracked\
2966
+ ) VALUES (\
2967
+ 'entity-b', 'test_state_schema', NULL, '{\"value\":\"B\"}', '1', false, false\
2968
+ )",
2969
+ &[],
2970
+ )
2971
+ .await?;
2972
+ session_a
2973
+ .execute(
2974
+ "INSERT INTO lix_state (\
2975
+ entity_id, schema_key, file_id, snapshot_content, schema_version, global, untracked\
2976
+ ) VALUES (\
2977
+ 'dir-docs', 'lix_directory_descriptor', NULL, '{\"id\":\"dir-docs\",\"parent_id\":null,\"name\":\"docs\",\"hidden\":false}', '1', false, false\
2978
+ )",
2979
+ &[],
2980
+ )
2981
+ .await?;
2982
+ session_a
2983
+ .execute(
2984
+ "INSERT INTO lix_file (id, path, data) \
2985
+ VALUES ('file-a', '/docs/readme.md', X'4142')",
2986
+ &[],
2987
+ )
2988
+ .await?;
2989
+ Ok((backend, schema_definition))
2990
+ }
2991
+
2992
+ fn test_live_state_context() -> LiveStateContext {
2993
+ LiveStateContext::new(
2994
+ TrackedStateContext::new(),
2995
+ UntrackedStateContext::new(),
2996
+ crate::commit_graph::CommitGraphContext::new(crate::changelog::ChangelogContext::new()),
2997
+ )
2998
+ }
2999
+
3000
+ fn run_async_test_with_large_stack(
3001
+ test: impl FnOnce() -> futures_util::future::LocalBoxFuture<'static, ()> + Send + 'static,
3002
+ ) {
3003
+ std::thread::Builder::new()
3004
+ .name("sql2-execute-test".to_string())
3005
+ .stack_size(32 * 1024 * 1024)
3006
+ .spawn(move || {
3007
+ tokio::runtime::Builder::new_current_thread()
3008
+ .enable_all()
3009
+ .build()
3010
+ .expect("test runtime should build")
3011
+ .block_on(test());
3012
+ })
3013
+ .expect("test thread should spawn")
3014
+ .join()
3015
+ .expect("test thread should join");
3016
+ }
3017
+
3018
+ #[test]
3019
+ fn execute_sql_reads_lix_state_by_version() {
3020
+ run_async_test_with_large_stack(|| {
3021
+ Box::pin(async move {
3022
+ let (backend, schema_definition) = setup_sql2_state_fixture()
3023
+ .await
3024
+ .expect("fixture should initialize");
3025
+ let backend = Arc::new(backend);
3026
+ let backend_ref: Arc<dyn crate::Backend + Send + Sync> = backend;
3027
+ let storage = StorageContext::new(Arc::clone(&backend_ref));
3028
+ let blob_reader: Arc<dyn BlobDataReader> =
3029
+ Arc::new(BackendBlobReader(storage.clone()));
3030
+ let ctx = BackendSqlExecutionContext {
3031
+ active_version_id: "version-a",
3032
+ storage: storage.clone(),
3033
+ blob_reader: Arc::clone(&blob_reader),
3034
+ live_state: Arc::new(test_live_state_context().reader(storage.clone())),
3035
+ schema_definitions: vec![schema_definition],
3036
+ };
3037
+
3038
+ let result = execute_sql(
3039
+ &ctx,
3040
+ "SELECT entity_id, version_id, snapshot_content, commit_id \
3041
+ FROM lix_state_by_version \
3042
+ WHERE version_id = 'version-b' AND schema_key = 'test_state_schema'",
3043
+ &[],
3044
+ )
3045
+ .await
3046
+ .expect("sql2 execute should read lix_state_by_version");
3047
+
3048
+ assert_eq!(
3049
+ result.columns,
3050
+ vec!["entity_id", "version_id", "snapshot_content", "commit_id"]
3051
+ );
3052
+ assert_eq!(result.rows.len(), 1);
3053
+ assert_eq!(result.rows[0][0], Value::Text("entity-b".to_string()));
3054
+ assert_eq!(result.rows[0][1], Value::Text("version-b".to_string()));
3055
+ assert_eq!(result.rows[0][2], Value::Json(json!({"value": "B"})));
3056
+ match &result.rows[0][3] {
3057
+ Value::Text(commit_id) => assert!(!commit_id.is_empty()),
3058
+ other => panic!("expected non-null commit_id text, got {other:?}"),
3059
+ }
3060
+ })
3061
+ });
3062
+ }
3063
+
3064
+ #[test]
3065
+ fn execute_sql_supports_broad_lix_state_by_version_reads() {
3066
+ run_async_test_with_large_stack(|| {
3067
+ Box::pin(async move {
3068
+ let (backend, schema_definition) = setup_sql2_state_fixture()
3069
+ .await
3070
+ .expect("fixture should initialize");
3071
+ let backend = Arc::new(backend);
3072
+ let backend_ref: Arc<dyn crate::Backend + Send + Sync> = backend;
3073
+ let storage = StorageContext::new(Arc::clone(&backend_ref));
3074
+ let blob_reader: Arc<dyn BlobDataReader> =
3075
+ Arc::new(BackendBlobReader(storage.clone()));
3076
+ let ctx = BackendSqlExecutionContext {
3077
+ active_version_id: "version-a",
3078
+ storage: storage.clone(),
3079
+ blob_reader: Arc::clone(&blob_reader),
3080
+ live_state: Arc::new(test_live_state_context().reader(storage.clone())),
3081
+ schema_definitions: vec![schema_definition],
3082
+ };
3083
+
3084
+ let result = execute_sql(
3085
+ &ctx,
3086
+ "SELECT entity_id FROM lix_state_by_version WHERE schema_key = 'test_state_schema'",
3087
+ &[],
3088
+ )
3089
+ .await
3090
+ .expect("broad by-version read should succeed");
3091
+
3092
+ assert!(
3093
+ result.rows.iter().any(|row| row[0] == Value::Text("entity-a".to_string()))
3094
+ && result.rows.iter().any(|row| row[0] == Value::Text("entity-b".to_string())),
3095
+ "expected broad by-version read to include rows from multiple visible versions: {:?}",
3096
+ result.rows
3097
+ );
3098
+ })
3099
+ });
3100
+ }
3101
+
3102
+ #[test]
3103
+ fn execute_sql_reads_lix_state_from_active_version() {
3104
+ run_async_test_with_large_stack(|| {
3105
+ Box::pin(async move {
3106
+ let (backend, schema_definition) = setup_sql2_state_fixture()
3107
+ .await
3108
+ .expect("fixture should initialize");
3109
+ let backend = Arc::new(backend);
3110
+ let backend_ref: Arc<dyn crate::Backend + Send + Sync> = backend;
3111
+ let storage = StorageContext::new(Arc::clone(&backend_ref));
3112
+ let blob_reader: Arc<dyn BlobDataReader> =
3113
+ Arc::new(BackendBlobReader(storage.clone()));
3114
+ let ctx = BackendSqlExecutionContext {
3115
+ active_version_id: "version-a",
3116
+ storage: storage.clone(),
3117
+ blob_reader: Arc::clone(&blob_reader),
3118
+ live_state: Arc::new(test_live_state_context().reader(storage.clone())),
3119
+ schema_definitions: vec![schema_definition],
3120
+ };
3121
+
3122
+ let result = execute_sql(
3123
+ &ctx,
3124
+ "SELECT entity_id, snapshot_content \
3125
+ FROM lix_state \
3126
+ WHERE schema_key = 'test_state_schema'",
3127
+ &[],
3128
+ )
3129
+ .await
3130
+ .expect("sql2 execute should read lix_state");
3131
+
3132
+ assert_eq!(result.columns, vec!["entity_id", "snapshot_content"]);
3133
+ assert_eq!(result.rows.len(), 1);
3134
+ assert_eq!(result.rows[0][0], Value::Text("entity-a".to_string()));
3135
+ assert_eq!(result.rows[0][1], Value::Json(json!({"value": "A"})));
3136
+ })
3137
+ });
3138
+ }
3139
+
3140
+ #[test]
3141
+ fn execute_sql_reads_entity_view_from_active_version() {
3142
+ run_async_test_with_large_stack(|| {
3143
+ Box::pin(async move {
3144
+ let (backend, schema_definition) = setup_sql2_state_fixture()
3145
+ .await
3146
+ .expect("fixture should initialize");
3147
+ let backend = Arc::new(backend);
3148
+ let backend_ref: Arc<dyn crate::Backend + Send + Sync> = backend;
3149
+ let storage = StorageContext::new(Arc::clone(&backend_ref));
3150
+ let blob_reader: Arc<dyn BlobDataReader> =
3151
+ Arc::new(BackendBlobReader(storage.clone()));
3152
+ let ctx = BackendSqlExecutionContext {
3153
+ active_version_id: "version-a",
3154
+ storage: storage.clone(),
3155
+ blob_reader: Arc::clone(&blob_reader),
3156
+ live_state: Arc::new(test_live_state_context().reader(storage.clone())),
3157
+ schema_definitions: vec![schema_definition],
3158
+ };
3159
+
3160
+ let result = execute_sql(
3161
+ &ctx,
3162
+ "SELECT value, lixcol_entity_id \
3163
+ FROM test_state_schema",
3164
+ &[],
3165
+ )
3166
+ .await
3167
+ .expect("sql2 execute should read entity view");
3168
+
3169
+ assert_eq!(result.columns, vec!["value", "lixcol_entity_id"]);
3170
+ assert_eq!(result.rows.len(), 1);
3171
+ assert_eq!(result.rows[0][0], Value::Text("A".to_string()));
3172
+ assert_eq!(result.rows[0][1], Value::Text("entity-a".to_string()));
3173
+ })
3174
+ });
3175
+ }
3176
+
3177
+ #[test]
3178
+ fn execute_sql_reads_entity_by_version_view() {
3179
+ run_async_test_with_large_stack(|| {
3180
+ Box::pin(async move {
3181
+ let (backend, schema_definition) = setup_sql2_state_fixture()
3182
+ .await
3183
+ .expect("fixture should initialize");
3184
+ let backend = Arc::new(backend);
3185
+ let backend_ref: Arc<dyn crate::Backend + Send + Sync> = backend;
3186
+ let storage = StorageContext::new(Arc::clone(&backend_ref));
3187
+ let blob_reader: Arc<dyn BlobDataReader> =
3188
+ Arc::new(BackendBlobReader(storage.clone()));
3189
+ let ctx = BackendSqlExecutionContext {
3190
+ active_version_id: "version-a",
3191
+ storage: storage.clone(),
3192
+ blob_reader: Arc::clone(&blob_reader),
3193
+ live_state: Arc::new(test_live_state_context().reader(storage.clone())),
3194
+ schema_definitions: vec![schema_definition],
3195
+ };
3196
+
3197
+ let result = execute_sql(
3198
+ &ctx,
3199
+ "SELECT value, lixcol_version_id \
3200
+ FROM test_state_schema_by_version \
3201
+ WHERE lixcol_version_id = 'version-b'",
3202
+ &[],
3203
+ )
3204
+ .await
3205
+ .expect("sql2 execute should read entity by-version view");
3206
+
3207
+ assert_eq!(result.columns, vec!["value", "lixcol_version_id"]);
3208
+ assert_eq!(result.rows.len(), 1);
3209
+ assert_eq!(result.rows[0][0], Value::Text("B".to_string()));
3210
+ assert_eq!(result.rows[0][1], Value::Text("version-b".to_string()));
3211
+ })
3212
+ });
3213
+ }
3214
+
3215
+ #[test]
3216
+ fn execute_sql_reads_lix_directory_by_version_view() {
3217
+ run_async_test_with_large_stack(|| {
3218
+ Box::pin(async move {
3219
+ let (backend, schema_definition) = setup_sql2_state_fixture()
3220
+ .await
3221
+ .expect("fixture should initialize");
3222
+ let backend = Arc::new(backend);
3223
+ let backend_ref: Arc<dyn crate::Backend + Send + Sync> = backend;
3224
+ let storage = StorageContext::new(Arc::clone(&backend_ref));
3225
+ let blob_reader: Arc<dyn BlobDataReader> =
3226
+ Arc::new(BackendBlobReader(storage.clone()));
3227
+ let ctx = BackendSqlExecutionContext {
3228
+ active_version_id: "version-a",
3229
+ storage: storage.clone(),
3230
+ blob_reader: Arc::clone(&blob_reader),
3231
+ live_state: Arc::new(test_live_state_context().reader(storage.clone())),
3232
+ schema_definitions: vec![schema_definition],
3233
+ };
3234
+
3235
+ let result = execute_sql(
3236
+ &ctx,
3237
+ "SELECT path, name, lixcol_version_id \
3238
+ FROM lix_directory_by_version \
3239
+ WHERE id = 'dir-docs' AND lixcol_version_id = 'version-a'",
3240
+ &[],
3241
+ )
3242
+ .await
3243
+ .expect("sql2 execute should read lix_directory_by_version");
3244
+
3245
+ assert_eq!(result.columns, vec!["path", "name", "lixcol_version_id"]);
3246
+ assert_eq!(result.rows.len(), 1);
3247
+ assert_eq!(result.rows[0][0], Value::Text("/docs/".to_string()));
3248
+ assert_eq!(result.rows[0][1], Value::Text("docs".to_string()));
3249
+ assert_eq!(result.rows[0][2], Value::Text("version-a".to_string()));
3250
+ })
3251
+ });
3252
+ }
3253
+
3254
+ #[test]
3255
+ fn execute_sql_reads_lix_directory_from_active_version() {
3256
+ run_async_test_with_large_stack(|| {
3257
+ Box::pin(async move {
3258
+ let (backend, schema_definition) = setup_sql2_state_fixture()
3259
+ .await
3260
+ .expect("fixture should initialize");
3261
+ let backend = Arc::new(backend);
3262
+ let backend_ref: Arc<dyn crate::Backend + Send + Sync> = backend;
3263
+ let storage = StorageContext::new(Arc::clone(&backend_ref));
3264
+ let blob_reader: Arc<dyn BlobDataReader> =
3265
+ Arc::new(BackendBlobReader(storage.clone()));
3266
+ let ctx = BackendSqlExecutionContext {
3267
+ active_version_id: "version-a",
3268
+ storage: storage.clone(),
3269
+ blob_reader: Arc::clone(&blob_reader),
3270
+ live_state: Arc::new(test_live_state_context().reader(storage.clone())),
3271
+ schema_definitions: vec![schema_definition],
3272
+ };
3273
+
3274
+ let result = execute_sql(
3275
+ &ctx,
3276
+ "SELECT path, name \
3277
+ FROM lix_directory \
3278
+ WHERE id = 'dir-docs'",
3279
+ &[],
3280
+ )
3281
+ .await
3282
+ .expect("sql2 execute should read lix_directory");
3283
+
3284
+ assert_eq!(result.columns, vec!["path", "name"]);
3285
+ assert_eq!(result.rows.len(), 1);
3286
+ assert_eq!(result.rows[0][0], Value::Text("/docs/".to_string()));
3287
+ assert_eq!(result.rows[0][1], Value::Text("docs".to_string()));
3288
+ })
3289
+ });
3290
+ }
3291
+
3292
+ #[test]
3293
+ fn execute_sql_reads_lix_file_by_version_view() {
3294
+ run_async_test_with_large_stack(|| {
3295
+ Box::pin(async move {
3296
+ let (backend, schema_definition) = setup_sql2_state_fixture()
3297
+ .await
3298
+ .expect("fixture should initialize");
3299
+ let backend = Arc::new(backend);
3300
+ let backend_ref: Arc<dyn crate::Backend + Send + Sync> = backend;
3301
+ let storage = StorageContext::new(Arc::clone(&backend_ref));
3302
+ let blob_reader: Arc<dyn BlobDataReader> =
3303
+ Arc::new(BackendBlobReader(storage.clone()));
3304
+ let ctx = BackendSqlExecutionContext {
3305
+ active_version_id: "version-a",
3306
+ storage: storage.clone(),
3307
+ blob_reader: Arc::clone(&blob_reader),
3308
+ live_state: Arc::new(test_live_state_context().reader(storage.clone())),
3309
+ schema_definitions: vec![schema_definition],
3310
+ };
3311
+
3312
+ let result = execute_sql(
3313
+ &ctx,
3314
+ "SELECT path, name, data, lixcol_version_id \
3315
+ FROM lix_file_by_version \
3316
+ WHERE id = 'file-a' AND lixcol_version_id = 'version-a'",
3317
+ &[],
3318
+ )
3319
+ .await
3320
+ .expect("sql2 execute should read lix_file_by_version");
3321
+
3322
+ assert_eq!(
3323
+ result.columns,
3324
+ vec!["path", "name", "data", "lixcol_version_id"]
3325
+ );
3326
+ assert_eq!(result.rows.len(), 1);
3327
+ assert_eq!(
3328
+ result.rows[0][0],
3329
+ Value::Text("/docs/readme.md".to_string())
3330
+ );
3331
+ assert_eq!(result.rows[0][1], Value::Text("readme.md".to_string()));
3332
+ assert_eq!(result.rows[0][2], Value::Blob(vec![0x41, 0x42]));
3333
+ assert_eq!(result.rows[0][3], Value::Text("version-a".to_string()));
3334
+ })
3335
+ });
3336
+ }
3337
+
3338
+ #[test]
3339
+ fn execute_sql_reads_lix_file_from_active_version() {
3340
+ run_async_test_with_large_stack(|| {
3341
+ Box::pin(async move {
3342
+ let (backend, schema_definition) = setup_sql2_state_fixture()
3343
+ .await
3344
+ .expect("fixture should initialize");
3345
+ let backend = Arc::new(backend);
3346
+ let backend_ref: Arc<dyn crate::Backend + Send + Sync> = backend;
3347
+ let storage = StorageContext::new(Arc::clone(&backend_ref));
3348
+ let blob_reader: Arc<dyn BlobDataReader> =
3349
+ Arc::new(BackendBlobReader(storage.clone()));
3350
+ let ctx = BackendSqlExecutionContext {
3351
+ active_version_id: "version-a",
3352
+ storage: storage.clone(),
3353
+ blob_reader: Arc::clone(&blob_reader),
3354
+ live_state: Arc::new(test_live_state_context().reader(storage.clone())),
3355
+ schema_definitions: vec![schema_definition],
3356
+ };
3357
+
3358
+ let result = execute_sql(
3359
+ &ctx,
3360
+ "SELECT path, name, data \
3361
+ FROM lix_file \
3362
+ WHERE id = 'file-a'",
3363
+ &[],
3364
+ )
3365
+ .await
3366
+ .expect("sql2 execute should read lix_file");
3367
+
3368
+ assert_eq!(result.columns, vec!["path", "name", "data"]);
3369
+ assert_eq!(result.rows.len(), 1);
3370
+ assert_eq!(
3371
+ result.rows[0][0],
3372
+ Value::Text("/docs/readme.md".to_string())
3373
+ );
3374
+ assert_eq!(result.rows[0][1], Value::Text("readme.md".to_string()));
3375
+ assert_eq!(result.rows[0][2], Value::Blob(vec![0x41, 0x42]));
3376
+ })
3377
+ });
3378
+ }
3379
+ }