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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/README.md +1 -1
  2. package/SKILL.md +65 -64
  3. package/dist/engine-wasm/index.js +4 -4
  4. package/dist/engine-wasm/wasm/lix_engine.d.ts +5 -5
  5. package/dist/engine-wasm/wasm/lix_engine.js +130 -118
  6. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  7. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +9 -8
  8. package/dist/generated/builtin-schemas.d.ts +69 -69
  9. package/dist/generated/builtin-schemas.js +94 -94
  10. package/dist/open-lix.d.ts +33 -26
  11. package/dist/open-lix.js +10 -10
  12. package/dist/sqlite/index.js +86 -30
  13. package/dist-engine-src/README.md +3 -3
  14. package/dist-engine-src/src/backend/capabilities.rs +67 -0
  15. package/dist-engine-src/src/backend/conformance/baseline.rs +1127 -0
  16. package/dist-engine-src/src/backend/conformance/factory.rs +93 -0
  17. package/dist-engine-src/src/backend/conformance/failure_tests.rs +608 -0
  18. package/dist-engine-src/src/backend/conformance/fixtures.rs +26 -0
  19. package/dist-engine-src/src/backend/conformance/mod.rs +75 -0
  20. package/dist-engine-src/src/backend/conformance/model.rs +28 -0
  21. package/dist-engine-src/src/backend/conformance/model_based.rs +257 -0
  22. package/dist-engine-src/src/backend/conformance/persistence.rs +204 -0
  23. package/dist-engine-src/src/backend/conformance/projection.rs +21 -0
  24. package/dist-engine-src/src/backend/conformance/pushdown.rs +24 -0
  25. package/dist-engine-src/src/backend/conformance/runner.rs +90 -0
  26. package/dist-engine-src/src/backend/conformance/scan.rs +24 -0
  27. package/dist-engine-src/src/backend/conformance/write.rs +16 -0
  28. package/dist-engine-src/src/backend/error.rs +94 -0
  29. package/dist-engine-src/src/backend/in_memory.rs +670 -0
  30. package/dist-engine-src/src/backend/mod.rs +36 -9
  31. package/dist-engine-src/src/backend/predicate.rs +80 -0
  32. package/dist-engine-src/src/backend/traits.rs +260 -0
  33. package/dist-engine-src/src/backend/types.rs +224 -81
  34. package/dist-engine-src/src/binary_cas/context.rs +8 -8
  35. package/dist-engine-src/src/binary_cas/kv.rs +234 -259
  36. package/dist-engine-src/src/{version → branch}/context.rs +12 -12
  37. package/dist-engine-src/src/branch/lifecycle.rs +221 -0
  38. package/dist-engine-src/src/branch/mod.rs +13 -0
  39. package/dist-engine-src/src/branch/refs.rs +321 -0
  40. package/dist-engine-src/src/branch/stage_rows.rs +67 -0
  41. package/dist-engine-src/src/branch/types.rs +21 -0
  42. package/dist-engine-src/src/catalog/context.rs +18 -18
  43. package/dist-engine-src/src/catalog/snapshot.rs +8 -8
  44. package/dist-engine-src/src/changelog/bench_support.rs +785 -0
  45. package/dist-engine-src/src/changelog/change.rs +1 -0
  46. package/dist-engine-src/src/changelog/codec.rs +497 -0
  47. package/dist-engine-src/src/changelog/commit.rs +1 -0
  48. package/dist-engine-src/src/changelog/context.rs +1614 -0
  49. package/dist-engine-src/src/changelog/mod.rs +29 -0
  50. package/dist-engine-src/src/changelog/store.rs +163 -0
  51. package/dist-engine-src/src/changelog/test_support.rs +54 -0
  52. package/dist-engine-src/src/changelog/types.rs +213 -0
  53. package/dist-engine-src/src/commit_graph/context.rs +317 -274
  54. package/dist-engine-src/src/commit_graph/mod.rs +2 -4
  55. package/dist-engine-src/src/commit_graph/types.rs +22 -42
  56. package/dist-engine-src/src/commit_graph/walker.rs +133 -103
  57. package/dist-engine-src/src/common/error.rs +52 -18
  58. package/dist-engine-src/src/common/identity.rs +2 -2
  59. package/dist-engine-src/src/common/mod.rs +1 -1
  60. package/dist-engine-src/src/domain.rs +42 -46
  61. package/dist-engine-src/src/engine.rs +74 -96
  62. package/dist-engine-src/src/{entity_identity.rs → entity_pk.rs} +89 -92
  63. package/dist-engine-src/src/functions/context.rs +56 -52
  64. package/dist-engine-src/src/functions/state.rs +51 -52
  65. package/dist-engine-src/src/init.rs +288 -154
  66. package/dist-engine-src/src/json_store/context.rs +15 -266
  67. package/dist-engine-src/src/json_store/mod.rs +26 -0
  68. package/dist-engine-src/src/json_store/store.rs +103 -718
  69. package/dist-engine-src/src/json_store/types.rs +4 -9
  70. package/dist-engine-src/src/lib.rs +49 -19
  71. package/dist-engine-src/src/live_state/context.rs +654 -790
  72. package/dist-engine-src/src/live_state/mod.rs +9 -3
  73. package/dist-engine-src/src/live_state/overlay.rs +4 -4
  74. package/dist-engine-src/src/live_state/types.rs +30 -21
  75. package/dist-engine-src/src/live_state/visibility.rs +514 -71
  76. package/dist-engine-src/src/plugin/install.rs +48 -48
  77. package/dist-engine-src/src/plugin/manifest.rs +7 -7
  78. package/dist-engine-src/src/plugin/materializer.rs +0 -275
  79. package/dist-engine-src/src/plugin/plugin_manifest.json +4 -3
  80. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +2 -2
  81. package/dist-engine-src/src/schema/builtin/lix_branch_descriptor.json +34 -0
  82. package/dist-engine-src/src/schema/builtin/lix_branch_ref.json +48 -0
  83. package/dist-engine-src/src/schema/builtin/lix_change.json +3 -3
  84. package/dist-engine-src/src/schema/builtin/lix_commit.json +1 -1
  85. package/dist-engine-src/src/schema/builtin/lix_label_assignment.json +6 -6
  86. package/dist-engine-src/src/schema/builtin/mod.rs +18 -20
  87. package/dist-engine-src/src/schema/compatibility.rs +11 -11
  88. package/dist-engine-src/src/schema/definition.json +2 -2
  89. package/dist-engine-src/src/schema/definition.rs +5 -5
  90. package/dist-engine-src/src/schema/key.rs +3 -3
  91. package/dist-engine-src/src/schema/mod.rs +1 -1
  92. package/dist-engine-src/src/schema/tests.rs +18 -18
  93. package/dist-engine-src/src/session/context.rs +803 -148
  94. package/dist-engine-src/src/session/create_branch.rs +94 -0
  95. package/dist-engine-src/src/session/execute.rs +223 -83
  96. package/dist-engine-src/src/session/merge/analysis.rs +9 -3
  97. package/dist-engine-src/src/session/merge/{version.rs → branch.rs} +119 -129
  98. package/dist-engine-src/src/session/merge/conflicts.rs +2 -2
  99. package/dist-engine-src/src/session/merge/mod.rs +5 -6
  100. package/dist-engine-src/src/session/merge/stats.rs +7 -11
  101. package/dist-engine-src/src/session/mod.rs +15 -12
  102. package/dist-engine-src/src/session/switch_branch.rs +113 -0
  103. package/dist-engine-src/src/session/transaction.rs +495 -14
  104. package/dist-engine-src/src/sql2/{classify.rs → bind/classify.rs} +3 -75
  105. package/dist-engine-src/src/sql2/bind/error.rs +5 -0
  106. package/dist-engine-src/src/sql2/bind/expr.rs +29 -0
  107. package/dist-engine-src/src/sql2/bind/mod.rs +12 -0
  108. package/dist-engine-src/src/sql2/{udfs/public_call.rs → bind/public_udf.rs} +71 -3
  109. package/dist-engine-src/src/sql2/bind/read.rs +65 -0
  110. package/dist-engine-src/src/sql2/bind/statement.rs +2236 -0
  111. package/dist-engine-src/src/sql2/bind/table.rs +273 -0
  112. package/dist-engine-src/src/sql2/bind/write.rs +86 -0
  113. package/dist-engine-src/src/sql2/branch_scope.rs +436 -0
  114. package/dist-engine-src/src/sql2/catalog/capability.rs +20 -0
  115. package/dist-engine-src/src/sql2/catalog/entity_surface.rs +296 -0
  116. package/dist-engine-src/src/sql2/catalog/mod.rs +15 -0
  117. package/dist-engine-src/src/sql2/catalog/registry.rs +556 -0
  118. package/dist-engine-src/src/sql2/catalog/schema.rs +88 -0
  119. package/dist-engine-src/src/sql2/catalog/surface.rs +41 -0
  120. package/dist-engine-src/src/sql2/change_materialization.rs +122 -0
  121. package/dist-engine-src/src/sql2/context.rs +36 -30
  122. package/dist-engine-src/src/sql2/error.rs +1 -1
  123. package/dist-engine-src/src/sql2/exec/bound_public_write.rs +1593 -0
  124. package/dist-engine-src/src/sql2/exec/datafusion.rs +5266 -0
  125. package/dist-engine-src/src/sql2/exec/fast_write.rs +82 -0
  126. package/dist-engine-src/src/sql2/exec/mod.rs +24 -0
  127. package/dist-engine-src/src/sql2/exec/write.rs +661 -0
  128. package/dist-engine-src/src/sql2/filesystem_planner.rs +72 -77
  129. package/dist-engine-src/src/sql2/filesystem_visibility.rs +21 -21
  130. package/dist-engine-src/src/sql2/history_projection.rs +8 -8
  131. package/dist-engine-src/src/sql2/history_route.rs +35 -31
  132. package/dist-engine-src/src/sql2/mod.rs +28 -23
  133. package/dist-engine-src/src/sql2/optimize/datafusion.rs +1 -0
  134. package/dist-engine-src/src/sql2/optimize/mod.rs +2 -0
  135. package/dist-engine-src/src/sql2/optimize/simple_write.rs +116 -0
  136. package/dist-engine-src/src/sql2/parse/mod.rs +69 -0
  137. package/dist-engine-src/src/sql2/parse/normalize.rs +1 -0
  138. package/dist-engine-src/src/sql2/plan/branch_scope.rs +24 -0
  139. package/dist-engine-src/src/sql2/plan/mod.rs +5 -0
  140. package/dist-engine-src/src/sql2/plan/predicate.rs +22 -0
  141. package/dist-engine-src/src/sql2/plan/write.rs +147 -0
  142. package/dist-engine-src/src/sql2/predicate_typecheck.rs +258 -0
  143. package/dist-engine-src/src/sql2/{version_provider.rs → providers/branch.rs} +218 -214
  144. package/dist-engine-src/src/sql2/{change_provider.rs → providers/change.rs} +156 -42
  145. package/dist-engine-src/src/sql2/{directory_provider.rs → providers/directory.rs} +291 -322
  146. package/dist-engine-src/src/sql2/{directory_history_provider.rs → providers/directory_history.rs} +56 -42
  147. package/dist-engine-src/src/sql2/providers/entity.rs +1484 -0
  148. package/dist-engine-src/src/sql2/{entity_history_provider.rs → providers/entity_history.rs} +43 -31
  149. package/dist-engine-src/src/sql2/{file_provider.rs → providers/file.rs} +323 -316
  150. package/dist-engine-src/src/sql2/{file_history_provider.rs → providers/file_history.rs} +60 -46
  151. package/dist-engine-src/src/sql2/{history_provider.rs → providers/history.rs} +46 -32
  152. package/dist-engine-src/src/sql2/{lix_state_provider.rs → providers/lix_state.rs} +359 -329
  153. package/dist-engine-src/src/sql2/providers/mod.rs +508 -0
  154. package/dist-engine-src/src/sql2/read_only.rs +2 -2
  155. package/dist-engine-src/src/sql2/session.rs +47 -96
  156. package/dist-engine-src/src/sql2/storage/constraints.rs +1 -0
  157. package/dist-engine-src/src/sql2/storage/mod.rs +1 -0
  158. package/dist-engine-src/src/sql2/test_support/differential.rs +712 -0
  159. package/dist-engine-src/src/sql2/test_support/generators.rs +354 -0
  160. package/dist-engine-src/src/sql2/test_support/mod.rs +2 -0
  161. package/dist-engine-src/src/sql2/udfs/{lix_active_version_commit_id.rs → lix_active_branch_commit_id.rs} +7 -7
  162. package/dist-engine-src/src/sql2/udfs/mod.rs +3 -6
  163. package/dist-engine-src/src/sql2/write_normalization.rs +45 -22
  164. package/dist-engine-src/src/storage/conformance.rs +399 -0
  165. package/dist-engine-src/src/storage/context.rs +552 -288
  166. package/dist-engine-src/src/storage/mod.rs +48 -10
  167. package/dist-engine-src/src/storage/point.rs +440 -0
  168. package/dist-engine-src/src/storage/read_scope.rs +43 -64
  169. package/dist-engine-src/src/storage/reader.rs +867 -0
  170. package/dist-engine-src/src/storage/scan.rs +784 -0
  171. package/dist-engine-src/src/storage/spaces.rs +236 -0
  172. package/dist-engine-src/src/storage/stats.rs +80 -0
  173. package/dist-engine-src/src/storage/write_set.rs +962 -0
  174. package/dist-engine-src/src/storage_bench.rs +136 -4828
  175. package/dist-engine-src/src/test_support.rs +360 -138
  176. package/dist-engine-src/src/tracked_state/bench_support.rs +394 -0
  177. package/dist-engine-src/src/tracked_state/codec.rs +155 -1057
  178. package/dist-engine-src/src/tracked_state/commit_root_rebuild.rs +358 -0
  179. package/dist-engine-src/src/tracked_state/context.rs +1927 -993
  180. package/dist-engine-src/src/tracked_state/diff.rs +1715 -261
  181. package/dist-engine-src/src/tracked_state/merge.rs +74 -88
  182. package/dist-engine-src/src/tracked_state/mod.rs +19 -16
  183. package/dist-engine-src/src/tracked_state/{materialization.rs → row_materialization.rs} +50 -178
  184. package/dist-engine-src/src/tracked_state/storage.rs +243 -191
  185. package/dist-engine-src/src/tracked_state/tree.rs +247 -371
  186. package/dist-engine-src/src/tracked_state/types.rs +49 -42
  187. package/dist-engine-src/src/transaction/bench_support.rs +407 -0
  188. package/dist-engine-src/src/transaction/commit.rs +821 -713
  189. package/dist-engine-src/src/transaction/context.rs +705 -600
  190. package/dist-engine-src/src/transaction/mod.rs +13 -2
  191. package/dist-engine-src/src/transaction/normalization.rs +63 -76
  192. package/dist-engine-src/src/transaction/prep.rs +13 -13
  193. package/dist-engine-src/src/transaction/schema_resolver.rs +19 -5
  194. package/dist-engine-src/src/transaction/staging.rs +228 -434
  195. package/dist-engine-src/src/transaction/types.rs +41 -98
  196. package/dist-engine-src/src/transaction/validation.rs +382 -446
  197. package/dist-engine-src/src/untracked_state/codec.rs +337 -29
  198. package/dist-engine-src/src/untracked_state/context.rs +7 -7
  199. package/dist-engine-src/src/untracked_state/materialization.rs +2 -2
  200. package/dist-engine-src/src/untracked_state/mod.rs +1 -1
  201. package/dist-engine-src/src/untracked_state/storage.rs +659 -157
  202. package/dist-engine-src/src/untracked_state/types.rs +21 -21
  203. package/package.json +71 -68
  204. package/dist-engine-src/src/backend/kv.rs +0 -358
  205. package/dist-engine-src/src/backend/testing.rs +0 -658
  206. package/dist-engine-src/src/commit_store/codec.rs +0 -887
  207. package/dist-engine-src/src/commit_store/context.rs +0 -944
  208. package/dist-engine-src/src/commit_store/materialization.rs +0 -84
  209. package/dist-engine-src/src/commit_store/mod.rs +0 -16
  210. package/dist-engine-src/src/commit_store/storage.rs +0 -600
  211. package/dist-engine-src/src/commit_store/types.rs +0 -215
  212. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +0 -34
  213. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +0 -48
  214. package/dist-engine-src/src/session/create_version.rs +0 -88
  215. package/dist-engine-src/src/session/merge/apply.rs +0 -23
  216. package/dist-engine-src/src/session/optimization9_sql2_bench.rs +0 -100
  217. package/dist-engine-src/src/session/switch_version.rs +0 -110
  218. package/dist-engine-src/src/sql2/entity_provider.rs +0 -3211
  219. package/dist-engine-src/src/sql2/execute.rs +0 -3533
  220. package/dist-engine-src/src/sql2/public_bind/assignment.rs +0 -46
  221. package/dist-engine-src/src/sql2/public_bind/capability.rs +0 -41
  222. package/dist-engine-src/src/sql2/public_bind/dml.rs +0 -172
  223. package/dist-engine-src/src/sql2/public_bind/mod.rs +0 -26
  224. package/dist-engine-src/src/sql2/public_bind/table.rs +0 -168
  225. package/dist-engine-src/src/sql2/version_scope.rs +0 -394
  226. package/dist-engine-src/src/storage/types.rs +0 -501
  227. package/dist-engine-src/src/tracked_state/by_file_index.rs +0 -98
  228. package/dist-engine-src/src/tracked_state/materializer.rs +0 -488
  229. package/dist-engine-src/src/transaction/live_state_overlay.rs +0 -35
  230. package/dist-engine-src/src/version/lifecycle.rs +0 -221
  231. package/dist-engine-src/src/version/mod.rs +0 -13
  232. package/dist-engine-src/src/version/refs.rs +0 -330
  233. package/dist-engine-src/src/version/stage_rows.rs +0 -67
  234. package/dist-engine-src/src/version/types.rs +0 -21
@@ -0,0 +1,1593 @@
1
+ use serde_json::Value as JsonValue;
2
+
3
+ use crate::common::validate_row_metadata;
4
+ use crate::entity_pk::EntityPk;
5
+ use crate::live_state::{LiveStateFilter, LiveStateScanRequest};
6
+ use crate::sql2::bind::expr::{BoundExpr, BoundLiteral};
7
+ use crate::sql2::bind::write::{
8
+ BoundInsertValues, BoundWriteInput, BoundWriteOp, BoundWriteTarget, EntityWriteSurface,
9
+ };
10
+ use crate::sql2::catalog::entity_surface::EntitySurfaceColumn;
11
+ use crate::sql2::catalog::{
12
+ derive_entity_surface_spec_from_schema, EntityColumnType, EntitySurfaceSpec,
13
+ };
14
+ use crate::sql2::plan::branch_scope::BranchScope;
15
+ use crate::sql2::plan::predicate::FilterSet;
16
+ use crate::sql2::plan::LogicalWritePlan;
17
+ use crate::sql2::read_only::reject_read_only_entity_surface;
18
+ use crate::sql2::SqlWriteExecutionContext;
19
+ use crate::transaction::types::{
20
+ TransactionJson, TransactionWrite, TransactionWriteMode, TransactionWriteRow,
21
+ };
22
+ use crate::{parse_row_metadata_value, LixError, Value};
23
+
24
+ pub(crate) fn supports_bound_public_write(plan: &LogicalWritePlan) -> bool {
25
+ matches!(plan.bound.target, BoundWriteTarget::Entity(_))
26
+ && bound_public_write_shape_supported(plan)
27
+ }
28
+
29
+ pub(crate) async fn execute_bound_public_write(
30
+ ctx: &mut dyn SqlWriteExecutionContext,
31
+ plan: &LogicalWritePlan,
32
+ params: &[Value],
33
+ ) -> Result<u64, LixError> {
34
+ match &plan.bound.target {
35
+ BoundWriteTarget::Entity(surface) => execute_entity_write(ctx, plan, surface, params).await,
36
+ _ => Err(LixError::new(
37
+ LixError::CODE_UNSUPPORTED_SQL,
38
+ "bound public write executor does not support this target yet",
39
+ )),
40
+ }
41
+ }
42
+
43
+ async fn execute_entity_write(
44
+ ctx: &mut dyn SqlWriteExecutionContext,
45
+ plan: &LogicalWritePlan,
46
+ surface: &EntityWriteSurface,
47
+ params: &[Value],
48
+ ) -> Result<u64, LixError> {
49
+ let schema_key = match surface {
50
+ EntityWriteSurface::Base { schema_key } | EntityWriteSurface::ByBranch { schema_key } => {
51
+ schema_key
52
+ }
53
+ };
54
+ reject_read_only_entity_surface(schema_key, entity_action(&plan.bound.op))
55
+ .map_err(crate::sql2::error::datafusion_error_to_lix_error)?;
56
+
57
+ if schema_key == "lix_registered_schema" && plan.bound.op == BoundWriteOp::Delete {
58
+ return Err(LixError::new(
59
+ LixError::CODE_UNSUPPORTED_SQL,
60
+ "delete lix_registered_schema is not supported",
61
+ ));
62
+ }
63
+
64
+ let spec = entity_spec(ctx, schema_key)?;
65
+ validate_bound_write_supported(plan, &spec)?;
66
+ let active_branch_commit_id = load_active_branch_commit_id(ctx).await?;
67
+ let no_op = matches!(plan.bound.branch_scope, BranchScope::Empty)
68
+ || matches!(plan.filters.rows, FilterSet::None);
69
+ match plan.bound.op {
70
+ BoundWriteOp::Insert => {
71
+ if no_op {
72
+ entity_insert_rows(ctx, plan, &spec, params, active_branch_commit_id.as_deref())?;
73
+ return Ok(0);
74
+ }
75
+ entity_insert(ctx, plan, &spec, params, active_branch_commit_id.as_deref()).await
76
+ }
77
+ BoundWriteOp::Update => {
78
+ if no_op {
79
+ return Ok(0);
80
+ }
81
+ entity_update(ctx, plan, &spec, params, active_branch_commit_id.as_deref()).await
82
+ }
83
+ BoundWriteOp::Delete => {
84
+ if no_op {
85
+ return Ok(0);
86
+ }
87
+ entity_delete(ctx, plan, &spec, params, active_branch_commit_id.as_deref()).await
88
+ }
89
+ }
90
+ }
91
+
92
+ async fn load_active_branch_commit_id(
93
+ ctx: &mut dyn SqlWriteExecutionContext,
94
+ ) -> Result<Option<String>, LixError> {
95
+ let active_branch_id = ctx.active_branch_id().to_string();
96
+ ctx.load_branch_head(&active_branch_id)
97
+ .await?
98
+ .map(Some)
99
+ .ok_or_else(|| {
100
+ LixError::branch_not_found(
101
+ active_branch_id,
102
+ "execute bound public write",
103
+ "active branch",
104
+ )
105
+ })
106
+ }
107
+
108
+ async fn entity_insert(
109
+ ctx: &mut dyn SqlWriteExecutionContext,
110
+ plan: &LogicalWritePlan,
111
+ spec: &EntitySurfaceSpec,
112
+ params: &[Value],
113
+ active_branch_commit_id: Option<&str>,
114
+ ) -> Result<u64, LixError> {
115
+ let write_rows = entity_insert_rows(ctx, plan, spec, params, active_branch_commit_id)?;
116
+ stage_rows(ctx, TransactionWriteMode::Insert, write_rows).await
117
+ }
118
+
119
+ fn entity_insert_rows(
120
+ ctx: &mut dyn SqlWriteExecutionContext,
121
+ plan: &LogicalWritePlan,
122
+ spec: &EntitySurfaceSpec,
123
+ params: &[Value],
124
+ active_branch_commit_id: Option<&str>,
125
+ ) -> Result<Vec<TransactionWriteRow>, LixError> {
126
+ let BoundWriteInput::Values(values) = &plan.bound.input else {
127
+ return Err(LixError::new(
128
+ LixError::CODE_UNSUPPORTED_SQL,
129
+ "bound entity INSERT supports VALUES only",
130
+ ));
131
+ };
132
+
133
+ let layout = InsertRowLayout::from_values(spec, values)?;
134
+ let mut write_rows = Vec::with_capacity(values.rows.len());
135
+ for row in &values.rows {
136
+ write_rows.push(entity_insert_row(
137
+ ctx,
138
+ plan,
139
+ &layout,
140
+ row,
141
+ params,
142
+ active_branch_commit_id,
143
+ )?);
144
+ }
145
+ Ok(write_rows)
146
+ }
147
+
148
+ async fn entity_update(
149
+ ctx: &mut dyn SqlWriteExecutionContext,
150
+ plan: &LogicalWritePlan,
151
+ spec: &EntitySurfaceSpec,
152
+ params: &[Value],
153
+ active_branch_commit_id: Option<&str>,
154
+ ) -> Result<u64, LixError> {
155
+ let candidates = scan_entity_candidates(ctx, plan, spec).await?;
156
+ let mut write_rows = Vec::new();
157
+ for candidate in candidates {
158
+ let Some(snapshot) = candidate_snapshot(&candidate)? else {
159
+ continue;
160
+ };
161
+ let original_context = EntityEvalContext::live(&snapshot, &candidate, spec);
162
+ if !predicate_matches(
163
+ &plan.bound.predicate,
164
+ &original_context,
165
+ spec,
166
+ ctx,
167
+ params,
168
+ active_branch_commit_id,
169
+ )? {
170
+ continue;
171
+ }
172
+ reject_projected_global_write(plan, &candidate, "UPDATE")?;
173
+ let mut updated = snapshot.clone();
174
+ let mut visible_assignments = Vec::new();
175
+ for assignment in &plan.bound.assignments {
176
+ if let Some(column) = spec.visible_column(&assignment.column.name) {
177
+ reject_direct_blob_json_value(&assignment.value, column.column_type, params)?;
178
+ let value = eval_expr_value(
179
+ &assignment.value,
180
+ &original_context,
181
+ ctx,
182
+ params,
183
+ active_branch_commit_id,
184
+ )?;
185
+ visible_assignments.push((
186
+ column.name.clone(),
187
+ entity_json_value(value, column.column_type)?,
188
+ ));
189
+ } else if assignment.column.name == "lixcol_metadata" {
190
+ // handled below from the assignment list
191
+ } else {
192
+ return Err(LixError::new(
193
+ LixError::CODE_UNSUPPORTED_SQL,
194
+ format!(
195
+ "bound entity UPDATE does not support assignment to '{}'",
196
+ assignment.column.name
197
+ ),
198
+ ));
199
+ }
200
+ }
201
+ for (column_name, value) in visible_assignments {
202
+ updated[&column_name] = value;
203
+ }
204
+ write_rows.push(entity_replace_row_from_live(
205
+ ctx,
206
+ spec,
207
+ &candidate,
208
+ Some(updated),
209
+ plan,
210
+ params,
211
+ active_branch_commit_id,
212
+ )?);
213
+ }
214
+ stage_rows(ctx, TransactionWriteMode::Replace, write_rows).await
215
+ }
216
+
217
+ async fn entity_delete(
218
+ ctx: &mut dyn SqlWriteExecutionContext,
219
+ plan: &LogicalWritePlan,
220
+ spec: &EntitySurfaceSpec,
221
+ params: &[Value],
222
+ active_branch_commit_id: Option<&str>,
223
+ ) -> Result<u64, LixError> {
224
+ let candidates = scan_entity_candidates(ctx, plan, spec).await?;
225
+ let mut write_rows = Vec::new();
226
+ for candidate in candidates {
227
+ let Some(snapshot) = candidate_snapshot(&candidate)? else {
228
+ continue;
229
+ };
230
+ let context = EntityEvalContext::live(&snapshot, &candidate, spec);
231
+ if predicate_matches(
232
+ &plan.bound.predicate,
233
+ &context,
234
+ spec,
235
+ ctx,
236
+ params,
237
+ active_branch_commit_id,
238
+ )? {
239
+ reject_projected_global_write(plan, &candidate, "DELETE")?;
240
+ write_rows.push(entity_replace_row_from_live(
241
+ ctx,
242
+ spec,
243
+ &candidate,
244
+ None,
245
+ plan,
246
+ params,
247
+ active_branch_commit_id,
248
+ )?);
249
+ }
250
+ }
251
+ stage_rows(ctx, TransactionWriteMode::Replace, write_rows).await
252
+ }
253
+
254
+ async fn stage_rows(
255
+ ctx: &mut dyn SqlWriteExecutionContext,
256
+ mode: TransactionWriteMode,
257
+ rows: Vec<TransactionWriteRow>,
258
+ ) -> Result<u64, LixError> {
259
+ if rows.is_empty() {
260
+ return Ok(0);
261
+ }
262
+ let outcome = ctx
263
+ .stage_write(TransactionWrite::Rows { mode, rows })
264
+ .await?;
265
+ Ok(outcome.count)
266
+ }
267
+
268
+ async fn scan_entity_candidates(
269
+ ctx: &mut dyn SqlWriteExecutionContext,
270
+ plan: &LogicalWritePlan,
271
+ spec: &EntitySurfaceSpec,
272
+ ) -> Result<Vec<crate::live_state::MaterializedLiveStateRow>, LixError> {
273
+ let branch_ids = scan_branch_ids(&plan.bound.branch_scope)?;
274
+ let request = LiveStateScanRequest {
275
+ filter: LiveStateFilter {
276
+ schema_keys: vec![spec.schema_key.clone()],
277
+ branch_ids,
278
+ include_tombstones: false,
279
+ ..LiveStateFilter::default()
280
+ },
281
+ ..LiveStateScanRequest::default()
282
+ };
283
+ ctx.scan_live_state(&request).await
284
+ }
285
+
286
+ struct InsertRowLayout {
287
+ schema_key: String,
288
+ visible_columns: Vec<EntitySurfaceColumn>,
289
+ snapshot_context: String,
290
+ snapshot_capacity: usize,
291
+ columns: Vec<InsertColumnTarget>,
292
+ }
293
+
294
+ #[derive(Clone)]
295
+ enum InsertColumnTarget {
296
+ Visible {
297
+ name: String,
298
+ column_type: EntityColumnType,
299
+ },
300
+ EntityPk,
301
+ FileId,
302
+ Metadata,
303
+ Global,
304
+ Untracked,
305
+ BranchId,
306
+ }
307
+
308
+ impl InsertRowLayout {
309
+ fn from_values(spec: &EntitySurfaceSpec, values: &BoundInsertValues) -> Result<Self, LixError> {
310
+ let mut snapshot_capacity = 0;
311
+ let mut seen_columns = std::collections::BTreeSet::new();
312
+ let columns = values
313
+ .columns
314
+ .iter()
315
+ .map(|column| {
316
+ if !seen_columns.insert(column.name.clone()) {
317
+ return Err(LixError::new(
318
+ LixError::CODE_UNSUPPORTED_SQL,
319
+ format!("duplicate entity INSERT column '{}'", column.name),
320
+ ));
321
+ }
322
+ if let Some(surface_column) = spec.visible_column(&column.name) {
323
+ snapshot_capacity += 1;
324
+ return Ok(InsertColumnTarget::Visible {
325
+ name: surface_column.name.clone(),
326
+ column_type: surface_column.column_type,
327
+ });
328
+ }
329
+ Ok(match column.name.as_str() {
330
+ "lixcol_entity_pk" => InsertColumnTarget::EntityPk,
331
+ "lixcol_file_id" => InsertColumnTarget::FileId,
332
+ "lixcol_metadata" => InsertColumnTarget::Metadata,
333
+ "lixcol_global" => InsertColumnTarget::Global,
334
+ "lixcol_untracked" => InsertColumnTarget::Untracked,
335
+ "lixcol_branch_id" => InsertColumnTarget::BranchId,
336
+ _ => {
337
+ return Err(LixError::new(
338
+ LixError::CODE_UNSUPPORTED_SQL,
339
+ format!(
340
+ "bound entity INSERT does not support column '{}'",
341
+ column.name
342
+ ),
343
+ ));
344
+ }
345
+ })
346
+ })
347
+ .collect::<Result<Vec<_>, LixError>>()?;
348
+ Ok(Self {
349
+ schema_key: spec.schema_key.clone(),
350
+ visible_columns: spec.columns.clone(),
351
+ snapshot_context: format!("{} insert snapshot_content", spec.schema_key),
352
+ snapshot_capacity,
353
+ columns,
354
+ })
355
+ }
356
+ }
357
+
358
+ fn entity_insert_row(
359
+ ctx: &mut dyn SqlWriteExecutionContext,
360
+ plan: &LogicalWritePlan,
361
+ layout: &InsertRowLayout,
362
+ row: &[BoundExpr],
363
+ params: &[Value],
364
+ active_branch_commit_id: Option<&str>,
365
+ ) -> Result<TransactionWriteRow, LixError> {
366
+ if row.len() != layout.columns.len() {
367
+ return Err(LixError::new(
368
+ LixError::CODE_UNSUPPORTED_SQL,
369
+ "entity INSERT rows must have a consistent column layout",
370
+ ));
371
+ }
372
+
373
+ let mut snapshot = serde_json::Map::with_capacity(layout.snapshot_capacity);
374
+ let mut entity_pk = None;
375
+ let mut file_id = None;
376
+ let mut metadata = None;
377
+ let mut global = None;
378
+ let mut untracked = None;
379
+ let mut explicit_branch_id = None;
380
+ let context = EntityEvalContext::insert(&JsonValue::Null, &layout.visible_columns);
381
+
382
+ for (expr, target) in row.iter().zip(layout.columns.iter()) {
383
+ if let InsertColumnTarget::Visible { column_type, .. } = target {
384
+ reject_direct_blob_json_value(expr, *column_type, params)?;
385
+ }
386
+ let eval_value = eval_expr_value(expr, &context, ctx, params, active_branch_commit_id)?;
387
+ if matches!(target, InsertColumnTarget::Metadata) {
388
+ metadata = optional_metadata_from_eval_value(
389
+ eval_value,
390
+ "lixcol_metadata",
391
+ &layout.schema_key,
392
+ )?;
393
+ continue;
394
+ }
395
+ match target {
396
+ InsertColumnTarget::Visible { name, column_type } => {
397
+ snapshot.insert(name.clone(), entity_json_value(eval_value, *column_type)?);
398
+ continue;
399
+ }
400
+ _ => {}
401
+ }
402
+ let value = eval_value.into_json();
403
+ match target {
404
+ InsertColumnTarget::Visible { .. } => unreachable!("visible columns handled above"),
405
+ InsertColumnTarget::EntityPk => {
406
+ entity_pk = Some(entity_pk_from_value(&value, "lixcol_entity_pk")?);
407
+ }
408
+ InsertColumnTarget::FileId => {
409
+ file_id = text_value(value, "lixcol_file_id")?;
410
+ }
411
+ InsertColumnTarget::Metadata => {
412
+ unreachable!("metadata handled before JSON value coercion")
413
+ }
414
+ InsertColumnTarget::Global => {
415
+ global = bool_value(value, "lixcol_global")?;
416
+ }
417
+ InsertColumnTarget::Untracked => {
418
+ untracked = bool_value(value, "lixcol_untracked")?;
419
+ }
420
+ InsertColumnTarget::BranchId => {
421
+ explicit_branch_id = text_value(value, "lixcol_branch_id")?;
422
+ }
423
+ }
424
+ }
425
+
426
+ let snapshot = JsonValue::Object(snapshot);
427
+ let global = global.unwrap_or(false);
428
+ let branch_id = entity_row_branch_id(plan, explicit_branch_id, global)?;
429
+ Ok(TransactionWriteRow {
430
+ entity_pk,
431
+ schema_key: layout.schema_key.clone(),
432
+ file_id,
433
+ snapshot: Some(TransactionJson::from_value(
434
+ snapshot,
435
+ &layout.snapshot_context,
436
+ )?),
437
+ metadata,
438
+ origin: None,
439
+ created_at: None,
440
+ updated_at: None,
441
+ global,
442
+ change_id: None,
443
+ commit_id: None,
444
+ untracked: untracked.unwrap_or(false),
445
+ branch_id,
446
+ })
447
+ }
448
+
449
+ fn reject_projected_global_write(
450
+ plan: &LogicalWritePlan,
451
+ row: &crate::live_state::MaterializedLiveStateRow,
452
+ action: &str,
453
+ ) -> Result<(), LixError> {
454
+ let target_is_by_branch = matches!(
455
+ &plan.bound.target,
456
+ BoundWriteTarget::Entity(EntityWriteSurface::ByBranch { .. })
457
+ );
458
+ if target_is_by_branch && row.global && row.branch_id != crate::GLOBAL_BRANCH_ID {
459
+ return Err(LixError::new(
460
+ LixError::CODE_UNSUPPORTED_SQL,
461
+ format!(
462
+ "{action} through an entity by-branch surface cannot mutate a projected global row"
463
+ ),
464
+ ));
465
+ }
466
+ Ok(())
467
+ }
468
+
469
+ fn entity_replace_row_from_live(
470
+ ctx: &mut dyn SqlWriteExecutionContext,
471
+ spec: &EntitySurfaceSpec,
472
+ row: &crate::live_state::MaterializedLiveStateRow,
473
+ snapshot: Option<JsonValue>,
474
+ plan: &LogicalWritePlan,
475
+ params: &[Value],
476
+ active_branch_commit_id: Option<&str>,
477
+ ) -> Result<TransactionWriteRow, LixError> {
478
+ let metadata = if let Some(expr) = assignment_value(plan, "lixcol_metadata") {
479
+ let snapshot_for_eval = candidate_snapshot(row)?.unwrap_or(JsonValue::Null);
480
+ let context = EntityEvalContext::live(&snapshot_for_eval, row, spec);
481
+ let value = eval_expr_value(expr, &context, ctx, params, active_branch_commit_id)?;
482
+ optional_metadata_from_eval_value(value, "lixcol_metadata", &spec.schema_key)?
483
+ } else {
484
+ inherited_metadata(row, spec)?
485
+ };
486
+
487
+ Ok(TransactionWriteRow {
488
+ entity_pk: Some(row.entity_pk.clone()),
489
+ schema_key: spec.schema_key.clone(),
490
+ file_id: row.file_id.clone(),
491
+ snapshot: snapshot
492
+ .map(|snapshot| {
493
+ TransactionJson::from_value(
494
+ snapshot,
495
+ &format!("{} update snapshot_content", spec.schema_key),
496
+ )
497
+ })
498
+ .transpose()?,
499
+ metadata,
500
+ origin: None,
501
+ created_at: None,
502
+ updated_at: None,
503
+ global: row.global,
504
+ change_id: None,
505
+ commit_id: None,
506
+ untracked: row.untracked,
507
+ branch_id: if row.global {
508
+ crate::GLOBAL_BRANCH_ID.to_string()
509
+ } else {
510
+ row.branch_id.clone()
511
+ },
512
+ })
513
+ }
514
+
515
+ fn inherited_metadata(
516
+ row: &crate::live_state::MaterializedLiveStateRow,
517
+ spec: &EntitySurfaceSpec,
518
+ ) -> Result<Option<TransactionJson>, LixError> {
519
+ row.metadata
520
+ .as_ref()
521
+ .map(|metadata| {
522
+ let metadata = parse_row_metadata_value(metadata, &spec.schema_key)?;
523
+ TransactionJson::from_value(metadata, &format!("{} metadata", spec.schema_key))
524
+ })
525
+ .transpose()
526
+ }
527
+
528
+ struct EntityEvalContext<'a> {
529
+ snapshot: &'a JsonValue,
530
+ row: Option<&'a crate::live_state::MaterializedLiveStateRow>,
531
+ visible_columns: &'a [EntitySurfaceColumn],
532
+ }
533
+
534
+ impl<'a> EntityEvalContext<'a> {
535
+ fn insert(snapshot: &'a JsonValue, visible_columns: &'a [EntitySurfaceColumn]) -> Self {
536
+ Self {
537
+ snapshot,
538
+ row: None,
539
+ visible_columns,
540
+ }
541
+ }
542
+
543
+ fn live(
544
+ snapshot: &'a JsonValue,
545
+ row: &'a crate::live_state::MaterializedLiveStateRow,
546
+ spec: &'a EntitySurfaceSpec,
547
+ ) -> Self {
548
+ Self {
549
+ snapshot,
550
+ row: Some(row),
551
+ visible_columns: &spec.columns,
552
+ }
553
+ }
554
+ }
555
+
556
+ fn entity_spec(
557
+ ctx: &dyn SqlWriteExecutionContext,
558
+ schema_key: &str,
559
+ ) -> Result<EntitySurfaceSpec, LixError> {
560
+ ctx.list_visible_schemas()?
561
+ .into_iter()
562
+ .filter_map(|schema| derive_entity_surface_spec_from_schema(&schema).ok())
563
+ .find(|spec| spec.schema_key == schema_key)
564
+ .ok_or_else(|| {
565
+ LixError::new(
566
+ LixError::CODE_SCHEMA_DEFINITION,
567
+ format!("entity surface '{schema_key}' is not visible"),
568
+ )
569
+ })
570
+ }
571
+
572
+ #[derive(Clone, Debug)]
573
+ enum EntityEvalValue {
574
+ SqlNull,
575
+ SqlText(String),
576
+ Json(JsonValue),
577
+ }
578
+
579
+ impl EntityEvalValue {
580
+ fn into_json(self) -> JsonValue {
581
+ match self {
582
+ Self::SqlNull => JsonValue::Null,
583
+ Self::SqlText(value) => JsonValue::String(value),
584
+ Self::Json(value) => value,
585
+ }
586
+ }
587
+ }
588
+
589
+ fn eval_expr(
590
+ expr: &BoundExpr,
591
+ context: &EntityEvalContext<'_>,
592
+ ctx: &mut dyn SqlWriteExecutionContext,
593
+ params: &[Value],
594
+ active_branch_commit_id: Option<&str>,
595
+ ) -> Result<JsonValue, LixError> {
596
+ eval_expr_value(expr, context, ctx, params, active_branch_commit_id)
597
+ .map(EntityEvalValue::into_json)
598
+ }
599
+
600
+ fn eval_expr_value(
601
+ expr: &BoundExpr,
602
+ context: &EntityEvalContext<'_>,
603
+ ctx: &mut dyn SqlWriteExecutionContext,
604
+ params: &[Value],
605
+ active_branch_commit_id: Option<&str>,
606
+ ) -> Result<EntityEvalValue, LixError> {
607
+ match expr {
608
+ BoundExpr::Literal(BoundLiteral::Null) => Ok(EntityEvalValue::SqlNull),
609
+ BoundExpr::Literal(BoundLiteral::Text(value)) => {
610
+ Ok(EntityEvalValue::SqlText(value.clone()))
611
+ }
612
+ BoundExpr::Literal(literal) => Ok(EntityEvalValue::Json(literal_json(literal))),
613
+ BoundExpr::Param(param) => params
614
+ .get(param.index.saturating_sub(1))
615
+ .map(value_eval)
616
+ .ok_or_else(|| {
617
+ LixError::new(
618
+ LixError::CODE_INVALID_PARAM,
619
+ format!("missing SQL parameter ${}", param.index),
620
+ )
621
+ }),
622
+ BoundExpr::Column(column) => column_eval_value(context, &column.name),
623
+ BoundExpr::Function { name, args } if name == "lix_json" && args.len() == 1 => {
624
+ let raw = eval_expr_value(&args[0], context, ctx, params, active_branch_commit_id)?;
625
+ let raw = match raw {
626
+ EntityEvalValue::SqlNull => return Ok(EntityEvalValue::Json(JsonValue::Null)),
627
+ EntityEvalValue::SqlText(value) => JsonValue::String(value),
628
+ EntityEvalValue::Json(value) => value,
629
+ };
630
+ let JsonValue::String(raw) = raw else {
631
+ return Err(LixError::new(
632
+ LixError::CODE_TYPE_MISMATCH,
633
+ "lix_json expects a text argument",
634
+ ));
635
+ };
636
+ serde_json::from_str(&raw)
637
+ .map_err(|error| {
638
+ LixError::new(
639
+ LixError::CODE_TYPE_MISMATCH,
640
+ format!("lix_json argument is not valid JSON: {error}"),
641
+ )
642
+ })
643
+ .map(EntityEvalValue::Json)
644
+ }
645
+ BoundExpr::Function { name, args } if name == "lix_uuid_v7" && args.is_empty() => {
646
+ Ok(EntityEvalValue::SqlText(ctx.functions().call_uuid_v7()))
647
+ }
648
+ BoundExpr::Function { name, args } if name == "lix_timestamp" && args.is_empty() => {
649
+ Ok(EntityEvalValue::SqlText(ctx.functions().call_timestamp()))
650
+ }
651
+ BoundExpr::Function { name, args } if name == "lix_empty_blob" && args.is_empty() => {
652
+ Ok(EntityEvalValue::Json(JsonValue::Array(Vec::new())))
653
+ }
654
+ BoundExpr::Function { name, args }
655
+ if name == "lix_active_branch_commit_id" && args.is_empty() =>
656
+ {
657
+ Ok(active_branch_commit_id
658
+ .map(|commit_id| EntityEvalValue::SqlText(commit_id.to_string()))
659
+ .unwrap_or(EntityEvalValue::SqlNull))
660
+ }
661
+ BoundExpr::Function { name, args }
662
+ if (name == "lix_json_get" || name == "lix_json_get_text") && args.len() >= 2 =>
663
+ {
664
+ let root = eval_expr_value(&args[0], context, ctx, params, active_branch_commit_id)?;
665
+ let mut current = match root {
666
+ EntityEvalValue::SqlNull => return Ok(EntityEvalValue::SqlNull),
667
+ EntityEvalValue::SqlText(raw) => {
668
+ serde_json::from_str::<JsonValue>(&raw).map_err(|error| {
669
+ LixError::new(
670
+ LixError::CODE_TYPE_MISMATCH,
671
+ format!(
672
+ "{name} expected valid JSON text in its first argument: {error}"
673
+ ),
674
+ )
675
+ })?
676
+ }
677
+ EntityEvalValue::Json(root) => match root {
678
+ JsonValue::Null => return Ok(EntityEvalValue::SqlNull),
679
+ value => value,
680
+ },
681
+ };
682
+ for arg in &args[1..] {
683
+ let segment = eval_expr(arg, context, ctx, params, active_branch_commit_id)?;
684
+ let Some(next) = json_path_get(&current, &segment, name)? else {
685
+ return Ok(EntityEvalValue::SqlNull);
686
+ };
687
+ current = next;
688
+ }
689
+ if name == "lix_json_get_text" {
690
+ if current.is_null() {
691
+ return Ok(EntityEvalValue::SqlNull);
692
+ }
693
+ Ok(EntityEvalValue::SqlText(json_text_value(&current)?))
694
+ } else {
695
+ Ok(EntityEvalValue::Json(current))
696
+ }
697
+ }
698
+ BoundExpr::Function { name, args }
699
+ if (name == "lix_text_encode" || name == "lix_text_decode")
700
+ && (1..=2).contains(&args.len()) =>
701
+ {
702
+ if args.len() == 2 {
703
+ validate_utf8_encoding(
704
+ eval_expr(&args[1], context, ctx, params, active_branch_commit_id)?,
705
+ name,
706
+ )?;
707
+ }
708
+ let value = eval_expr(&args[0], context, ctx, params, active_branch_commit_id)?;
709
+ if name == "lix_text_encode" {
710
+ Ok(EntityEvalValue::Json(JsonValue::Array(
711
+ text_like_bytes(&value, name)?
712
+ .into_iter()
713
+ .map(JsonValue::from)
714
+ .collect(),
715
+ )))
716
+ } else {
717
+ let bytes = binary_like_bytes(&value, name)?;
718
+ String::from_utf8(bytes)
719
+ .map_err(|error| {
720
+ LixError::new(
721
+ LixError::CODE_TYPE_MISMATCH,
722
+ format!("lix_text_decode() expected valid UTF8 bytes: {error}"),
723
+ )
724
+ })
725
+ .map(EntityEvalValue::SqlText)
726
+ }
727
+ }
728
+ BoundExpr::Function { name, .. } => Err(LixError::new(
729
+ LixError::CODE_UNSUPPORTED_SQL,
730
+ format!("bound entity write does not support function '{name}' yet"),
731
+ )),
732
+ }
733
+ }
734
+
735
+ fn predicate_matches(
736
+ predicate: &crate::sql2::plan::predicate::BoundPredicate,
737
+ context: &EntityEvalContext<'_>,
738
+ spec: &EntitySurfaceSpec,
739
+ ctx: &mut dyn SqlWriteExecutionContext,
740
+ params: &[Value],
741
+ active_branch_commit_id: Option<&str>,
742
+ ) -> Result<bool, LixError> {
743
+ use crate::sql2::plan::predicate::BoundPredicate;
744
+ match predicate {
745
+ BoundPredicate::True => Ok(true),
746
+ BoundPredicate::False => Ok(false),
747
+ BoundPredicate::And(predicates) => {
748
+ for predicate in predicates {
749
+ if !predicate_matches(
750
+ predicate,
751
+ context,
752
+ spec,
753
+ ctx,
754
+ params,
755
+ active_branch_commit_id,
756
+ )? {
757
+ return Ok(false);
758
+ }
759
+ }
760
+ Ok(true)
761
+ }
762
+ BoundPredicate::Or(predicates) => {
763
+ for predicate in predicates {
764
+ if predicate_matches(
765
+ predicate,
766
+ context,
767
+ spec,
768
+ ctx,
769
+ params,
770
+ active_branch_commit_id,
771
+ )? {
772
+ return Ok(true);
773
+ }
774
+ }
775
+ Ok(false)
776
+ }
777
+ BoundPredicate::Eq(left, right) => {
778
+ let (left, right) = eval_comparison_operands(
779
+ left,
780
+ right,
781
+ context,
782
+ spec,
783
+ ctx,
784
+ params,
785
+ active_branch_commit_id,
786
+ )?;
787
+ Ok(!left.is_null() && !right.is_null() && left == right)
788
+ }
789
+ BoundPredicate::IsNull(expr) => {
790
+ let value = eval_expr(expr, context, ctx, params, active_branch_commit_id)?;
791
+ Ok(value.is_null())
792
+ }
793
+ BoundPredicate::IsNotNull(expr) => {
794
+ let value = eval_expr(expr, context, ctx, params, active_branch_commit_id)?;
795
+ Ok(!value.is_null())
796
+ }
797
+ BoundPredicate::In { expr, values } => {
798
+ let candidate = eval_expr(expr, context, ctx, params, active_branch_commit_id)?;
799
+ if candidate.is_null() {
800
+ return Ok(false);
801
+ }
802
+ for value_expr in values {
803
+ let value = eval_expr(value_expr, context, ctx, params, active_branch_commit_id)?;
804
+ let (candidate, value) = normalize_comparison_operands(
805
+ expr,
806
+ candidate.clone(),
807
+ value_expr,
808
+ value,
809
+ spec,
810
+ )?;
811
+ if !value.is_null() && candidate == value {
812
+ return Ok(true);
813
+ }
814
+ }
815
+ Ok(false)
816
+ }
817
+ }
818
+ }
819
+
820
+ fn eval_comparison_operands(
821
+ left: &BoundExpr,
822
+ right: &BoundExpr,
823
+ context: &EntityEvalContext<'_>,
824
+ spec: &EntitySurfaceSpec,
825
+ ctx: &mut dyn SqlWriteExecutionContext,
826
+ params: &[Value],
827
+ active_branch_commit_id: Option<&str>,
828
+ ) -> Result<(JsonValue, JsonValue), LixError> {
829
+ let left_value = eval_expr(left, context, ctx, params, active_branch_commit_id)?;
830
+ let right_value = eval_expr(right, context, ctx, params, active_branch_commit_id)?;
831
+ normalize_comparison_operands(left, left_value, right, right_value, spec)
832
+ }
833
+
834
+ fn normalize_comparison_operands(
835
+ left_expr: &BoundExpr,
836
+ left_value: JsonValue,
837
+ right_expr: &BoundExpr,
838
+ right_value: JsonValue,
839
+ spec: &EntitySurfaceSpec,
840
+ ) -> Result<(JsonValue, JsonValue), LixError> {
841
+ let left_is_json = bound_expr_is_json(left_expr, spec);
842
+ let right_is_json = bound_expr_is_json(right_expr, spec);
843
+ Ok((
844
+ normalize_json_comparison_value(
845
+ left_expr,
846
+ left_value,
847
+ right_is_json,
848
+ is_identity_json_expr(right_expr),
849
+ )?,
850
+ normalize_json_comparison_value(
851
+ right_expr,
852
+ right_value,
853
+ left_is_json,
854
+ is_identity_json_expr(left_expr),
855
+ )?,
856
+ ))
857
+ }
858
+
859
+ fn normalize_json_comparison_value(
860
+ expr: &BoundExpr,
861
+ value: JsonValue,
862
+ other_side_is_json: bool,
863
+ other_side_is_identity_json: bool,
864
+ ) -> Result<JsonValue, LixError> {
865
+ if !other_side_is_json {
866
+ return Ok(value);
867
+ }
868
+ let should_parse = matches!(expr, BoundExpr::Param(_))
869
+ || (other_side_is_identity_json
870
+ && matches!(expr, BoundExpr::Literal(BoundLiteral::Text(_))));
871
+ if !should_parse {
872
+ return Ok(value);
873
+ }
874
+ let JsonValue::String(raw) = value else {
875
+ return Ok(value);
876
+ };
877
+ serde_json::from_str(&raw).map_err(|error| {
878
+ LixError::new(
879
+ LixError::CODE_TYPE_MISMATCH,
880
+ format!("JSON comparison parameter is not valid JSON: {error}"),
881
+ )
882
+ })
883
+ }
884
+
885
+ fn validate_bound_write_supported(
886
+ plan: &LogicalWritePlan,
887
+ spec: &EntitySurfaceSpec,
888
+ ) -> Result<(), LixError> {
889
+ validate_predicate_supported(&plan.bound.predicate)?;
890
+ validate_json_predicate_types(&plan.bound.predicate, spec)?;
891
+ match &plan.bound.input {
892
+ BoundWriteInput::Values(values) => {
893
+ for row in &values.rows {
894
+ for expr in row {
895
+ validate_expr_supported(expr)?;
896
+ }
897
+ }
898
+ }
899
+ BoundWriteInput::Query { .. } | BoundWriteInput::None => {}
900
+ }
901
+ for assignment in &plan.bound.assignments {
902
+ validate_expr_supported(&assignment.value)?;
903
+ }
904
+ Ok(())
905
+ }
906
+
907
+ fn bound_public_write_shape_supported(plan: &LogicalWritePlan) -> bool {
908
+ let input_supported = match (&plan.bound.op, &plan.bound.input) {
909
+ (BoundWriteOp::Insert, BoundWriteInput::Values(values)) => values
910
+ .rows
911
+ .iter()
912
+ .flatten()
913
+ .all(|expr| validate_expr_supported(expr).is_ok()),
914
+ (BoundWriteOp::Update | BoundWriteOp::Delete, BoundWriteInput::None) => true,
915
+ _ => false,
916
+ };
917
+ input_supported
918
+ && validate_predicate_supported(&plan.bound.predicate).is_ok()
919
+ && plan
920
+ .bound
921
+ .assignments
922
+ .iter()
923
+ .all(|assignment| validate_expr_supported(&assignment.value).is_ok())
924
+ }
925
+
926
+ fn validate_predicate_supported(
927
+ predicate: &crate::sql2::plan::predicate::BoundPredicate,
928
+ ) -> Result<(), LixError> {
929
+ use crate::sql2::plan::predicate::BoundPredicate;
930
+ match predicate {
931
+ BoundPredicate::True | BoundPredicate::False => Ok(()),
932
+ BoundPredicate::And(predicates) => {
933
+ for predicate in predicates {
934
+ validate_predicate_supported(predicate)?;
935
+ }
936
+ Ok(())
937
+ }
938
+ BoundPredicate::Or(predicates) => {
939
+ for predicate in predicates {
940
+ validate_predicate_supported(predicate)?;
941
+ }
942
+ Ok(())
943
+ }
944
+ BoundPredicate::Eq(left, right) => {
945
+ validate_expr_supported(left)?;
946
+ validate_expr_supported(right)
947
+ }
948
+ BoundPredicate::IsNull(expr) | BoundPredicate::IsNotNull(expr) => {
949
+ validate_expr_supported(expr)
950
+ }
951
+ BoundPredicate::In { expr, values } => {
952
+ validate_expr_supported(expr)?;
953
+ for value in values {
954
+ validate_expr_supported(value)?;
955
+ }
956
+ Ok(())
957
+ }
958
+ }
959
+ }
960
+
961
+ fn validate_json_predicate_types(
962
+ predicate: &crate::sql2::plan::predicate::BoundPredicate,
963
+ spec: &EntitySurfaceSpec,
964
+ ) -> Result<(), LixError> {
965
+ use crate::sql2::plan::predicate::BoundPredicate;
966
+ match predicate {
967
+ BoundPredicate::True | BoundPredicate::False => Ok(()),
968
+ BoundPredicate::And(predicates) => {
969
+ for predicate in predicates {
970
+ validate_json_predicate_types(predicate, spec)?;
971
+ }
972
+ Ok(())
973
+ }
974
+ BoundPredicate::Or(predicates) => {
975
+ for predicate in predicates {
976
+ validate_json_predicate_types(predicate, spec)?;
977
+ }
978
+ Ok(())
979
+ }
980
+ BoundPredicate::Eq(left, right) => validate_json_comparison_operands(left, right, spec),
981
+ BoundPredicate::IsNull(_) | BoundPredicate::IsNotNull(_) => Ok(()),
982
+ BoundPredicate::In { expr, values } => {
983
+ if bound_expr_is_json(expr, spec) {
984
+ for value in values {
985
+ if is_identity_json_expr(expr) && is_parseable_json_text_literal(value) {
986
+ continue;
987
+ }
988
+ require_json_comparison_operand(value, spec)?;
989
+ }
990
+ }
991
+ for value in values {
992
+ if bound_expr_is_json(value, spec) {
993
+ if is_identity_json_expr(value) && is_parseable_json_text_literal(expr) {
994
+ continue;
995
+ }
996
+ require_json_comparison_operand(expr, spec)?;
997
+ }
998
+ }
999
+ Ok(())
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ fn validate_json_comparison_operands(
1005
+ left: &BoundExpr,
1006
+ right: &BoundExpr,
1007
+ spec: &EntitySurfaceSpec,
1008
+ ) -> Result<(), LixError> {
1009
+ if bound_expr_is_json(left, spec) {
1010
+ if is_identity_json_expr(left) && is_parseable_json_text_literal(right) {
1011
+ return Ok(());
1012
+ }
1013
+ require_json_comparison_operand(right, spec)?;
1014
+ }
1015
+ if bound_expr_is_json(right, spec) {
1016
+ if is_identity_json_expr(right) && is_parseable_json_text_literal(left) {
1017
+ return Ok(());
1018
+ }
1019
+ require_json_comparison_operand(left, spec)?;
1020
+ }
1021
+ Ok(())
1022
+ }
1023
+
1024
+ fn require_json_comparison_operand(
1025
+ expr: &BoundExpr,
1026
+ spec: &EntitySurfaceSpec,
1027
+ ) -> Result<(), LixError> {
1028
+ if bound_expr_is_json(expr, spec)
1029
+ || matches!(expr, BoundExpr::Param(_))
1030
+ || matches!(expr, BoundExpr::Literal(BoundLiteral::Null))
1031
+ {
1032
+ return Ok(());
1033
+ }
1034
+ Err(LixError::new(
1035
+ LixError::CODE_TYPE_MISMATCH,
1036
+ "JSON columns can only be compared with JSON expressions",
1037
+ )
1038
+ .with_hint("Wrap JSON text with lix_json(...), use lix_json_get(...) for JSON values, or use IS NULL for null checks."))
1039
+ }
1040
+
1041
+ fn is_identity_json_expr(expr: &BoundExpr) -> bool {
1042
+ matches!(
1043
+ expr,
1044
+ BoundExpr::Column(column)
1045
+ if matches!(column.name.as_str(), "entity_pk" | "lixcol_entity_pk")
1046
+ )
1047
+ }
1048
+
1049
+ fn is_parseable_json_text_literal(expr: &BoundExpr) -> bool {
1050
+ match expr {
1051
+ BoundExpr::Literal(BoundLiteral::Text(value)) => {
1052
+ serde_json::from_str::<JsonValue>(value).is_ok()
1053
+ }
1054
+ _ => false,
1055
+ }
1056
+ }
1057
+
1058
+ fn bound_expr_is_json(expr: &BoundExpr, spec: &EntitySurfaceSpec) -> bool {
1059
+ match expr {
1060
+ BoundExpr::Column(column) => {
1061
+ spec.visible_column(&column.name)
1062
+ .is_some_and(|column| column.column_type == EntityColumnType::Json)
1063
+ || matches!(
1064
+ column.name.as_str(),
1065
+ "lixcol_entity_pk" | "lixcol_metadata" | "lixcol_snapshot_content"
1066
+ )
1067
+ }
1068
+ BoundExpr::Literal(BoundLiteral::Json(_)) => true,
1069
+ BoundExpr::Function { name, .. } => matches!(name.as_str(), "lix_json" | "lix_json_get"),
1070
+ _ => false,
1071
+ }
1072
+ }
1073
+
1074
+ fn validate_expr_supported(expr: &BoundExpr) -> Result<(), LixError> {
1075
+ match expr {
1076
+ BoundExpr::Column(_) | BoundExpr::Param(_) | BoundExpr::Literal(_) => Ok(()),
1077
+ BoundExpr::Function { name, args } => {
1078
+ match name.as_str() {
1079
+ "lix_json" if args.len() == 1 => {}
1080
+ "lix_empty_blob"
1081
+ | "lix_uuid_v7"
1082
+ | "lix_timestamp"
1083
+ | "lix_active_branch_commit_id"
1084
+ if args.is_empty() => {}
1085
+ "lix_json_get" | "lix_json_get_text" if args.len() >= 2 => {}
1086
+ "lix_text_encode" | "lix_text_decode" if (1..=2).contains(&args.len()) => {}
1087
+ _ => {
1088
+ return Err(LixError::new(
1089
+ LixError::CODE_UNSUPPORTED_SQL,
1090
+ format!("bound entity write does not support function '{name}' yet"),
1091
+ ));
1092
+ }
1093
+ }
1094
+ for arg in args {
1095
+ validate_expr_supported(arg)?;
1096
+ }
1097
+ Ok(())
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+ fn candidate_snapshot(
1103
+ row: &crate::live_state::MaterializedLiveStateRow,
1104
+ ) -> Result<Option<JsonValue>, LixError> {
1105
+ row.snapshot_content
1106
+ .as_deref()
1107
+ .map(|snapshot| {
1108
+ serde_json::from_str(snapshot).map_err(|error| {
1109
+ LixError::new(
1110
+ LixError::CODE_TYPE_MISMATCH,
1111
+ format!("entity row snapshot_content is not valid JSON: {error}"),
1112
+ )
1113
+ })
1114
+ })
1115
+ .transpose()
1116
+ }
1117
+
1118
+ fn entity_json_value(
1119
+ value: EntityEvalValue,
1120
+ column_type: EntityColumnType,
1121
+ ) -> Result<JsonValue, LixError> {
1122
+ Ok(match (value, column_type) {
1123
+ (EntityEvalValue::SqlNull, _) => JsonValue::Null,
1124
+ (EntityEvalValue::SqlText(value), EntityColumnType::Json) => {
1125
+ serde_json::from_str(&value).unwrap_or(JsonValue::String(value))
1126
+ }
1127
+ (EntityEvalValue::SqlText(value), _) => JsonValue::String(value),
1128
+ (EntityEvalValue::Json(value), EntityColumnType::Json) => value,
1129
+ (EntityEvalValue::Json(JsonValue::String(value)), EntityColumnType::String) => {
1130
+ JsonValue::String(value)
1131
+ }
1132
+ (EntityEvalValue::Json(JsonValue::Number(value)), EntityColumnType::Integer)
1133
+ if value.is_i64() =>
1134
+ {
1135
+ JsonValue::Number(value)
1136
+ }
1137
+ (
1138
+ EntityEvalValue::Json(JsonValue::Number(value)),
1139
+ EntityColumnType::Number | EntityColumnType::Integer,
1140
+ ) => JsonValue::Number(value),
1141
+ (EntityEvalValue::Json(JsonValue::Bool(value)), EntityColumnType::Boolean) => {
1142
+ JsonValue::Bool(value)
1143
+ }
1144
+ (EntityEvalValue::Json(value), _) => value,
1145
+ })
1146
+ }
1147
+
1148
+ fn reject_direct_blob_json_value(
1149
+ expr: &BoundExpr,
1150
+ column_type: EntityColumnType,
1151
+ params: &[Value],
1152
+ ) -> Result<(), LixError> {
1153
+ if column_type != EntityColumnType::Json {
1154
+ return Ok(());
1155
+ }
1156
+ let is_blob = match expr {
1157
+ BoundExpr::Literal(BoundLiteral::Blob(_)) => true,
1158
+ BoundExpr::Param(param) => params
1159
+ .get(param.index.saturating_sub(1))
1160
+ .is_some_and(|value| matches!(value, Value::Blob(_))),
1161
+ _ => false,
1162
+ };
1163
+ if is_blob {
1164
+ return Err(LixError::new(
1165
+ LixError::CODE_INVALID_PARAM,
1166
+ "cannot store blob values directly in JSON entity columns",
1167
+ ));
1168
+ }
1169
+ Ok(())
1170
+ }
1171
+
1172
+ fn literal_json(literal: &BoundLiteral) -> JsonValue {
1173
+ match literal {
1174
+ BoundLiteral::Null => JsonValue::Null,
1175
+ BoundLiteral::Bool(value) => JsonValue::Bool(*value),
1176
+ BoundLiteral::Integer(value) => JsonValue::from(*value),
1177
+ BoundLiteral::Text(value) => JsonValue::String(value.clone()),
1178
+ BoundLiteral::Json(value) => value.clone(),
1179
+ BoundLiteral::Blob(value) => {
1180
+ JsonValue::Array(value.iter().copied().map(JsonValue::from).collect())
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ fn value_eval(value: &Value) -> EntityEvalValue {
1186
+ match value {
1187
+ Value::Null => EntityEvalValue::SqlNull,
1188
+ Value::Text(value) => EntityEvalValue::SqlText(value.clone()),
1189
+ _ => EntityEvalValue::Json(value_json(value)),
1190
+ }
1191
+ }
1192
+
1193
+ fn value_json(value: &Value) -> JsonValue {
1194
+ match value {
1195
+ Value::Null => JsonValue::Null,
1196
+ Value::Boolean(value) => JsonValue::Bool(*value),
1197
+ Value::Integer(value) => JsonValue::from(*value),
1198
+ Value::Real(value) => serde_json::Number::from_f64(*value)
1199
+ .map(JsonValue::Number)
1200
+ .unwrap_or(JsonValue::Null),
1201
+ Value::Text(value) => JsonValue::String(value.clone()),
1202
+ Value::Json(value) => value.clone(),
1203
+ Value::Blob(value) => {
1204
+ JsonValue::Array(value.iter().copied().map(JsonValue::from).collect())
1205
+ }
1206
+ }
1207
+ }
1208
+
1209
+ fn json_path_get(
1210
+ value: &JsonValue,
1211
+ segment: &JsonValue,
1212
+ fn_name: &str,
1213
+ ) -> Result<Option<JsonValue>, LixError> {
1214
+ match segment {
1215
+ JsonValue::String(key) => {
1216
+ if key == "$" || key.starts_with("$.") || key.starts_with("$[") || key.starts_with('/')
1217
+ {
1218
+ return Err(LixError::new(
1219
+ LixError::CODE_TYPE_MISMATCH,
1220
+ format!(
1221
+ "{fn_name}() uses variadic path segments, not JSONPath or JSON Pointer; got '{key}'"
1222
+ ),
1223
+ ));
1224
+ }
1225
+ Ok(value.get(key).cloned())
1226
+ }
1227
+ JsonValue::Number(number) => {
1228
+ let Some(index) = number
1229
+ .as_u64()
1230
+ .and_then(|value| usize::try_from(value).ok())
1231
+ else {
1232
+ return Err(LixError::new(
1233
+ LixError::CODE_TYPE_MISMATCH,
1234
+ format!("{fn_name}() path indexes must be non-negative integers"),
1235
+ ));
1236
+ };
1237
+ Ok(value
1238
+ .as_array()
1239
+ .and_then(|values| values.get(index))
1240
+ .cloned())
1241
+ }
1242
+ JsonValue::Null => Ok(None),
1243
+ other => Err(LixError::new(
1244
+ LixError::CODE_TYPE_MISMATCH,
1245
+ format!(
1246
+ "{fn_name}() path arguments must be strings or non-negative integers, got {other}"
1247
+ ),
1248
+ )),
1249
+ }
1250
+ }
1251
+
1252
+ fn json_text_value(value: &JsonValue) -> Result<String, LixError> {
1253
+ match value {
1254
+ JsonValue::String(text) => Ok(text.clone()),
1255
+ JsonValue::Number(number) => Ok(number.to_string()),
1256
+ JsonValue::Bool(boolean) => Ok(boolean.to_string()),
1257
+ JsonValue::Array(_) | JsonValue::Object(_) => {
1258
+ serde_json::to_string(value).map_err(|error| {
1259
+ LixError::new(
1260
+ LixError::CODE_TYPE_MISMATCH,
1261
+ format!("lix_json_get_text() could not render JSON value: {error}"),
1262
+ )
1263
+ })
1264
+ }
1265
+ JsonValue::Null => Ok("null".to_string()),
1266
+ }
1267
+ }
1268
+
1269
+ fn validate_utf8_encoding(value: JsonValue, fn_name: &str) -> Result<(), LixError> {
1270
+ let value = json_text_value(&value)?;
1271
+ let normalized = value.trim().to_ascii_uppercase().replace('-', "");
1272
+ if normalized == "UTF8" {
1273
+ Ok(())
1274
+ } else {
1275
+ Err(LixError::new(
1276
+ LixError::CODE_TYPE_MISMATCH,
1277
+ format!("{fn_name}() only supports UTF8 encoding, got '{value}'"),
1278
+ ))
1279
+ }
1280
+ }
1281
+
1282
+ fn text_like_bytes(value: &JsonValue, fn_name: &str) -> Result<Vec<u8>, LixError> {
1283
+ Ok(match value {
1284
+ JsonValue::String(value) => value.as_bytes().to_vec(),
1285
+ JsonValue::Number(value) => value.to_string().into_bytes(),
1286
+ JsonValue::Bool(value) => value.to_string().into_bytes(),
1287
+ JsonValue::Array(values) => values
1288
+ .iter()
1289
+ .map(byte_from_json_value)
1290
+ .collect::<Result<Vec<_>, _>>()?,
1291
+ JsonValue::Null => Vec::new(),
1292
+ other => {
1293
+ return Err(LixError::new(
1294
+ LixError::CODE_TYPE_MISMATCH,
1295
+ format!("{fn_name}() expected text or binary-compatible input, got {other}"),
1296
+ ));
1297
+ }
1298
+ })
1299
+ }
1300
+
1301
+ fn binary_like_bytes(value: &JsonValue, fn_name: &str) -> Result<Vec<u8>, LixError> {
1302
+ match value {
1303
+ JsonValue::Array(values) => values.iter().map(byte_from_json_value).collect(),
1304
+ JsonValue::String(value) => Ok(value.as_bytes().to_vec()),
1305
+ JsonValue::Null => Ok(Vec::new()),
1306
+ other => Err(LixError::new(
1307
+ LixError::CODE_TYPE_MISMATCH,
1308
+ format!("{fn_name}() expected binary or text-compatible input, got {other}"),
1309
+ )),
1310
+ }
1311
+ }
1312
+
1313
+ fn byte_from_json_value(value: &JsonValue) -> Result<u8, LixError> {
1314
+ value
1315
+ .as_u64()
1316
+ .and_then(|value| u8::try_from(value).ok())
1317
+ .ok_or_else(|| {
1318
+ LixError::new(
1319
+ LixError::CODE_TYPE_MISMATCH,
1320
+ format!("binary value must contain integer bytes, got {value}"),
1321
+ )
1322
+ })
1323
+ }
1324
+
1325
+ fn column_eval_value(
1326
+ context: &EntityEvalContext<'_>,
1327
+ column_name: &str,
1328
+ ) -> Result<EntityEvalValue, LixError> {
1329
+ if let Some(value) = context.snapshot.get(column_name) {
1330
+ return Ok(visible_column_eval_value(
1331
+ context
1332
+ .visible_columns
1333
+ .iter()
1334
+ .find(|column| column.name == column_name),
1335
+ value,
1336
+ ));
1337
+ }
1338
+ let Some(row) = context.row else {
1339
+ return Ok(EntityEvalValue::SqlNull);
1340
+ };
1341
+ match column_name {
1342
+ "lixcol_entity_pk" => row
1343
+ .entity_pk
1344
+ .as_json_array_value()
1345
+ .map(EntityEvalValue::Json),
1346
+ "lixcol_schema_key" => Ok(EntityEvalValue::Json(JsonValue::String(
1347
+ row.schema_key.clone(),
1348
+ ))),
1349
+ "lixcol_file_id" => Ok(row
1350
+ .file_id
1351
+ .as_ref()
1352
+ .map(|value| EntityEvalValue::Json(JsonValue::String(value.clone())))
1353
+ .unwrap_or(EntityEvalValue::SqlNull)),
1354
+ "lixcol_metadata" => row
1355
+ .metadata
1356
+ .as_ref()
1357
+ .map(|metadata| parse_row_metadata_value(metadata, &row.schema_key))
1358
+ .transpose()
1359
+ .map(|metadata| {
1360
+ metadata
1361
+ .map(EntityEvalValue::Json)
1362
+ .unwrap_or(EntityEvalValue::SqlNull)
1363
+ }),
1364
+ "lixcol_change_id" => Ok(row
1365
+ .change_id
1366
+ .as_ref()
1367
+ .map(|value| EntityEvalValue::Json(JsonValue::String(value.clone())))
1368
+ .unwrap_or(EntityEvalValue::SqlNull)),
1369
+ "lixcol_created_at" => Ok(EntityEvalValue::Json(JsonValue::String(
1370
+ row.created_at.clone(),
1371
+ ))),
1372
+ "lixcol_updated_at" => Ok(EntityEvalValue::Json(JsonValue::String(
1373
+ row.updated_at.clone(),
1374
+ ))),
1375
+ "lixcol_commit_id" => Ok(row
1376
+ .commit_id
1377
+ .as_ref()
1378
+ .map(|value| EntityEvalValue::Json(JsonValue::String(value.clone())))
1379
+ .unwrap_or(EntityEvalValue::SqlNull)),
1380
+ "lixcol_global" => Ok(EntityEvalValue::Json(JsonValue::Bool(row.global))),
1381
+ "lixcol_untracked" => Ok(EntityEvalValue::Json(JsonValue::Bool(row.untracked))),
1382
+ "lixcol_branch_id" => Ok(EntityEvalValue::Json(JsonValue::String(
1383
+ row.branch_id.clone(),
1384
+ ))),
1385
+ _ => Ok(EntityEvalValue::SqlNull),
1386
+ }
1387
+ }
1388
+
1389
+ fn visible_column_eval_value(
1390
+ column: Option<&EntitySurfaceColumn>,
1391
+ value: &JsonValue,
1392
+ ) -> EntityEvalValue {
1393
+ match (column.map(|column| column.column_type), value) {
1394
+ (Some(EntityColumnType::String), JsonValue::String(value)) => {
1395
+ EntityEvalValue::SqlText(value.clone())
1396
+ }
1397
+ _ => EntityEvalValue::Json(value.clone()),
1398
+ }
1399
+ }
1400
+
1401
+ fn scan_branch_ids(scope: &BranchScope) -> Result<Vec<String>, LixError> {
1402
+ Ok(match scope {
1403
+ BranchScope::Active { branch_id } => vec![branch_id.clone()],
1404
+ BranchScope::Explicit { branch_ids } | BranchScope::ExplicitRequired { branch_ids } => {
1405
+ branch_ids.iter().cloned().collect()
1406
+ }
1407
+ BranchScope::ExplicitDynamic { .. } | BranchScope::ExplicitRequiredDynamic { .. } => {
1408
+ return Err(LixError::new(
1409
+ LixError::CODE_INVALID_PARAM,
1410
+ "parameterized branch scope was not resolved before write execution",
1411
+ ));
1412
+ }
1413
+ BranchScope::Global => vec![crate::GLOBAL_BRANCH_ID.to_string()],
1414
+ BranchScope::Empty => Vec::new(),
1415
+ })
1416
+ }
1417
+
1418
+ fn entity_row_branch_id(
1419
+ plan: &LogicalWritePlan,
1420
+ explicit_branch_id: Option<String>,
1421
+ global: bool,
1422
+ ) -> Result<String, LixError> {
1423
+ if global {
1424
+ let target_branch_ids = insert_target_branch_ids(&plan.bound.branch_scope);
1425
+ let target_is_by_branch = matches!(
1426
+ &plan.bound.target,
1427
+ BoundWriteTarget::Entity(EntityWriteSurface::ByBranch { .. })
1428
+ );
1429
+ if explicit_branch_id
1430
+ .as_deref()
1431
+ .is_some_and(|branch_id| branch_id != crate::GLOBAL_BRANCH_ID)
1432
+ {
1433
+ return Err(LixError::new(
1434
+ LixError::CODE_TYPE_MISMATCH,
1435
+ "entity INSERT cannot combine lixcol_global = true with a non-global lixcol_branch_id",
1436
+ ));
1437
+ }
1438
+ if target_is_by_branch
1439
+ && target_branch_ids.iter().any(|branch_ids| {
1440
+ !branch_ids
1441
+ .iter()
1442
+ .any(|branch_id| branch_id == crate::GLOBAL_BRANCH_ID)
1443
+ })
1444
+ {
1445
+ return Err(LixError::new(
1446
+ LixError::CODE_TYPE_MISMATCH,
1447
+ "entity INSERT cannot combine lixcol_global = true with a non-global target branch",
1448
+ ));
1449
+ }
1450
+ return Ok(crate::GLOBAL_BRANCH_ID.to_string());
1451
+ }
1452
+ if explicit_branch_id.as_deref() == Some(crate::GLOBAL_BRANCH_ID) {
1453
+ return Err(LixError::new(
1454
+ LixError::CODE_TYPE_MISMATCH,
1455
+ "entity INSERT with lixcol_branch_id = 'global' must also set lixcol_global = true",
1456
+ ));
1457
+ }
1458
+ let target_is_by_branch = matches!(
1459
+ &plan.bound.target,
1460
+ BoundWriteTarget::Entity(EntityWriteSurface::ByBranch { .. })
1461
+ );
1462
+ if target_is_by_branch && matches!(plan.bound.branch_scope, BranchScope::Global) {
1463
+ return Err(LixError::new(
1464
+ LixError::CODE_TYPE_MISMATCH,
1465
+ "entity INSERT into the global scope must set lixcol_global = true",
1466
+ ));
1467
+ }
1468
+ if let Some(branch_id) = explicit_branch_id {
1469
+ if target_is_by_branch {
1470
+ let target_branch_ids = insert_target_branch_ids(&plan.bound.branch_scope);
1471
+ if let Some(target_branch_ids) = &target_branch_ids {
1472
+ if !target_branch_ids.contains(&branch_id) {
1473
+ return Err(LixError::new(
1474
+ LixError::CODE_TYPE_MISMATCH,
1475
+ format!(
1476
+ "entity INSERT lixcol_branch_id '{branch_id}' does not match the target branch scope"
1477
+ ),
1478
+ ));
1479
+ }
1480
+ } else {
1481
+ return Err(LixError::new(
1482
+ LixError::CODE_TYPE_MISMATCH,
1483
+ "entity INSERT has no target branch scope",
1484
+ ));
1485
+ }
1486
+ }
1487
+ return Ok(branch_id);
1488
+ }
1489
+ match &plan.bound.branch_scope {
1490
+ BranchScope::Active { branch_id } => Ok(branch_id.clone()),
1491
+ BranchScope::ExplicitRequired { branch_ids } if branch_ids.len() == 1 => {
1492
+ Ok(branch_ids.iter().next().expect("len checked").clone())
1493
+ }
1494
+ BranchScope::Explicit { branch_ids } if branch_ids.len() == 1 => {
1495
+ Ok(branch_ids.iter().next().expect("len checked").clone())
1496
+ }
1497
+ BranchScope::ExplicitDynamic { .. } | BranchScope::ExplicitRequiredDynamic { .. } => {
1498
+ Err(LixError::new(
1499
+ LixError::CODE_INVALID_PARAM,
1500
+ "parameterized branch scope was not resolved before write execution",
1501
+ ))
1502
+ }
1503
+ BranchScope::Global => Ok(crate::GLOBAL_BRANCH_ID.to_string()),
1504
+ BranchScope::Empty => Ok(crate::GLOBAL_BRANCH_ID.to_string()),
1505
+ _ => Err(LixError::new(
1506
+ LixError::CODE_UNSUPPORTED_SQL,
1507
+ "entity write requires exactly one target branch",
1508
+ )),
1509
+ }
1510
+ }
1511
+
1512
+ fn insert_target_branch_ids(scope: &BranchScope) -> Option<Vec<String>> {
1513
+ match scope {
1514
+ BranchScope::Active { branch_id } => Some(vec![branch_id.clone()]),
1515
+ BranchScope::Explicit { branch_ids } | BranchScope::ExplicitRequired { branch_ids } => {
1516
+ Some(branch_ids.iter().cloned().collect())
1517
+ }
1518
+ BranchScope::ExplicitDynamic { .. } | BranchScope::ExplicitRequiredDynamic { .. } => None,
1519
+ BranchScope::Global => Some(vec![crate::GLOBAL_BRANCH_ID.to_string()]),
1520
+ BranchScope::Empty => Some(Vec::new()),
1521
+ }
1522
+ }
1523
+
1524
+ fn assignment_value<'a>(plan: &'a LogicalWritePlan, column_name: &str) -> Option<&'a BoundExpr> {
1525
+ plan.bound
1526
+ .assignments
1527
+ .iter()
1528
+ .find(|assignment| assignment.column.name == column_name)
1529
+ .map(|assignment| &assignment.value)
1530
+ }
1531
+
1532
+ fn optional_metadata_from_eval_value(
1533
+ value: EntityEvalValue,
1534
+ column_name: &str,
1535
+ context: &str,
1536
+ ) -> Result<Option<TransactionJson>, LixError> {
1537
+ let metadata = match value {
1538
+ EntityEvalValue::SqlNull => return Ok(None),
1539
+ EntityEvalValue::SqlText(value) => parse_row_metadata_value(&value, context)?,
1540
+ EntityEvalValue::Json(value) => {
1541
+ validate_row_metadata(&value, context)?;
1542
+ value
1543
+ }
1544
+ };
1545
+ TransactionJson::from_value(metadata, &format!("{context} {column_name}")).map(Some)
1546
+ }
1547
+
1548
+ fn text_value(value: JsonValue, column_name: &str) -> Result<Option<String>, LixError> {
1549
+ match value {
1550
+ JsonValue::Null => Ok(None),
1551
+ JsonValue::String(value) => Ok(Some(value)),
1552
+ other => Err(LixError::new(
1553
+ LixError::CODE_TYPE_MISMATCH,
1554
+ format!("entity write expected text-compatible column '{column_name}', got {other}"),
1555
+ )),
1556
+ }
1557
+ }
1558
+
1559
+ fn bool_value(value: JsonValue, column_name: &str) -> Result<Option<bool>, LixError> {
1560
+ match value {
1561
+ JsonValue::Null => Ok(None),
1562
+ JsonValue::Bool(value) => Ok(Some(value)),
1563
+ other => Err(LixError::new(
1564
+ LixError::CODE_TYPE_MISMATCH,
1565
+ format!("entity write expected boolean column '{column_name}', got {other}"),
1566
+ )),
1567
+ }
1568
+ }
1569
+
1570
+ fn entity_pk_from_value(value: &JsonValue, column_name: &str) -> Result<EntityPk, LixError> {
1571
+ match value {
1572
+ JsonValue::String(value) => EntityPk::from_json_array_text(value).map_err(|error| {
1573
+ LixError::new(
1574
+ LixError::CODE_TYPE_MISMATCH,
1575
+ format!("entity write has invalid {column_name}: {error}"),
1576
+ )
1577
+ }),
1578
+ value => EntityPk::from_json_array_value(value).map_err(|error| {
1579
+ LixError::new(
1580
+ LixError::CODE_TYPE_MISMATCH,
1581
+ format!("entity write has invalid {column_name}: {error}"),
1582
+ )
1583
+ }),
1584
+ }
1585
+ }
1586
+
1587
+ fn entity_action(op: &BoundWriteOp) -> &'static str {
1588
+ match op {
1589
+ BoundWriteOp::Insert => "INSERT into entity surface",
1590
+ BoundWriteOp::Update => "UPDATE entity surface",
1591
+ BoundWriteOp::Delete => "DELETE from entity surface",
1592
+ }
1593
+ }