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

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