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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.md +9 -0
  2. package/SKILL.md +468 -0
  3. package/dist/engine-wasm/index.d.ts +15 -11
  4. package/dist/engine-wasm/index.js +105 -38
  5. package/dist/engine-wasm/wasm/lix_engine.d.ts +14 -2
  6. package/dist/engine-wasm/wasm/lix_engine.js +18 -17
  7. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  8. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +2 -1
  9. package/dist/generated/builtin-schemas.d.ts +31 -41
  10. package/dist/generated/builtin-schemas.js +52 -56
  11. package/dist/open-lix.d.ts +141 -24
  12. package/dist/open-lix.js +199 -35
  13. package/dist/sqlite/index.js +99 -22
  14. package/dist-engine-src/README.md +18 -0
  15. package/dist-engine-src/src/backend/kv.rs +358 -0
  16. package/dist-engine-src/src/backend/mod.rs +12 -0
  17. package/dist-engine-src/src/backend/testing.rs +658 -0
  18. package/dist-engine-src/src/backend/types.rs +96 -0
  19. package/dist-engine-src/src/binary_cas/chunking.rs +31 -0
  20. package/dist-engine-src/src/binary_cas/codec.rs +346 -0
  21. package/dist-engine-src/src/binary_cas/context.rs +139 -0
  22. package/dist-engine-src/src/binary_cas/kv.rs +1063 -0
  23. package/dist-engine-src/src/binary_cas/mod.rs +11 -0
  24. package/dist-engine-src/src/binary_cas/types.rs +127 -0
  25. package/dist-engine-src/src/cel/context.rs +86 -0
  26. package/dist-engine-src/src/cel/error.rs +19 -0
  27. package/dist-engine-src/src/cel/mod.rs +8 -0
  28. package/dist-engine-src/src/cel/provider.rs +9 -0
  29. package/dist-engine-src/src/cel/runtime.rs +167 -0
  30. package/dist-engine-src/src/cel/value.rs +50 -0
  31. package/dist-engine-src/src/changelog/codec.rs +321 -0
  32. package/dist-engine-src/src/changelog/context.rs +92 -0
  33. package/dist-engine-src/src/changelog/materialization.rs +121 -0
  34. package/dist-engine-src/src/changelog/mod.rs +13 -0
  35. package/dist-engine-src/src/changelog/reader.rs +20 -0
  36. package/dist-engine-src/src/changelog/storage.rs +220 -0
  37. package/dist-engine-src/src/changelog/types.rs +38 -0
  38. package/dist-engine-src/src/commit_graph/context.rs +1588 -0
  39. package/dist-engine-src/src/commit_graph/mod.rs +12 -0
  40. package/dist-engine-src/src/commit_graph/types.rs +145 -0
  41. package/dist-engine-src/src/commit_graph/walker.rs +780 -0
  42. package/dist-engine-src/src/common/error.rs +313 -0
  43. package/dist-engine-src/src/common/fingerprint.rs +3 -0
  44. package/dist-engine-src/src/common/fs_path.rs +1336 -0
  45. package/dist-engine-src/src/common/identity.rs +135 -0
  46. package/dist-engine-src/src/common/metadata.rs +35 -0
  47. package/dist-engine-src/src/common/mod.rs +23 -0
  48. package/dist-engine-src/src/common/types.rs +105 -0
  49. package/dist-engine-src/src/common/wire.rs +222 -0
  50. package/dist-engine-src/src/engine.rs +239 -0
  51. package/dist-engine-src/src/entity_identity.rs +285 -0
  52. package/dist-engine-src/src/functions/context.rs +327 -0
  53. package/dist-engine-src/src/functions/deterministic.rs +113 -0
  54. package/dist-engine-src/src/functions/mod.rs +18 -0
  55. package/dist-engine-src/src/functions/provider.rs +130 -0
  56. package/dist-engine-src/src/functions/state.rs +363 -0
  57. package/dist-engine-src/src/functions/types.rs +37 -0
  58. package/dist-engine-src/src/init.rs +505 -0
  59. package/dist-engine-src/src/json_store/compression.rs +77 -0
  60. package/dist-engine-src/src/json_store/context.rs +129 -0
  61. package/dist-engine-src/src/json_store/encoded.rs +15 -0
  62. package/dist-engine-src/src/json_store/mod.rs +9 -0
  63. package/dist-engine-src/src/json_store/store.rs +236 -0
  64. package/dist-engine-src/src/json_store/types.rs +52 -0
  65. package/dist-engine-src/src/lib.rs +61 -0
  66. package/dist-engine-src/src/live_state/context.rs +2241 -0
  67. package/dist-engine-src/src/live_state/mod.rs +15 -0
  68. package/dist-engine-src/src/live_state/overlay.rs +75 -0
  69. package/dist-engine-src/src/live_state/reader.rs +23 -0
  70. package/dist-engine-src/src/live_state/types.rs +239 -0
  71. package/dist-engine-src/src/live_state/visibility.rs +218 -0
  72. package/dist-engine-src/src/plugin/archive.rs +441 -0
  73. package/dist-engine-src/src/plugin/component.rs +183 -0
  74. package/dist-engine-src/src/plugin/install.rs +637 -0
  75. package/dist-engine-src/src/plugin/manifest.rs +516 -0
  76. package/dist-engine-src/src/plugin/materializer.rs +477 -0
  77. package/dist-engine-src/src/plugin/mod.rs +33 -0
  78. package/dist-engine-src/src/plugin/plugin_manifest.json +119 -0
  79. package/dist-engine-src/src/plugin/storage.rs +74 -0
  80. package/dist-engine-src/src/schema/annotations/defaults.rs +280 -0
  81. package/dist-engine-src/src/schema/annotations/mod.rs +1 -0
  82. package/dist-engine-src/src/schema/builtin/lix_account.json +22 -0
  83. package/dist-engine-src/src/schema/builtin/lix_active_account.json +30 -0
  84. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +30 -0
  85. package/dist-engine-src/src/schema/builtin/lix_change.json +62 -0
  86. package/dist-engine-src/src/schema/builtin/lix_change_author.json +46 -0
  87. package/dist-engine-src/src/schema/builtin/lix_change_set.json +18 -0
  88. package/dist-engine-src/src/schema/builtin/lix_change_set_element.json +75 -0
  89. package/dist-engine-src/src/schema/builtin/lix_commit.json +62 -0
  90. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +46 -0
  91. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +53 -0
  92. package/dist-engine-src/src/schema/builtin/lix_entity_label.json +63 -0
  93. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +53 -0
  94. package/dist-engine-src/src/schema/builtin/lix_key_value.json +41 -0
  95. package/dist-engine-src/src/schema/builtin/lix_label.json +22 -0
  96. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +31 -0
  97. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +35 -0
  98. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +49 -0
  99. package/dist-engine-src/src/schema/builtin/mod.rs +271 -0
  100. package/dist-engine-src/src/schema/definition.json +157 -0
  101. package/dist-engine-src/src/schema/definition.rs +636 -0
  102. package/dist-engine-src/src/schema/key.rs +206 -0
  103. package/dist-engine-src/src/schema/mod.rs +20 -0
  104. package/dist-engine-src/src/schema/seed.rs +14 -0
  105. package/dist-engine-src/src/schema/tests.rs +739 -0
  106. package/dist-engine-src/src/schema_registry.rs +294 -0
  107. package/dist-engine-src/src/session/context.rs +366 -0
  108. package/dist-engine-src/src/session/create_version.rs +80 -0
  109. package/dist-engine-src/src/session/execute.rs +447 -0
  110. package/dist-engine-src/src/session/merge/analysis.rs +102 -0
  111. package/dist-engine-src/src/session/merge/apply.rs +23 -0
  112. package/dist-engine-src/src/session/merge/conflicts.rs +62 -0
  113. package/dist-engine-src/src/session/merge/mod.rs +11 -0
  114. package/dist-engine-src/src/session/merge/stats.rs +65 -0
  115. package/dist-engine-src/src/session/merge/version.rs +437 -0
  116. package/dist-engine-src/src/session/mod.rs +25 -0
  117. package/dist-engine-src/src/session/switch_version.rs +121 -0
  118. package/dist-engine-src/src/sql2/change_provider.rs +337 -0
  119. package/dist-engine-src/src/sql2/classify.rs +147 -0
  120. package/dist-engine-src/src/sql2/commit_derived_provider.rs +591 -0
  121. package/dist-engine-src/src/sql2/context.rs +307 -0
  122. package/dist-engine-src/src/sql2/directory_history_provider.rs +623 -0
  123. package/dist-engine-src/src/sql2/directory_provider.rs +2405 -0
  124. package/dist-engine-src/src/sql2/dml.rs +148 -0
  125. package/dist-engine-src/src/sql2/entity_history_provider.rs +444 -0
  126. package/dist-engine-src/src/sql2/entity_provider.rs +2700 -0
  127. package/dist-engine-src/src/sql2/error.rs +196 -0
  128. package/dist-engine-src/src/sql2/execute.rs +3379 -0
  129. package/dist-engine-src/src/sql2/file_history_provider.rs +902 -0
  130. package/dist-engine-src/src/sql2/file_provider.rs +3254 -0
  131. package/dist-engine-src/src/sql2/filesystem_planner.rs +1526 -0
  132. package/dist-engine-src/src/sql2/filesystem_predicates.rs +159 -0
  133. package/dist-engine-src/src/sql2/filesystem_visibility.rs +369 -0
  134. package/dist-engine-src/src/sql2/history_projection.rs +80 -0
  135. package/dist-engine-src/src/sql2/history_provider.rs +418 -0
  136. package/dist-engine-src/src/sql2/history_route.rs +643 -0
  137. package/dist-engine-src/src/sql2/lix_state_provider.rs +2430 -0
  138. package/dist-engine-src/src/sql2/mod.rs +43 -0
  139. package/dist-engine-src/src/sql2/read_only.rs +65 -0
  140. package/dist-engine-src/src/sql2/record_batch.rs +17 -0
  141. package/dist-engine-src/src/sql2/result_metadata.rs +29 -0
  142. package/dist-engine-src/src/sql2/runtime.rs +60 -0
  143. package/dist-engine-src/src/sql2/session.rs +135 -0
  144. package/dist-engine-src/src/sql2/udfs/common.rs +295 -0
  145. package/dist-engine-src/src/sql2/udfs/lix_active_version_commit_id.rs +53 -0
  146. package/dist-engine-src/src/sql2/udfs/lix_empty_blob.rs +47 -0
  147. package/dist-engine-src/src/sql2/udfs/lix_json.rs +100 -0
  148. package/dist-engine-src/src/sql2/udfs/lix_json_get.rs +99 -0
  149. package/dist-engine-src/src/sql2/udfs/lix_json_get_text.rs +99 -0
  150. package/dist-engine-src/src/sql2/udfs/lix_text_decode.rs +82 -0
  151. package/dist-engine-src/src/sql2/udfs/lix_text_encode.rs +85 -0
  152. package/dist-engine-src/src/sql2/udfs/lix_uuid_v7.rs +76 -0
  153. package/dist-engine-src/src/sql2/udfs/mod.rs +82 -0
  154. package/dist-engine-src/src/sql2/version_provider.rs +1187 -0
  155. package/dist-engine-src/src/sql2/version_scope.rs +394 -0
  156. package/dist-engine-src/src/sql2/write_normalization.rs +345 -0
  157. package/dist-engine-src/src/storage/context.rs +356 -0
  158. package/dist-engine-src/src/storage/mod.rs +14 -0
  159. package/dist-engine-src/src/storage/read_scope.rs +88 -0
  160. package/dist-engine-src/src/storage/types.rs +501 -0
  161. package/dist-engine-src/src/storage_bench.rs +3406 -0
  162. package/dist-engine-src/src/test_support.rs +81 -0
  163. package/dist-engine-src/src/tracked_state/by_file_index.rs +102 -0
  164. package/dist-engine-src/src/tracked_state/codec.rs +747 -0
  165. package/dist-engine-src/src/tracked_state/context.rs +983 -0
  166. package/dist-engine-src/src/tracked_state/diff.rs +494 -0
  167. package/dist-engine-src/src/tracked_state/materialization.rs +141 -0
  168. package/dist-engine-src/src/tracked_state/merge.rs +474 -0
  169. package/dist-engine-src/src/tracked_state/mod.rs +31 -0
  170. package/dist-engine-src/src/tracked_state/rebuild.rs +771 -0
  171. package/dist-engine-src/src/tracked_state/storage.rs +243 -0
  172. package/dist-engine-src/src/tracked_state/tree.rs +2744 -0
  173. package/dist-engine-src/src/tracked_state/tree_types.rs +176 -0
  174. package/dist-engine-src/src/tracked_state/types.rs +61 -0
  175. package/dist-engine-src/src/transaction/commit.rs +1224 -0
  176. package/dist-engine-src/src/transaction/context.rs +1307 -0
  177. package/dist-engine-src/src/transaction/live_state_overlay.rs +34 -0
  178. package/dist-engine-src/src/transaction/mod.rs +11 -0
  179. package/dist-engine-src/src/transaction/normalization.rs +1026 -0
  180. package/dist-engine-src/src/transaction/schema_resolver.rs +127 -0
  181. package/dist-engine-src/src/transaction/staging.rs +1436 -0
  182. package/dist-engine-src/src/transaction/types.rs +351 -0
  183. package/dist-engine-src/src/transaction/validation.rs +4811 -0
  184. package/dist-engine-src/src/untracked_state/codec.rs +363 -0
  185. package/dist-engine-src/src/untracked_state/context.rs +82 -0
  186. package/dist-engine-src/src/untracked_state/materialization.rs +157 -0
  187. package/dist-engine-src/src/untracked_state/mod.rs +17 -0
  188. package/dist-engine-src/src/untracked_state/storage.rs +348 -0
  189. package/dist-engine-src/src/untracked_state/types.rs +96 -0
  190. package/dist-engine-src/src/version/context.rs +52 -0
  191. package/dist-engine-src/src/version/mod.rs +12 -0
  192. package/dist-engine-src/src/version/refs.rs +421 -0
  193. package/dist-engine-src/src/version/stage_rows.rs +71 -0
  194. package/dist-engine-src/src/version/types.rs +21 -0
  195. package/dist-engine-src/src/wasm/mod.rs +60 -0
  196. package/package.json +68 -63
@@ -0,0 +1,4811 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+
3
+ use jsonschema::JSONSchema;
4
+ use serde_json::Value as JsonValue;
5
+
6
+ use crate::common::json_pointer_get;
7
+ use crate::entity_identity::{EntityIdentity, EntityIdentityError, EntityIdentityPart};
8
+ use crate::live_state::{
9
+ LiveStateFilter, LiveStateReader, LiveStateRow, LiveStateRowIdentity, LiveStateRowRequest,
10
+ LiveStateScanRequest,
11
+ };
12
+ use crate::schema::{
13
+ compile_lix_schema, format_lix_schema_validation_errors, schema_from_registered_snapshot,
14
+ };
15
+ #[cfg(test)]
16
+ use crate::schema::{
17
+ is_seed_schema_key, reject_unsupported_registered_schema_version, validate_lix_schema,
18
+ validate_lix_schema_definition, SchemaKey,
19
+ };
20
+ use crate::transaction::normalization::{SchemaCatalogKey, TransactionSchemaCatalog};
21
+ use crate::transaction::staging::duplicate_insert_identity_message;
22
+ use crate::transaction::staging::StagedWriteSet;
23
+ use crate::transaction::types::StagedStateRow;
24
+ use crate::version::{VERSION_DESCRIPTOR_SCHEMA_KEY, VERSION_REF_SCHEMA_KEY};
25
+ use crate::{validate_row_metadata, LixError, NullableKeyFilter};
26
+
27
+ const REGISTERED_SCHEMA_KEY: &str = "lix_registered_schema";
28
+ const DIRECTORY_DESCRIPTOR_SCHEMA_KEY: &str = "lix_directory_descriptor";
29
+ const FILE_DESCRIPTOR_SCHEMA_KEY: &str = "lix_file_descriptor";
30
+ const STATE_SURFACE_SCHEMA_KEY: &str = "lix_state";
31
+ const MAX_DIRECTORY_PARENT_DEPTH: usize = 1024;
32
+
33
+ /// Immutable view of the final transaction write set before persistence.
34
+ ///
35
+ /// Validation intentionally runs after staging has coalesced overwrites and
36
+ /// hydrated generated fields, but before changelog, tracked-state, untracked
37
+ /// state, or binary CAS writes are flushed.
38
+ pub(crate) struct TransactionValidationInput<'a> {
39
+ staged_writes: &'a StagedWriteSet,
40
+ schema_catalog: &'a TransactionSchemaCatalog,
41
+ live_state: &'a dyn LiveStateReader,
42
+ }
43
+
44
+ impl<'a> TransactionValidationInput<'a> {
45
+ pub(crate) fn new(
46
+ staged_writes: &'a StagedWriteSet,
47
+ schema_catalog: &'a TransactionSchemaCatalog,
48
+ live_state: &'a dyn LiveStateReader,
49
+ ) -> Self {
50
+ Self {
51
+ staged_writes,
52
+ schema_catalog,
53
+ live_state,
54
+ }
55
+ }
56
+
57
+ #[cfg(test)]
58
+ fn from_visible_schemas_for_tests(
59
+ staged_writes: &'a StagedWriteSet,
60
+ visible_schemas: &'a [JsonValue],
61
+ live_state: &'a dyn LiveStateReader,
62
+ ) -> Self {
63
+ let catalog = Box::leak(Box::new(
64
+ TransactionSchemaCatalog::from_visible_schemas(visible_schemas)
65
+ .expect("test schema catalog should build"),
66
+ ));
67
+ Self::new(staged_writes, catalog, live_state)
68
+ }
69
+ }
70
+
71
+ /// Validates the final transaction write set before durable persistence.
72
+ ///
73
+ /// The validator owns semantic write correctness for every engine2 write
74
+ /// frontend. It builds one transaction-visible schema catalog, validates pending
75
+ /// schema registrations, checks exact schema existence, and validates each
76
+ /// non-tombstone snapshot against the compiled JSON Schema for its
77
+ /// `(schema_key, schema_version)`.
78
+ ///
79
+ /// Cross-row constraints such as `x-lix-unique` and foreign keys should also
80
+ /// live here so they can share transaction-local indexes and see the final
81
+ /// coalesced staged write set.
82
+ pub(crate) async fn validate_staged_writes(
83
+ input: TransactionValidationInput<'_>,
84
+ ) -> Result<(), LixError> {
85
+ validate_foreign_key_definitions(input.schema_catalog)?;
86
+ let schema_catalog = input.schema_catalog.clone();
87
+ let pending_file_descriptors =
88
+ PendingFileDescriptorIndex::from_staged_writes(input.staged_writes);
89
+ let staged_rows = input.staged_writes.state_rows_for_validation();
90
+ validate_registered_schema_identity_is_canonical(&input, &staged_rows).await?;
91
+ let mut compiled_schemas = CompiledSchemaCatalog::new(&schema_catalog);
92
+ let mut pending_constraints = PendingConstraintIndexes::default();
93
+ let mut staged_snapshots = Vec::new();
94
+ for row in &staged_rows {
95
+ validate_staged_row_shape(row)?;
96
+ validate_staged_row_metadata(row)?;
97
+ validate_schema_exists(row, &schema_catalog)?;
98
+ let snapshot = validate_snapshot_content(row, &mut compiled_schemas)?;
99
+ if let Some(snapshot) = snapshot.as_ref() {
100
+ let schema = schema_catalog
101
+ .schema(&row.schema_key, &row.schema_version)
102
+ .ok_or_else(|| {
103
+ LixError::new(
104
+ LixError::CODE_SCHEMA_DEFINITION,
105
+ format!(
106
+ "schema '{}' version '{}' is not visible to this transaction",
107
+ row.schema_key, row.schema_version
108
+ ),
109
+ )
110
+ })?;
111
+ validate_file_owner_reference(&input, &pending_file_descriptors, row).await?;
112
+ validate_primary_key_identity(row, schema, snapshot)?;
113
+ pending_constraints.remember_row(row, schema, snapshot)?;
114
+ pending_constraints.remember_foreign_key_references(
115
+ &schema_catalog,
116
+ row,
117
+ schema,
118
+ snapshot,
119
+ )?;
120
+ staged_snapshots.push((row, schema, snapshot.clone()));
121
+ } else {
122
+ pending_constraints.remember_tombstone(row);
123
+ }
124
+ }
125
+ let unresolved_foreign_keys =
126
+ validate_pending_foreign_keys(&schema_catalog, &pending_constraints, &staged_snapshots)?;
127
+ validate_pending_delete_restrictions(&schema_catalog, &pending_constraints)?;
128
+ let unresolved_foreign_keys =
129
+ validate_committed_foreign_keys(&input, &pending_constraints, &unresolved_foreign_keys)
130
+ .await?;
131
+ reject_unresolved_foreign_keys(&unresolved_foreign_keys)?;
132
+ validate_committed_delete_restrictions(&input, &schema_catalog, &pending_constraints).await?;
133
+ validate_version_ref_delete_restrictions(&input, &pending_constraints).await?;
134
+ validate_committed_insert_identities(&input, &pending_constraints).await?;
135
+ validate_committed_unique_constraints(&input, &pending_constraints).await?;
136
+ validate_directory_descriptor_parent_graph(&input, &staged_rows).await?;
137
+ validate_filesystem_namespace(&input, &staged_rows).await?;
138
+ Ok(())
139
+ }
140
+
141
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
142
+ struct DirectoryDescriptorScope {
143
+ version_id: String,
144
+ schema_version: String,
145
+ untracked: bool,
146
+ file_id: Option<String>,
147
+ }
148
+
149
+ #[derive(Debug, Clone, serde::Deserialize)]
150
+ struct DirectoryDescriptorSnapshot {
151
+ id: String,
152
+ parent_id: Option<String>,
153
+ name: String,
154
+ }
155
+
156
+ #[derive(Debug, Clone, serde::Deserialize)]
157
+ struct FileDescriptorSnapshot {
158
+ directory_id: Option<String>,
159
+ name: String,
160
+ }
161
+
162
+ async fn validate_directory_descriptor_parent_graph(
163
+ input: &TransactionValidationInput<'_>,
164
+ staged_rows: &[StagedStateRow],
165
+ ) -> Result<(), LixError> {
166
+ let scopes = staged_directory_descriptor_scopes(staged_rows);
167
+ for scope in scopes {
168
+ let mut parents = committed_directory_parent_map(input.live_state, &scope).await?;
169
+ apply_staged_directory_parent_rows(staged_rows, &scope, &mut parents)?;
170
+ validate_directory_parent_map(&scope, &parents)?;
171
+ }
172
+ Ok(())
173
+ }
174
+
175
+ async fn validate_registered_schema_identity_is_canonical(
176
+ input: &TransactionValidationInput<'_>,
177
+ staged_rows: &[StagedStateRow],
178
+ ) -> Result<(), LixError> {
179
+ let pending_schema_rows = staged_rows
180
+ .iter()
181
+ .filter(|row| row.schema_key == REGISTERED_SCHEMA_KEY && row.snapshot_content.is_some())
182
+ .collect::<Vec<_>>();
183
+ if pending_schema_rows.is_empty() {
184
+ return Ok(());
185
+ }
186
+
187
+ let pending_entity_ids = pending_schema_rows
188
+ .iter()
189
+ .map(|row| row.entity_id.clone())
190
+ .collect::<Vec<_>>();
191
+ let committed_rows = input
192
+ .live_state
193
+ .scan_rows(&LiveStateScanRequest {
194
+ filter: LiveStateFilter {
195
+ schema_keys: vec![REGISTERED_SCHEMA_KEY.to_string()],
196
+ entity_ids: pending_entity_ids,
197
+ file_ids: vec![NullableKeyFilter::Null],
198
+ include_tombstones: false,
199
+ ..LiveStateFilter::default()
200
+ },
201
+ ..LiveStateScanRequest::default()
202
+ })
203
+ .await?;
204
+
205
+ for row in committed_rows {
206
+ let Some(snapshot_content) = row.snapshot_content.as_deref() else {
207
+ continue;
208
+ };
209
+ let snapshot = parse_registered_schema_snapshot(snapshot_content)?;
210
+ let Some(pending_row) = pending_schema_rows
211
+ .iter()
212
+ .find(|pending_row| pending_row.entity_id == row.entity_id)
213
+ else {
214
+ continue;
215
+ };
216
+ let pending_snapshot = parse_registered_schema_snapshot(
217
+ pending_row
218
+ .snapshot_content
219
+ .as_deref()
220
+ .expect("pending registered schema row has snapshot_content"),
221
+ )?;
222
+ if snapshot != pending_snapshot {
223
+ let (key, _) = schema_from_registered_snapshot(&pending_snapshot)?;
224
+ return Err(LixError::new(
225
+ LixError::CODE_SCHEMA_DEFINITION,
226
+ format!(
227
+ "schema '{}' version '{}' is already registered with a different definition; schema identity must be canonical",
228
+ key.schema_key, key.schema_version
229
+ ),
230
+ ));
231
+ }
232
+ }
233
+
234
+ Ok(())
235
+ }
236
+
237
+ fn parse_registered_schema_snapshot(snapshot_content: &str) -> Result<JsonValue, LixError> {
238
+ serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
239
+ LixError::new(
240
+ LixError::CODE_SCHEMA_DEFINITION,
241
+ format!("registered schema snapshot_content is invalid JSON: {error}"),
242
+ )
243
+ })
244
+ }
245
+
246
+ fn staged_directory_descriptor_scopes(
247
+ staged_rows: &[StagedStateRow],
248
+ ) -> BTreeSet<DirectoryDescriptorScope> {
249
+ staged_rows
250
+ .iter()
251
+ .filter(|row| row.schema_key == DIRECTORY_DESCRIPTOR_SCHEMA_KEY)
252
+ .map(|row| DirectoryDescriptorScope {
253
+ version_id: row.version_id.clone(),
254
+ schema_version: row.schema_version.clone(),
255
+ untracked: row.untracked,
256
+ file_id: row.file_id.clone(),
257
+ })
258
+ .collect()
259
+ }
260
+
261
+ async fn committed_directory_parent_map(
262
+ live_state: &dyn LiveStateReader,
263
+ scope: &DirectoryDescriptorScope,
264
+ ) -> Result<BTreeMap<String, Option<String>>, LixError> {
265
+ let rows = live_state
266
+ .scan_rows(&LiveStateScanRequest {
267
+ filter: LiveStateFilter {
268
+ schema_keys: vec![DIRECTORY_DESCRIPTOR_SCHEMA_KEY.to_string()],
269
+ version_ids: vec![scope.version_id.clone()],
270
+ file_ids: vec![nullable_filter_from_option(&scope.file_id)],
271
+ include_tombstones: false,
272
+ ..Default::default()
273
+ },
274
+ ..Default::default()
275
+ })
276
+ .await?;
277
+ let mut parents = BTreeMap::new();
278
+ for row in rows {
279
+ if !committed_directory_row_is_in_scope(&row, scope) {
280
+ continue;
281
+ }
282
+ let Some(snapshot_content) = row.snapshot_content.as_deref() else {
283
+ continue;
284
+ };
285
+ let snapshot = parse_directory_descriptor_snapshot(&row.schema_version, snapshot_content)?;
286
+ parents.insert(snapshot.id, snapshot.parent_id);
287
+ }
288
+ Ok(parents)
289
+ }
290
+
291
+ fn committed_directory_row_is_in_scope(
292
+ row: &LiveStateRow,
293
+ scope: &DirectoryDescriptorScope,
294
+ ) -> bool {
295
+ row.schema_key == DIRECTORY_DESCRIPTOR_SCHEMA_KEY
296
+ && row.schema_version == scope.schema_version
297
+ && row.untracked == scope.untracked
298
+ && row.file_id == scope.file_id
299
+ && committed_row_is_exact_version_scoped(row, &scope.version_id)
300
+ }
301
+
302
+ fn apply_staged_directory_parent_rows(
303
+ staged_rows: &[StagedStateRow],
304
+ scope: &DirectoryDescriptorScope,
305
+ parents: &mut BTreeMap<String, Option<String>>,
306
+ ) -> Result<(), LixError> {
307
+ for row in staged_rows {
308
+ if row.schema_key != DIRECTORY_DESCRIPTOR_SCHEMA_KEY
309
+ || row.version_id != scope.version_id
310
+ || row.schema_version != scope.schema_version
311
+ || row.untracked != scope.untracked
312
+ || row.file_id != scope.file_id
313
+ {
314
+ continue;
315
+ }
316
+ let id = row.entity_id.as_string()?;
317
+ let Some(snapshot_content) = row.snapshot_content.as_deref() else {
318
+ parents.remove(&id);
319
+ continue;
320
+ };
321
+ let snapshot = parse_directory_descriptor_snapshot(&row.schema_version, snapshot_content)?;
322
+ parents.insert(snapshot.id, snapshot.parent_id);
323
+ }
324
+ Ok(())
325
+ }
326
+
327
+ fn parse_directory_descriptor_snapshot(
328
+ schema_version: &str,
329
+ snapshot_content: &str,
330
+ ) -> Result<DirectoryDescriptorSnapshot, LixError> {
331
+ serde_json::from_str::<DirectoryDescriptorSnapshot>(snapshot_content).map_err(|error| {
332
+ LixError::new(
333
+ LixError::CODE_SCHEMA_VALIDATION,
334
+ format!(
335
+ "lix_directory_descriptor version '{schema_version}' snapshot_content is invalid JSON: {error}"
336
+ ),
337
+ )
338
+ })
339
+ }
340
+
341
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
342
+ struct FilesystemStorageScope {
343
+ version_id: String,
344
+ untracked: bool,
345
+ file_id: Option<String>,
346
+ }
347
+
348
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
349
+ struct FilesystemNamespaceIdentity {
350
+ schema_key: String,
351
+ entity_id: EntityIdentity,
352
+ }
353
+
354
+ #[derive(Debug, Clone, PartialEq, Eq)]
355
+ enum FilesystemNamespaceOccupant {
356
+ Directory {
357
+ entity_id: EntityIdentity,
358
+ parent_id: Option<String>,
359
+ name: String,
360
+ },
361
+ File {
362
+ entity_id: EntityIdentity,
363
+ directory_id: Option<String>,
364
+ entry_name: String,
365
+ },
366
+ }
367
+
368
+ impl FilesystemNamespaceOccupant {
369
+ fn entity_id(&self) -> &EntityIdentity {
370
+ match self {
371
+ Self::Directory { entity_id, .. } | Self::File { entity_id, .. } => entity_id,
372
+ }
373
+ }
374
+
375
+ fn kind(&self) -> &'static str {
376
+ match self {
377
+ Self::Directory { .. } => "directory",
378
+ Self::File { .. } => "file",
379
+ }
380
+ }
381
+
382
+ fn parent_id(&self) -> &Option<String> {
383
+ match self {
384
+ Self::Directory { parent_id, .. } => parent_id,
385
+ Self::File { directory_id, .. } => directory_id,
386
+ }
387
+ }
388
+
389
+ fn entry_name(&self) -> &str {
390
+ match self {
391
+ Self::Directory { name, .. } => name,
392
+ Self::File { entry_name, .. } => entry_name,
393
+ }
394
+ }
395
+ }
396
+
397
+ async fn validate_filesystem_namespace(
398
+ input: &TransactionValidationInput<'_>,
399
+ staged_rows: &[StagedStateRow],
400
+ ) -> Result<(), LixError> {
401
+ // Filesystem namespace constraints are storage-scope local. Global rows are
402
+ // validated in the global scope and may be projected into version reads, but
403
+ // projected globals do not participate in version-local constraint checks.
404
+ let scopes = staged_filesystem_namespace_scopes(staged_rows);
405
+ for scope in scopes {
406
+ let mut occupants =
407
+ committed_filesystem_namespace_occupants(input.live_state, &scope).await?;
408
+ apply_staged_filesystem_namespace_rows(staged_rows, &scope, &mut occupants)?;
409
+ validate_filesystem_namespace_occupants(&scope, occupants)?;
410
+ }
411
+ Ok(())
412
+ }
413
+
414
+ fn staged_filesystem_namespace_scopes(
415
+ staged_rows: &[StagedStateRow],
416
+ ) -> BTreeSet<FilesystemStorageScope> {
417
+ staged_rows
418
+ .iter()
419
+ .filter(|row| {
420
+ row.schema_key == DIRECTORY_DESCRIPTOR_SCHEMA_KEY
421
+ || row.schema_key == FILE_DESCRIPTOR_SCHEMA_KEY
422
+ })
423
+ .map(|row| FilesystemStorageScope {
424
+ version_id: row.version_id.clone(),
425
+ untracked: row.untracked,
426
+ file_id: row.file_id.clone(),
427
+ })
428
+ .collect()
429
+ }
430
+
431
+ async fn committed_filesystem_namespace_occupants(
432
+ live_state: &dyn LiveStateReader,
433
+ scope: &FilesystemStorageScope,
434
+ ) -> Result<BTreeMap<FilesystemNamespaceIdentity, FilesystemNamespaceOccupant>, LixError> {
435
+ let rows = live_state
436
+ .scan_rows(&LiveStateScanRequest {
437
+ filter: LiveStateFilter {
438
+ schema_keys: vec![
439
+ DIRECTORY_DESCRIPTOR_SCHEMA_KEY.to_string(),
440
+ FILE_DESCRIPTOR_SCHEMA_KEY.to_string(),
441
+ ],
442
+ version_ids: vec![scope.version_id.clone()],
443
+ file_ids: vec![nullable_filter_from_option(&scope.file_id)],
444
+ include_tombstones: false,
445
+ ..Default::default()
446
+ },
447
+ ..Default::default()
448
+ })
449
+ .await?;
450
+ let mut occupants = BTreeMap::new();
451
+ for row in rows {
452
+ if !committed_filesystem_row_is_in_scope(&row, scope) {
453
+ continue;
454
+ }
455
+ if let Some((identity, occupant)) = filesystem_namespace_occupant_from_live_row(&row)? {
456
+ occupants.insert(identity, occupant);
457
+ }
458
+ }
459
+ Ok(occupants)
460
+ }
461
+
462
+ fn committed_filesystem_row_is_in_scope(
463
+ row: &LiveStateRow,
464
+ scope: &FilesystemStorageScope,
465
+ ) -> bool {
466
+ (row.schema_key == DIRECTORY_DESCRIPTOR_SCHEMA_KEY
467
+ || row.schema_key == FILE_DESCRIPTOR_SCHEMA_KEY)
468
+ && row.untracked == scope.untracked
469
+ && row.file_id == scope.file_id
470
+ && committed_row_is_exact_version_scoped(row, &scope.version_id)
471
+ }
472
+
473
+ fn apply_staged_filesystem_namespace_rows(
474
+ staged_rows: &[StagedStateRow],
475
+ scope: &FilesystemStorageScope,
476
+ occupants: &mut BTreeMap<FilesystemNamespaceIdentity, FilesystemNamespaceOccupant>,
477
+ ) -> Result<(), LixError> {
478
+ for row in staged_rows {
479
+ if (row.schema_key != DIRECTORY_DESCRIPTOR_SCHEMA_KEY
480
+ && row.schema_key != FILE_DESCRIPTOR_SCHEMA_KEY)
481
+ || row.version_id != scope.version_id
482
+ || row.untracked != scope.untracked
483
+ || row.file_id != scope.file_id
484
+ {
485
+ continue;
486
+ }
487
+ let identity = FilesystemNamespaceIdentity {
488
+ schema_key: row.schema_key.clone(),
489
+ entity_id: row.entity_id.clone(),
490
+ };
491
+ let Some(snapshot_content) = row.snapshot_content.as_deref() else {
492
+ occupants.remove(&identity);
493
+ continue;
494
+ };
495
+ occupants.insert(
496
+ identity,
497
+ filesystem_namespace_occupant_from_staged_row(row, snapshot_content)?,
498
+ );
499
+ }
500
+ Ok(())
501
+ }
502
+
503
+ fn filesystem_namespace_occupant_from_live_row(
504
+ row: &LiveStateRow,
505
+ ) -> Result<Option<(FilesystemNamespaceIdentity, FilesystemNamespaceOccupant)>, LixError> {
506
+ let Some(snapshot_content) = row.snapshot_content.as_deref() else {
507
+ return Ok(None);
508
+ };
509
+ let identity = FilesystemNamespaceIdentity {
510
+ schema_key: row.schema_key.clone(),
511
+ entity_id: row.entity_id.clone(),
512
+ };
513
+ let occupant = match row.schema_key.as_str() {
514
+ DIRECTORY_DESCRIPTOR_SCHEMA_KEY => {
515
+ directory_namespace_occupant(&row.schema_version, &row.entity_id, snapshot_content)?
516
+ }
517
+ FILE_DESCRIPTOR_SCHEMA_KEY => {
518
+ file_namespace_occupant(&row.schema_version, &row.entity_id, snapshot_content)?
519
+ }
520
+ _ => return Ok(None),
521
+ };
522
+ Ok(Some((identity, occupant)))
523
+ }
524
+
525
+ fn filesystem_namespace_occupant_from_staged_row(
526
+ row: &StagedStateRow,
527
+ snapshot_content: &str,
528
+ ) -> Result<FilesystemNamespaceOccupant, LixError> {
529
+ match row.schema_key.as_str() {
530
+ DIRECTORY_DESCRIPTOR_SCHEMA_KEY => {
531
+ directory_namespace_occupant(&row.schema_version, &row.entity_id, snapshot_content)
532
+ }
533
+ FILE_DESCRIPTOR_SCHEMA_KEY => {
534
+ file_namespace_occupant(&row.schema_version, &row.entity_id, snapshot_content)
535
+ }
536
+ _ => Err(LixError::new(
537
+ LixError::CODE_SCHEMA_VALIDATION,
538
+ format!(
539
+ "filesystem namespace validation cannot parse schema '{}'",
540
+ row.schema_key
541
+ ),
542
+ )),
543
+ }
544
+ }
545
+
546
+ fn directory_namespace_occupant(
547
+ schema_version: &str,
548
+ entity_id: &EntityIdentity,
549
+ snapshot_content: &str,
550
+ ) -> Result<FilesystemNamespaceOccupant, LixError> {
551
+ let snapshot = parse_directory_descriptor_snapshot(schema_version, snapshot_content)?;
552
+ Ok(FilesystemNamespaceOccupant::Directory {
553
+ entity_id: entity_id.clone(),
554
+ parent_id: snapshot.parent_id,
555
+ name: snapshot.name,
556
+ })
557
+ }
558
+
559
+ fn file_namespace_occupant(
560
+ schema_version: &str,
561
+ entity_id: &EntityIdentity,
562
+ snapshot_content: &str,
563
+ ) -> Result<FilesystemNamespaceOccupant, LixError> {
564
+ let snapshot = serde_json::from_str::<FileDescriptorSnapshot>(snapshot_content).map_err(|error| {
565
+ LixError::new(
566
+ LixError::CODE_SCHEMA_VALIDATION,
567
+ format!(
568
+ "lix_file_descriptor version '{schema_version}' snapshot_content is invalid JSON: {error}"
569
+ ),
570
+ )
571
+ })?;
572
+ Ok(FilesystemNamespaceOccupant::File {
573
+ entity_id: entity_id.clone(),
574
+ directory_id: snapshot.directory_id,
575
+ entry_name: snapshot.name,
576
+ })
577
+ }
578
+
579
+ fn validate_filesystem_namespace_occupants(
580
+ scope: &FilesystemStorageScope,
581
+ occupants: BTreeMap<FilesystemNamespaceIdentity, FilesystemNamespaceOccupant>,
582
+ ) -> Result<(), LixError> {
583
+ let mut by_parent_and_name =
584
+ BTreeMap::<(Option<String>, String), FilesystemNamespaceOccupant>::new();
585
+ for occupant in occupants.into_values() {
586
+ let key = (
587
+ occupant.parent_id().clone(),
588
+ occupant.entry_name().to_string(),
589
+ );
590
+ if let Some(existing) = by_parent_and_name.insert(key.clone(), occupant.clone()) {
591
+ if existing != occupant {
592
+ return Err(filesystem_namespace_conflict_error(
593
+ scope, &key.0, &key.1, &existing, &occupant,
594
+ ));
595
+ }
596
+ }
597
+ }
598
+ Ok(())
599
+ }
600
+
601
+ fn filesystem_namespace_conflict_error(
602
+ scope: &FilesystemStorageScope,
603
+ parent_id: &Option<String>,
604
+ entry_name: &str,
605
+ existing: &FilesystemNamespaceOccupant,
606
+ conflicting: &FilesystemNamespaceOccupant,
607
+ ) -> LixError {
608
+ let parent = parent_id.as_deref().unwrap_or("<root>");
609
+ let existing_id = existing
610
+ .entity_id()
611
+ .as_string()
612
+ .unwrap_or_else(|_| "<non-string-entity-id>".to_string());
613
+ let conflicting_id = conflicting
614
+ .entity_id()
615
+ .as_string()
616
+ .unwrap_or_else(|_| "<non-string-entity-id>".to_string());
617
+ LixError::new(
618
+ LixError::CODE_UNIQUE,
619
+ format!(
620
+ "filesystem namespace conflict in version '{}' for parent {parent:?} entry {entry_name:?}: {} '{}' conflicts with {} '{}'",
621
+ scope.version_id,
622
+ existing.kind(),
623
+ existing_id,
624
+ conflicting.kind(),
625
+ conflicting_id
626
+ ),
627
+ )
628
+ }
629
+
630
+ fn validate_directory_parent_map(
631
+ scope: &DirectoryDescriptorScope,
632
+ parents: &BTreeMap<String, Option<String>>,
633
+ ) -> Result<(), LixError> {
634
+ for directory_id in parents.keys() {
635
+ validate_directory_parent_chain(scope, parents, directory_id)?;
636
+ }
637
+ Ok(())
638
+ }
639
+
640
+ fn validate_directory_parent_chain(
641
+ scope: &DirectoryDescriptorScope,
642
+ parents: &BTreeMap<String, Option<String>>,
643
+ start_id: &str,
644
+ ) -> Result<(), LixError> {
645
+ let mut current_id = start_id;
646
+ let mut seen = BTreeSet::<String>::new();
647
+ for depth in 0..=MAX_DIRECTORY_PARENT_DEPTH {
648
+ if !seen.insert(current_id.to_string()) {
649
+ return Err(directory_parent_cycle_error(scope, start_id, current_id));
650
+ }
651
+ let Some(parent_id) = parents.get(current_id) else {
652
+ return Err(directory_parent_missing_error(scope, start_id, current_id));
653
+ };
654
+ let Some(parent_id) = parent_id.as_deref() else {
655
+ return Ok(());
656
+ };
657
+ current_id = parent_id;
658
+ if depth == MAX_DIRECTORY_PARENT_DEPTH {
659
+ return Err(directory_parent_depth_error(scope, start_id));
660
+ }
661
+ }
662
+ Err(directory_parent_depth_error(scope, start_id))
663
+ }
664
+
665
+ fn directory_parent_cycle_error(
666
+ scope: &DirectoryDescriptorScope,
667
+ start_id: &str,
668
+ repeated_id: &str,
669
+ ) -> LixError {
670
+ LixError::new(
671
+ LixError::CODE_CONSTRAINT_VIOLATION,
672
+ format!(
673
+ "lix_directory_descriptor parent_id cycle in version '{}': directory '{}' reaches ancestor '{}' twice",
674
+ scope.version_id, start_id, repeated_id
675
+ ),
676
+ )
677
+ .with_hint("Set parent_id to null or to an existing directory outside the directory's descendants.")
678
+ }
679
+
680
+ fn directory_parent_missing_error(
681
+ scope: &DirectoryDescriptorScope,
682
+ start_id: &str,
683
+ missing_id: &str,
684
+ ) -> LixError {
685
+ LixError::new(
686
+ LixError::CODE_FOREIGN_KEY,
687
+ format!(
688
+ "lix_directory_descriptor parent_id chain in version '{}' for directory '{}' references missing directory '{}'",
689
+ scope.version_id, start_id, missing_id
690
+ ),
691
+ )
692
+ }
693
+
694
+ fn directory_parent_depth_error(scope: &DirectoryDescriptorScope, start_id: &str) -> LixError {
695
+ LixError::new(
696
+ LixError::CODE_CONSTRAINT_VIOLATION,
697
+ format!(
698
+ "lix_directory_descriptor parent_id chain in version '{}' for directory '{}' exceeds maximum depth {}",
699
+ scope.version_id, start_id, MAX_DIRECTORY_PARENT_DEPTH
700
+ ),
701
+ )
702
+ }
703
+
704
+ async fn validate_committed_insert_identities(
705
+ input: &TransactionValidationInput<'_>,
706
+ pending_constraints: &PendingConstraintIndexes,
707
+ ) -> Result<(), LixError> {
708
+ for (identity, origin) in &input.staged_writes.insert_identities {
709
+ let Some(committed_row) = input
710
+ .live_state
711
+ .load_row(&LiveStateRowRequest {
712
+ schema_key: identity.schema_key.clone(),
713
+ version_id: identity.version_id.clone(),
714
+ entity_id: identity.entity_id.clone(),
715
+ file_id: nullable_filter_from_option(&identity.file_id),
716
+ })
717
+ .await?
718
+ else {
719
+ continue;
720
+ };
721
+ if committed_row.snapshot_content.is_none()
722
+ || !committed_row_is_exact_version_scoped(&committed_row, &identity.version_id)
723
+ || pending_constraints.tombstones_identity(&committed_row)
724
+ {
725
+ continue;
726
+ }
727
+ return Err(LixError::new(
728
+ LixError::CODE_UNIQUE,
729
+ duplicate_insert_identity_message(
730
+ &identity.schema_key,
731
+ &committed_row.schema_version,
732
+ &identity.entity_id,
733
+ None,
734
+ origin.as_ref(),
735
+ ),
736
+ ));
737
+ }
738
+ Ok(())
739
+ }
740
+
741
+ async fn validate_version_ref_delete_restrictions(
742
+ input: &TransactionValidationInput<'_>,
743
+ pending_constraints: &PendingConstraintIndexes,
744
+ ) -> Result<(), LixError> {
745
+ for tombstone in &pending_constraints.tombstones {
746
+ if tombstone.identity.schema_key != VERSION_REF_SCHEMA_KEY {
747
+ continue;
748
+ }
749
+
750
+ let descriptor_identity = LiveStateRowIdentity {
751
+ version_id: tombstone.identity.version_id.clone(),
752
+ schema_key: VERSION_DESCRIPTOR_SCHEMA_KEY.to_string(),
753
+ entity_id: tombstone.identity.entity_id.clone(),
754
+ file_id: tombstone.identity.file_id.clone(),
755
+ };
756
+ if pending_constraints.tombstones_target_identity(&descriptor_identity) {
757
+ continue;
758
+ }
759
+ if pending_constraints.has_identity_target(&descriptor_identity) {
760
+ return Err(version_ref_delete_restriction_error(
761
+ &tombstone.identity,
762
+ &descriptor_identity,
763
+ )?);
764
+ }
765
+
766
+ let Some(descriptor_row) = input
767
+ .live_state
768
+ .load_row(&LiveStateRowRequest {
769
+ schema_key: descriptor_identity.schema_key.clone(),
770
+ version_id: descriptor_identity.version_id.clone(),
771
+ entity_id: descriptor_identity.entity_id.clone(),
772
+ file_id: nullable_filter_from_option(&descriptor_identity.file_id),
773
+ })
774
+ .await?
775
+ else {
776
+ continue;
777
+ };
778
+ if descriptor_row.snapshot_content.is_some()
779
+ && committed_row_is_exact_version_scoped(
780
+ &descriptor_row,
781
+ &descriptor_identity.version_id,
782
+ )
783
+ && !pending_constraints.tombstones_identity(&descriptor_row)
784
+ {
785
+ return Err(version_ref_delete_restriction_error(
786
+ &tombstone.identity,
787
+ &descriptor_identity,
788
+ )?);
789
+ }
790
+ }
791
+ Ok(())
792
+ }
793
+
794
+ fn version_ref_delete_restriction_error(
795
+ ref_identity: &LiveStateRowIdentity,
796
+ descriptor_identity: &LiveStateRowIdentity,
797
+ ) -> Result<LixError, LixError> {
798
+ Ok(LixError::new(
799
+ LixError::CODE_FOREIGN_KEY,
800
+ format!(
801
+ "cannot delete '{}' row '{}' in version '{}' because matching '{}' row '{}' would remain without a version ref",
802
+ ref_identity.schema_key,
803
+ ref_identity.entity_id.as_string()?,
804
+ ref_identity.version_id,
805
+ descriptor_identity.schema_key,
806
+ descriptor_identity.entity_id.as_string()?,
807
+ ),
808
+ ))
809
+ }
810
+
811
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
812
+ enum PendingFileDescriptorState {
813
+ Present,
814
+ Tombstone,
815
+ }
816
+
817
+ #[derive(Debug, Clone, Default)]
818
+ struct PendingFileDescriptorIndex {
819
+ by_version_and_file_id: BTreeMap<(String, String), PendingFileDescriptorState>,
820
+ }
821
+
822
+ impl PendingFileDescriptorIndex {
823
+ fn from_staged_writes(staged_writes: &StagedWriteSet) -> Self {
824
+ let mut index = Self::default();
825
+ for row in staged_writes.state_rows_for_validation() {
826
+ if row.schema_key != FILE_DESCRIPTOR_SCHEMA_KEY || row.file_id.is_some() {
827
+ continue;
828
+ }
829
+ if let Ok(file_id) = row.entity_id.as_string() {
830
+ let state = if row.snapshot_content.is_some() {
831
+ PendingFileDescriptorState::Present
832
+ } else {
833
+ PendingFileDescriptorState::Tombstone
834
+ };
835
+ index
836
+ .by_version_and_file_id
837
+ .insert((row.version_id.clone(), file_id), state);
838
+ }
839
+ }
840
+ index
841
+ }
842
+
843
+ fn state(&self, version_id: &str, file_id: &str) -> Option<PendingFileDescriptorState> {
844
+ self.by_version_and_file_id
845
+ .get(&(version_id.to_string(), file_id.to_string()))
846
+ .copied()
847
+ }
848
+ }
849
+
850
+ async fn validate_file_owner_reference(
851
+ input: &TransactionValidationInput<'_>,
852
+ pending_file_descriptors: &PendingFileDescriptorIndex,
853
+ row: &StagedStateRow,
854
+ ) -> Result<(), LixError> {
855
+ let Some(file_id) = row.file_id.as_deref() else {
856
+ return Ok(());
857
+ };
858
+
859
+ if pending_file_descriptor_exists(pending_file_descriptors, &row.version_id, file_id) {
860
+ return Ok(());
861
+ }
862
+
863
+ if committed_file_descriptor_exists(input.live_state, &row.version_id, file_id).await? {
864
+ return Ok(());
865
+ }
866
+
867
+ Err(missing_file_owner_reference_error(row, file_id)?)
868
+ }
869
+
870
+ fn pending_file_descriptor_exists(
871
+ pending_file_descriptors: &PendingFileDescriptorIndex,
872
+ version_id: &str,
873
+ file_id: &str,
874
+ ) -> bool {
875
+ matches!(
876
+ pending_file_descriptors.state(version_id, file_id),
877
+ Some(PendingFileDescriptorState::Present)
878
+ )
879
+ }
880
+
881
+ async fn committed_file_descriptor_exists(
882
+ live_state: &dyn LiveStateReader,
883
+ version_id: &str,
884
+ file_id: &str,
885
+ ) -> Result<bool, LixError> {
886
+ committed_file_descriptor_exists_in_exact_version(live_state, version_id, file_id).await
887
+ }
888
+
889
+ async fn committed_file_descriptor_exists_in_exact_version(
890
+ live_state: &dyn LiveStateReader,
891
+ version_id: &str,
892
+ file_id: &str,
893
+ ) -> Result<bool, LixError> {
894
+ let Some(row) = live_state
895
+ .load_row(&LiveStateRowRequest {
896
+ schema_key: FILE_DESCRIPTOR_SCHEMA_KEY.to_string(),
897
+ version_id: version_id.to_string(),
898
+ entity_id: EntityIdentity::single(file_id),
899
+ file_id: NullableKeyFilter::Null,
900
+ })
901
+ .await?
902
+ else {
903
+ return Ok(false);
904
+ };
905
+ Ok(row.snapshot_content.is_some()
906
+ && row.schema_key == FILE_DESCRIPTOR_SCHEMA_KEY
907
+ && row.entity_id == EntityIdentity::single(file_id)
908
+ && row.file_id.is_none()
909
+ && committed_row_is_exact_version_scoped(&row, version_id))
910
+ }
911
+
912
+ fn missing_file_owner_reference_error(
913
+ row: &StagedStateRow,
914
+ file_id: &str,
915
+ ) -> Result<LixError, LixError> {
916
+ Ok(LixError::new(
917
+ LixError::CODE_FILE_NOT_FOUND,
918
+ format!(
919
+ "file ownership validation failed for schema '{}': entity '{}' references missing file_id '{}' in effective file scope for version '{}'",
920
+ row.schema_key,
921
+ row.entity_id.as_string()?,
922
+ file_id,
923
+ row.version_id
924
+ ),
925
+ )
926
+ .with_hint("Insert a row into lix_file with this id first, or use null for a global entity."))
927
+ }
928
+
929
+ fn validate_staged_row_shape(row: &StagedStateRow) -> Result<(), LixError> {
930
+ if row.schema_key.is_empty() {
931
+ return Err(LixError::new(
932
+ "LIX_ERROR_UNKNOWN",
933
+ "engine2 transaction validation requires non-empty schema_key",
934
+ ));
935
+ }
936
+ if row.schema_version.is_empty() {
937
+ return Err(LixError::new(
938
+ "LIX_ERROR_UNKNOWN",
939
+ "engine2 transaction validation requires non-empty schema_version",
940
+ ));
941
+ }
942
+ Ok(())
943
+ }
944
+
945
+ fn validate_staged_row_metadata(row: &StagedStateRow) -> Result<(), LixError> {
946
+ let Some(metadata) = row.metadata.as_ref() else {
947
+ return Ok(());
948
+ };
949
+ validate_row_metadata(
950
+ metadata.clone(),
951
+ format!(
952
+ "metadata for schema '{}' version '{}'",
953
+ row.schema_key, row.schema_version
954
+ ),
955
+ )?;
956
+ Ok(())
957
+ }
958
+
959
+ fn validate_schema_exists(
960
+ row: &StagedStateRow,
961
+ schema_catalog: &TransactionSchemaCatalog,
962
+ ) -> Result<(), LixError> {
963
+ if !schema_catalog.contains(&row.schema_key, &row.schema_version) {
964
+ return Err(LixError::new(
965
+ LixError::CODE_SCHEMA_DEFINITION,
966
+ format!(
967
+ "schema '{}' version '{}' is not visible to this transaction",
968
+ row.schema_key, row.schema_version
969
+ ),
970
+ ));
971
+ }
972
+ Ok(())
973
+ }
974
+
975
+ fn validate_snapshot_content(
976
+ row: &StagedStateRow,
977
+ compiled_schemas: &mut CompiledSchemaCatalog<'_>,
978
+ ) -> Result<Option<JsonValue>, LixError> {
979
+ let Some(snapshot_content) = row.snapshot_content.as_deref() else {
980
+ return Ok(None);
981
+ };
982
+ let snapshot = serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
983
+ LixError::new(
984
+ LixError::CODE_SCHEMA_VALIDATION,
985
+ format!(
986
+ "snapshot_content for schema '{}' version '{}' is invalid JSON: {error}",
987
+ row.schema_key, row.schema_version
988
+ ),
989
+ )
990
+ })?;
991
+ let compiled_schema = compiled_schemas.compiled_schema(&row.schema_key, &row.schema_version)?;
992
+ if let Err(errors) = compiled_schema.validate(&snapshot) {
993
+ let details = format_lix_schema_validation_errors(errors);
994
+ return Err(LixError::new(
995
+ LixError::CODE_SCHEMA_VALIDATION,
996
+ format!(
997
+ "snapshot_content validation failed for schema '{}' version '{}': {details}",
998
+ row.schema_key, row.schema_version
999
+ ),
1000
+ ));
1001
+ }
1002
+ Ok(Some(snapshot))
1003
+ }
1004
+
1005
+ fn validate_primary_key_identity(
1006
+ row: &StagedStateRow,
1007
+ schema: &JsonValue,
1008
+ snapshot: &JsonValue,
1009
+ ) -> Result<(), LixError> {
1010
+ let Some(primary_key_paths) = pointer_groups(schema, "x-lix-primary-key")?
1011
+ .into_iter()
1012
+ .next()
1013
+ else {
1014
+ return Ok(());
1015
+ };
1016
+ let derived = EntityIdentity::from_primary_key_paths(snapshot, &primary_key_paths)
1017
+ .map_err(|error| primary_key_identity_error(row, &primary_key_paths, error))?;
1018
+ if row.entity_id != derived {
1019
+ return Err(LixError::new(
1020
+ LixError::CODE_UNIQUE,
1021
+ format!(
1022
+ "primary-key constraint violation on schema '{}' version '{}': entity_id '{}' does not match derived primary key '{}'",
1023
+ row.schema_key,
1024
+ row.schema_version,
1025
+ row.entity_id.as_string()?,
1026
+ derived.as_string()?
1027
+ ),
1028
+ ));
1029
+ }
1030
+ Ok(())
1031
+ }
1032
+
1033
+ #[derive(Default)]
1034
+ struct PendingConstraintIndexes {
1035
+ unique_values: BTreeMap<PendingUniqueKey, EntityIdentity>,
1036
+ identity_targets: Vec<LiveStateRowIdentity>,
1037
+ fk_targets: BTreeMap<PendingForeignKeyTargetKey, Vec<EntityIdentity>>,
1038
+ fk_references: BTreeMap<PendingForeignKeyReferenceTarget, Vec<LiveStateRowIdentity>>,
1039
+ tombstones: Vec<PendingTombstone>,
1040
+ }
1041
+
1042
+ impl PendingConstraintIndexes {
1043
+ fn remember_tombstone(&mut self, row: &StagedStateRow) {
1044
+ self.tombstones.push(PendingTombstone {
1045
+ identity: LiveStateRowIdentity {
1046
+ version_id: row.version_id.clone(),
1047
+ schema_key: row.schema_key.clone(),
1048
+ entity_id: row.entity_id.clone(),
1049
+ file_id: row.file_id.clone(),
1050
+ },
1051
+ schema_version: row.schema_version.clone(),
1052
+ });
1053
+ }
1054
+
1055
+ fn remember_row(
1056
+ &mut self,
1057
+ row: &StagedStateRow,
1058
+ schema: &JsonValue,
1059
+ snapshot: &JsonValue,
1060
+ ) -> Result<(), LixError> {
1061
+ self.remember_identity_target(row);
1062
+ self.remember_primary_key_target(row, schema, snapshot)?;
1063
+ self.remember_unique_targets(row, schema, snapshot)?;
1064
+ Ok(())
1065
+ }
1066
+
1067
+ fn remember_identity_target(&mut self, row: &StagedStateRow) {
1068
+ self.identity_targets.push(LiveStateRowIdentity {
1069
+ version_id: row.version_id.clone(),
1070
+ schema_key: row.schema_key.clone(),
1071
+ entity_id: row.entity_id.clone(),
1072
+ file_id: row.file_id.clone(),
1073
+ });
1074
+ }
1075
+
1076
+ fn remember_primary_key_target(
1077
+ &mut self,
1078
+ row: &StagedStateRow,
1079
+ schema: &JsonValue,
1080
+ snapshot: &JsonValue,
1081
+ ) -> Result<(), LixError> {
1082
+ for primary_key_paths in pointer_groups(schema, "x-lix-primary-key")? {
1083
+ self.remember_fk_target(row, &primary_key_paths, snapshot);
1084
+ }
1085
+ Ok(())
1086
+ }
1087
+
1088
+ fn remember_unique_targets(
1089
+ &mut self,
1090
+ row: &StagedStateRow,
1091
+ schema: &JsonValue,
1092
+ snapshot: &JsonValue,
1093
+ ) -> Result<(), LixError> {
1094
+ for unique_paths in pointer_groups(schema, "x-lix-unique")? {
1095
+ let Some(value) = UniqueConstraintValue::from_snapshot(snapshot, &unique_paths) else {
1096
+ continue;
1097
+ };
1098
+ self.remember_fk_target(row, &unique_paths, snapshot);
1099
+ let key = PendingUniqueKey {
1100
+ schema_key: row.schema_key.clone(),
1101
+ schema_version: row.schema_version.clone(),
1102
+ version_id: row.version_id.clone(),
1103
+ untracked: row.untracked,
1104
+ file_id: row.file_id.clone(),
1105
+ pointer_group: unique_paths.clone(),
1106
+ value,
1107
+ };
1108
+ if let Some(existing_entity_id) = self
1109
+ .unique_values
1110
+ .insert(key.clone(), row.entity_id.clone())
1111
+ {
1112
+ if existing_entity_id != row.entity_id {
1113
+ return Err(LixError::new(
1114
+ LixError::CODE_UNIQUE,
1115
+ format!(
1116
+ "unique constraint violation on {}.{} for value {}: rows '{}' and '{}' conflict",
1117
+ row.schema_key,
1118
+ format_pointer_group(&key.pointer_group),
1119
+ key.value.display(),
1120
+ existing_entity_id.as_string()?,
1121
+ row.entity_id.as_string()?
1122
+ ),
1123
+ ));
1124
+ }
1125
+ }
1126
+ }
1127
+ Ok(())
1128
+ }
1129
+
1130
+ fn remember_fk_target(
1131
+ &mut self,
1132
+ row: &StagedStateRow,
1133
+ pointer_group: &[Vec<String>],
1134
+ snapshot: &JsonValue,
1135
+ ) {
1136
+ let Some(value) = UniqueConstraintValue::from_snapshot(snapshot, pointer_group) else {
1137
+ return;
1138
+ };
1139
+ self.fk_targets
1140
+ .entry(PendingForeignKeyTargetKey {
1141
+ schema_key: row.schema_key.clone(),
1142
+ schema_version: row.schema_version.clone(),
1143
+ version_id: row.version_id.clone(),
1144
+ file_id: row.file_id.clone(),
1145
+ pointer_group: pointer_group.to_vec(),
1146
+ value,
1147
+ })
1148
+ .or_default()
1149
+ .push(row.entity_id.clone());
1150
+ }
1151
+
1152
+ fn remember_foreign_key_references(
1153
+ &mut self,
1154
+ schema_catalog: &TransactionSchemaCatalog,
1155
+ row: &StagedStateRow,
1156
+ schema: &JsonValue,
1157
+ snapshot: &JsonValue,
1158
+ ) -> Result<(), LixError> {
1159
+ for foreign_key in foreign_key_definitions(schema)? {
1160
+ let Some(local_value) = UniqueConstraintValue::from_snapshot_non_null(
1161
+ snapshot,
1162
+ &foreign_key.local_properties,
1163
+ ) else {
1164
+ continue;
1165
+ };
1166
+ let target = if foreign_key.referenced_schema_key == STATE_SURFACE_SCHEMA_KEY {
1167
+ PendingForeignKeyReferenceTarget::StateSurfaceIdentity(
1168
+ state_surface_target_identity(&row.version_id, &foreign_key, snapshot)?,
1169
+ )
1170
+ } else {
1171
+ let target_key = schema_catalog
1172
+ .schema_key_by_key(&foreign_key.referenced_schema_key)
1173
+ .ok_or_else(|| {
1174
+ LixError::new(
1175
+ LixError::CODE_SCHEMA_DEFINITION,
1176
+ format!(
1177
+ "foreign key on schema '{}' references missing schema '{}'",
1178
+ row.schema_key, foreign_key.referenced_schema_key
1179
+ ),
1180
+ )
1181
+ })?;
1182
+ PendingForeignKeyReferenceTarget::Key(PendingForeignKeyTargetKey {
1183
+ schema_key: target_key.schema_key,
1184
+ schema_version: target_key.schema_version,
1185
+ version_id: row.version_id.clone(),
1186
+ file_id: row.file_id.clone(),
1187
+ pointer_group: foreign_key.referenced_properties,
1188
+ value: local_value,
1189
+ })
1190
+ };
1191
+ self.fk_references
1192
+ .entry(target)
1193
+ .or_default()
1194
+ .push(LiveStateRowIdentity {
1195
+ version_id: row.version_id.clone(),
1196
+ schema_key: row.schema_key.clone(),
1197
+ entity_id: row.entity_id.clone(),
1198
+ file_id: row.file_id.clone(),
1199
+ });
1200
+ }
1201
+ Ok(())
1202
+ }
1203
+
1204
+ fn tombstones_identity(&self, row: &LiveStateRow) -> bool {
1205
+ let identity = LiveStateRowIdentity::from_row(row);
1206
+ self.tombstones
1207
+ .iter()
1208
+ .any(|tombstone| tombstone.identity == identity)
1209
+ }
1210
+
1211
+ fn has_identity_target(&self, identity: &LiveStateRowIdentity) -> bool {
1212
+ self.identity_targets.contains(identity)
1213
+ }
1214
+
1215
+ fn tombstones_target_identity(&self, identity: &LiveStateRowIdentity) -> bool {
1216
+ self.tombstones
1217
+ .iter()
1218
+ .any(|tombstone| tombstone.identity == *identity)
1219
+ }
1220
+
1221
+ fn has_fk_target_key(&self, key: &PendingForeignKeyTargetKey) -> bool {
1222
+ self.fk_targets.contains_key(key)
1223
+ }
1224
+
1225
+ fn active_references_to(
1226
+ &self,
1227
+ target: &PendingForeignKeyReferenceTarget,
1228
+ ) -> Vec<&LiveStateRowIdentity> {
1229
+ self.fk_references
1230
+ .get(target)
1231
+ .into_iter()
1232
+ .flat_map(|references| references.iter())
1233
+ .filter(|source_identity| !self.tombstones_target_identity(source_identity))
1234
+ .collect()
1235
+ }
1236
+
1237
+ #[cfg(test)]
1238
+ fn has_fk_reference_to_key(
1239
+ &self,
1240
+ schema_key: &str,
1241
+ schema_version: &str,
1242
+ version_id: &str,
1243
+ file_id: Option<&str>,
1244
+ pointer_group: &[&str],
1245
+ value: UniqueConstraintValue,
1246
+ ) -> Result<bool, LixError> {
1247
+ let pointer_group = pointer_group
1248
+ .iter()
1249
+ .map(|pointer| parse_json_pointer(pointer))
1250
+ .collect::<Result<Vec<_>, _>>()?;
1251
+ let key = PendingForeignKeyReferenceTarget::Key(PendingForeignKeyTargetKey {
1252
+ schema_key: schema_key.to_string(),
1253
+ schema_version: schema_version.to_string(),
1254
+ version_id: version_id.to_string(),
1255
+ file_id: file_id.map(str::to_string),
1256
+ pointer_group,
1257
+ value,
1258
+ });
1259
+ Ok(self.fk_references.contains_key(&key))
1260
+ }
1261
+
1262
+ #[cfg(test)]
1263
+ fn has_fk_reference_to_identity(&self, identity: LiveStateRowIdentity) -> bool {
1264
+ self.fk_references
1265
+ .contains_key(&PendingForeignKeyReferenceTarget::StateSurfaceIdentity(
1266
+ identity,
1267
+ ))
1268
+ }
1269
+
1270
+ #[cfg(test)]
1271
+ fn has_fk_target(
1272
+ &self,
1273
+ schema_key: &str,
1274
+ schema_version: &str,
1275
+ version_id: &str,
1276
+ file_id: Option<&str>,
1277
+ pointer_group: &[&str],
1278
+ value: UniqueConstraintValue,
1279
+ ) -> Result<bool, LixError> {
1280
+ let pointer_group = pointer_group
1281
+ .iter()
1282
+ .map(|pointer| parse_json_pointer(pointer))
1283
+ .collect::<Result<Vec<_>, _>>()?;
1284
+ let key = PendingForeignKeyTargetKey {
1285
+ schema_key: schema_key.to_string(),
1286
+ schema_version: schema_version.to_string(),
1287
+ version_id: version_id.to_string(),
1288
+ file_id: file_id.map(str::to_string),
1289
+ pointer_group,
1290
+ value,
1291
+ };
1292
+ Ok(self.fk_targets.contains_key(&key))
1293
+ }
1294
+ }
1295
+
1296
+ #[derive(Debug, Clone, PartialEq, Eq)]
1297
+ struct PendingTombstone {
1298
+ identity: LiveStateRowIdentity,
1299
+ schema_version: String,
1300
+ }
1301
+
1302
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1303
+ struct PendingUniqueKey {
1304
+ schema_key: String,
1305
+ schema_version: String,
1306
+ version_id: String,
1307
+ untracked: bool,
1308
+ file_id: Option<String>,
1309
+ pointer_group: Vec<Vec<String>>,
1310
+ value: UniqueConstraintValue,
1311
+ }
1312
+
1313
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1314
+ struct PendingForeignKeyTargetKey {
1315
+ schema_key: String,
1316
+ schema_version: String,
1317
+ version_id: String,
1318
+ file_id: Option<String>,
1319
+ pointer_group: Vec<Vec<String>>,
1320
+ value: UniqueConstraintValue,
1321
+ }
1322
+
1323
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1324
+ enum PendingForeignKeyReferenceTarget {
1325
+ Key(PendingForeignKeyTargetKey),
1326
+ StateSurfaceIdentity(LiveStateRowIdentity),
1327
+ }
1328
+
1329
+ fn validate_pending_delete_restrictions(
1330
+ schema_catalog: &TransactionSchemaCatalog,
1331
+ pending_constraints: &PendingConstraintIndexes,
1332
+ ) -> Result<(), LixError> {
1333
+ for tombstone in &pending_constraints.tombstones {
1334
+ let identity_target =
1335
+ PendingForeignKeyReferenceTarget::StateSurfaceIdentity(tombstone.identity.clone());
1336
+ reject_pending_delete_references(
1337
+ &tombstone.identity,
1338
+ &identity_target,
1339
+ pending_constraints.active_references_to(&identity_target),
1340
+ )?;
1341
+
1342
+ let Some(schema) =
1343
+ schema_catalog.schema(&tombstone.identity.schema_key, &tombstone.schema_version)
1344
+ else {
1345
+ continue;
1346
+ };
1347
+ for primary_key_paths in pointer_groups(schema, "x-lix-primary-key")? {
1348
+ let target = PendingForeignKeyReferenceTarget::Key(PendingForeignKeyTargetKey {
1349
+ schema_key: tombstone.identity.schema_key.clone(),
1350
+ schema_version: tombstone.schema_version.clone(),
1351
+ version_id: tombstone.identity.version_id.clone(),
1352
+ file_id: tombstone.identity.file_id.clone(),
1353
+ pointer_group: primary_key_paths,
1354
+ value: UniqueConstraintValue::from_entity_identity(&tombstone.identity.entity_id),
1355
+ });
1356
+ reject_pending_delete_references(
1357
+ &tombstone.identity,
1358
+ &target,
1359
+ pending_constraints.active_references_to(&target),
1360
+ )?;
1361
+ }
1362
+ }
1363
+ Ok(())
1364
+ }
1365
+
1366
+ fn reject_pending_delete_references(
1367
+ deleted_identity: &LiveStateRowIdentity,
1368
+ target: &PendingForeignKeyReferenceTarget,
1369
+ references: Vec<&LiveStateRowIdentity>,
1370
+ ) -> Result<(), LixError> {
1371
+ let Some(reference) = references.first() else {
1372
+ return Ok(());
1373
+ };
1374
+ Err(LixError::new(
1375
+ LixError::CODE_FOREIGN_KEY,
1376
+ format!(
1377
+ "cannot delete '{}' row '{}' in version '{}' because pending row '{}' references it{}",
1378
+ deleted_identity.schema_key,
1379
+ deleted_identity.entity_id.as_string()?,
1380
+ deleted_identity.version_id,
1381
+ reference.entity_id.as_string()?,
1382
+ pending_foreign_key_reference_target_description(target)?
1383
+ ),
1384
+ ))
1385
+ }
1386
+
1387
+ fn pending_foreign_key_reference_target_description(
1388
+ target: &PendingForeignKeyReferenceTarget,
1389
+ ) -> Result<String, LixError> {
1390
+ match target {
1391
+ PendingForeignKeyReferenceTarget::Key(target) => Ok(format!(
1392
+ " through '{}.{}' value {}",
1393
+ target.schema_key,
1394
+ format_pointer_group(&target.pointer_group),
1395
+ target.value.display()
1396
+ )),
1397
+ PendingForeignKeyReferenceTarget::StateSurfaceIdentity(target) => Ok(format!(
1398
+ " through '{}:{}'",
1399
+ target.schema_key,
1400
+ target.entity_id.as_string()?
1401
+ )),
1402
+ }
1403
+ }
1404
+
1405
+ async fn validate_committed_delete_restrictions(
1406
+ input: &TransactionValidationInput<'_>,
1407
+ schema_catalog: &TransactionSchemaCatalog,
1408
+ pending_constraints: &PendingConstraintIndexes,
1409
+ ) -> Result<(), LixError> {
1410
+ for tombstone in &pending_constraints.tombstones {
1411
+ for (source_key, source_schema) in schema_catalog.schemas() {
1412
+ for foreign_key in foreign_key_definitions(source_schema)? {
1413
+ if foreign_key.referenced_schema_key == STATE_SURFACE_SCHEMA_KEY {
1414
+ validate_committed_state_surface_delete_restriction(
1415
+ input.live_state,
1416
+ pending_constraints,
1417
+ tombstone,
1418
+ source_key,
1419
+ &foreign_key,
1420
+ )
1421
+ .await?;
1422
+ } else if foreign_key.referenced_schema_key == tombstone.identity.schema_key {
1423
+ validate_committed_normal_delete_restriction(
1424
+ input.live_state,
1425
+ pending_constraints,
1426
+ tombstone,
1427
+ source_key,
1428
+ &foreign_key,
1429
+ )
1430
+ .await?;
1431
+ }
1432
+ }
1433
+ }
1434
+ }
1435
+ Ok(())
1436
+ }
1437
+
1438
+ async fn validate_committed_normal_delete_restriction(
1439
+ live_state: &dyn LiveStateReader,
1440
+ pending_constraints: &PendingConstraintIndexes,
1441
+ tombstone: &PendingTombstone,
1442
+ source_key: &SchemaCatalogKey,
1443
+ foreign_key: &ForeignKeyDefinition,
1444
+ ) -> Result<(), LixError> {
1445
+ let Some(deleted_value) =
1446
+ committed_deleted_row_value(live_state, tombstone, &foreign_key.referenced_properties)
1447
+ .await?
1448
+ else {
1449
+ return Ok(());
1450
+ };
1451
+ let rows = live_state
1452
+ .scan_rows(&LiveStateScanRequest {
1453
+ filter: LiveStateFilter {
1454
+ schema_keys: vec![source_key.schema_key.clone()],
1455
+ version_ids: vec![tombstone.identity.version_id.clone()],
1456
+ file_ids: vec![nullable_filter_from_option(&tombstone.identity.file_id)],
1457
+ include_tombstones: false,
1458
+ ..Default::default()
1459
+ },
1460
+ ..Default::default()
1461
+ })
1462
+ .await?;
1463
+
1464
+ for row in rows {
1465
+ if !committed_row_is_exact_version_scoped(&row, &tombstone.identity.version_id) {
1466
+ continue;
1467
+ }
1468
+ if row.schema_version != source_key.schema_version
1469
+ || row.file_id != tombstone.identity.file_id
1470
+ || pending_constraints.tombstones_identity(&row)
1471
+ {
1472
+ continue;
1473
+ }
1474
+ let Some(snapshot_content) = row.snapshot_content.as_deref() else {
1475
+ continue;
1476
+ };
1477
+ let snapshot = parse_committed_snapshot(&row, snapshot_content)?;
1478
+ if UniqueConstraintValue::from_snapshot_non_null(&snapshot, &foreign_key.local_properties)
1479
+ .as_ref()
1480
+ == Some(&deleted_value)
1481
+ {
1482
+ return Err(committed_delete_restriction_error(
1483
+ &tombstone.identity,
1484
+ &row,
1485
+ &foreign_key.local_properties,
1486
+ )?);
1487
+ }
1488
+ }
1489
+ Ok(())
1490
+ }
1491
+
1492
+ async fn validate_committed_state_surface_delete_restriction(
1493
+ live_state: &dyn LiveStateReader,
1494
+ pending_constraints: &PendingConstraintIndexes,
1495
+ tombstone: &PendingTombstone,
1496
+ source_key: &SchemaCatalogKey,
1497
+ foreign_key: &ForeignKeyDefinition,
1498
+ ) -> Result<(), LixError> {
1499
+ let rows = live_state
1500
+ .scan_rows(&LiveStateScanRequest {
1501
+ filter: LiveStateFilter {
1502
+ schema_keys: vec![source_key.schema_key.clone()],
1503
+ version_ids: vec![tombstone.identity.version_id.clone()],
1504
+ file_ids: vec![nullable_filter_from_option(&tombstone.identity.file_id)],
1505
+ include_tombstones: false,
1506
+ ..Default::default()
1507
+ },
1508
+ ..Default::default()
1509
+ })
1510
+ .await?;
1511
+
1512
+ for row in rows {
1513
+ if !committed_row_is_exact_version_scoped(&row, &tombstone.identity.version_id) {
1514
+ continue;
1515
+ }
1516
+ if row.schema_version != source_key.schema_version
1517
+ || row.file_id != tombstone.identity.file_id
1518
+ || pending_constraints.tombstones_identity(&row)
1519
+ {
1520
+ continue;
1521
+ }
1522
+ let Some(snapshot_content) = row.snapshot_content.as_deref() else {
1523
+ continue;
1524
+ };
1525
+ let snapshot = parse_committed_snapshot(&row, snapshot_content)?;
1526
+ if state_surface_target_identity(&row.version_id, foreign_key, &snapshot)?
1527
+ == tombstone.identity
1528
+ {
1529
+ return Err(committed_delete_restriction_error(
1530
+ &tombstone.identity,
1531
+ &row,
1532
+ &foreign_key.local_properties,
1533
+ )?);
1534
+ }
1535
+ }
1536
+ Ok(())
1537
+ }
1538
+
1539
+ async fn committed_deleted_row_value(
1540
+ live_state: &dyn LiveStateReader,
1541
+ tombstone: &PendingTombstone,
1542
+ referenced_properties: &[Vec<String>],
1543
+ ) -> Result<Option<UniqueConstraintValue>, LixError> {
1544
+ let Some(row) = live_state
1545
+ .load_row(&LiveStateRowRequest {
1546
+ schema_key: tombstone.identity.schema_key.clone(),
1547
+ version_id: tombstone.identity.version_id.clone(),
1548
+ entity_id: tombstone.identity.entity_id.clone(),
1549
+ file_id: nullable_filter_from_option(&tombstone.identity.file_id),
1550
+ })
1551
+ .await?
1552
+ else {
1553
+ return Ok(None);
1554
+ };
1555
+ if !committed_row_is_exact_version_scoped(&row, &tombstone.identity.version_id)
1556
+ || row.schema_version != tombstone.schema_version
1557
+ || row.file_id != tombstone.identity.file_id
1558
+ {
1559
+ return Ok(None);
1560
+ }
1561
+ let Some(snapshot_content) = row.snapshot_content.as_deref() else {
1562
+ return Ok(None);
1563
+ };
1564
+ let snapshot = parse_committed_snapshot(&row, snapshot_content)?;
1565
+ Ok(UniqueConstraintValue::from_snapshot(
1566
+ &snapshot,
1567
+ referenced_properties,
1568
+ ))
1569
+ }
1570
+
1571
+ fn committed_delete_restriction_error(
1572
+ deleted_identity: &LiveStateRowIdentity,
1573
+ referencing_row: &LiveStateRow,
1574
+ local_properties: &[Vec<String>],
1575
+ ) -> Result<LixError, LixError> {
1576
+ Ok(LixError::new(
1577
+ LixError::CODE_FOREIGN_KEY,
1578
+ format!(
1579
+ "cannot delete '{}' row '{}' in version '{}' because committed row '{}' references it through {}",
1580
+ deleted_identity.schema_key,
1581
+ deleted_identity.entity_id.as_string()?,
1582
+ deleted_identity.version_id,
1583
+ referencing_row.entity_id.as_string()?,
1584
+ format_pointer_group(local_properties)
1585
+ ),
1586
+ ))
1587
+ }
1588
+
1589
+ fn parse_committed_snapshot(
1590
+ row: &LiveStateRow,
1591
+ snapshot_content: &str,
1592
+ ) -> Result<JsonValue, LixError> {
1593
+ serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
1594
+ LixError::new(
1595
+ LixError::CODE_SCHEMA_VALIDATION,
1596
+ format!(
1597
+ "committed snapshot_content for schema '{}' version '{}' is invalid JSON: {error}",
1598
+ row.schema_key, row.schema_version
1599
+ ),
1600
+ )
1601
+ })
1602
+ }
1603
+
1604
+ #[derive(Debug, Clone, PartialEq, Eq)]
1605
+ struct UnresolvedForeignKeyCheck {
1606
+ source_identity: LiveStateRowIdentity,
1607
+ source_schema_key: String,
1608
+ source_pointer_group: Vec<Vec<String>>,
1609
+ target: UnresolvedForeignKeyTarget,
1610
+ }
1611
+
1612
+ #[derive(Debug, Clone, PartialEq, Eq)]
1613
+ enum UnresolvedForeignKeyTarget {
1614
+ Key(PendingForeignKeyTargetKey),
1615
+ StateSurfaceIdentity(LiveStateRowIdentity),
1616
+ }
1617
+
1618
+ fn validate_pending_foreign_keys(
1619
+ schema_catalog: &TransactionSchemaCatalog,
1620
+ pending_constraints: &PendingConstraintIndexes,
1621
+ staged_snapshots: &[(&StagedStateRow, &JsonValue, JsonValue)],
1622
+ ) -> Result<Vec<UnresolvedForeignKeyCheck>, LixError> {
1623
+ let mut unresolved = Vec::new();
1624
+ for (row, schema, snapshot) in staged_snapshots {
1625
+ for foreign_key in foreign_key_definitions(schema)? {
1626
+ let Some(local_value) = UniqueConstraintValue::from_snapshot_non_null(
1627
+ snapshot,
1628
+ &foreign_key.local_properties,
1629
+ ) else {
1630
+ continue;
1631
+ };
1632
+ if foreign_key.referenced_schema_key == STATE_SURFACE_SCHEMA_KEY {
1633
+ if let Some(check) = validate_pending_state_surface_foreign_key(
1634
+ row,
1635
+ &foreign_key,
1636
+ snapshot,
1637
+ pending_constraints,
1638
+ )? {
1639
+ unresolved.push(check);
1640
+ }
1641
+ } else {
1642
+ if let Some(check) = validate_pending_normal_foreign_key(
1643
+ schema_catalog,
1644
+ row,
1645
+ &foreign_key,
1646
+ local_value,
1647
+ pending_constraints,
1648
+ )? {
1649
+ unresolved.push(check);
1650
+ }
1651
+ }
1652
+ }
1653
+ }
1654
+ Ok(unresolved)
1655
+ }
1656
+
1657
+ fn validate_pending_normal_foreign_key(
1658
+ schema_catalog: &TransactionSchemaCatalog,
1659
+ row: &StagedStateRow,
1660
+ foreign_key: &ForeignKeyDefinition,
1661
+ local_value: UniqueConstraintValue,
1662
+ pending_constraints: &PendingConstraintIndexes,
1663
+ ) -> Result<Option<UnresolvedForeignKeyCheck>, LixError> {
1664
+ let target_key = schema_catalog
1665
+ .schema_key_by_key(&foreign_key.referenced_schema_key)
1666
+ .ok_or_else(|| {
1667
+ LixError::new(
1668
+ LixError::CODE_SCHEMA_DEFINITION,
1669
+ format!(
1670
+ "foreign key on schema '{}' references missing schema '{}'",
1671
+ row.schema_key, foreign_key.referenced_schema_key
1672
+ ),
1673
+ )
1674
+ })?;
1675
+ let key = PendingForeignKeyTargetKey {
1676
+ schema_key: target_key.schema_key,
1677
+ schema_version: target_key.schema_version,
1678
+ version_id: row.version_id.clone(),
1679
+ file_id: row.file_id.clone(),
1680
+ pointer_group: foreign_key.referenced_properties.clone(),
1681
+ value: local_value,
1682
+ };
1683
+ if pending_constraints.has_fk_target_key(&key) {
1684
+ return Ok(None);
1685
+ }
1686
+ Ok(Some(UnresolvedForeignKeyCheck {
1687
+ source_identity: LiveStateRowIdentity {
1688
+ version_id: row.version_id.clone(),
1689
+ schema_key: row.schema_key.clone(),
1690
+ entity_id: row.entity_id.clone(),
1691
+ file_id: row.file_id.clone(),
1692
+ },
1693
+ source_schema_key: row.schema_key.clone(),
1694
+ source_pointer_group: foreign_key.local_properties.clone(),
1695
+ target: UnresolvedForeignKeyTarget::Key(key),
1696
+ }))
1697
+ }
1698
+
1699
+ fn validate_pending_state_surface_foreign_key(
1700
+ row: &StagedStateRow,
1701
+ foreign_key: &ForeignKeyDefinition,
1702
+ snapshot: &JsonValue,
1703
+ pending_constraints: &PendingConstraintIndexes,
1704
+ ) -> Result<Option<UnresolvedForeignKeyCheck>, LixError> {
1705
+ let target_identity = state_surface_target_identity(&row.version_id, foreign_key, snapshot)?;
1706
+ if pending_constraints.tombstones_target_identity(&target_identity) {
1707
+ return Err(LixError::new(
1708
+ LixError::CODE_FOREIGN_KEY,
1709
+ format!(
1710
+ "foreign key on {}.{} references target deleted in this transaction",
1711
+ row.schema_key,
1712
+ format_pointer_group(&foreign_key.local_properties)
1713
+ ),
1714
+ ));
1715
+ }
1716
+ if pending_constraints.has_identity_target(&target_identity) {
1717
+ return Ok(None);
1718
+ }
1719
+ Ok(Some(UnresolvedForeignKeyCheck {
1720
+ source_identity: LiveStateRowIdentity {
1721
+ version_id: row.version_id.clone(),
1722
+ schema_key: row.schema_key.clone(),
1723
+ entity_id: row.entity_id.clone(),
1724
+ file_id: row.file_id.clone(),
1725
+ },
1726
+ source_schema_key: row.schema_key.clone(),
1727
+ source_pointer_group: foreign_key.local_properties.clone(),
1728
+ target: UnresolvedForeignKeyTarget::StateSurfaceIdentity(target_identity),
1729
+ }))
1730
+ }
1731
+
1732
+ async fn validate_committed_foreign_keys(
1733
+ input: &TransactionValidationInput<'_>,
1734
+ pending_constraints: &PendingConstraintIndexes,
1735
+ unresolved_checks: &[UnresolvedForeignKeyCheck],
1736
+ ) -> Result<Vec<UnresolvedForeignKeyCheck>, LixError> {
1737
+ let mut still_unresolved = Vec::new();
1738
+ for check in unresolved_checks {
1739
+ let resolved = match &check.target {
1740
+ UnresolvedForeignKeyTarget::Key(target) => {
1741
+ committed_normal_foreign_key_target_exists(
1742
+ input.live_state,
1743
+ pending_constraints,
1744
+ target,
1745
+ )
1746
+ .await?
1747
+ }
1748
+ UnresolvedForeignKeyTarget::StateSurfaceIdentity(target_identity) => {
1749
+ committed_state_surface_foreign_key_target_exists(
1750
+ input.live_state,
1751
+ pending_constraints,
1752
+ target_identity,
1753
+ )
1754
+ .await?
1755
+ }
1756
+ };
1757
+ if !resolved {
1758
+ still_unresolved.push(check.clone());
1759
+ }
1760
+ }
1761
+ Ok(still_unresolved)
1762
+ }
1763
+
1764
+ fn reject_unresolved_foreign_keys(
1765
+ unresolved_checks: &[UnresolvedForeignKeyCheck],
1766
+ ) -> Result<(), LixError> {
1767
+ let Some(check) = unresolved_checks.first() else {
1768
+ return Ok(());
1769
+ };
1770
+ Err(LixError::new(
1771
+ LixError::CODE_FOREIGN_KEY,
1772
+ format!(
1773
+ "foreign key on schema '{}' row '{}' via {} has no matching target in version '{}'{}",
1774
+ check.source_schema_key,
1775
+ check.source_identity.entity_id.as_string()?,
1776
+ format_pointer_group(&check.source_pointer_group),
1777
+ check.source_identity.version_id,
1778
+ unresolved_foreign_key_target_description(&check.target)?
1779
+ ),
1780
+ ))
1781
+ }
1782
+
1783
+ fn unresolved_foreign_key_target_description(
1784
+ target: &UnresolvedForeignKeyTarget,
1785
+ ) -> Result<String, LixError> {
1786
+ match target {
1787
+ UnresolvedForeignKeyTarget::Key(target) => Ok(format!(
1788
+ " for target '{}.{}' value {}",
1789
+ target.schema_key,
1790
+ format_pointer_group(&target.pointer_group),
1791
+ target.value.display()
1792
+ )),
1793
+ UnresolvedForeignKeyTarget::StateSurfaceIdentity(target) => Ok(format!(
1794
+ " for target '{}:{}'",
1795
+ target.schema_key,
1796
+ target.entity_id.as_string()?
1797
+ )),
1798
+ }
1799
+ }
1800
+
1801
+ async fn committed_normal_foreign_key_target_exists(
1802
+ live_state: &dyn LiveStateReader,
1803
+ pending_constraints: &PendingConstraintIndexes,
1804
+ target: &PendingForeignKeyTargetKey,
1805
+ ) -> Result<bool, LixError> {
1806
+ let rows = live_state
1807
+ .scan_rows(&LiveStateScanRequest {
1808
+ filter: LiveStateFilter {
1809
+ schema_keys: vec![target.schema_key.clone()],
1810
+ version_ids: vec![target.version_id.clone()],
1811
+ file_ids: vec![nullable_filter_from_option(&target.file_id)],
1812
+ include_tombstones: false,
1813
+ ..Default::default()
1814
+ },
1815
+ ..Default::default()
1816
+ })
1817
+ .await?;
1818
+
1819
+ for row in rows {
1820
+ if !committed_row_is_exact_version_scoped(&row, &target.version_id) {
1821
+ continue;
1822
+ }
1823
+ if pending_constraints.tombstones_identity(&row) {
1824
+ continue;
1825
+ }
1826
+ if row.schema_key != target.schema_key
1827
+ || row.schema_version != target.schema_version
1828
+ || row.file_id != target.file_id
1829
+ {
1830
+ continue;
1831
+ }
1832
+ let Some(snapshot_content) = row.snapshot_content.as_deref() else {
1833
+ continue;
1834
+ };
1835
+ let snapshot = serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
1836
+ LixError::new(
1837
+ LixError::CODE_SCHEMA_VALIDATION,
1838
+ format!(
1839
+ "committed snapshot_content for schema '{}' version '{}' is invalid JSON: {error}",
1840
+ row.schema_key, row.schema_version
1841
+ ),
1842
+ )
1843
+ })?;
1844
+ if UniqueConstraintValue::from_snapshot(&snapshot, &target.pointer_group).as_ref()
1845
+ == Some(&target.value)
1846
+ {
1847
+ return Ok(true);
1848
+ }
1849
+ }
1850
+ Ok(false)
1851
+ }
1852
+
1853
+ async fn committed_state_surface_foreign_key_target_exists(
1854
+ live_state: &dyn LiveStateReader,
1855
+ pending_constraints: &PendingConstraintIndexes,
1856
+ target_identity: &LiveStateRowIdentity,
1857
+ ) -> Result<bool, LixError> {
1858
+ let Some(row) = live_state
1859
+ .load_row(&LiveStateRowRequest {
1860
+ schema_key: target_identity.schema_key.clone(),
1861
+ version_id: target_identity.version_id.clone(),
1862
+ entity_id: target_identity.entity_id.clone(),
1863
+ file_id: nullable_filter_from_option(&target_identity.file_id),
1864
+ })
1865
+ .await?
1866
+ else {
1867
+ return Ok(false);
1868
+ };
1869
+ if pending_constraints.tombstones_identity(&row) {
1870
+ return Ok(false);
1871
+ }
1872
+ Ok(
1873
+ committed_row_is_exact_version_scoped(&row, &target_identity.version_id)
1874
+ && row.schema_key == target_identity.schema_key
1875
+ && row.entity_id == target_identity.entity_id
1876
+ && row.file_id == target_identity.file_id,
1877
+ )
1878
+ }
1879
+
1880
+ fn state_surface_target_identity(
1881
+ version_id: &str,
1882
+ foreign_key: &ForeignKeyDefinition,
1883
+ snapshot: &JsonValue,
1884
+ ) -> Result<LiveStateRowIdentity, LixError> {
1885
+ let entity_id =
1886
+ state_surface_local_value_for_referenced_pointer(foreign_key, snapshot, "/entity_id")?;
1887
+ let schema_key =
1888
+ state_surface_local_value_for_referenced_pointer(foreign_key, snapshot, "/schema_key")?;
1889
+ let file_id = state_surface_optional_local_value_for_referenced_pointer(
1890
+ foreign_key,
1891
+ snapshot,
1892
+ "/file_id",
1893
+ )?;
1894
+ Ok(LiveStateRowIdentity {
1895
+ version_id: version_id.to_string(),
1896
+ schema_key,
1897
+ entity_id: EntityIdentity::from_string(&entity_id).map_err(|error| {
1898
+ LixError::new(
1899
+ LixError::CODE_FOREIGN_KEY,
1900
+ format!("state-surface foreign key entity_id is invalid: {error}"),
1901
+ )
1902
+ })?,
1903
+ file_id,
1904
+ })
1905
+ }
1906
+
1907
+ fn state_surface_local_value_for_referenced_pointer(
1908
+ foreign_key: &ForeignKeyDefinition,
1909
+ snapshot: &JsonValue,
1910
+ referenced_pointer: &str,
1911
+ ) -> Result<String, LixError> {
1912
+ state_surface_optional_local_value_for_referenced_pointer(
1913
+ foreign_key,
1914
+ snapshot,
1915
+ referenced_pointer,
1916
+ )?
1917
+ .ok_or_else(|| {
1918
+ LixError::new(
1919
+ LixError::CODE_FOREIGN_KEY,
1920
+ format!("state-surface foreign key target '{referenced_pointer}' is missing"),
1921
+ )
1922
+ })
1923
+ }
1924
+
1925
+ fn state_surface_optional_local_value_for_referenced_pointer(
1926
+ foreign_key: &ForeignKeyDefinition,
1927
+ snapshot: &JsonValue,
1928
+ referenced_pointer: &str,
1929
+ ) -> Result<Option<String>, LixError> {
1930
+ let referenced_pointer = parse_json_pointer(referenced_pointer)?;
1931
+ let Some(index) = foreign_key
1932
+ .referenced_properties
1933
+ .iter()
1934
+ .position(|pointer| pointer == &referenced_pointer)
1935
+ else {
1936
+ return Ok(None);
1937
+ };
1938
+ let local_pointer = &foreign_key.local_properties[index];
1939
+ let Some(value) = json_pointer_get(snapshot, local_pointer) else {
1940
+ return Ok(None);
1941
+ };
1942
+ if value.is_null() {
1943
+ return Ok(None);
1944
+ }
1945
+ value
1946
+ .as_str()
1947
+ .map(|value| Some(value.to_string()))
1948
+ .ok_or_else(|| {
1949
+ LixError::new(
1950
+ LixError::CODE_FOREIGN_KEY,
1951
+ format!(
1952
+ "state-surface foreign key value at '{}' must be a string",
1953
+ format_json_pointer(local_pointer)
1954
+ ),
1955
+ )
1956
+ })
1957
+ }
1958
+
1959
+ async fn validate_committed_unique_constraints(
1960
+ input: &TransactionValidationInput<'_>,
1961
+ pending_constraints: &PendingConstraintIndexes,
1962
+ ) -> Result<(), LixError> {
1963
+ for (key, pending_entity_id) in &pending_constraints.unique_values {
1964
+ let committed_rows = input
1965
+ .live_state
1966
+ .scan_rows(&LiveStateScanRequest {
1967
+ filter: LiveStateFilter {
1968
+ schema_keys: vec![key.schema_key.clone()],
1969
+ version_ids: vec![key.version_id.clone()],
1970
+ file_ids: vec![nullable_filter_from_option(&key.file_id)],
1971
+ include_tombstones: false,
1972
+ ..Default::default()
1973
+ },
1974
+ ..Default::default()
1975
+ })
1976
+ .await?;
1977
+
1978
+ for committed_row in committed_rows {
1979
+ if !committed_row_is_in_exact_validation_scope(&committed_row, key) {
1980
+ continue;
1981
+ }
1982
+ if committed_row.entity_id == *pending_entity_id {
1983
+ continue;
1984
+ }
1985
+ if pending_constraints.tombstones_identity(&committed_row) {
1986
+ continue;
1987
+ }
1988
+ let Some(snapshot_content) = committed_row.snapshot_content.as_deref() else {
1989
+ continue;
1990
+ };
1991
+ let snapshot = serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
1992
+ LixError::new(
1993
+ LixError::CODE_SCHEMA_VALIDATION,
1994
+ format!(
1995
+ "committed snapshot_content for schema '{}' version '{}' is invalid JSON: {error}",
1996
+ committed_row.schema_key, committed_row.schema_version
1997
+ ),
1998
+ )
1999
+ })?;
2000
+ let Some(committed_value) =
2001
+ UniqueConstraintValue::from_snapshot(&snapshot, &key.pointer_group)
2002
+ else {
2003
+ continue;
2004
+ };
2005
+ if committed_value == key.value {
2006
+ return Err(LixError::new(
2007
+ LixError::CODE_UNIQUE,
2008
+ format!(
2009
+ "unique constraint violation on {}.{} for value {}: committed row '{}' conflicts with staged row '{}'",
2010
+ key.schema_key,
2011
+ format_pointer_group(&key.pointer_group),
2012
+ key.value.display(),
2013
+ committed_row.entity_id.as_string()?,
2014
+ pending_entity_id.as_string()?
2015
+ ),
2016
+ ));
2017
+ }
2018
+ }
2019
+ }
2020
+ Ok(())
2021
+ }
2022
+
2023
+ fn nullable_filter_from_option(value: &Option<String>) -> NullableKeyFilter<String> {
2024
+ match value {
2025
+ Some(value) => NullableKeyFilter::Value(value.clone()),
2026
+ None => NullableKeyFilter::Null,
2027
+ }
2028
+ }
2029
+
2030
+ fn committed_row_is_in_exact_validation_scope(row: &LiveStateRow, key: &PendingUniqueKey) -> bool {
2031
+ // LiveStateReader may return serving projections such as global rows
2032
+ // projected into a requested version. Constraint validation is root-local:
2033
+ // only rows authored in the exact version participate.
2034
+ row.version_id == key.version_id
2035
+ && row.schema_key == key.schema_key
2036
+ && row.schema_version == key.schema_version
2037
+ && row.untracked == key.untracked
2038
+ && row.file_id == key.file_id
2039
+ && committed_row_is_exact_version_scoped(row, &key.version_id)
2040
+ }
2041
+
2042
+ fn committed_row_is_exact_version_scoped(row: &LiveStateRow, version_id: &str) -> bool {
2043
+ row.version_id == version_id && row.global == (row.version_id == crate::GLOBAL_VERSION_ID)
2044
+ }
2045
+
2046
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2047
+ struct UniqueConstraintValue(Vec<String>);
2048
+
2049
+ impl UniqueConstraintValue {
2050
+ #[cfg(test)]
2051
+ fn string_values<const N: usize>(values: [&str; N]) -> Self {
2052
+ Self(
2053
+ values
2054
+ .into_iter()
2055
+ .map(|value| format!("{value:?}"))
2056
+ .collect(),
2057
+ )
2058
+ }
2059
+
2060
+ fn from_entity_identity(identity: &EntityIdentity) -> Self {
2061
+ Self(
2062
+ identity
2063
+ .parts
2064
+ .iter()
2065
+ .map(|part| match part {
2066
+ EntityIdentityPart::String(value) => format!("{value:?}"),
2067
+ EntityIdentityPart::Bool(value) => value.to_string(),
2068
+ EntityIdentityPart::Number(value) => value.clone(),
2069
+ })
2070
+ .collect(),
2071
+ )
2072
+ }
2073
+
2074
+ fn from_snapshot(snapshot: &JsonValue, pointers: &[Vec<String>]) -> Option<Self> {
2075
+ let mut values = Vec::with_capacity(pointers.len());
2076
+ for pointer in pointers {
2077
+ let value = json_pointer_get(snapshot, pointer)?;
2078
+ values.push(stable_unique_value(value));
2079
+ }
2080
+ Some(Self(values))
2081
+ }
2082
+
2083
+ fn from_snapshot_non_null(snapshot: &JsonValue, pointers: &[Vec<String>]) -> Option<Self> {
2084
+ let mut values = Vec::with_capacity(pointers.len());
2085
+ for pointer in pointers {
2086
+ let value = json_pointer_get(snapshot, pointer)?;
2087
+ if value.is_null() {
2088
+ return None;
2089
+ }
2090
+ values.push(stable_unique_value(value));
2091
+ }
2092
+ Some(Self(values))
2093
+ }
2094
+
2095
+ fn display(&self) -> String {
2096
+ if let [value] = self.0.as_slice() {
2097
+ return value.clone();
2098
+ }
2099
+ format!("({})", self.0.join(", "))
2100
+ }
2101
+ }
2102
+
2103
+ fn stable_unique_value(value: &JsonValue) -> String {
2104
+ match value {
2105
+ JsonValue::String(value) => format!("{value:?}"),
2106
+ JsonValue::Number(value) => value.to_string(),
2107
+ JsonValue::Bool(value) => value.to_string(),
2108
+ JsonValue::Null => "null".to_string(),
2109
+ JsonValue::Array(_) | JsonValue::Object(_) => value.to_string(),
2110
+ }
2111
+ }
2112
+
2113
+ fn pointer_groups(schema: &JsonValue, field: &str) -> Result<Vec<Vec<Vec<String>>>, LixError> {
2114
+ let Some(value) = schema.get(field) else {
2115
+ return Ok(Vec::new());
2116
+ };
2117
+ let groups = if field == "x-lix-primary-key" {
2118
+ vec![value]
2119
+ } else {
2120
+ value
2121
+ .as_array()
2122
+ .map(|groups| groups.iter().collect())
2123
+ .unwrap_or_default()
2124
+ };
2125
+ groups
2126
+ .into_iter()
2127
+ .map(|group| {
2128
+ let group = group.as_array().ok_or_else(|| {
2129
+ LixError::new(
2130
+ LixError::CODE_SCHEMA_DEFINITION,
2131
+ format!("schema {field} must contain arrays of JSON Pointers"),
2132
+ )
2133
+ })?;
2134
+ group
2135
+ .iter()
2136
+ .enumerate()
2137
+ .map(|(index, pointer)| {
2138
+ let pointer = pointer.as_str().ok_or_else(|| {
2139
+ LixError::new(
2140
+ LixError::CODE_SCHEMA_DEFINITION,
2141
+ format!("schema {field} entry at index {index} must be a string"),
2142
+ )
2143
+ })?;
2144
+ parse_json_pointer(pointer)
2145
+ })
2146
+ .collect::<Result<Vec<_>, _>>()
2147
+ })
2148
+ .collect()
2149
+ }
2150
+
2151
+ fn parse_json_pointer(pointer: &str) -> Result<Vec<String>, LixError> {
2152
+ if pointer.is_empty() {
2153
+ return Ok(Vec::new());
2154
+ }
2155
+ if !pointer.starts_with('/') {
2156
+ return Err(LixError::new(
2157
+ LixError::CODE_SCHEMA_DEFINITION,
2158
+ format!("invalid JSON pointer '{pointer}'"),
2159
+ ));
2160
+ }
2161
+ pointer[1..]
2162
+ .split('/')
2163
+ .map(unescape_json_pointer_segment)
2164
+ .collect()
2165
+ }
2166
+
2167
+ fn unescape_json_pointer_segment(segment: &str) -> Result<String, LixError> {
2168
+ let mut output = String::new();
2169
+ let mut chars = segment.chars();
2170
+ while let Some(ch) = chars.next() {
2171
+ if ch == '~' {
2172
+ match chars.next() {
2173
+ Some('0') => output.push('~'),
2174
+ Some('1') => output.push('/'),
2175
+ _ => {
2176
+ return Err(LixError::new(
2177
+ LixError::CODE_SCHEMA_DEFINITION,
2178
+ "invalid JSON pointer escape",
2179
+ ));
2180
+ }
2181
+ }
2182
+ } else {
2183
+ output.push(ch);
2184
+ }
2185
+ }
2186
+ Ok(output)
2187
+ }
2188
+
2189
+ fn format_pointer_group(group: &[Vec<String>]) -> String {
2190
+ let pointers = group
2191
+ .iter()
2192
+ .map(|pointer| format_json_pointer(pointer))
2193
+ .collect::<Vec<_>>();
2194
+ if let [pointer] = pointers.as_slice() {
2195
+ pointer.clone()
2196
+ } else {
2197
+ format!("({})", pointers.join(", "))
2198
+ }
2199
+ }
2200
+
2201
+ fn format_json_pointer(pointer: &[String]) -> String {
2202
+ if pointer.is_empty() {
2203
+ return String::new();
2204
+ }
2205
+ format!(
2206
+ "/{}",
2207
+ pointer
2208
+ .iter()
2209
+ .map(|segment| segment.replace('~', "~0").replace('/', "~1"))
2210
+ .collect::<Vec<_>>()
2211
+ .join("/")
2212
+ )
2213
+ }
2214
+
2215
+ fn primary_key_identity_error(
2216
+ row: &StagedStateRow,
2217
+ primary_key_paths: &[Vec<String>],
2218
+ error: EntityIdentityError,
2219
+ ) -> LixError {
2220
+ let reason = match error {
2221
+ EntityIdentityError::EmptyPrimaryKey => "empty x-lix-primary-key".to_string(),
2222
+ EntityIdentityError::EmptyPrimaryKeyPath { index } => {
2223
+ format!("empty x-lix-primary-key pointer at index {index}")
2224
+ }
2225
+ EntityIdentityError::MissingPrimaryKeyValue { index } => {
2226
+ let pointer = format_json_pointer(&primary_key_paths[index]);
2227
+ format!("missing value at primary-key pointer '{pointer}'")
2228
+ }
2229
+ EntityIdentityError::NullPrimaryKeyValue { index } => {
2230
+ let pointer = format_json_pointer(&primary_key_paths[index]);
2231
+ format!("null value at primary-key pointer '{pointer}'")
2232
+ }
2233
+ EntityIdentityError::EmptyPrimaryKeyValue { index } => {
2234
+ let pointer = format_json_pointer(&primary_key_paths[index]);
2235
+ format!("empty value at primary-key pointer '{pointer}'")
2236
+ }
2237
+ EntityIdentityError::UnsupportedPrimaryKeyValue { index } => {
2238
+ let pointer = format_json_pointer(&primary_key_paths[index]);
2239
+ format!("unsupported non-scalar value at primary-key pointer '{pointer}'")
2240
+ }
2241
+ EntityIdentityError::InvalidEncodedEntityIdentity => {
2242
+ "invalid encoded entity identity".to_string()
2243
+ }
2244
+ };
2245
+ LixError::new(
2246
+ LixError::CODE_UNIQUE,
2247
+ format!(
2248
+ "primary-key constraint violation on schema '{}' version '{}': {reason}",
2249
+ row.schema_key, row.schema_version
2250
+ ),
2251
+ )
2252
+ }
2253
+
2254
+ #[derive(Debug, Clone)]
2255
+ struct ForeignKeyDefinition {
2256
+ local_properties: Vec<Vec<String>>,
2257
+ referenced_schema_key: String,
2258
+ referenced_properties: Vec<Vec<String>>,
2259
+ }
2260
+
2261
+ fn foreign_key_definitions(schema: &JsonValue) -> Result<Vec<ForeignKeyDefinition>, LixError> {
2262
+ let Some(value) = schema.get("x-lix-foreign-keys") else {
2263
+ return Ok(Vec::new());
2264
+ };
2265
+ let Some(foreign_keys) = value.as_array() else {
2266
+ return Err(LixError::new(
2267
+ LixError::CODE_SCHEMA_DEFINITION,
2268
+ "schema x-lix-foreign-keys must be an array",
2269
+ ));
2270
+ };
2271
+
2272
+ foreign_keys
2273
+ .iter()
2274
+ .enumerate()
2275
+ .map(|(index, foreign_key)| {
2276
+ let object = foreign_key.as_object().ok_or_else(|| {
2277
+ LixError::new(
2278
+ LixError::CODE_SCHEMA_DEFINITION,
2279
+ format!("x-lix-foreign-keys[{index}] must be an object"),
2280
+ )
2281
+ })?;
2282
+ let references = object
2283
+ .get("references")
2284
+ .and_then(JsonValue::as_object)
2285
+ .ok_or_else(|| {
2286
+ LixError::new(
2287
+ LixError::CODE_SCHEMA_DEFINITION,
2288
+ format!("x-lix-foreign-keys[{index}].references must be an object"),
2289
+ )
2290
+ })?;
2291
+ let referenced_schema_key = references
2292
+ .get("schemaKey")
2293
+ .and_then(JsonValue::as_str)
2294
+ .ok_or_else(|| {
2295
+ LixError::new(
2296
+ LixError::CODE_SCHEMA_DEFINITION,
2297
+ format!("x-lix-foreign-keys[{index}].references.schemaKey must be a string"),
2298
+ )
2299
+ })?
2300
+ .to_string();
2301
+ let local_properties = pointer_array(
2302
+ object.get("properties"),
2303
+ &format!("x-lix-foreign-keys[{index}].properties"),
2304
+ )?;
2305
+ let referenced_properties = pointer_array(
2306
+ references.get("properties"),
2307
+ &format!("x-lix-foreign-keys[{index}].references.properties"),
2308
+ )?;
2309
+ if local_properties.len() != referenced_properties.len() {
2310
+ return Err(LixError::new(
2311
+ LixError::CODE_SCHEMA_DEFINITION,
2312
+ format!(
2313
+ "x-lix-foreign-keys[{index}] must have the same number of local and referenced properties"
2314
+ ),
2315
+ ));
2316
+ }
2317
+ Ok(ForeignKeyDefinition {
2318
+ local_properties,
2319
+ referenced_schema_key,
2320
+ referenced_properties,
2321
+ })
2322
+ })
2323
+ .collect()
2324
+ }
2325
+
2326
+ fn pointer_array(value: Option<&JsonValue>, label: &str) -> Result<Vec<Vec<String>>, LixError> {
2327
+ let Some(value) = value else {
2328
+ return Err(LixError::new(
2329
+ LixError::CODE_SCHEMA_DEFINITION,
2330
+ format!("{label} is required"),
2331
+ ));
2332
+ };
2333
+ let Some(values) = value.as_array() else {
2334
+ return Err(LixError::new(
2335
+ LixError::CODE_SCHEMA_DEFINITION,
2336
+ format!("{label} must be an array"),
2337
+ ));
2338
+ };
2339
+ if values.is_empty() {
2340
+ return Err(LixError::new(
2341
+ LixError::CODE_SCHEMA_DEFINITION,
2342
+ format!("{label} must not be empty"),
2343
+ ));
2344
+ }
2345
+ values
2346
+ .iter()
2347
+ .enumerate()
2348
+ .map(|(index, pointer)| {
2349
+ let pointer = pointer.as_str().ok_or_else(|| {
2350
+ LixError::new(
2351
+ LixError::CODE_SCHEMA_DEFINITION,
2352
+ format!("{label}[{index}] must be a string"),
2353
+ )
2354
+ })?;
2355
+ parse_json_pointer(pointer)
2356
+ })
2357
+ .collect()
2358
+ }
2359
+
2360
+ fn validate_foreign_key_definition(
2361
+ catalog: &TransactionSchemaCatalog,
2362
+ source_key: &SchemaCatalogKey,
2363
+ source_schema: &JsonValue,
2364
+ foreign_key: ForeignKeyDefinition,
2365
+ ) -> Result<(), LixError> {
2366
+ for pointer in &foreign_key.local_properties {
2367
+ validate_schema_field_pointer(source_schema, pointer).map_err(|detail| {
2368
+ LixError::new(
2369
+ LixError::CODE_SCHEMA_DEFINITION,
2370
+ format!(
2371
+ "foreign key on schema '{}' references missing local property '{}': {detail}",
2372
+ source_key.schema_key,
2373
+ format_json_pointer(pointer)
2374
+ ),
2375
+ )
2376
+ })?;
2377
+ }
2378
+
2379
+ let target_schema = catalog
2380
+ .schema_by_key(&foreign_key.referenced_schema_key)
2381
+ .or_else(|| {
2382
+ (foreign_key.referenced_schema_key == STATE_SURFACE_SCHEMA_KEY)
2383
+ .then_some(state_surface_foreign_key_schema())
2384
+ })
2385
+ .ok_or_else(|| {
2386
+ LixError::new(
2387
+ LixError::CODE_SCHEMA_DEFINITION,
2388
+ format!(
2389
+ "foreign key on schema '{}' references missing schema '{}'",
2390
+ source_key.schema_key, foreign_key.referenced_schema_key
2391
+ ),
2392
+ )
2393
+ })?;
2394
+
2395
+ for pointer in &foreign_key.referenced_properties {
2396
+ validate_schema_field_pointer(target_schema, pointer).map_err(|detail| {
2397
+ LixError::new(
2398
+ LixError::CODE_SCHEMA_DEFINITION,
2399
+ format!(
2400
+ "foreign key on schema '{}' references missing target property '{}.{}': {detail}",
2401
+ source_key.schema_key,
2402
+ foreign_key.referenced_schema_key,
2403
+ format_json_pointer(pointer)
2404
+ ),
2405
+ )
2406
+ })?;
2407
+ }
2408
+
2409
+ if foreign_key.referenced_schema_key == STATE_SURFACE_SCHEMA_KEY {
2410
+ validate_state_surface_foreign_key_target(source_key, &foreign_key)?;
2411
+ } else if !referenced_properties_are_keyed(target_schema, &foreign_key.referenced_properties)? {
2412
+ return Err(LixError::new(
2413
+ LixError::CODE_SCHEMA_DEFINITION,
2414
+ format!(
2415
+ "foreign key on schema '{}' references '{}.{}', but referenced properties must match the target primary key or a unique constraint",
2416
+ source_key.schema_key,
2417
+ foreign_key.referenced_schema_key,
2418
+ format_pointer_group(&foreign_key.referenced_properties)
2419
+ ),
2420
+ ));
2421
+ }
2422
+
2423
+ Ok(())
2424
+ }
2425
+
2426
+ fn state_surface_foreign_key_schema() -> &'static JsonValue {
2427
+ crate::schema::lix_state_surface_schema_definition()
2428
+ }
2429
+
2430
+ fn validate_state_surface_foreign_key_target(
2431
+ source_key: &SchemaCatalogKey,
2432
+ foreign_key: &ForeignKeyDefinition,
2433
+ ) -> Result<(), LixError> {
2434
+ for required_pointer in ["/entity_id", "/schema_key"] {
2435
+ let required_pointer = parse_json_pointer(required_pointer)?;
2436
+ if !foreign_key
2437
+ .referenced_properties
2438
+ .iter()
2439
+ .any(|pointer| pointer == &required_pointer)
2440
+ {
2441
+ return Err(LixError::new(
2442
+ LixError::CODE_SCHEMA_DEFINITION,
2443
+ format!(
2444
+ "foreign key on schema '{}' references lix_state and must include '{}'",
2445
+ source_key.schema_key,
2446
+ format_json_pointer(&required_pointer)
2447
+ ),
2448
+ ));
2449
+ }
2450
+ }
2451
+ Ok(())
2452
+ }
2453
+
2454
+ fn validate_schema_field_pointer(schema: &JsonValue, pointer: &[String]) -> Result<(), String> {
2455
+ if pointer.is_empty() {
2456
+ return Err("empty pointer does not name a field".to_string());
2457
+ }
2458
+ let mut current = schema;
2459
+ for segment in pointer {
2460
+ let properties = current
2461
+ .get("properties")
2462
+ .and_then(JsonValue::as_object)
2463
+ .ok_or_else(|| {
2464
+ format!(
2465
+ "schema segment before '{}' has no object properties",
2466
+ segment
2467
+ )
2468
+ })?;
2469
+ current = properties
2470
+ .get(segment)
2471
+ .ok_or_else(|| format!("property '{}' does not exist", segment))?;
2472
+ }
2473
+ Ok(())
2474
+ }
2475
+
2476
+ fn referenced_properties_are_keyed(
2477
+ target_schema: &JsonValue,
2478
+ referenced_properties: &[Vec<String>],
2479
+ ) -> Result<bool, LixError> {
2480
+ if let Some(primary_key) = pointer_groups(target_schema, "x-lix-primary-key")?
2481
+ .into_iter()
2482
+ .next()
2483
+ {
2484
+ if primary_key == referenced_properties {
2485
+ return Ok(true);
2486
+ }
2487
+ }
2488
+ Ok(pointer_groups(target_schema, "x-lix-unique")?
2489
+ .into_iter()
2490
+ .any(|unique_group| unique_group == referenced_properties))
2491
+ }
2492
+
2493
+ fn validate_foreign_key_definitions(catalog: &TransactionSchemaCatalog) -> Result<(), LixError> {
2494
+ for (key, schema) in catalog.schemas() {
2495
+ let foreign_keys = foreign_key_definitions(schema)?;
2496
+ for foreign_key in foreign_keys {
2497
+ validate_foreign_key_definition(catalog, key, schema, foreign_key)?;
2498
+ }
2499
+ }
2500
+ Ok(())
2501
+ }
2502
+
2503
+ /// Per-transaction compiled schema cache.
2504
+ ///
2505
+ /// Compilation is lazy and keyed by exact `(schema_key, schema_version)`, so a
2506
+ /// transaction that writes many rows for one schema pays the JSON Schema
2507
+ /// compilation cost only once.
2508
+ struct CompiledSchemaCatalog<'a> {
2509
+ schema_catalog: &'a TransactionSchemaCatalog,
2510
+ compiled_by_key: BTreeMap<SchemaCatalogKey, JSONSchema>,
2511
+ }
2512
+
2513
+ impl<'a> CompiledSchemaCatalog<'a> {
2514
+ fn new(schema_catalog: &'a TransactionSchemaCatalog) -> Self {
2515
+ Self {
2516
+ schema_catalog,
2517
+ compiled_by_key: BTreeMap::new(),
2518
+ }
2519
+ }
2520
+
2521
+ fn compiled_schema(
2522
+ &mut self,
2523
+ schema_key: &str,
2524
+ schema_version: &str,
2525
+ ) -> Result<&JSONSchema, LixError> {
2526
+ let key = SchemaCatalogKey {
2527
+ schema_key: schema_key.to_string(),
2528
+ schema_version: schema_version.to_string(),
2529
+ };
2530
+ if !self.compiled_by_key.contains_key(&key) {
2531
+ let schema = self
2532
+ .schema_catalog
2533
+ .schema(schema_key, schema_version)
2534
+ .ok_or_else(|| {
2535
+ LixError::new(
2536
+ LixError::CODE_SCHEMA_DEFINITION,
2537
+ format!(
2538
+ "schema '{schema_key}' version '{schema_version}' is not visible to this transaction"
2539
+ ),
2540
+ )
2541
+ })?;
2542
+ let compiled = compile_lix_schema(schema)?;
2543
+ self.compiled_by_key.insert(key.clone(), compiled);
2544
+ }
2545
+ self.compiled_by_key.get(&key).ok_or_else(|| {
2546
+ LixError::new(
2547
+ LixError::CODE_UNKNOWN,
2548
+ format!(
2549
+ "compiled schema cache lookup failed for schema '{schema_key}' version '{schema_version}'"
2550
+ ),
2551
+ )
2552
+ })
2553
+ }
2554
+
2555
+ #[cfg(test)]
2556
+ fn compiled_count(&self) -> usize {
2557
+ self.compiled_by_key.len()
2558
+ }
2559
+ }
2560
+
2561
+ #[cfg(test)]
2562
+ fn validate_pending_registered_schema(
2563
+ row: &StagedStateRow,
2564
+ registered_schema_definition: &JsonValue,
2565
+ ) -> Result<(SchemaKey, JsonValue), LixError> {
2566
+ let snapshot_content = row.snapshot_content.as_deref().ok_or_else(|| {
2567
+ LixError::new(
2568
+ LixError::CODE_SCHEMA_DEFINITION,
2569
+ "registered schema write requires snapshot_content",
2570
+ )
2571
+ })?;
2572
+ let snapshot = serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
2573
+ LixError::new(
2574
+ LixError::CODE_SCHEMA_DEFINITION,
2575
+ format!("pending registered schema snapshot_content is invalid JSON: {error}"),
2576
+ )
2577
+ })?;
2578
+ if !snapshot.get("value").is_some_and(JsonValue::is_object) {
2579
+ validate_lix_schema(registered_schema_definition, &snapshot)?;
2580
+ }
2581
+ // A registered-schema row stores the schema definition under `value`.
2582
+ // Validate both layers: the outer row must match the builtin
2583
+ // `lix_registered_schema` schema, and the inner definition must be a valid
2584
+ // Lix schema before it can extend the transaction-visible catalog.
2585
+ let (key, schema) = schema_from_registered_snapshot(&snapshot)?;
2586
+ reject_unsupported_registered_schema_version(&key)?;
2587
+ reject_seed_schema_registration(&key)?;
2588
+ validate_lix_schema_definition(&schema)?;
2589
+ validate_lix_schema(registered_schema_definition, &snapshot)?;
2590
+ Ok((key, schema))
2591
+ }
2592
+
2593
+ #[cfg(test)]
2594
+ fn reject_seed_schema_registration(key: &SchemaKey) -> Result<(), LixError> {
2595
+ if is_seed_schema_key(&key.schema_key) {
2596
+ return Err(LixError::new(
2597
+ LixError::CODE_SCHEMA_DEFINITION,
2598
+ format!(
2599
+ "schema '{}' is a system schema and cannot be registered at runtime",
2600
+ key.schema_key
2601
+ ),
2602
+ ));
2603
+ }
2604
+ Ok(())
2605
+ }
2606
+
2607
+ #[cfg(test)]
2608
+ mod tests {
2609
+ use async_trait::async_trait;
2610
+ use serde_json::json;
2611
+
2612
+ use super::*;
2613
+ use crate::live_state::{LiveStateRow, LiveStateRowRequest, LiveStateScanRequest};
2614
+ use crate::schema::{schema_key_from_definition, seed_schema_definition};
2615
+
2616
+ struct EmptyLiveStateReader;
2617
+
2618
+ #[async_trait]
2619
+ impl LiveStateReader for EmptyLiveStateReader {
2620
+ async fn scan_rows(
2621
+ &self,
2622
+ request: &LiveStateScanRequest,
2623
+ ) -> Result<Vec<LiveStateRow>, LixError> {
2624
+ Ok(test_file_descriptor_rows()
2625
+ .into_iter()
2626
+ .filter(|row| live_state_row_matches_scan(row, request))
2627
+ .collect())
2628
+ }
2629
+
2630
+ async fn load_row(
2631
+ &self,
2632
+ request: &LiveStateRowRequest,
2633
+ ) -> Result<Option<LiveStateRow>, LixError> {
2634
+ Ok(test_file_descriptor_rows()
2635
+ .into_iter()
2636
+ .find(|row| live_state_row_matches_load(row, request)))
2637
+ }
2638
+ }
2639
+
2640
+ fn validation_input<'a>(
2641
+ staged_writes: &'a StagedWriteSet,
2642
+ visible_schemas: &'a [JsonValue],
2643
+ ) -> TransactionValidationInput<'a> {
2644
+ let catalog = Box::leak(Box::new(
2645
+ catalog_from_transaction_parts_unchecked(staged_writes, visible_schemas)
2646
+ .expect("test schema catalog should build"),
2647
+ ));
2648
+ TransactionValidationInput::new(staged_writes, catalog, &EmptyLiveStateReader)
2649
+ }
2650
+
2651
+ fn catalog_from_transaction_input(
2652
+ input: &TransactionValidationInput<'_>,
2653
+ ) -> Result<TransactionSchemaCatalog, LixError> {
2654
+ let catalog = input.schema_catalog.clone();
2655
+ validate_foreign_key_definitions(&catalog)?;
2656
+ Ok(catalog)
2657
+ }
2658
+
2659
+ fn catalog_from_transaction_parts(
2660
+ staged_writes: &StagedWriteSet,
2661
+ visible_schemas: &[JsonValue],
2662
+ ) -> Result<TransactionSchemaCatalog, LixError> {
2663
+ let catalog = catalog_from_transaction_parts_unchecked(staged_writes, visible_schemas)?;
2664
+ let mut pending_keys =
2665
+ BTreeMap::<SchemaCatalogKey, crate::entity_identity::EntityIdentity>::new();
2666
+ for row in staged_writes
2667
+ .state_rows_for_validation()
2668
+ .iter()
2669
+ .filter(|row| row.schema_key == REGISTERED_SCHEMA_KEY)
2670
+ {
2671
+ let snapshot_content = row.snapshot_content.as_deref().ok_or_else(|| {
2672
+ LixError::new(
2673
+ LixError::CODE_SCHEMA_DEFINITION,
2674
+ "registered schema write requires snapshot_content",
2675
+ )
2676
+ })?;
2677
+ let snapshot =
2678
+ serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
2679
+ LixError::new(
2680
+ LixError::CODE_SCHEMA_DEFINITION,
2681
+ format!(
2682
+ "pending registered schema snapshot_content is invalid JSON: {error}"
2683
+ ),
2684
+ )
2685
+ })?;
2686
+ let (key, _) = schema_from_registered_snapshot(&snapshot)?;
2687
+ let catalog_key = SchemaCatalogKey::from_schema_key(key);
2688
+ if let Some(existing_entity_id) =
2689
+ pending_keys.insert(catalog_key.clone(), row.entity_id.clone())
2690
+ {
2691
+ return Err(LixError::new(
2692
+ LixError::CODE_SCHEMA_DEFINITION,
2693
+ format!(
2694
+ "duplicate pending registered schema '{}' version '{}' in transaction: rows '{}' and '{}'",
2695
+ catalog_key.schema_key,
2696
+ catalog_key.schema_version,
2697
+ existing_entity_id.as_string()?,
2698
+ row.entity_id.as_string()?
2699
+ ),
2700
+ ));
2701
+ }
2702
+ }
2703
+ validate_foreign_key_definitions(&catalog)?;
2704
+ Ok(catalog)
2705
+ }
2706
+
2707
+ fn catalog_from_transaction_parts_unchecked(
2708
+ staged_writes: &StagedWriteSet,
2709
+ visible_schemas: &[JsonValue],
2710
+ ) -> Result<TransactionSchemaCatalog, LixError> {
2711
+ let mut catalog = TransactionSchemaCatalog::from_visible_schemas(visible_schemas)?;
2712
+ let staged_rows = staged_writes.state_rows_for_validation();
2713
+ for row in staged_rows
2714
+ .iter()
2715
+ .filter(|row| row.schema_key == REGISTERED_SCHEMA_KEY)
2716
+ {
2717
+ let registered_schema_definition = catalog
2718
+ .schema(REGISTERED_SCHEMA_KEY, "1")
2719
+ .cloned()
2720
+ .ok_or_else(|| {
2721
+ LixError::new(
2722
+ LixError::CODE_SCHEMA_DEFINITION,
2723
+ "lix_registered_schema schema is not visible to this transaction",
2724
+ )
2725
+ })?;
2726
+ let (key, schema) =
2727
+ validate_pending_registered_schema(row, &registered_schema_definition)?;
2728
+ catalog.insert_schema(key, schema);
2729
+ }
2730
+ Ok(catalog)
2731
+ }
2732
+
2733
+ struct StaticLiveStateReader {
2734
+ rows: Vec<LiveStateRow>,
2735
+ }
2736
+
2737
+ #[async_trait]
2738
+ impl LiveStateReader for StaticLiveStateReader {
2739
+ async fn scan_rows(
2740
+ &self,
2741
+ request: &LiveStateScanRequest,
2742
+ ) -> Result<Vec<LiveStateRow>, LixError> {
2743
+ Ok(self
2744
+ .rows
2745
+ .iter()
2746
+ .cloned()
2747
+ .chain(test_file_descriptor_rows())
2748
+ .filter(|row| {
2749
+ request.filter.schema_keys.is_empty()
2750
+ || request.filter.schema_keys.contains(&row.schema_key)
2751
+ })
2752
+ .filter(|row| {
2753
+ request.filter.version_ids.is_empty()
2754
+ || request.filter.version_ids.contains(&row.version_id)
2755
+ })
2756
+ .filter(|row| {
2757
+ request.filter.file_ids.is_empty()
2758
+ || request
2759
+ .filter
2760
+ .file_ids
2761
+ .iter()
2762
+ .any(|filter| filter.matches(row.file_id.as_ref()))
2763
+ })
2764
+ .collect())
2765
+ }
2766
+
2767
+ async fn load_row(
2768
+ &self,
2769
+ request: &LiveStateRowRequest,
2770
+ ) -> Result<Option<LiveStateRow>, LixError> {
2771
+ Ok(self
2772
+ .rows
2773
+ .iter()
2774
+ .cloned()
2775
+ .chain(test_file_descriptor_rows())
2776
+ .find(|row| {
2777
+ row.schema_key == request.schema_key
2778
+ && row.version_id == request.version_id
2779
+ && row.entity_id == request.entity_id
2780
+ && request.file_id.matches(row.file_id.as_ref())
2781
+ }))
2782
+ }
2783
+ }
2784
+
2785
+ struct StrictEmptyLiveStateReader;
2786
+
2787
+ #[async_trait]
2788
+ impl LiveStateReader for StrictEmptyLiveStateReader {
2789
+ async fn scan_rows(
2790
+ &self,
2791
+ _request: &LiveStateScanRequest,
2792
+ ) -> Result<Vec<LiveStateRow>, LixError> {
2793
+ Ok(Vec::new())
2794
+ }
2795
+
2796
+ async fn load_row(
2797
+ &self,
2798
+ _request: &LiveStateRowRequest,
2799
+ ) -> Result<Option<LiveStateRow>, LixError> {
2800
+ Ok(None)
2801
+ }
2802
+ }
2803
+
2804
+ struct StrictStaticLiveStateReader {
2805
+ rows: Vec<LiveStateRow>,
2806
+ }
2807
+
2808
+ #[async_trait]
2809
+ impl LiveStateReader for StrictStaticLiveStateReader {
2810
+ async fn scan_rows(
2811
+ &self,
2812
+ request: &LiveStateScanRequest,
2813
+ ) -> Result<Vec<LiveStateRow>, LixError> {
2814
+ Ok(self
2815
+ .rows
2816
+ .iter()
2817
+ .filter(|row| live_state_row_matches_scan(row, request))
2818
+ .cloned()
2819
+ .collect())
2820
+ }
2821
+
2822
+ async fn load_row(
2823
+ &self,
2824
+ request: &LiveStateRowRequest,
2825
+ ) -> Result<Option<LiveStateRow>, LixError> {
2826
+ Ok(self
2827
+ .rows
2828
+ .iter()
2829
+ .find(|row| live_state_row_matches_load(row, request))
2830
+ .cloned())
2831
+ }
2832
+ }
2833
+
2834
+ #[test]
2835
+ fn schema_catalog_indexes_visible_schemas_by_key_and_version() {
2836
+ let visible_schemas = vec![json!({
2837
+ "x-lix-key": "visible_schema",
2838
+ "x-lix-version": "1",
2839
+ "type": "object",
2840
+ })];
2841
+ let staged_writes = empty_staged_write_set();
2842
+ let input = validation_input(&staged_writes, &visible_schemas);
2843
+
2844
+ let catalog = catalog_from_transaction_input(&input).expect("schema catalog should build");
2845
+
2846
+ assert_eq!(catalog.len(), 1);
2847
+ assert!(catalog.contains("visible_schema", "1"));
2848
+ }
2849
+
2850
+ #[test]
2851
+ fn schema_catalog_includes_pending_registered_schema_rows() {
2852
+ let visible_schemas = vec![
2853
+ registered_schema(),
2854
+ json!({
2855
+ "x-lix-key": "visible_schema",
2856
+ "x-lix-version": "1",
2857
+ "type": "object",
2858
+ }),
2859
+ ];
2860
+ let staged_writes = StagedWriteSet {
2861
+ state_rows: vec![pending_registered_schema_row("pending_schema", "1")],
2862
+ adopted_rows: Vec::new(),
2863
+ ..empty_staged_write_set()
2864
+ };
2865
+ let input = validation_input(&staged_writes, &visible_schemas);
2866
+
2867
+ let catalog = catalog_from_transaction_input(&input).expect("schema catalog should build");
2868
+
2869
+ assert_eq!(catalog.len(), 3);
2870
+ assert!(catalog.contains("visible_schema", "1"));
2871
+ assert!(catalog.contains("pending_schema", "1"));
2872
+ }
2873
+
2874
+ #[test]
2875
+ fn schema_catalog_pending_schema_overrides_same_visible_identity() {
2876
+ let visible_schemas = vec![
2877
+ registered_schema(),
2878
+ json!({
2879
+ "x-lix-key": "same_schema",
2880
+ "x-lix-version": "1",
2881
+ "type": "object",
2882
+ "properties": {
2883
+ "old": { "type": "string" }
2884
+ }
2885
+ }),
2886
+ ];
2887
+ let staged_writes = StagedWriteSet {
2888
+ state_rows: vec![pending_registered_schema_row("same_schema", "1")],
2889
+ adopted_rows: Vec::new(),
2890
+ ..empty_staged_write_set()
2891
+ };
2892
+ let input = validation_input(&staged_writes, &visible_schemas);
2893
+
2894
+ let catalog = catalog_from_transaction_input(&input).expect("schema catalog should build");
2895
+
2896
+ assert_eq!(catalog.len(), 2);
2897
+ assert!(catalog.contains("same_schema", "1"));
2898
+ }
2899
+
2900
+ #[test]
2901
+ fn pending_registered_schema_requires_snapshot_content() {
2902
+ let mut row = pending_registered_schema_row("missing_snapshot", "1");
2903
+ row.snapshot_content = None;
2904
+
2905
+ let error = validate_pending_registered_schema(&row, &registered_schema())
2906
+ .expect_err("registered schema writes require snapshot_content");
2907
+
2908
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
2909
+ }
2910
+
2911
+ #[test]
2912
+ fn pending_registered_schema_rejects_invalid_snapshot_json() {
2913
+ let mut row = pending_registered_schema_row("invalid_json", "1");
2914
+ row.snapshot_content = Some("{not-json".to_string());
2915
+
2916
+ let error = validate_pending_registered_schema(&row, &registered_schema())
2917
+ .expect_err("invalid JSON should fail");
2918
+
2919
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
2920
+ }
2921
+
2922
+ #[test]
2923
+ fn pending_registered_schema_uses_builtin_schema_for_outer_value_shape() {
2924
+ let mut row = pending_registered_schema_row("missing_value", "1");
2925
+ row.snapshot_content = Some(json!({}).to_string());
2926
+
2927
+ let error = validate_pending_registered_schema(&row, &registered_schema())
2928
+ .expect_err("builtin lix_registered_schema validation should fail");
2929
+
2930
+ assert_eq!(error.code, LixError::CODE_SCHEMA_VALIDATION);
2931
+ }
2932
+
2933
+ #[test]
2934
+ fn pending_registered_schema_rejects_malformed_nested_lix_schema_definition() {
2935
+ let mut row = pending_registered_schema_row("bad_schema_version", "v1");
2936
+ row.snapshot_content = Some(
2937
+ json!({
2938
+ "value": {
2939
+ "x-lix-key": "bad_schema_version",
2940
+ "x-lix-version": "v1",
2941
+ "type": "object",
2942
+ "properties": {
2943
+ "id": { "type": "string" }
2944
+ },
2945
+ "required": ["id"],
2946
+ "additionalProperties": false,
2947
+ }
2948
+ })
2949
+ .to_string(),
2950
+ );
2951
+
2952
+ let error = validate_pending_registered_schema(&row, &registered_schema())
2953
+ .expect_err("nested Lix schema definition should be rejected");
2954
+
2955
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
2956
+ }
2957
+
2958
+ #[test]
2959
+ fn schema_catalog_rejects_duplicate_pending_registered_schema_identity() {
2960
+ let mut duplicate = pending_registered_schema_row("duplicate_schema", "1");
2961
+ duplicate.entity_id = registered_schema_entity_id("duplicate_schema_duplicate", "1");
2962
+ let staged_writes = StagedWriteSet {
2963
+ state_rows: vec![
2964
+ pending_registered_schema_row("duplicate_schema", "1"),
2965
+ duplicate,
2966
+ ],
2967
+ ..empty_staged_write_set()
2968
+ };
2969
+ let visible_schemas = vec![registered_schema()];
2970
+
2971
+ let error = catalog_from_transaction_parts(&staged_writes, &visible_schemas)
2972
+ .expect_err("duplicate pending schema keys should fail");
2973
+
2974
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
2975
+ }
2976
+
2977
+ #[test]
2978
+ fn schema_catalog_allows_pending_foreign_key_to_pending_schema() {
2979
+ let staged_writes = StagedWriteSet {
2980
+ state_rows: vec![
2981
+ pending_registered_schema_from_definition(fk_parent_schema()),
2982
+ pending_registered_schema_from_definition(fk_child_schema()),
2983
+ ],
2984
+ ..empty_staged_write_set()
2985
+ };
2986
+ let visible_schemas = vec![registered_schema()];
2987
+ let input = validation_input(&staged_writes, &visible_schemas);
2988
+
2989
+ let catalog = catalog_from_transaction_input(&input)
2990
+ .expect("pending parent schema should satisfy pending child foreign key");
2991
+
2992
+ assert!(catalog.contains("fk_parent_schema", "1"));
2993
+ assert!(catalog.contains("fk_child_schema", "1"));
2994
+ }
2995
+
2996
+ #[test]
2997
+ fn schema_catalog_rejects_foreign_key_missing_target_schema() {
2998
+ let staged_writes = StagedWriteSet {
2999
+ state_rows: vec![pending_registered_schema_from_definition(fk_child_schema())],
3000
+ adopted_rows: Vec::new(),
3001
+ ..empty_staged_write_set()
3002
+ };
3003
+ let visible_schemas = vec![registered_schema()];
3004
+ let input = validation_input(&staged_writes, &visible_schemas);
3005
+
3006
+ let error = catalog_from_transaction_input(&input)
3007
+ .expect_err("missing referenced schema should fail");
3008
+
3009
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
3010
+ }
3011
+
3012
+ #[test]
3013
+ fn schema_catalog_rejects_foreign_key_missing_local_field() {
3014
+ let mut child = fk_child_schema();
3015
+ child["x-lix-foreign-keys"][0]["properties"] = json!(["/missing_parent_id"]);
3016
+ let staged_writes = StagedWriteSet {
3017
+ state_rows: vec![
3018
+ pending_registered_schema_from_definition(fk_parent_schema()),
3019
+ pending_registered_schema_from_definition(child),
3020
+ ],
3021
+ ..empty_staged_write_set()
3022
+ };
3023
+ let visible_schemas = vec![registered_schema()];
3024
+ let input = validation_input(&staged_writes, &visible_schemas);
3025
+
3026
+ let error =
3027
+ catalog_from_transaction_input(&input).expect_err("missing local FK field should fail");
3028
+
3029
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
3030
+ }
3031
+
3032
+ #[test]
3033
+ fn schema_catalog_rejects_foreign_key_missing_referenced_field() {
3034
+ let mut child = fk_child_schema();
3035
+ child["x-lix-foreign-keys"][0]["references"]["properties"] = json!(["/missing_id"]);
3036
+ let staged_writes = StagedWriteSet {
3037
+ state_rows: vec![
3038
+ pending_registered_schema_from_definition(fk_parent_schema()),
3039
+ pending_registered_schema_from_definition(child),
3040
+ ],
3041
+ ..empty_staged_write_set()
3042
+ };
3043
+ let visible_schemas = vec![registered_schema()];
3044
+ let input = validation_input(&staged_writes, &visible_schemas);
3045
+
3046
+ let error = catalog_from_transaction_input(&input)
3047
+ .expect_err("missing referenced FK field should fail");
3048
+
3049
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
3050
+ }
3051
+
3052
+ #[test]
3053
+ fn schema_catalog_rejects_foreign_key_to_non_unique_target_field() {
3054
+ let mut parent = fk_parent_schema();
3055
+ parent["properties"]["name"] = json!({ "type": "string" });
3056
+ let mut child = fk_child_schema();
3057
+ child["x-lix-foreign-keys"][0]["references"]["properties"] = json!(["/name"]);
3058
+ let staged_writes = StagedWriteSet {
3059
+ state_rows: vec![
3060
+ pending_registered_schema_from_definition(parent),
3061
+ pending_registered_schema_from_definition(child),
3062
+ ],
3063
+ ..empty_staged_write_set()
3064
+ };
3065
+ let visible_schemas = vec![registered_schema()];
3066
+ let input = validation_input(&staged_writes, &visible_schemas);
3067
+
3068
+ let error = catalog_from_transaction_input(&input)
3069
+ .expect_err("FK target must be primary-key or unique");
3070
+
3071
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
3072
+ }
3073
+
3074
+ #[test]
3075
+ fn schema_catalog_allows_state_surface_foreign_key_target() {
3076
+ let staged_writes = StagedWriteSet {
3077
+ state_rows: vec![pending_registered_schema_from_definition(
3078
+ state_surface_ref_schema(),
3079
+ )],
3080
+ ..empty_staged_write_set()
3081
+ };
3082
+ let visible_schemas = vec![registered_schema()];
3083
+ let input = validation_input(&staged_writes, &visible_schemas);
3084
+
3085
+ let catalog = catalog_from_transaction_input(&input)
3086
+ .expect("lix_state should validate as a state-surface FK target");
3087
+
3088
+ assert!(catalog.contains("state_surface_ref_schema", "1"));
3089
+ }
3090
+
3091
+ #[test]
3092
+ fn schema_catalog_rejects_state_surface_foreign_key_without_schema_key() {
3093
+ let mut schema = state_surface_ref_schema();
3094
+ schema["x-lix-foreign-keys"][0]["properties"] = json!(["/target_entity_id"]);
3095
+ schema["x-lix-foreign-keys"][0]["references"]["properties"] = json!(["/entity_id"]);
3096
+ let staged_writes = StagedWriteSet {
3097
+ state_rows: vec![pending_registered_schema_from_definition(schema)],
3098
+ adopted_rows: Vec::new(),
3099
+ ..empty_staged_write_set()
3100
+ };
3101
+ let visible_schemas = vec![registered_schema()];
3102
+ let input = validation_input(&staged_writes, &visible_schemas);
3103
+
3104
+ let error = catalog_from_transaction_input(&input)
3105
+ .expect_err("lix_state FK target must include schema identity");
3106
+
3107
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
3108
+ }
3109
+
3110
+ #[tokio::test]
3111
+ async fn validation_rejects_unknown_schema_key() {
3112
+ let visible_schemas = vec![key_value_schema()];
3113
+ let staged_writes = StagedWriteSet {
3114
+ state_rows: vec![staged_row(
3115
+ "unknown_schema",
3116
+ "1",
3117
+ Some(json!({}).to_string()),
3118
+ )],
3119
+ ..empty_staged_write_set()
3120
+ };
3121
+
3122
+ let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3123
+ .await
3124
+ .expect_err("unknown schema_key should fail");
3125
+
3126
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
3127
+ }
3128
+
3129
+ #[tokio::test]
3130
+ async fn validation_rejects_unknown_schema_version() {
3131
+ let visible_schemas = vec![key_value_schema()];
3132
+ let staged_writes = StagedWriteSet {
3133
+ state_rows: vec![staged_row(
3134
+ "lix_key_value",
3135
+ "2",
3136
+ Some(json!({ "key": "k", "value": "v" }).to_string()),
3137
+ )],
3138
+ ..empty_staged_write_set()
3139
+ };
3140
+
3141
+ let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3142
+ .await
3143
+ .expect_err("unknown schema_version should fail");
3144
+
3145
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
3146
+ }
3147
+
3148
+ #[tokio::test]
3149
+ async fn validation_checks_schema_existence_for_tombstones() {
3150
+ let visible_schemas = vec![key_value_schema()];
3151
+ let staged_writes = StagedWriteSet {
3152
+ state_rows: vec![staged_row("unknown_schema", "1", None)],
3153
+ adopted_rows: Vec::new(),
3154
+ ..empty_staged_write_set()
3155
+ };
3156
+
3157
+ let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3158
+ .await
3159
+ .expect_err("tombstone with unknown schema should fail");
3160
+
3161
+ assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
3162
+ }
3163
+
3164
+ #[tokio::test]
3165
+ async fn validation_allows_pending_registered_schema_to_validate_later_rows() {
3166
+ let visible_schemas = vec![key_value_schema(), registered_schema()];
3167
+ let staged_writes = StagedWriteSet {
3168
+ state_rows: vec![
3169
+ pending_registered_schema_row("pending_schema", "1"),
3170
+ staged_row(
3171
+ "pending_schema",
3172
+ "1",
3173
+ Some(json!({ "id": "entity-1" }).to_string()),
3174
+ ),
3175
+ ],
3176
+ ..empty_staged_write_set()
3177
+ };
3178
+
3179
+ validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3180
+ .await
3181
+ .expect("pending registered schema should be visible to later staged rows");
3182
+ }
3183
+
3184
+ #[tokio::test]
3185
+ async fn validation_validates_snapshot_content_against_schema() {
3186
+ let visible_schemas = vec![key_value_schema()];
3187
+ let staged_writes = StagedWriteSet {
3188
+ state_rows: vec![staged_row(
3189
+ "lix_key_value",
3190
+ "1",
3191
+ Some(json!({ "key": "k" }).to_string()),
3192
+ )],
3193
+ ..empty_staged_write_set()
3194
+ };
3195
+
3196
+ let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3197
+ .await
3198
+ .expect_err("missing required snapshot field should fail");
3199
+
3200
+ assert_eq!(error.code, LixError::CODE_SCHEMA_VALIDATION);
3201
+ }
3202
+
3203
+ #[tokio::test]
3204
+ async fn validation_rejects_invalid_snapshot_json() {
3205
+ let visible_schemas = vec![key_value_schema()];
3206
+ let staged_writes = StagedWriteSet {
3207
+ state_rows: vec![staged_row(
3208
+ "lix_key_value",
3209
+ "1",
3210
+ Some("{not-json".to_string()),
3211
+ )],
3212
+ ..empty_staged_write_set()
3213
+ };
3214
+
3215
+ let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3216
+ .await
3217
+ .expect_err("invalid snapshot JSON should fail");
3218
+
3219
+ assert_eq!(error.code, LixError::CODE_SCHEMA_VALIDATION);
3220
+ }
3221
+
3222
+ #[tokio::test]
3223
+ async fn validation_skips_snapshot_validation_for_tombstones() {
3224
+ let visible_schemas = vec![key_value_schema()];
3225
+ let staged_writes = StagedWriteSet {
3226
+ state_rows: vec![staged_row("lix_key_value", "1", None)],
3227
+ adopted_rows: Vec::new(),
3228
+ ..empty_staged_write_set()
3229
+ };
3230
+
3231
+ validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3232
+ .await
3233
+ .expect("tombstone should only require schema existence");
3234
+ }
3235
+
3236
+ #[tokio::test]
3237
+ async fn validation_rejects_missing_file_owner_reference() {
3238
+ let visible_schemas = vec![unique_schema()];
3239
+ let staged_writes = StagedWriteSet {
3240
+ state_rows: vec![unique_row("post-1", "hello-world", "first")],
3241
+ adopted_rows: Vec::new(),
3242
+ ..empty_staged_write_set()
3243
+ };
3244
+
3245
+ let error =
3246
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3247
+ &staged_writes,
3248
+ &visible_schemas,
3249
+ &StrictEmptyLiveStateReader,
3250
+ ))
3251
+ .await
3252
+ .expect_err("non-null file_id should require a file descriptor");
3253
+
3254
+ assert_eq!(error.code, LixError::CODE_FILE_NOT_FOUND);
3255
+ }
3256
+
3257
+ #[tokio::test]
3258
+ async fn validation_allows_pending_file_owner_reference() {
3259
+ let visible_schemas = vec![
3260
+ unique_schema(),
3261
+ file_descriptor_schema(),
3262
+ directory_descriptor_schema(),
3263
+ ];
3264
+ let staged_writes = StagedWriteSet {
3265
+ state_rows: vec![
3266
+ staged_file_descriptor_row("file-a", "version-a"),
3267
+ unique_row("post-1", "hello-world", "first"),
3268
+ ],
3269
+ ..empty_staged_write_set()
3270
+ };
3271
+
3272
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3273
+ &staged_writes,
3274
+ &visible_schemas,
3275
+ &StrictEmptyLiveStateReader,
3276
+ ))
3277
+ .await
3278
+ .expect("same-transaction file descriptor should satisfy file ownership");
3279
+ }
3280
+
3281
+ #[tokio::test]
3282
+ async fn validation_allows_committed_file_owner_reference() {
3283
+ let visible_schemas = vec![unique_schema()];
3284
+ let staged_writes = StagedWriteSet {
3285
+ state_rows: vec![unique_row("post-1", "hello-world", "first")],
3286
+ adopted_rows: Vec::new(),
3287
+ ..empty_staged_write_set()
3288
+ };
3289
+ let live_state = StaticLiveStateReader {
3290
+ rows: vec![committed_file_descriptor_row("file-a", "version-a")],
3291
+ };
3292
+
3293
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3294
+ &staged_writes,
3295
+ &visible_schemas,
3296
+ &live_state,
3297
+ ))
3298
+ .await
3299
+ .expect("committed file descriptor should satisfy file ownership");
3300
+ }
3301
+
3302
+ #[tokio::test]
3303
+ async fn validation_rejects_file_owner_reference_that_exists_only_in_global() {
3304
+ let visible_schemas = vec![unique_schema()];
3305
+ let staged_writes = StagedWriteSet {
3306
+ state_rows: vec![unique_row("post-1", "hello-world", "first")],
3307
+ adopted_rows: Vec::new(),
3308
+ ..empty_staged_write_set()
3309
+ };
3310
+ let live_state = StrictStaticLiveStateReader {
3311
+ rows: vec![committed_file_descriptor_row(
3312
+ "file-a",
3313
+ crate::GLOBAL_VERSION_ID,
3314
+ )],
3315
+ };
3316
+
3317
+ let error =
3318
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3319
+ &staged_writes,
3320
+ &visible_schemas,
3321
+ &live_state,
3322
+ ))
3323
+ .await
3324
+ .expect_err("global file descriptor should not satisfy a version-local row");
3325
+
3326
+ assert_eq!(error.code, LixError::CODE_FILE_NOT_FOUND);
3327
+ }
3328
+
3329
+ #[tokio::test]
3330
+ async fn validation_rejects_primary_key_duplicate_with_different_identity() {
3331
+ let visible_schemas = vec![unique_schema()];
3332
+ let mut conflicting = unique_row("post-1", "hello-world", "first");
3333
+ conflicting.entity_id = crate::entity_identity::EntityIdentity::single("post-2");
3334
+ let staged_writes = StagedWriteSet {
3335
+ state_rows: vec![unique_row("post-1", "hello-world", "first"), conflicting],
3336
+ adopted_rows: Vec::new(),
3337
+ ..empty_staged_write_set()
3338
+ };
3339
+
3340
+ let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3341
+ .await
3342
+ .expect_err("same primary key under different identity should fail");
3343
+
3344
+ assert_eq!(error.code, LixError::CODE_UNIQUE);
3345
+ }
3346
+
3347
+ #[tokio::test]
3348
+ async fn validation_rejects_pending_unique_value_duplicate() {
3349
+ let visible_schemas = vec![unique_schema()];
3350
+ let staged_writes = StagedWriteSet {
3351
+ state_rows: vec![
3352
+ unique_row("post-1", "hello-world", "first"),
3353
+ unique_row("post-2", "hello-world", "second"),
3354
+ ],
3355
+ ..empty_staged_write_set()
3356
+ };
3357
+
3358
+ let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3359
+ .await
3360
+ .expect_err("duplicate pending unique value should fail");
3361
+
3362
+ assert_eq!(error.code, LixError::CODE_UNIQUE);
3363
+ }
3364
+
3365
+ #[tokio::test]
3366
+ async fn validation_rejects_pending_unique_duplicate_with_null_component() {
3367
+ let visible_schemas = vec![nullable_unique_schema()];
3368
+ let staged_writes = StagedWriteSet {
3369
+ state_rows: vec![
3370
+ nullable_unique_row("row-1", None, "root-name"),
3371
+ nullable_unique_row("row-2", None, "root-name"),
3372
+ ],
3373
+ ..empty_staged_write_set()
3374
+ };
3375
+
3376
+ let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3377
+ .await
3378
+ .expect_err("duplicate nullable unique value should fail");
3379
+
3380
+ assert_eq!(error.code, LixError::CODE_UNIQUE);
3381
+ }
3382
+
3383
+ #[tokio::test]
3384
+ async fn validation_rejects_pending_unique_same_value_in_same_version() {
3385
+ let visible_schemas = vec![unique_schema()];
3386
+ let mut duplicate = unique_row("post-2", "hello-world", "second");
3387
+ duplicate.version_id = "version-a".to_string();
3388
+ let staged_writes = StagedWriteSet {
3389
+ state_rows: vec![unique_row("post-1", "hello-world", "first"), duplicate],
3390
+ adopted_rows: Vec::new(),
3391
+ ..empty_staged_write_set()
3392
+ };
3393
+
3394
+ let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3395
+ .await
3396
+ .expect_err("same unique value in the same version should fail");
3397
+
3398
+ assert_eq!(error.code, LixError::CODE_UNIQUE);
3399
+ }
3400
+
3401
+ #[tokio::test]
3402
+ async fn validation_allows_pending_unique_same_value_in_different_versions() {
3403
+ let visible_schemas = vec![unique_schema()];
3404
+ let mut version_b = unique_row("post-2", "hello-world", "second");
3405
+ version_b.version_id = "version-b".to_string();
3406
+ let staged_writes = StagedWriteSet {
3407
+ state_rows: vec![unique_row("post-1", "hello-world", "first"), version_b],
3408
+ adopted_rows: Vec::new(),
3409
+ ..empty_staged_write_set()
3410
+ };
3411
+
3412
+ validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3413
+ .await
3414
+ .expect("unique values should be scoped to the exact version_id");
3415
+ }
3416
+
3417
+ #[tokio::test]
3418
+ async fn validation_allows_pending_unique_overwrite_of_same_identity() {
3419
+ let visible_schemas = vec![unique_schema()];
3420
+ let staged_writes = StagedWriteSet {
3421
+ state_rows: vec![
3422
+ unique_row("post-1", "hello-world", "first"),
3423
+ unique_row("post-1", "hello-world", "updated"),
3424
+ ],
3425
+ ..empty_staged_write_set()
3426
+ };
3427
+
3428
+ validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3429
+ .await
3430
+ .expect("same identity should be treated as replacement, not duplicate");
3431
+ }
3432
+
3433
+ #[tokio::test]
3434
+ async fn validation_skips_pending_unique_indexes_for_tombstones() {
3435
+ let visible_schemas = vec![unique_schema()];
3436
+ let mut tombstone = unique_row("post-1", "hello-world", "deleted");
3437
+ tombstone.snapshot_content = None;
3438
+ let staged_writes = StagedWriteSet {
3439
+ state_rows: vec![tombstone, unique_row("post-2", "hello-world", "second")],
3440
+ adopted_rows: Vec::new(),
3441
+ ..empty_staged_write_set()
3442
+ };
3443
+
3444
+ validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3445
+ .await
3446
+ .expect("tombstones should not claim pending unique values");
3447
+ }
3448
+
3449
+ #[tokio::test]
3450
+ async fn validation_scopes_pending_unique_values_by_file_and_version() {
3451
+ let visible_schemas = vec![unique_schema()];
3452
+ let mut different_file = unique_row("post-2", "hello-world", "second");
3453
+ different_file.file_id = Some("file-b".to_string());
3454
+ let mut different_version = unique_row("post-3", "hello-world", "third");
3455
+ different_version.version_id = "version-b".to_string();
3456
+ let staged_writes = StagedWriteSet {
3457
+ state_rows: vec![
3458
+ unique_row("post-1", "hello-world", "first"),
3459
+ different_file,
3460
+ different_version,
3461
+ ],
3462
+ ..empty_staged_write_set()
3463
+ };
3464
+
3465
+ validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3466
+ .await
3467
+ .expect("unique values are scoped by file and version");
3468
+ }
3469
+
3470
+ #[tokio::test]
3471
+ async fn validation_rejects_committed_visible_unique_value_duplicate() {
3472
+ let visible_schemas = vec![unique_schema()];
3473
+ let staged_writes = StagedWriteSet {
3474
+ state_rows: vec![unique_row("post-2", "hello-world", "second")],
3475
+ adopted_rows: Vec::new(),
3476
+ ..empty_staged_write_set()
3477
+ };
3478
+ let live_state = StaticLiveStateReader {
3479
+ rows: vec![committed_unique_row("post-1", "hello-world", "first")],
3480
+ };
3481
+
3482
+ let error =
3483
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3484
+ &staged_writes,
3485
+ &visible_schemas,
3486
+ &live_state,
3487
+ ))
3488
+ .await
3489
+ .expect_err("committed visible unique value should conflict");
3490
+
3491
+ assert_eq!(error.code, LixError::CODE_UNIQUE);
3492
+ }
3493
+
3494
+ #[tokio::test]
3495
+ async fn validation_rejects_committed_unique_duplicate_with_null_component() {
3496
+ let visible_schemas = vec![nullable_unique_schema()];
3497
+ let staged_writes = StagedWriteSet {
3498
+ state_rows: vec![nullable_unique_row("row-2", None, "root-name")],
3499
+ adopted_rows: Vec::new(),
3500
+ ..empty_staged_write_set()
3501
+ };
3502
+ let live_state = StaticLiveStateReader {
3503
+ rows: vec![committed_nullable_unique_row("row-1", None, "root-name")],
3504
+ };
3505
+
3506
+ let error =
3507
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3508
+ &staged_writes,
3509
+ &visible_schemas,
3510
+ &live_state,
3511
+ ))
3512
+ .await
3513
+ .expect_err("committed duplicate nullable unique value should conflict");
3514
+
3515
+ assert_eq!(error.code, LixError::CODE_UNIQUE);
3516
+ }
3517
+
3518
+ #[tokio::test]
3519
+ async fn validation_rejects_committed_unique_same_value_in_same_version() {
3520
+ let visible_schemas = vec![unique_schema()];
3521
+ let staged_writes = StagedWriteSet {
3522
+ state_rows: vec![unique_row("post-2", "hello-world", "second")],
3523
+ adopted_rows: Vec::new(),
3524
+ ..empty_staged_write_set()
3525
+ };
3526
+ let live_state = StaticLiveStateReader {
3527
+ rows: vec![committed_unique_row("post-1", "hello-world", "first")],
3528
+ };
3529
+
3530
+ let error =
3531
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3532
+ &staged_writes,
3533
+ &visible_schemas,
3534
+ &live_state,
3535
+ ))
3536
+ .await
3537
+ .expect_err("same unique value in the same version should conflict");
3538
+
3539
+ assert_eq!(error.code, LixError::CODE_UNIQUE);
3540
+ }
3541
+
3542
+ #[tokio::test]
3543
+ async fn validation_allows_committed_unique_same_value_in_different_versions() {
3544
+ let visible_schemas = vec![unique_schema()];
3545
+ let mut version_b = unique_row("post-2", "hello-world", "second");
3546
+ version_b.version_id = "version-b".to_string();
3547
+ let staged_writes = StagedWriteSet {
3548
+ state_rows: vec![version_b],
3549
+ adopted_rows: Vec::new(),
3550
+ ..empty_staged_write_set()
3551
+ };
3552
+ let live_state = StaticLiveStateReader {
3553
+ rows: vec![committed_unique_row("post-1", "hello-world", "first")],
3554
+ };
3555
+
3556
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3557
+ &staged_writes,
3558
+ &visible_schemas,
3559
+ &live_state,
3560
+ ))
3561
+ .await
3562
+ .expect("committed unique values should be scoped to the exact version_id");
3563
+ }
3564
+
3565
+ #[tokio::test]
3566
+ async fn validation_ignores_projected_live_state_rows_for_unique_constraints() {
3567
+ let visible_schemas = vec![unique_schema()];
3568
+ let staged_writes = StagedWriteSet {
3569
+ state_rows: vec![unique_row("post-2", "hello-world", "second")],
3570
+ adopted_rows: Vec::new(),
3571
+ ..empty_staged_write_set()
3572
+ };
3573
+ let mut projected_overlay_row = committed_unique_row("post-1", "hello-world", "first");
3574
+ projected_overlay_row.version_id = "version-a".to_string();
3575
+ projected_overlay_row.global = true;
3576
+ let live_state = StaticLiveStateReader {
3577
+ rows: vec![projected_overlay_row],
3578
+ };
3579
+
3580
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3581
+ &staged_writes,
3582
+ &visible_schemas,
3583
+ &live_state,
3584
+ ))
3585
+ .await
3586
+ .expect("validation should ignore live-state overlay projections");
3587
+ }
3588
+
3589
+ #[tokio::test]
3590
+ async fn validation_allows_committed_visible_unique_update_of_same_identity() {
3591
+ let visible_schemas = vec![unique_schema()];
3592
+ let staged_writes = StagedWriteSet {
3593
+ state_rows: vec![unique_row("post-1", "hello-world", "updated")],
3594
+ adopted_rows: Vec::new(),
3595
+ ..empty_staged_write_set()
3596
+ };
3597
+ let live_state = StaticLiveStateReader {
3598
+ rows: vec![committed_unique_row("post-1", "hello-world", "first")],
3599
+ };
3600
+
3601
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3602
+ &staged_writes,
3603
+ &visible_schemas,
3604
+ &live_state,
3605
+ ))
3606
+ .await
3607
+ .expect("same identity should update committed unique owner");
3608
+ }
3609
+
3610
+ #[tokio::test]
3611
+ async fn validation_ignores_committed_unique_owner_tombstoned_by_transaction() {
3612
+ let visible_schemas = vec![unique_schema()];
3613
+ let mut tombstone = unique_row("post-1", "hello-world", "deleted");
3614
+ tombstone.snapshot_content = None;
3615
+ let staged_writes = StagedWriteSet {
3616
+ state_rows: vec![tombstone, unique_row("post-2", "hello-world", "second")],
3617
+ adopted_rows: Vec::new(),
3618
+ ..empty_staged_write_set()
3619
+ };
3620
+ let live_state = StaticLiveStateReader {
3621
+ rows: vec![committed_unique_row("post-1", "hello-world", "first")],
3622
+ };
3623
+
3624
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3625
+ &staged_writes,
3626
+ &visible_schemas,
3627
+ &live_state,
3628
+ ))
3629
+ .await
3630
+ .expect("tombstoned committed owner should not conflict");
3631
+ }
3632
+
3633
+ #[tokio::test]
3634
+ async fn validation_allows_committed_unique_same_value_in_different_file_or_version() {
3635
+ let visible_schemas = vec![unique_schema()];
3636
+ let mut different_file = unique_row("post-2", "hello-world", "second");
3637
+ different_file.file_id = Some("file-b".to_string());
3638
+ let mut different_version = unique_row("post-3", "hello-world", "third");
3639
+ different_version.version_id = "version-b".to_string();
3640
+ let staged_writes = StagedWriteSet {
3641
+ state_rows: vec![different_file, different_version],
3642
+ adopted_rows: Vec::new(),
3643
+ ..empty_staged_write_set()
3644
+ };
3645
+ let live_state = StaticLiveStateReader {
3646
+ rows: vec![committed_unique_row("post-1", "hello-world", "first")],
3647
+ };
3648
+
3649
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3650
+ &staged_writes,
3651
+ &visible_schemas,
3652
+ &live_state,
3653
+ ))
3654
+ .await
3655
+ .expect("committed uniqueness is scoped by file and version");
3656
+ }
3657
+
3658
+ #[tokio::test]
3659
+ async fn validation_rejects_foreign_key_target_missing_in_same_version() {
3660
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
3661
+ let staged_writes = StagedWriteSet {
3662
+ state_rows: vec![fk_child_row("child-1", "parent-1", "version-a")],
3663
+ adopted_rows: Vec::new(),
3664
+ ..empty_staged_write_set()
3665
+ };
3666
+
3667
+ let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3668
+ .await
3669
+ .expect_err("foreign key must resolve in the same version");
3670
+
3671
+ assert_eq!(error.code, LixError::CODE_FOREIGN_KEY);
3672
+ }
3673
+
3674
+ #[tokio::test]
3675
+ async fn validation_allows_foreign_key_target_in_same_version() {
3676
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
3677
+ let staged_writes = StagedWriteSet {
3678
+ state_rows: vec![
3679
+ fk_parent_row("parent-1", "version-a"),
3680
+ fk_child_row("child-1", "parent-1", "version-a"),
3681
+ ],
3682
+ ..empty_staged_write_set()
3683
+ };
3684
+
3685
+ validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3686
+ .await
3687
+ .expect("foreign key should resolve against pending rows in the same version");
3688
+ }
3689
+
3690
+ #[tokio::test]
3691
+ async fn validation_rejects_foreign_key_target_that_exists_only_in_different_version() {
3692
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
3693
+ let staged_writes = StagedWriteSet {
3694
+ state_rows: vec![
3695
+ fk_parent_row("parent-1", "version-b"),
3696
+ fk_child_row("child-1", "parent-1", "version-a"),
3697
+ ],
3698
+ ..empty_staged_write_set()
3699
+ };
3700
+
3701
+ let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3702
+ .await
3703
+ .expect_err("foreign key target in another version should not satisfy this version");
3704
+
3705
+ assert_eq!(error.code, LixError::CODE_FOREIGN_KEY);
3706
+ }
3707
+
3708
+ #[tokio::test]
3709
+ async fn validation_allows_foreign_key_target_committed_in_same_version() {
3710
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
3711
+ let staged_writes = StagedWriteSet {
3712
+ state_rows: vec![fk_child_row("child-1", "parent-1", "version-a")],
3713
+ adopted_rows: Vec::new(),
3714
+ ..empty_staged_write_set()
3715
+ };
3716
+ let live_state = StaticLiveStateReader {
3717
+ rows: vec![LiveStateRow::from(fk_parent_row("parent-1", "version-a"))],
3718
+ };
3719
+
3720
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3721
+ &staged_writes,
3722
+ &visible_schemas,
3723
+ &live_state,
3724
+ ))
3725
+ .await
3726
+ .expect("foreign key should resolve against committed rows in the same version");
3727
+ }
3728
+
3729
+ #[tokio::test]
3730
+ async fn validation_rejects_foreign_key_target_committed_only_in_different_version() {
3731
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
3732
+ let staged_writes = StagedWriteSet {
3733
+ state_rows: vec![fk_child_row("child-1", "parent-1", "version-a")],
3734
+ adopted_rows: Vec::new(),
3735
+ ..empty_staged_write_set()
3736
+ };
3737
+ let live_state = StaticLiveStateReader {
3738
+ rows: vec![LiveStateRow::from(fk_parent_row("parent-1", "version-b"))],
3739
+ };
3740
+
3741
+ let error =
3742
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3743
+ &staged_writes,
3744
+ &visible_schemas,
3745
+ &live_state,
3746
+ ))
3747
+ .await
3748
+ .expect_err(
3749
+ "foreign key target in another committed version should not satisfy this version",
3750
+ );
3751
+
3752
+ assert_eq!(error.code, LixError::CODE_FOREIGN_KEY);
3753
+ }
3754
+
3755
+ #[tokio::test]
3756
+ async fn validation_rejects_foreign_key_target_tombstoned_by_transaction() {
3757
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
3758
+ let mut parent_delete = fk_parent_row("parent-1", "version-a");
3759
+ parent_delete.snapshot_content = None;
3760
+ let staged_writes = StagedWriteSet {
3761
+ state_rows: vec![
3762
+ parent_delete,
3763
+ fk_child_row("child-1", "parent-1", "version-a"),
3764
+ ],
3765
+ ..empty_staged_write_set()
3766
+ };
3767
+ let live_state = StaticLiveStateReader {
3768
+ rows: vec![LiveStateRow::from(fk_parent_row("parent-1", "version-a"))],
3769
+ };
3770
+
3771
+ let error =
3772
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3773
+ &staged_writes,
3774
+ &visible_schemas,
3775
+ &live_state,
3776
+ ))
3777
+ .await
3778
+ .expect_err("same-transaction tombstone should hide the committed FK target");
3779
+
3780
+ assert_eq!(error.code, LixError::CODE_FOREIGN_KEY);
3781
+ }
3782
+
3783
+ #[tokio::test]
3784
+ async fn validation_rejects_pending_reference_to_deleted_identity() {
3785
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
3786
+ let mut parent_delete = fk_parent_row("parent-1", "version-a");
3787
+ parent_delete.snapshot_content = None;
3788
+ let staged_writes = StagedWriteSet {
3789
+ state_rows: vec![
3790
+ parent_delete,
3791
+ fk_child_row("child-1", "parent-1", "version-a"),
3792
+ ],
3793
+ ..empty_staged_write_set()
3794
+ };
3795
+ let live_state = StaticLiveStateReader {
3796
+ rows: vec![LiveStateRow::from(fk_parent_row("parent-1", "version-a"))],
3797
+ };
3798
+
3799
+ let error =
3800
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3801
+ &staged_writes,
3802
+ &visible_schemas,
3803
+ &live_state,
3804
+ ))
3805
+ .await
3806
+ .expect_err("pending child reference should block parent delete");
3807
+
3808
+ assert_eq!(error.code, LixError::CODE_FOREIGN_KEY);
3809
+ }
3810
+
3811
+ #[tokio::test]
3812
+ async fn validation_allows_delete_with_pending_reference_in_different_version() {
3813
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
3814
+ let mut parent_delete = fk_parent_row("parent-1", "version-a");
3815
+ parent_delete.snapshot_content = None;
3816
+ let staged_writes = StagedWriteSet {
3817
+ state_rows: vec![
3818
+ parent_delete,
3819
+ fk_parent_row("parent-1", "version-b"),
3820
+ fk_child_row("child-1", "parent-1", "version-b"),
3821
+ ],
3822
+ ..empty_staged_write_set()
3823
+ };
3824
+
3825
+ validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
3826
+ .await
3827
+ .expect("pending references in another version should not block this delete");
3828
+ }
3829
+
3830
+ #[tokio::test]
3831
+ async fn validation_allows_state_surface_fk_target_committed_by_exact_identity() {
3832
+ let visible_schemas = vec![fk_parent_schema(), state_surface_ref_schema()];
3833
+ let staged_writes = StagedWriteSet {
3834
+ state_rows: vec![state_surface_ref_row(
3835
+ "ref-1",
3836
+ "target-1",
3837
+ "fk_parent_schema",
3838
+ "file-a",
3839
+ )],
3840
+ ..empty_staged_write_set()
3841
+ };
3842
+ let live_state = StaticLiveStateReader {
3843
+ rows: vec![LiveStateRow::from(fk_parent_row("target-1", "version-a"))],
3844
+ };
3845
+
3846
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3847
+ &staged_writes,
3848
+ &visible_schemas,
3849
+ &live_state,
3850
+ ))
3851
+ .await
3852
+ .expect("lix_state FK should resolve against exact committed identity");
3853
+ }
3854
+
3855
+ #[tokio::test]
3856
+ async fn validation_rejects_delete_when_same_version_reference_exists() {
3857
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
3858
+ let mut parent_delete = fk_parent_row("parent-1", "version-a");
3859
+ parent_delete.snapshot_content = None;
3860
+ let live_state = StaticLiveStateReader {
3861
+ rows: vec![
3862
+ LiveStateRow::from(fk_parent_row("parent-1", "version-a")),
3863
+ LiveStateRow::from(fk_child_row("child-1", "parent-1", "version-a")),
3864
+ ],
3865
+ };
3866
+ let staged_writes = StagedWriteSet {
3867
+ state_rows: vec![parent_delete],
3868
+ adopted_rows: Vec::new(),
3869
+ ..empty_staged_write_set()
3870
+ };
3871
+
3872
+ let error =
3873
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3874
+ &staged_writes,
3875
+ &visible_schemas,
3876
+ &live_state,
3877
+ ))
3878
+ .await
3879
+ .expect_err("delete should be restricted by same-version references");
3880
+
3881
+ assert_eq!(error.code, LixError::CODE_FOREIGN_KEY);
3882
+ }
3883
+
3884
+ #[tokio::test]
3885
+ async fn validation_allows_delete_when_only_different_version_reference_exists() {
3886
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
3887
+ let mut parent_delete = fk_parent_row("parent-1", "version-a");
3888
+ parent_delete.snapshot_content = None;
3889
+ let live_state = StaticLiveStateReader {
3890
+ rows: vec![
3891
+ LiveStateRow::from(fk_parent_row("parent-1", "version-a")),
3892
+ LiveStateRow::from(fk_child_row("child-1", "parent-1", "version-b")),
3893
+ ],
3894
+ };
3895
+ let staged_writes = StagedWriteSet {
3896
+ state_rows: vec![parent_delete],
3897
+ adopted_rows: Vec::new(),
3898
+ ..empty_staged_write_set()
3899
+ };
3900
+
3901
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3902
+ &staged_writes,
3903
+ &visible_schemas,
3904
+ &live_state,
3905
+ ))
3906
+ .await
3907
+ .expect("references in another version should not restrict this version");
3908
+ }
3909
+
3910
+ #[tokio::test]
3911
+ async fn validation_allows_delete_when_committed_reference_is_also_deleted() {
3912
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
3913
+ let mut parent_delete = fk_parent_row("parent-1", "version-a");
3914
+ parent_delete.snapshot_content = None;
3915
+ let mut child_delete = fk_child_row("child-1", "parent-1", "version-a");
3916
+ child_delete.snapshot_content = None;
3917
+ let live_state = StaticLiveStateReader {
3918
+ rows: vec![
3919
+ LiveStateRow::from(fk_parent_row("parent-1", "version-a")),
3920
+ LiveStateRow::from(fk_child_row("child-1", "parent-1", "version-a")),
3921
+ ],
3922
+ };
3923
+ let staged_writes = StagedWriteSet {
3924
+ state_rows: vec![parent_delete, child_delete],
3925
+ adopted_rows: Vec::new(),
3926
+ ..empty_staged_write_set()
3927
+ };
3928
+
3929
+ validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
3930
+ &staged_writes,
3931
+ &visible_schemas,
3932
+ &live_state,
3933
+ ))
3934
+ .await
3935
+ .expect("committed references deleted in the same transaction should not restrict delete");
3936
+ }
3937
+
3938
+ #[test]
3939
+ fn compiled_schema_catalog_compiles_each_schema_once() {
3940
+ let visible_schemas = vec![key_value_schema()];
3941
+ let staged_writes = empty_staged_write_set();
3942
+ let input = validation_input(&staged_writes, &visible_schemas);
3943
+ let catalog = catalog_from_transaction_input(&input).expect("schema catalog should build");
3944
+ let mut compiled = CompiledSchemaCatalog::new(&catalog);
3945
+
3946
+ compiled
3947
+ .compiled_schema("lix_key_value", "1")
3948
+ .expect("schema should compile");
3949
+ compiled
3950
+ .compiled_schema("lix_key_value", "1")
3951
+ .expect("schema should be cached");
3952
+
3953
+ assert_eq!(compiled.compiled_count(), 1);
3954
+ }
3955
+
3956
+ #[test]
3957
+ fn pending_indexes_record_primary_key_fk_targets_by_exact_scope() {
3958
+ let mut indexes = PendingConstraintIndexes::default();
3959
+ let row = fk_parent_row("parent-1", "version-a");
3960
+ let snapshot = serde_json::from_str::<JsonValue>(
3961
+ row.snapshot_content
3962
+ .as_deref()
3963
+ .expect("fixture should have snapshot"),
3964
+ )
3965
+ .expect("fixture JSON should parse");
3966
+
3967
+ indexes
3968
+ .remember_row(&row, &fk_parent_schema(), &snapshot)
3969
+ .expect("parent row should index");
3970
+
3971
+ assert!(indexes
3972
+ .has_fk_target(
3973
+ "fk_parent_schema",
3974
+ "1",
3975
+ "version-a",
3976
+ Some("file-a"),
3977
+ &["/id"],
3978
+ UniqueConstraintValue::string_values(["parent-1"]),
3979
+ )
3980
+ .expect("lookup should build"));
3981
+ assert!(!indexes
3982
+ .has_fk_target(
3983
+ "fk_parent_schema",
3984
+ "1",
3985
+ "version-b",
3986
+ Some("file-a"),
3987
+ &["/id"],
3988
+ UniqueConstraintValue::string_values(["parent-1"]),
3989
+ )
3990
+ .expect("lookup should build"));
3991
+ }
3992
+
3993
+ #[test]
3994
+ fn pending_indexes_record_unique_fk_targets_by_exact_scope() {
3995
+ let mut indexes = PendingConstraintIndexes::default();
3996
+ let row = unique_row("post-1", "hello-world", "first");
3997
+ let snapshot = serde_json::from_str::<JsonValue>(
3998
+ row.snapshot_content
3999
+ .as_deref()
4000
+ .expect("fixture should have snapshot"),
4001
+ )
4002
+ .expect("fixture JSON should parse");
4003
+
4004
+ indexes
4005
+ .remember_row(&row, &unique_schema(), &snapshot)
4006
+ .expect("unique row should index");
4007
+
4008
+ assert!(indexes
4009
+ .has_fk_target(
4010
+ "unique_schema",
4011
+ "1",
4012
+ "version-a",
4013
+ Some("file-a"),
4014
+ &["/slug"],
4015
+ UniqueConstraintValue::string_values(["hello-world"]),
4016
+ )
4017
+ .expect("lookup should build"));
4018
+ }
4019
+
4020
+ #[test]
4021
+ fn pending_indexes_record_normal_fk_references_by_exact_scope() {
4022
+ let mut indexes = PendingConstraintIndexes::default();
4023
+ let row = fk_child_row("child-1", "parent-1", "version-a");
4024
+ let snapshot = serde_json::from_str::<JsonValue>(
4025
+ row.snapshot_content
4026
+ .as_deref()
4027
+ .expect("fixture should have snapshot"),
4028
+ )
4029
+ .expect("fixture JSON should parse");
4030
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
4031
+ let staged_writes = empty_staged_write_set();
4032
+ let input = validation_input(&staged_writes, &visible_schemas);
4033
+ let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
4034
+
4035
+ indexes
4036
+ .remember_foreign_key_references(&catalog, &row, &fk_child_schema(), &snapshot)
4037
+ .expect("child row should index FK reference");
4038
+
4039
+ assert!(indexes
4040
+ .has_fk_reference_to_key(
4041
+ "fk_parent_schema",
4042
+ "1",
4043
+ "version-a",
4044
+ Some("file-a"),
4045
+ &["/id"],
4046
+ UniqueConstraintValue::string_values(["parent-1"]),
4047
+ )
4048
+ .expect("lookup should build"));
4049
+ assert!(!indexes
4050
+ .has_fk_reference_to_key(
4051
+ "fk_parent_schema",
4052
+ "1",
4053
+ "version-b",
4054
+ Some("file-a"),
4055
+ &["/id"],
4056
+ UniqueConstraintValue::string_values(["parent-1"]),
4057
+ )
4058
+ .expect("lookup should build"));
4059
+ }
4060
+
4061
+ #[test]
4062
+ fn pending_indexes_record_state_surface_fk_references_by_exact_identity() {
4063
+ let mut indexes = PendingConstraintIndexes::default();
4064
+ let row = state_surface_ref_row("ref-1", "target-1", "fk_parent_schema", "file-a");
4065
+ let snapshot = serde_json::from_str::<JsonValue>(
4066
+ row.snapshot_content
4067
+ .as_deref()
4068
+ .expect("fixture should have snapshot"),
4069
+ )
4070
+ .expect("fixture JSON should parse");
4071
+ let visible_schemas = vec![state_surface_ref_schema()];
4072
+ let staged_writes = empty_staged_write_set();
4073
+ let input = validation_input(&staged_writes, &visible_schemas);
4074
+ let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
4075
+
4076
+ indexes
4077
+ .remember_foreign_key_references(&catalog, &row, &state_surface_ref_schema(), &snapshot)
4078
+ .expect("state-surface row should index FK reference");
4079
+
4080
+ assert!(indexes.has_fk_reference_to_identity(LiveStateRowIdentity {
4081
+ version_id: "version-a".to_string(),
4082
+ schema_key: "fk_parent_schema".to_string(),
4083
+ entity_id: EntityIdentity::single("target-1"),
4084
+ file_id: Some("file-a".to_string()),
4085
+ }));
4086
+ }
4087
+
4088
+ #[test]
4089
+ fn pending_delete_restrictions_ignore_tombstoned_referencing_rows() {
4090
+ let mut indexes = PendingConstraintIndexes::default();
4091
+ let mut parent_delete = fk_parent_row("parent-1", "version-a");
4092
+ parent_delete.snapshot_content = None;
4093
+ indexes.remember_tombstone(&parent_delete);
4094
+
4095
+ let child = fk_child_row("child-1", "parent-1", "version-a");
4096
+ let child_snapshot = serde_json::from_str::<JsonValue>(
4097
+ child
4098
+ .snapshot_content
4099
+ .as_deref()
4100
+ .expect("fixture should have snapshot"),
4101
+ )
4102
+ .expect("fixture JSON should parse");
4103
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
4104
+ let staged_writes = empty_staged_write_set();
4105
+ let input = validation_input(&staged_writes, &visible_schemas);
4106
+ let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
4107
+ indexes
4108
+ .remember_foreign_key_references(&catalog, &child, &fk_child_schema(), &child_snapshot)
4109
+ .expect("child row should index FK reference");
4110
+
4111
+ let mut child_delete = fk_child_row("child-1", "parent-1", "version-a");
4112
+ child_delete.snapshot_content = None;
4113
+ indexes.remember_tombstone(&child_delete);
4114
+
4115
+ validate_pending_delete_restrictions(&catalog, &indexes)
4116
+ .expect("a row deleted in the same transaction should not block target delete");
4117
+ }
4118
+
4119
+ #[test]
4120
+ fn pending_fk_validation_collects_unresolved_normal_fk_check() {
4121
+ let indexes = PendingConstraintIndexes::default();
4122
+ let row = fk_child_row("child-1", "parent-1", "version-a");
4123
+ let snapshot = serde_json::from_str::<JsonValue>(
4124
+ row.snapshot_content
4125
+ .as_deref()
4126
+ .expect("fixture should have snapshot"),
4127
+ )
4128
+ .expect("fixture JSON should parse");
4129
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
4130
+ let staged_writes = empty_staged_write_set();
4131
+ let input = validation_input(&staged_writes, &visible_schemas);
4132
+ let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
4133
+
4134
+ let unresolved = validate_pending_foreign_keys(
4135
+ &catalog,
4136
+ &indexes,
4137
+ &[(&row, &fk_child_schema(), snapshot)],
4138
+ )
4139
+ .expect("FK validation should collect unresolved checks");
4140
+
4141
+ assert_eq!(unresolved.len(), 1);
4142
+ assert_eq!(
4143
+ unresolved[0].source_identity,
4144
+ LiveStateRowIdentity {
4145
+ version_id: "version-a".to_string(),
4146
+ schema_key: "fk_child_schema".to_string(),
4147
+ entity_id: EntityIdentity::single("child-1"),
4148
+ file_id: Some("file-a".to_string()),
4149
+ }
4150
+ );
4151
+ assert_eq!(unresolved[0].source_schema_key, "fk_child_schema");
4152
+ assert_eq!(
4153
+ unresolved[0].source_pointer_group,
4154
+ vec![vec!["parent_id".to_string()]]
4155
+ );
4156
+ let UnresolvedForeignKeyTarget::Key(target) = &unresolved[0].target else {
4157
+ panic!("normal FK should produce key target");
4158
+ };
4159
+ assert_eq!(target.schema_key, "fk_parent_schema");
4160
+ assert_eq!(target.schema_version, "1");
4161
+ assert_eq!(target.version_id, "version-a");
4162
+ assert_eq!(target.file_id.as_deref(), Some("file-a"));
4163
+ assert_eq!(target.pointer_group, vec![vec!["id".to_string()]]);
4164
+ assert_eq!(
4165
+ target.value,
4166
+ UniqueConstraintValue::string_values(["parent-1"])
4167
+ );
4168
+ }
4169
+
4170
+ #[test]
4171
+ fn pending_fk_validation_resolves_normal_fk_against_pending_target() {
4172
+ let mut indexes = PendingConstraintIndexes::default();
4173
+ let parent = fk_parent_row("parent-1", "version-a");
4174
+ let parent_snapshot = serde_json::from_str::<JsonValue>(
4175
+ parent
4176
+ .snapshot_content
4177
+ .as_deref()
4178
+ .expect("fixture should have snapshot"),
4179
+ )
4180
+ .expect("fixture JSON should parse");
4181
+ indexes
4182
+ .remember_row(&parent, &fk_parent_schema(), &parent_snapshot)
4183
+ .expect("parent should index as pending FK target");
4184
+
4185
+ let child = fk_child_row("child-1", "parent-1", "version-a");
4186
+ let child_snapshot = serde_json::from_str::<JsonValue>(
4187
+ child
4188
+ .snapshot_content
4189
+ .as_deref()
4190
+ .expect("fixture should have snapshot"),
4191
+ )
4192
+ .expect("fixture JSON should parse");
4193
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
4194
+ let staged_writes = empty_staged_write_set();
4195
+ let input = validation_input(&staged_writes, &visible_schemas);
4196
+ let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
4197
+
4198
+ let unresolved = validate_pending_foreign_keys(
4199
+ &catalog,
4200
+ &indexes,
4201
+ &[(&child, &fk_child_schema(), child_snapshot)],
4202
+ )
4203
+ .expect("FK validation should inspect pending targets");
4204
+
4205
+ assert!(
4206
+ unresolved.is_empty(),
4207
+ "same-version pending parent should satisfy the child FK"
4208
+ );
4209
+ }
4210
+
4211
+ #[test]
4212
+ fn pending_fk_validation_keeps_normal_fk_unresolved_across_versions() {
4213
+ let mut indexes = PendingConstraintIndexes::default();
4214
+ let parent = fk_parent_row("parent-1", "version-b");
4215
+ let parent_snapshot = serde_json::from_str::<JsonValue>(
4216
+ parent
4217
+ .snapshot_content
4218
+ .as_deref()
4219
+ .expect("fixture should have snapshot"),
4220
+ )
4221
+ .expect("fixture JSON should parse");
4222
+ indexes
4223
+ .remember_row(&parent, &fk_parent_schema(), &parent_snapshot)
4224
+ .expect("parent should index as pending FK target");
4225
+
4226
+ let child = fk_child_row("child-1", "parent-1", "version-a");
4227
+ let child_snapshot = serde_json::from_str::<JsonValue>(
4228
+ child
4229
+ .snapshot_content
4230
+ .as_deref()
4231
+ .expect("fixture should have snapshot"),
4232
+ )
4233
+ .expect("fixture JSON should parse");
4234
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
4235
+ let staged_writes = empty_staged_write_set();
4236
+ let input = validation_input(&staged_writes, &visible_schemas);
4237
+ let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
4238
+
4239
+ let unresolved = validate_pending_foreign_keys(
4240
+ &catalog,
4241
+ &indexes,
4242
+ &[(&child, &fk_child_schema(), child_snapshot)],
4243
+ )
4244
+ .expect("FK validation should inspect pending targets");
4245
+
4246
+ assert_eq!(unresolved.len(), 1);
4247
+ let UnresolvedForeignKeyTarget::Key(target) = &unresolved[0].target else {
4248
+ panic!("normal FK should produce key target");
4249
+ };
4250
+ assert_eq!(
4251
+ target.version_id, "version-a",
4252
+ "FK checks are exact-version scoped, not overlay scoped"
4253
+ );
4254
+ }
4255
+
4256
+ #[test]
4257
+ fn pending_fk_validation_collects_unresolved_state_surface_check() {
4258
+ let indexes = PendingConstraintIndexes::default();
4259
+ let row = state_surface_ref_row("ref-1", "target-1", "fk_parent_schema", "file-a");
4260
+ let snapshot = serde_json::from_str::<JsonValue>(
4261
+ row.snapshot_content
4262
+ .as_deref()
4263
+ .expect("fixture should have snapshot"),
4264
+ )
4265
+ .expect("fixture JSON should parse");
4266
+ let visible_schemas = vec![state_surface_ref_schema()];
4267
+ let staged_writes = empty_staged_write_set();
4268
+ let input = validation_input(&staged_writes, &visible_schemas);
4269
+ let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
4270
+
4271
+ let unresolved = validate_pending_foreign_keys(
4272
+ &catalog,
4273
+ &indexes,
4274
+ &[(&row, &state_surface_ref_schema(), snapshot)],
4275
+ )
4276
+ .expect("FK validation should collect unresolved checks");
4277
+
4278
+ assert_eq!(unresolved.len(), 1);
4279
+ assert_eq!(
4280
+ unresolved[0].source_identity,
4281
+ LiveStateRowIdentity {
4282
+ version_id: "version-a".to_string(),
4283
+ schema_key: "state_surface_ref_schema".to_string(),
4284
+ entity_id: EntityIdentity::single("ref-1"),
4285
+ file_id: Some("file-a".to_string()),
4286
+ }
4287
+ );
4288
+ assert_eq!(unresolved[0].source_schema_key, "state_surface_ref_schema");
4289
+ assert_eq!(
4290
+ unresolved[0].source_pointer_group,
4291
+ vec![
4292
+ vec!["target_entity_id".to_string()],
4293
+ vec!["target_schema_key".to_string()],
4294
+ vec!["target_file_id".to_string()],
4295
+ ]
4296
+ );
4297
+ let UnresolvedForeignKeyTarget::StateSurfaceIdentity(target) = &unresolved[0].target else {
4298
+ panic!("lix_state FK should produce state-surface identity target");
4299
+ };
4300
+ assert_eq!(target.version_id, "version-a");
4301
+ assert_eq!(target.schema_key, "fk_parent_schema");
4302
+ assert_eq!(target.entity_id, EntityIdentity::single("target-1"));
4303
+ assert_eq!(target.file_id.as_deref(), Some("file-a"));
4304
+ }
4305
+
4306
+ #[tokio::test]
4307
+ async fn committed_fk_lookup_resolves_normal_fk_in_exact_scope() {
4308
+ let indexes = PendingConstraintIndexes::default();
4309
+ let child = fk_child_row("child-1", "parent-1", "version-a");
4310
+ let child_snapshot = serde_json::from_str::<JsonValue>(
4311
+ child
4312
+ .snapshot_content
4313
+ .as_deref()
4314
+ .expect("fixture should have snapshot"),
4315
+ )
4316
+ .expect("fixture JSON should parse");
4317
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
4318
+ let staged_writes = empty_staged_write_set();
4319
+ let input = validation_input(&staged_writes, &visible_schemas);
4320
+ let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
4321
+ let unresolved = validate_pending_foreign_keys(
4322
+ &catalog,
4323
+ &indexes,
4324
+ &[(&child, &fk_child_schema(), child_snapshot)],
4325
+ )
4326
+ .expect("pending FK validation should collect unresolved check");
4327
+ let live_state = StaticLiveStateReader {
4328
+ rows: vec![LiveStateRow::from(fk_parent_row("parent-1", "version-a"))],
4329
+ };
4330
+
4331
+ let still_unresolved = validate_committed_foreign_keys(
4332
+ &TransactionValidationInput::from_visible_schemas_for_tests(
4333
+ &staged_writes,
4334
+ &visible_schemas,
4335
+ &live_state,
4336
+ ),
4337
+ &indexes,
4338
+ &unresolved,
4339
+ )
4340
+ .await
4341
+ .expect("committed FK lookup should scan live state");
4342
+
4343
+ assert!(
4344
+ still_unresolved.is_empty(),
4345
+ "same-version committed parent should satisfy unresolved FK"
4346
+ );
4347
+ }
4348
+
4349
+ #[tokio::test]
4350
+ async fn committed_fk_lookup_keeps_normal_fk_unresolved_across_versions() {
4351
+ let indexes = PendingConstraintIndexes::default();
4352
+ let child = fk_child_row("child-1", "parent-1", "version-a");
4353
+ let child_snapshot = serde_json::from_str::<JsonValue>(
4354
+ child
4355
+ .snapshot_content
4356
+ .as_deref()
4357
+ .expect("fixture should have snapshot"),
4358
+ )
4359
+ .expect("fixture JSON should parse");
4360
+ let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
4361
+ let staged_writes = empty_staged_write_set();
4362
+ let input = validation_input(&staged_writes, &visible_schemas);
4363
+ let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
4364
+ let unresolved = validate_pending_foreign_keys(
4365
+ &catalog,
4366
+ &indexes,
4367
+ &[(&child, &fk_child_schema(), child_snapshot)],
4368
+ )
4369
+ .expect("pending FK validation should collect unresolved check");
4370
+ let live_state = StaticLiveStateReader {
4371
+ rows: vec![LiveStateRow::from(fk_parent_row("parent-1", "version-b"))],
4372
+ };
4373
+
4374
+ let still_unresolved = validate_committed_foreign_keys(
4375
+ &TransactionValidationInput::from_visible_schemas_for_tests(
4376
+ &staged_writes,
4377
+ &visible_schemas,
4378
+ &live_state,
4379
+ ),
4380
+ &indexes,
4381
+ &unresolved,
4382
+ )
4383
+ .await
4384
+ .expect("committed FK lookup should scan live state");
4385
+
4386
+ assert_eq!(
4387
+ still_unresolved.len(),
4388
+ 1,
4389
+ "committed FK lookup is exact-version scoped"
4390
+ );
4391
+ }
4392
+
4393
+ #[tokio::test]
4394
+ async fn committed_fk_lookup_resolves_state_surface_fk_by_exact_identity() {
4395
+ let indexes = PendingConstraintIndexes::default();
4396
+ let row = state_surface_ref_row("ref-1", "target-1", "fk_parent_schema", "file-a");
4397
+ let snapshot = serde_json::from_str::<JsonValue>(
4398
+ row.snapshot_content
4399
+ .as_deref()
4400
+ .expect("fixture should have snapshot"),
4401
+ )
4402
+ .expect("fixture JSON should parse");
4403
+ let visible_schemas = vec![state_surface_ref_schema()];
4404
+ let staged_writes = empty_staged_write_set();
4405
+ let input = validation_input(&staged_writes, &visible_schemas);
4406
+ let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
4407
+ let unresolved = validate_pending_foreign_keys(
4408
+ &catalog,
4409
+ &indexes,
4410
+ &[(&row, &state_surface_ref_schema(), snapshot)],
4411
+ )
4412
+ .expect("pending FK validation should collect unresolved check");
4413
+ let live_state = StaticLiveStateReader {
4414
+ rows: vec![LiveStateRow::from(fk_parent_row("target-1", "version-a"))],
4415
+ };
4416
+
4417
+ let still_unresolved = validate_committed_foreign_keys(
4418
+ &TransactionValidationInput::from_visible_schemas_for_tests(
4419
+ &staged_writes,
4420
+ &visible_schemas,
4421
+ &live_state,
4422
+ ),
4423
+ &indexes,
4424
+ &unresolved,
4425
+ )
4426
+ .await
4427
+ .expect("committed FK lookup should load exact live-state row");
4428
+
4429
+ assert!(
4430
+ still_unresolved.is_empty(),
4431
+ "committed state-surface target should satisfy unresolved FK"
4432
+ );
4433
+ }
4434
+
4435
+ fn empty_staged_write_set() -> StagedWriteSet {
4436
+ StagedWriteSet {
4437
+ state_rows: Vec::new(),
4438
+ adopted_rows: Vec::new(),
4439
+ insert_identities: BTreeMap::new(),
4440
+ commit_members_by_version: BTreeMap::new(),
4441
+ extra_commit_parents_by_version: BTreeMap::new(),
4442
+ file_data_writes: Vec::new(),
4443
+ }
4444
+ }
4445
+
4446
+ fn live_state_row_matches_scan(row: &LiveStateRow, request: &LiveStateScanRequest) -> bool {
4447
+ (request.filter.schema_keys.is_empty()
4448
+ || request.filter.schema_keys.contains(&row.schema_key))
4449
+ && (request.filter.version_ids.is_empty()
4450
+ || request.filter.version_ids.contains(&row.version_id))
4451
+ && (request.filter.file_ids.is_empty()
4452
+ || request
4453
+ .filter
4454
+ .file_ids
4455
+ .iter()
4456
+ .any(|filter| filter.matches(row.file_id.as_ref())))
4457
+ }
4458
+
4459
+ fn live_state_row_matches_load(row: &LiveStateRow, request: &LiveStateRowRequest) -> bool {
4460
+ row.schema_key == request.schema_key
4461
+ && row.version_id == request.version_id
4462
+ && row.entity_id == request.entity_id
4463
+ && request.file_id.matches(row.file_id.as_ref())
4464
+ }
4465
+
4466
+ fn test_file_descriptor_rows() -> Vec<LiveStateRow> {
4467
+ vec![
4468
+ committed_file_descriptor_row("file-a", "version-a"),
4469
+ committed_file_descriptor_row("file-a", "version-b"),
4470
+ committed_file_descriptor_row("file-b", "version-a"),
4471
+ committed_file_descriptor_row("file-b", "version-b"),
4472
+ ]
4473
+ }
4474
+
4475
+ fn pending_registered_schema_row(schema_key: &str, schema_version: &str) -> StagedStateRow {
4476
+ pending_registered_schema_from_definition(json!({
4477
+ "x-lix-key": schema_key,
4478
+ "x-lix-version": schema_version,
4479
+ "type": "object",
4480
+ "properties": {
4481
+ "id": { "type": "string" }
4482
+ },
4483
+ "required": ["id"],
4484
+ "additionalProperties": false,
4485
+ }))
4486
+ }
4487
+
4488
+ fn pending_registered_schema_from_definition(schema: JsonValue) -> StagedStateRow {
4489
+ let key = schema_key_from_definition(&schema).expect("test schema should have a key");
4490
+ StagedStateRow {
4491
+ entity_id: registered_schema_entity_id(&key.schema_key, &key.schema_version),
4492
+ schema_key: REGISTERED_SCHEMA_KEY.to_string(),
4493
+ file_id: None,
4494
+ snapshot_content: Some(json!({ "value": schema }).to_string()),
4495
+ metadata: None,
4496
+ origin: None,
4497
+ schema_version: "1".to_string(),
4498
+ created_at: "2026-04-29T00:00:00.000Z".to_string(),
4499
+ updated_at: "2026-04-29T00:00:00.000Z".to_string(),
4500
+ global: true,
4501
+ change_id: Some("change-registered-schema".to_string()),
4502
+ commit_id: Some("commit-registered-schema".to_string()),
4503
+ untracked: false,
4504
+ version_id: crate::GLOBAL_VERSION_ID.to_string(),
4505
+ }
4506
+ }
4507
+
4508
+ fn registered_schema_entity_id(
4509
+ schema_key: &str,
4510
+ schema_version: &str,
4511
+ ) -> crate::entity_identity::EntityIdentity {
4512
+ crate::entity_identity::EntityIdentity::from_primary_key_paths(
4513
+ &serde_json::json!({
4514
+ "value": {
4515
+ "x-lix-key": schema_key,
4516
+ "x-lix-version": schema_version,
4517
+ }
4518
+ }),
4519
+ &[
4520
+ vec!["value".to_string(), "x-lix-key".to_string()],
4521
+ vec!["value".to_string(), "x-lix-version".to_string()],
4522
+ ],
4523
+ )
4524
+ .expect("registered schema identity should derive")
4525
+ }
4526
+
4527
+ fn key_value_schema() -> JsonValue {
4528
+ seed_schema_definition("lix_key_value")
4529
+ .expect("lix_key_value builtin schema should exist")
4530
+ .clone()
4531
+ }
4532
+
4533
+ fn registered_schema() -> JsonValue {
4534
+ seed_schema_definition(REGISTERED_SCHEMA_KEY)
4535
+ .expect("lix_registered_schema builtin schema should exist")
4536
+ .clone()
4537
+ }
4538
+
4539
+ fn file_descriptor_schema() -> JsonValue {
4540
+ seed_schema_definition(FILE_DESCRIPTOR_SCHEMA_KEY)
4541
+ .expect("lix_file_descriptor builtin schema should exist")
4542
+ .clone()
4543
+ }
4544
+
4545
+ fn directory_descriptor_schema() -> JsonValue {
4546
+ seed_schema_definition(DIRECTORY_DESCRIPTOR_SCHEMA_KEY)
4547
+ .expect("lix_directory_descriptor builtin schema should exist")
4548
+ .clone()
4549
+ }
4550
+
4551
+ fn unique_schema() -> JsonValue {
4552
+ json!({
4553
+ "x-lix-key": "unique_schema",
4554
+ "x-lix-version": "1",
4555
+ "x-lix-primary-key": ["/id"],
4556
+ "x-lix-unique": [["/slug"]],
4557
+ "type": "object",
4558
+ "properties": {
4559
+ "id": { "type": "string" },
4560
+ "slug": { "type": "string" },
4561
+ "title": { "type": "string" }
4562
+ },
4563
+ "required": ["id", "slug", "title"],
4564
+ "additionalProperties": false
4565
+ })
4566
+ }
4567
+
4568
+ fn nullable_unique_schema() -> JsonValue {
4569
+ json!({
4570
+ "x-lix-key": "nullable_unique_schema",
4571
+ "x-lix-version": "1",
4572
+ "x-lix-primary-key": ["/id"],
4573
+ "x-lix-unique": [["/scope", "/name"]],
4574
+ "type": "object",
4575
+ "properties": {
4576
+ "id": { "type": "string" },
4577
+ "scope": { "type": ["string", "null"] },
4578
+ "name": { "type": "string" }
4579
+ },
4580
+ "required": ["id", "scope", "name"],
4581
+ "additionalProperties": false
4582
+ })
4583
+ }
4584
+
4585
+ fn fk_parent_schema() -> JsonValue {
4586
+ json!({
4587
+ "x-lix-key": "fk_parent_schema",
4588
+ "x-lix-version": "1",
4589
+ "x-lix-primary-key": ["/id"],
4590
+ "type": "object",
4591
+ "properties": {
4592
+ "id": { "type": "string" }
4593
+ },
4594
+ "required": ["id"],
4595
+ "additionalProperties": false
4596
+ })
4597
+ }
4598
+
4599
+ fn fk_child_schema() -> JsonValue {
4600
+ json!({
4601
+ "x-lix-key": "fk_child_schema",
4602
+ "x-lix-version": "1",
4603
+ "x-lix-primary-key": ["/id"],
4604
+ "x-lix-foreign-keys": [{
4605
+ "properties": ["/parent_id"],
4606
+ "references": {
4607
+ "schemaKey": "fk_parent_schema",
4608
+ "properties": ["/id"]
4609
+ }
4610
+ }],
4611
+ "type": "object",
4612
+ "properties": {
4613
+ "id": { "type": "string" },
4614
+ "parent_id": { "type": "string" }
4615
+ },
4616
+ "required": ["id", "parent_id"],
4617
+ "additionalProperties": false
4618
+ })
4619
+ }
4620
+
4621
+ fn state_surface_ref_schema() -> JsonValue {
4622
+ json!({
4623
+ "x-lix-key": "state_surface_ref_schema",
4624
+ "x-lix-version": "1",
4625
+ "x-lix-primary-key": ["/id"],
4626
+ "x-lix-foreign-keys": [{
4627
+ "properties": ["/target_entity_id", "/target_schema_key", "/target_file_id"],
4628
+ "references": {
4629
+ "schemaKey": "lix_state",
4630
+ "properties": ["/entity_id", "/schema_key", "/file_id"]
4631
+ }
4632
+ }],
4633
+ "type": "object",
4634
+ "properties": {
4635
+ "id": { "type": "string" },
4636
+ "target_entity_id": { "type": "string" },
4637
+ "target_schema_key": { "type": "string" },
4638
+ "target_file_id": { "type": ["string", "null"] }
4639
+ },
4640
+ "required": ["id", "target_entity_id", "target_schema_key", "target_file_id"],
4641
+ "additionalProperties": false
4642
+ })
4643
+ }
4644
+
4645
+ fn unique_row(entity_id: &str, slug: &str, title: &str) -> StagedStateRow {
4646
+ let mut row = staged_row(
4647
+ "unique_schema",
4648
+ "1",
4649
+ Some(
4650
+ json!({
4651
+ "id": entity_id,
4652
+ "slug": slug,
4653
+ "title": title,
4654
+ })
4655
+ .to_string(),
4656
+ ),
4657
+ );
4658
+ row.entity_id = crate::entity_identity::EntityIdentity::single(entity_id);
4659
+ row.file_id = Some("file-a".to_string());
4660
+ row.version_id = "version-a".to_string();
4661
+ row.global = false;
4662
+ row
4663
+ }
4664
+
4665
+ fn nullable_unique_row(entity_id: &str, scope: Option<&str>, name: &str) -> StagedStateRow {
4666
+ let mut row = staged_row(
4667
+ "nullable_unique_schema",
4668
+ "1",
4669
+ Some(
4670
+ json!({
4671
+ "id": entity_id,
4672
+ "scope": scope,
4673
+ "name": name,
4674
+ })
4675
+ .to_string(),
4676
+ ),
4677
+ );
4678
+ row.entity_id = crate::entity_identity::EntityIdentity::single(entity_id);
4679
+ row.file_id = Some("file-a".to_string());
4680
+ row.version_id = "version-a".to_string();
4681
+ row.global = false;
4682
+ row
4683
+ }
4684
+
4685
+ fn fk_parent_row(entity_id: &str, version_id: &str) -> StagedStateRow {
4686
+ let mut row = staged_row(
4687
+ "fk_parent_schema",
4688
+ "1",
4689
+ Some(json!({ "id": entity_id }).to_string()),
4690
+ );
4691
+ row.entity_id = crate::entity_identity::EntityIdentity::single(entity_id);
4692
+ row.file_id = Some("file-a".to_string());
4693
+ row.version_id = version_id.to_string();
4694
+ row.global = false;
4695
+ row
4696
+ }
4697
+
4698
+ fn fk_child_row(entity_id: &str, parent_id: &str, version_id: &str) -> StagedStateRow {
4699
+ let mut row = staged_row(
4700
+ "fk_child_schema",
4701
+ "1",
4702
+ Some(json!({ "id": entity_id, "parent_id": parent_id }).to_string()),
4703
+ );
4704
+ row.entity_id = crate::entity_identity::EntityIdentity::single(entity_id);
4705
+ row.file_id = Some("file-a".to_string());
4706
+ row.version_id = version_id.to_string();
4707
+ row.global = false;
4708
+ row
4709
+ }
4710
+
4711
+ fn state_surface_ref_row(
4712
+ entity_id: &str,
4713
+ target_entity_id: &str,
4714
+ target_schema_key: &str,
4715
+ target_file_id: &str,
4716
+ ) -> StagedStateRow {
4717
+ let mut row = staged_row(
4718
+ "state_surface_ref_schema",
4719
+ "1",
4720
+ Some(
4721
+ json!({
4722
+ "id": entity_id,
4723
+ "target_entity_id": target_entity_id,
4724
+ "target_schema_key": target_schema_key,
4725
+ "target_file_id": target_file_id,
4726
+ })
4727
+ .to_string(),
4728
+ ),
4729
+ );
4730
+ row.entity_id = crate::entity_identity::EntityIdentity::single(entity_id);
4731
+ row.file_id = Some("file-a".to_string());
4732
+ row.version_id = "version-a".to_string();
4733
+ row.global = false;
4734
+ row
4735
+ }
4736
+
4737
+ fn staged_file_descriptor_row(file_id: &str, version_id: &str) -> StagedStateRow {
4738
+ let mut row = staged_row(
4739
+ FILE_DESCRIPTOR_SCHEMA_KEY,
4740
+ "1",
4741
+ Some(
4742
+ json!({
4743
+ "id": file_id,
4744
+ "directory_id": null,
4745
+ "name": file_id,
4746
+ "hidden": false,
4747
+ })
4748
+ .to_string(),
4749
+ ),
4750
+ );
4751
+ row.entity_id = crate::entity_identity::EntityIdentity::single(file_id);
4752
+ row.file_id = None;
4753
+ row.version_id = version_id.to_string();
4754
+ row.global = version_id == crate::GLOBAL_VERSION_ID;
4755
+ row
4756
+ }
4757
+
4758
+ fn committed_file_descriptor_row(file_id: &str, version_id: &str) -> LiveStateRow {
4759
+ LiveStateRow::from(staged_file_descriptor_row(file_id, version_id))
4760
+ }
4761
+
4762
+ fn committed_unique_row(entity_id: &str, slug: &str, title: &str) -> LiveStateRow {
4763
+ let row = unique_row(entity_id, slug, title);
4764
+ LiveStateRow {
4765
+ entity_id: row.entity_id,
4766
+ schema_key: row.schema_key,
4767
+ file_id: row.file_id,
4768
+ snapshot_content: row.snapshot_content,
4769
+ metadata: row.metadata,
4770
+ schema_version: row.schema_version,
4771
+ created_at: row.created_at,
4772
+ updated_at: row.updated_at,
4773
+ global: row.global,
4774
+ change_id: row.change_id,
4775
+ commit_id: row.commit_id,
4776
+ untracked: row.untracked,
4777
+ version_id: row.version_id,
4778
+ }
4779
+ }
4780
+
4781
+ fn committed_nullable_unique_row(
4782
+ entity_id: &str,
4783
+ scope: Option<&str>,
4784
+ name: &str,
4785
+ ) -> LiveStateRow {
4786
+ LiveStateRow::from(nullable_unique_row(entity_id, scope, name))
4787
+ }
4788
+
4789
+ fn staged_row(
4790
+ schema_key: &str,
4791
+ schema_version: &str,
4792
+ snapshot_content: Option<String>,
4793
+ ) -> StagedStateRow {
4794
+ StagedStateRow {
4795
+ entity_id: crate::entity_identity::EntityIdentity::single("entity-1"),
4796
+ schema_key: schema_key.to_string(),
4797
+ file_id: None,
4798
+ snapshot_content,
4799
+ metadata: None,
4800
+ origin: None,
4801
+ schema_version: schema_version.to_string(),
4802
+ created_at: "2026-04-29T00:00:00.000Z".to_string(),
4803
+ updated_at: "2026-04-29T00:00:00.000Z".to_string(),
4804
+ global: true,
4805
+ change_id: Some("change-1".to_string()),
4806
+ commit_id: Some("commit-1".to_string()),
4807
+ untracked: false,
4808
+ version_id: crate::GLOBAL_VERSION_ID.to_string(),
4809
+ }
4810
+ }
4811
+ }