@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,1026 @@
1
+ use std::collections::BTreeMap;
2
+
3
+ use serde_json::{Map as JsonMap, Value as JsonValue};
4
+
5
+ use crate::common::normalize_path_segment;
6
+ use crate::entity_identity::{EntityIdentity, EntityIdentityError};
7
+ use crate::functions::FunctionProviderHandle;
8
+ use crate::schema::{
9
+ apply_schema_defaults_with_shared_runtime, is_seed_schema_key,
10
+ reject_unsupported_registered_schema_version, schema_from_registered_snapshot,
11
+ schema_key_from_definition, validate_lix_schema, validate_lix_schema_definition, SchemaKey,
12
+ };
13
+ use crate::transaction::types::StageRow;
14
+ use crate::LixError;
15
+
16
+ pub(crate) const REGISTERED_SCHEMA_KEY: &str = "lix_registered_schema";
17
+ const DIRECTORY_DESCRIPTOR_SCHEMA_KEY: &str = "lix_directory_descriptor";
18
+ const FILE_DESCRIPTOR_SCHEMA_KEY: &str = "lix_file_descriptor";
19
+
20
+ /// Transaction-local schema catalog used while raw writes are staged.
21
+ ///
22
+ /// Normalization has to happen before rows are keyed in the staged-write map:
23
+ /// defaults may fill primary-key fields and primary keys may derive the final
24
+ /// entity id. The catalog starts with session-visible schemas and is updated as
25
+ /// pending `lix_registered_schema` rows are staged, so later rows in the same
26
+ /// transaction can target newly registered schemas.
27
+ #[derive(Debug, Clone, Default)]
28
+ pub(crate) struct TransactionSchemaCatalog {
29
+ schemas_by_key: BTreeMap<SchemaCatalogKey, JsonValue>,
30
+ }
31
+
32
+ impl TransactionSchemaCatalog {
33
+ pub(crate) fn from_visible_schemas(visible_schemas: &[JsonValue]) -> Result<Self, LixError> {
34
+ let mut catalog = Self::default();
35
+ for schema in visible_schemas {
36
+ let key = schema_key_from_definition(schema)?;
37
+ catalog.insert_schema(key, schema.clone());
38
+ }
39
+ Ok(catalog)
40
+ }
41
+
42
+ pub(crate) fn schema(&self, schema_key: &str, schema_version: &str) -> Option<&JsonValue> {
43
+ self.schemas_by_key.get(&SchemaCatalogKey {
44
+ schema_key: schema_key.to_string(),
45
+ schema_version: schema_version.to_string(),
46
+ })
47
+ }
48
+
49
+ pub(crate) fn insert_schema(&mut self, key: SchemaKey, schema: JsonValue) {
50
+ self.schemas_by_key
51
+ .insert(SchemaCatalogKey::from_schema_key(key), schema);
52
+ }
53
+
54
+ pub(crate) fn contains(&self, schema_key: &str, schema_version: &str) -> bool {
55
+ self.schemas_by_key.contains_key(&SchemaCatalogKey {
56
+ schema_key: schema_key.to_string(),
57
+ schema_version: schema_version.to_string(),
58
+ })
59
+ }
60
+
61
+ #[cfg(test)]
62
+ pub(crate) fn len(&self) -> usize {
63
+ self.schemas_by_key.len()
64
+ }
65
+
66
+ pub(crate) fn schema_by_key(&self, schema_key: &str) -> Option<&JsonValue> {
67
+ self.schemas_by_key
68
+ .iter()
69
+ .find_map(|(key, schema)| (key.schema_key == schema_key).then_some(schema))
70
+ }
71
+
72
+ pub(crate) fn schema_key_by_key(&self, schema_key: &str) -> Option<SchemaCatalogKey> {
73
+ self.schemas_by_key
74
+ .keys()
75
+ .find(|key| key.schema_key == schema_key)
76
+ .cloned()
77
+ }
78
+
79
+ pub(crate) fn schemas(&self) -> impl Iterator<Item = (&SchemaCatalogKey, &JsonValue)> {
80
+ self.schemas_by_key.iter()
81
+ }
82
+ }
83
+
84
+ /// Normalizes one incoming row into a row with final snapshot/entity identity.
85
+ ///
86
+ /// This is the canonical schema-semantics boundary for staged writes. It owns
87
+ /// schema default application, primary-key identity derivation, and explicit
88
+ /// identity mismatch validation. SQL providers should not pre-derive primary
89
+ /// keys for schemas that can be normalized here; they should stage decoded
90
+ /// snapshots and let this layer complete them.
91
+ ///
92
+ /// This function intentionally does not assign timestamps, change ids, or
93
+ /// commit ids; those are transaction hydration fields handled by staging after
94
+ /// semantic normalization has produced the final identity.
95
+ pub(crate) fn normalize_stage_row(
96
+ mut row: StageRow,
97
+ schema_catalog: &mut TransactionSchemaCatalog,
98
+ functions: FunctionProviderHandle,
99
+ ) -> Result<StageRow, LixError> {
100
+ validate_stage_row_schema_identity(&row)?;
101
+
102
+ let Some(schema) = schema_catalog
103
+ .schema(&row.schema_key, &row.schema_version)
104
+ .cloned()
105
+ else {
106
+ return Err(LixError::new(
107
+ LixError::CODE_SCHEMA_DEFINITION,
108
+ format!(
109
+ "schema '{}' version '{}' is not visible to this transaction",
110
+ row.schema_key, row.schema_version
111
+ ),
112
+ ));
113
+ };
114
+
115
+ if let Some(snapshot_content) = row.snapshot_content.as_deref() {
116
+ let mut snapshot = parse_snapshot_object(snapshot_content, &row)?;
117
+ apply_defaults(&mut snapshot, &schema, &row, functions)?;
118
+ normalize_filesystem_descriptor_snapshot(&row, &mut snapshot)?;
119
+ let snapshot = JsonValue::Object(snapshot);
120
+ row.entity_id = Some(resolve_entity_id(&row, &schema, &snapshot)?);
121
+ row.snapshot_content = Some(serde_json::to_string(&snapshot).map_err(|error| {
122
+ LixError::new(
123
+ LixError::CODE_UNKNOWN,
124
+ format!(
125
+ "failed to serialize normalized snapshot_content for schema '{}' version '{}': {error}",
126
+ row.schema_key, row.schema_version
127
+ ),
128
+ )
129
+ })?);
130
+ } else if row.entity_id.is_none() {
131
+ return Err(LixError::new(
132
+ LixError::CODE_SCHEMA_VALIDATION,
133
+ format!(
134
+ "tombstone for schema '{}' version '{}' requires entity_id",
135
+ row.schema_key, row.schema_version
136
+ ),
137
+ ));
138
+ }
139
+
140
+ if row.schema_key == REGISTERED_SCHEMA_KEY {
141
+ remember_pending_registered_schema(row.snapshot_content.as_deref(), schema_catalog)?;
142
+ }
143
+
144
+ Ok(row)
145
+ }
146
+
147
+ fn validate_stage_row_schema_identity(row: &StageRow) -> Result<(), LixError> {
148
+ if row.schema_key.is_empty() {
149
+ return Err(LixError::new(
150
+ LixError::CODE_UNKNOWN,
151
+ "engine2 transaction staging requires non-empty schema_key",
152
+ ));
153
+ }
154
+ if row.schema_version.is_empty() {
155
+ return Err(LixError::new(
156
+ LixError::CODE_UNKNOWN,
157
+ "engine2 transaction staging requires non-empty schema_version",
158
+ ));
159
+ }
160
+ Ok(())
161
+ }
162
+
163
+ fn parse_snapshot_object(
164
+ snapshot_content: &str,
165
+ row: &StageRow,
166
+ ) -> Result<JsonMap<String, JsonValue>, LixError> {
167
+ let snapshot = serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
168
+ LixError::new(
169
+ LixError::CODE_SCHEMA_VALIDATION,
170
+ format!(
171
+ "snapshot_content for schema '{}' version '{}' is invalid JSON: {error}",
172
+ row.schema_key, row.schema_version
173
+ ),
174
+ )
175
+ })?;
176
+ snapshot.as_object().cloned().ok_or_else(|| {
177
+ LixError::new(
178
+ LixError::CODE_SCHEMA_VALIDATION,
179
+ format!(
180
+ "snapshot_content for schema '{}' version '{}' must be a JSON object",
181
+ row.schema_key, row.schema_version
182
+ ),
183
+ )
184
+ })
185
+ }
186
+
187
+ fn apply_defaults(
188
+ snapshot: &mut JsonMap<String, JsonValue>,
189
+ schema: &JsonValue,
190
+ row: &StageRow,
191
+ functions: FunctionProviderHandle,
192
+ ) -> Result<(), LixError> {
193
+ apply_schema_defaults_with_shared_runtime(
194
+ snapshot,
195
+ schema,
196
+ functions,
197
+ &row.schema_key,
198
+ &row.schema_version,
199
+ )?;
200
+ Ok(())
201
+ }
202
+
203
+ fn normalize_filesystem_descriptor_snapshot(
204
+ row: &StageRow,
205
+ snapshot: &mut JsonMap<String, JsonValue>,
206
+ ) -> Result<(), LixError> {
207
+ match row.schema_key.as_str() {
208
+ DIRECTORY_DESCRIPTOR_SCHEMA_KEY => normalize_directory_descriptor_snapshot(row, snapshot),
209
+ FILE_DESCRIPTOR_SCHEMA_KEY => normalize_file_descriptor_snapshot(row, snapshot),
210
+ _ => Ok(()),
211
+ }
212
+ }
213
+
214
+ fn normalize_directory_descriptor_snapshot(
215
+ row: &StageRow,
216
+ snapshot: &mut JsonMap<String, JsonValue>,
217
+ ) -> Result<(), LixError> {
218
+ let Some(name) = optional_string_field(snapshot, "name", row)? else {
219
+ return Ok(());
220
+ };
221
+ let normalized_name = normalize_path_segment(name)?;
222
+ snapshot.insert("name".to_string(), JsonValue::String(normalized_name));
223
+ Ok(())
224
+ }
225
+
226
+ fn normalize_file_descriptor_snapshot(
227
+ row: &StageRow,
228
+ snapshot: &mut JsonMap<String, JsonValue>,
229
+ ) -> Result<(), LixError> {
230
+ let Some(name) = optional_string_field(snapshot, "name", row)? else {
231
+ return Ok(());
232
+ };
233
+ let normalized_name = normalize_path_segment(name)?;
234
+ snapshot.insert("name".to_string(), JsonValue::String(normalized_name));
235
+ Ok(())
236
+ }
237
+
238
+ fn optional_string_field<'a>(
239
+ snapshot: &'a JsonMap<String, JsonValue>,
240
+ field: &str,
241
+ row: &StageRow,
242
+ ) -> Result<Option<&'a str>, LixError> {
243
+ let Some(value) = snapshot.get(field) else {
244
+ return Ok(None);
245
+ };
246
+ value.as_str().map(Some).ok_or_else(|| {
247
+ LixError::new(
248
+ LixError::CODE_SCHEMA_VALIDATION,
249
+ format!(
250
+ "snapshot_content for schema '{}' version '{}' field '{}' must be a string",
251
+ row.schema_key, row.schema_version, field
252
+ ),
253
+ )
254
+ })
255
+ }
256
+
257
+ fn resolve_entity_id(
258
+ row: &StageRow,
259
+ schema: &JsonValue,
260
+ snapshot: &JsonValue,
261
+ ) -> Result<EntityIdentity, LixError> {
262
+ let Some(primary_key_paths) = primary_key_paths(schema)? else {
263
+ return row.entity_id.clone().ok_or_else(|| {
264
+ LixError::new(
265
+ LixError::CODE_SCHEMA_VALIDATION,
266
+ format!(
267
+ "write for schema '{}' version '{}' requires entity_id because the schema has no x-lix-primary-key",
268
+ row.schema_key, row.schema_version
269
+ ),
270
+ )
271
+ });
272
+ };
273
+ let derived = EntityIdentity::from_primary_key_paths(snapshot, &primary_key_paths)
274
+ .map_err(|error| entity_id_derivation_error(row, &primary_key_paths, error))?;
275
+ if let Some(entity_id) = row.entity_id.as_ref() {
276
+ if entity_id != &derived {
277
+ return Err(LixError::new(
278
+ LixError::CODE_SCHEMA_VALIDATION,
279
+ format!(
280
+ "entity_id '{}' does not match x-lix-primary-key derived entity_id '{}' for schema '{}' version '{}'",
281
+ entity_id.as_string()?, derived.as_string()?, row.schema_key, row.schema_version
282
+ ),
283
+ ));
284
+ }
285
+ }
286
+ Ok(derived)
287
+ }
288
+
289
+ fn primary_key_paths(schema: &JsonValue) -> Result<Option<Vec<Vec<String>>>, LixError> {
290
+ let Some(primary_key) = schema.get("x-lix-primary-key") else {
291
+ return Ok(None);
292
+ };
293
+ let primary_key = primary_key.as_array().ok_or_else(|| {
294
+ LixError::new(
295
+ LixError::CODE_SCHEMA_DEFINITION,
296
+ "schema x-lix-primary-key must be an array of JSON Pointers",
297
+ )
298
+ })?;
299
+ primary_key
300
+ .iter()
301
+ .enumerate()
302
+ .map(|(index, pointer)| {
303
+ let pointer = pointer.as_str().ok_or_else(|| {
304
+ LixError::new(
305
+ LixError::CODE_SCHEMA_DEFINITION,
306
+ format!("schema x-lix-primary-key entry at index {index} must be a string"),
307
+ )
308
+ })?;
309
+ parse_json_pointer(pointer)
310
+ })
311
+ .collect::<Result<Vec<_>, _>>()
312
+ .map(Some)
313
+ }
314
+
315
+ fn parse_json_pointer(pointer: &str) -> Result<Vec<String>, LixError> {
316
+ if pointer.is_empty() {
317
+ return Ok(Vec::new());
318
+ }
319
+ if !pointer.starts_with('/') {
320
+ return Err(LixError::new(
321
+ LixError::CODE_SCHEMA_DEFINITION,
322
+ format!("invalid JSON pointer '{pointer}'"),
323
+ ));
324
+ }
325
+ pointer[1..]
326
+ .split('/')
327
+ .map(decode_json_pointer_segment)
328
+ .collect()
329
+ }
330
+
331
+ fn decode_json_pointer_segment(segment: &str) -> Result<String, LixError> {
332
+ let mut out = String::new();
333
+ let mut chars = segment.chars();
334
+ while let Some(ch) = chars.next() {
335
+ if ch == '~' {
336
+ match chars.next() {
337
+ Some('0') => out.push('~'),
338
+ Some('1') => out.push('/'),
339
+ _ => {
340
+ return Err(LixError::new(
341
+ LixError::CODE_SCHEMA_DEFINITION,
342
+ "invalid JSON pointer escape",
343
+ ))
344
+ }
345
+ }
346
+ } else {
347
+ out.push(ch);
348
+ }
349
+ }
350
+ Ok(out)
351
+ }
352
+
353
+ fn entity_id_derivation_error(
354
+ row: &StageRow,
355
+ primary_key_paths: &[Vec<String>],
356
+ error: EntityIdentityError,
357
+ ) -> LixError {
358
+ let detail = match error {
359
+ EntityIdentityError::EmptyPrimaryKey => "empty x-lix-primary-key".to_string(),
360
+ EntityIdentityError::EmptyPrimaryKeyPath { index } => {
361
+ format!("empty x-lix-primary-key pointer at index {index}")
362
+ }
363
+ EntityIdentityError::MissingPrimaryKeyValue { index } => {
364
+ let pointer = format_json_pointer(&primary_key_paths[index]);
365
+ format!("missing value at primary-key pointer '{pointer}'")
366
+ }
367
+ EntityIdentityError::NullPrimaryKeyValue { index } => {
368
+ let pointer = format_json_pointer(&primary_key_paths[index]);
369
+ format!("null value at primary-key pointer '{pointer}'")
370
+ }
371
+ EntityIdentityError::EmptyPrimaryKeyValue { index } => {
372
+ let pointer = format_json_pointer(&primary_key_paths[index]);
373
+ format!("empty value at primary-key pointer '{pointer}'")
374
+ }
375
+ EntityIdentityError::UnsupportedPrimaryKeyValue { index } => {
376
+ let pointer = format_json_pointer(&primary_key_paths[index]);
377
+ format!("unsupported non-scalar value at primary-key pointer '{pointer}'")
378
+ }
379
+ EntityIdentityError::InvalidEncodedEntityIdentity => {
380
+ "invalid encoded entity identity".to_string()
381
+ }
382
+ };
383
+ LixError::new(
384
+ LixError::CODE_SCHEMA_VALIDATION,
385
+ format!(
386
+ "failed to derive entity_id for schema '{}' version '{}': {detail}",
387
+ row.schema_key, row.schema_version
388
+ ),
389
+ )
390
+ }
391
+
392
+ fn format_json_pointer(segments: &[String]) -> String {
393
+ if segments.is_empty() {
394
+ return String::new();
395
+ }
396
+ format!(
397
+ "/{}",
398
+ segments
399
+ .iter()
400
+ .map(|segment| segment.replace('~', "~0").replace('/', "~1"))
401
+ .collect::<Vec<_>>()
402
+ .join("/")
403
+ )
404
+ }
405
+
406
+ pub(crate) fn remember_pending_registered_schema(
407
+ snapshot_content: Option<&str>,
408
+ schema_catalog: &mut TransactionSchemaCatalog,
409
+ ) -> Result<(), LixError> {
410
+ let Some(snapshot_content) = snapshot_content else {
411
+ return Err(LixError::new(
412
+ LixError::CODE_SCHEMA_DEFINITION,
413
+ "lix_registered_schema rows cannot be deleted yet; schema deletion is not supported",
414
+ ));
415
+ };
416
+ let snapshot = serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
417
+ LixError::new(
418
+ LixError::CODE_SCHEMA_DEFINITION,
419
+ format!("pending registered schema snapshot_content is invalid JSON: {error}"),
420
+ )
421
+ })?;
422
+ let registered_schema_definition = schema_catalog
423
+ .schema(REGISTERED_SCHEMA_KEY, "1")
424
+ .cloned()
425
+ .ok_or_else(|| {
426
+ LixError::new(
427
+ LixError::CODE_SCHEMA_DEFINITION,
428
+ "lix_registered_schema schema is not visible to this transaction",
429
+ )
430
+ })?;
431
+ if !snapshot.get("value").is_some_and(JsonValue::is_object) {
432
+ validate_lix_schema(&registered_schema_definition, &snapshot)?;
433
+ }
434
+ let (key, schema) = schema_from_registered_snapshot(&snapshot)?;
435
+ reject_unsupported_registered_schema_version(&key)?;
436
+ if is_seed_schema_key(&key.schema_key) {
437
+ return Err(LixError::new(
438
+ LixError::CODE_SCHEMA_DEFINITION,
439
+ format!(
440
+ "schema '{}' is a system schema and cannot be registered at runtime",
441
+ key.schema_key
442
+ ),
443
+ ));
444
+ }
445
+ validate_lix_schema_definition(&schema)?;
446
+ validate_lix_schema(&registered_schema_definition, &snapshot)?;
447
+ schema_catalog.insert_schema(key, schema);
448
+ Ok(())
449
+ }
450
+
451
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
452
+ pub(crate) struct SchemaCatalogKey {
453
+ pub(crate) schema_key: String,
454
+ pub(crate) schema_version: String,
455
+ }
456
+
457
+ impl SchemaCatalogKey {
458
+ pub(crate) fn from_schema_key(key: SchemaKey) -> Self {
459
+ Self {
460
+ schema_key: key.schema_key,
461
+ schema_version: key.schema_version,
462
+ }
463
+ }
464
+ }
465
+
466
+ #[cfg(test)]
467
+ mod tests {
468
+ use serde_json::json;
469
+
470
+ use super::*;
471
+ use crate::functions::{FunctionProvider, SharedFunctionProvider};
472
+ use crate::schema::seed_schema_definition;
473
+
474
+ #[test]
475
+ fn normalization_derives_entity_id_from_primary_key() {
476
+ let mut catalog = catalog_with(vec![schema_with_default_id()]);
477
+ let row = StageRow {
478
+ entity_id: None,
479
+ schema_key: "normalization_schema".to_string(),
480
+ schema_version: "1".to_string(),
481
+ snapshot_content: Some(r#"{"id":"entity-from-snapshot","value":"hello"}"#.to_string()),
482
+ ..base_stage_row()
483
+ };
484
+
485
+ let row = normalize_stage_row(row, &mut catalog, functions()).expect("normalize row");
486
+
487
+ assert_eq!(
488
+ row.entity_id.as_ref(),
489
+ Some(&crate::entity_identity::EntityIdentity::single(
490
+ "entity-from-snapshot"
491
+ ))
492
+ );
493
+ }
494
+
495
+ #[test]
496
+ fn normalization_applies_json_and_cel_defaults_before_identity_derivation() {
497
+ let mut catalog = catalog_with(vec![schema_with_default_id()]);
498
+ let row = StageRow {
499
+ entity_id: None,
500
+ schema_key: "normalization_schema".to_string(),
501
+ schema_version: "1".to_string(),
502
+ snapshot_content: Some(r#"{}"#.to_string()),
503
+ ..base_stage_row()
504
+ };
505
+
506
+ let row = normalize_stage_row(row, &mut catalog, functions()).expect("normalize row");
507
+ let snapshot: JsonValue =
508
+ serde_json::from_str(row.snapshot_content.as_deref().unwrap()).unwrap();
509
+
510
+ assert_eq!(
511
+ row.entity_id.as_ref(),
512
+ Some(&crate::entity_identity::EntityIdentity::single(
513
+ "uuid-default"
514
+ ))
515
+ );
516
+ assert_eq!(snapshot["id"], "uuid-default");
517
+ assert_eq!(snapshot["value"], "literal-default");
518
+ }
519
+
520
+ #[test]
521
+ fn normalization_applies_cel_defaults_from_snapshot_context() {
522
+ let mut catalog = catalog_with(vec![schema_with_cel_field_default()]);
523
+ let row = StageRow {
524
+ entity_id: None,
525
+ schema_key: "cel_field_default_schema".to_string(),
526
+ schema_version: "1".to_string(),
527
+ snapshot_content: Some(r#"{"id":"entity-1","name":"Sample"}"#.to_string()),
528
+ ..base_stage_row()
529
+ };
530
+
531
+ let row = normalize_stage_row(row, &mut catalog, functions()).expect("normalize row");
532
+ let snapshot: JsonValue =
533
+ serde_json::from_str(row.snapshot_content.as_deref().unwrap()).unwrap();
534
+
535
+ assert_eq!(snapshot["slug"], "Sample-slug");
536
+ }
537
+
538
+ #[test]
539
+ fn normalization_x_lix_default_overrides_json_default() {
540
+ let mut catalog = catalog_with(vec![schema_with_overridden_default()]);
541
+ let row = StageRow {
542
+ entity_id: None,
543
+ schema_key: "overridden_default_schema".to_string(),
544
+ schema_version: "1".to_string(),
545
+ snapshot_content: Some(r#"{"id":"entity-1"}"#.to_string()),
546
+ ..base_stage_row()
547
+ };
548
+
549
+ let row = normalize_stage_row(row, &mut catalog, functions()).expect("normalize row");
550
+ let snapshot: JsonValue =
551
+ serde_json::from_str(row.snapshot_content.as_deref().unwrap()).unwrap();
552
+
553
+ assert_eq!(snapshot["status"], "computed");
554
+ }
555
+
556
+ #[test]
557
+ fn normalization_does_not_overwrite_explicit_null_with_default() {
558
+ let mut catalog = catalog_with(vec![schema_with_nullable_default()]);
559
+ let row = StageRow {
560
+ entity_id: None,
561
+ schema_key: "nullable_default_schema".to_string(),
562
+ schema_version: "1".to_string(),
563
+ snapshot_content: Some(r#"{"id":"entity-1","status":null}"#.to_string()),
564
+ ..base_stage_row()
565
+ };
566
+
567
+ let row = normalize_stage_row(row, &mut catalog, functions()).expect("normalize row");
568
+ let snapshot: JsonValue =
569
+ serde_json::from_str(row.snapshot_content.as_deref().unwrap()).unwrap();
570
+
571
+ assert_eq!(snapshot["status"], JsonValue::Null);
572
+ }
573
+
574
+ #[test]
575
+ fn normalization_applies_timestamp_function_default() {
576
+ let mut catalog = catalog_with(vec![schema_with_timestamp_default()]);
577
+ let row = StageRow {
578
+ entity_id: None,
579
+ schema_key: "timestamp_default_schema".to_string(),
580
+ schema_version: "1".to_string(),
581
+ snapshot_content: Some(r#"{"id":"entity-1"}"#.to_string()),
582
+ ..base_stage_row()
583
+ };
584
+
585
+ let row = normalize_stage_row(row, &mut catalog, functions()).expect("normalize row");
586
+ let snapshot: JsonValue =
587
+ serde_json::from_str(row.snapshot_content.as_deref().unwrap()).unwrap();
588
+
589
+ assert_eq!(snapshot["created_at"], "1970-01-01T00:00:00.000Z");
590
+ }
591
+
592
+ #[test]
593
+ fn normalization_surfaces_cel_default_errors() {
594
+ let mut catalog = catalog_with(vec![schema_with_unknown_cel_default()]);
595
+ let row = StageRow {
596
+ entity_id: None,
597
+ schema_key: "unknown_cel_default_schema".to_string(),
598
+ schema_version: "1".to_string(),
599
+ snapshot_content: Some(r#"{"id":"entity-1"}"#.to_string()),
600
+ ..base_stage_row()
601
+ };
602
+
603
+ let error =
604
+ normalize_stage_row(row, &mut catalog, functions()).expect_err("default should fail");
605
+
606
+ assert!(error.message.contains("failed to evaluate x-lix-default"));
607
+ assert!(error.message.contains("unknown_cel_default_schema.slug"));
608
+ }
609
+
610
+ #[test]
611
+ fn normalization_rejects_entity_id_that_disagrees_with_primary_key() {
612
+ let mut catalog = catalog_with(vec![schema_with_default_id()]);
613
+ let row = StageRow {
614
+ entity_id: Some(crate::entity_identity::EntityIdentity::single("wrong-id")),
615
+ schema_key: "normalization_schema".to_string(),
616
+ schema_version: "1".to_string(),
617
+ snapshot_content: Some(r#"{"id":"right-id","value":"hello"}"#.to_string()),
618
+ ..base_stage_row()
619
+ };
620
+
621
+ let error =
622
+ normalize_stage_row(row, &mut catalog, functions()).expect_err("id mismatch fails");
623
+
624
+ assert!(error
625
+ .message
626
+ .contains("does not match x-lix-primary-key derived entity_id"));
627
+ }
628
+
629
+ #[test]
630
+ fn normalization_derives_opaque_entity_id_for_composite_primary_key() {
631
+ let mut catalog = catalog_with(vec![composite_key_schema()]);
632
+ let row = StageRow {
633
+ entity_id: None,
634
+ schema_key: "composite_key_schema".to_string(),
635
+ schema_version: "1".to_string(),
636
+ snapshot_content: Some(r#"{"namespace":"a~b","key":"1"}"#.to_string()),
637
+ ..base_stage_row()
638
+ };
639
+
640
+ let row = normalize_stage_row(row, &mut catalog, functions()).expect("normalize row");
641
+ let entity_id = row.entity_id.expect("composite entity id");
642
+ let projected_entity_id = entity_id.as_string().expect("entity id should project");
643
+
644
+ assert!(projected_entity_id.starts_with("pk:v1:"));
645
+ assert_ne!(projected_entity_id, "a~b~1");
646
+ }
647
+
648
+ #[test]
649
+ fn normalization_validates_explicit_composite_entity_id_against_projection() {
650
+ let mut catalog = catalog_with(vec![composite_key_schema()]);
651
+ let snapshot = json!({
652
+ "namespace": "a~b",
653
+ "key": "1",
654
+ });
655
+ let derived = EntityIdentity::from_primary_key_paths(
656
+ &snapshot,
657
+ &[vec!["namespace".to_string()], vec!["key".to_string()]],
658
+ )
659
+ .expect("identity should derive");
660
+ let row = StageRow {
661
+ entity_id: Some(derived.clone()),
662
+ schema_key: "composite_key_schema".to_string(),
663
+ schema_version: "1".to_string(),
664
+ snapshot_content: Some(snapshot.to_string()),
665
+ ..base_stage_row()
666
+ };
667
+
668
+ let row = normalize_stage_row(row, &mut catalog, functions()).expect("normalize row");
669
+
670
+ assert_eq!(row.entity_id.as_ref(), Some(&derived));
671
+ }
672
+
673
+ #[test]
674
+ fn normalization_makes_pending_registered_schema_visible_to_later_rows() {
675
+ let mut catalog = catalog_with(vec![seed_schema_definition(REGISTERED_SCHEMA_KEY)
676
+ .expect("registered schema builtin")
677
+ .clone()]);
678
+ let registered = StageRow {
679
+ entity_id: None,
680
+ schema_key: REGISTERED_SCHEMA_KEY.to_string(),
681
+ schema_version: "1".to_string(),
682
+ snapshot_content: Some(
683
+ json!({
684
+ "value": dynamic_schema_definition(),
685
+ })
686
+ .to_string(),
687
+ ),
688
+ ..base_stage_row()
689
+ };
690
+
691
+ normalize_stage_row(registered, &mut catalog, functions()).expect("register schema");
692
+
693
+ let dynamic = StageRow {
694
+ entity_id: None,
695
+ schema_key: "dynamic_schema".to_string(),
696
+ schema_version: "1".to_string(),
697
+ snapshot_content: Some(r#"{"id":"dynamic-1"}"#.to_string()),
698
+ ..base_stage_row()
699
+ };
700
+ let dynamic = normalize_stage_row(dynamic, &mut catalog, functions()).expect("dynamic row");
701
+
702
+ assert_eq!(
703
+ dynamic.entity_id.as_ref(),
704
+ Some(&crate::entity_identity::EntityIdentity::single("dynamic-1"))
705
+ );
706
+ }
707
+
708
+ #[test]
709
+ fn normalization_canonicalizes_filesystem_descriptor_segments() {
710
+ let mut catalog = catalog_with(vec![
711
+ builtin_schema(FILE_DESCRIPTOR_SCHEMA_KEY),
712
+ builtin_schema(DIRECTORY_DESCRIPTOR_SCHEMA_KEY),
713
+ ]);
714
+
715
+ let file = StageRow {
716
+ entity_id: None,
717
+ schema_key: FILE_DESCRIPTOR_SCHEMA_KEY.to_string(),
718
+ snapshot_content: Some(
719
+ json!({
720
+ "id": "file-cafe",
721
+ "directory_id": null,
722
+ "name": "Cafe\u{301}.txt",
723
+ })
724
+ .to_string(),
725
+ ),
726
+ global: false,
727
+ ..base_stage_row()
728
+ };
729
+ let file = normalize_stage_row(file, &mut catalog, functions()).expect("normalize file");
730
+ let file_snapshot: JsonValue =
731
+ serde_json::from_str(file.snapshot_content.as_deref().unwrap()).unwrap();
732
+ assert_eq!(file_snapshot["name"], "Café.txt");
733
+
734
+ let directory = StageRow {
735
+ entity_id: None,
736
+ schema_key: DIRECTORY_DESCRIPTOR_SCHEMA_KEY.to_string(),
737
+ snapshot_content: Some(
738
+ json!({
739
+ "id": "dir-cafe",
740
+ "parent_id": null,
741
+ "name": "Cafe\u{301}",
742
+ })
743
+ .to_string(),
744
+ ),
745
+ global: false,
746
+ ..base_stage_row()
747
+ };
748
+ let directory =
749
+ normalize_stage_row(directory, &mut catalog, functions()).expect("normalize directory");
750
+ let directory_snapshot: JsonValue =
751
+ serde_json::from_str(directory.snapshot_content.as_deref().unwrap()).unwrap();
752
+ assert_eq!(directory_snapshot["name"], "Café");
753
+ }
754
+
755
+ #[test]
756
+ fn normalization_rejects_invalid_filesystem_descriptor_segments() {
757
+ let mut catalog = catalog_with(vec![
758
+ builtin_schema(FILE_DESCRIPTOR_SCHEMA_KEY),
759
+ builtin_schema(DIRECTORY_DESCRIPTOR_SCHEMA_KEY),
760
+ ]);
761
+
762
+ let dot_segment = normalize_stage_row(
763
+ StageRow {
764
+ entity_id: None,
765
+ schema_key: FILE_DESCRIPTOR_SCHEMA_KEY.to_string(),
766
+ snapshot_content: Some(
767
+ json!({
768
+ "id": "file-dotdot",
769
+ "directory_id": null,
770
+ "name": "..",
771
+ })
772
+ .to_string(),
773
+ ),
774
+ global: false,
775
+ ..base_stage_row()
776
+ },
777
+ &mut catalog,
778
+ functions(),
779
+ )
780
+ .expect_err("file descriptor name should reject dot segments");
781
+ assert_eq!(dot_segment.code, "LIX_ERROR_PATH_DOT_SEGMENT");
782
+
783
+ let bidi = normalize_stage_row(
784
+ StageRow {
785
+ entity_id: None,
786
+ schema_key: FILE_DESCRIPTOR_SCHEMA_KEY.to_string(),
787
+ snapshot_content: Some(
788
+ json!({
789
+ "id": "file-bidi",
790
+ "directory_id": null,
791
+ "name": "safe\u{202E}txt",
792
+ })
793
+ .to_string(),
794
+ ),
795
+ global: false,
796
+ ..base_stage_row()
797
+ },
798
+ &mut catalog,
799
+ functions(),
800
+ )
801
+ .expect_err("file descriptor name should reject bidi formatting characters");
802
+ assert_eq!(bidi.code, "LIX_ERROR_PATH_INVALID_SEGMENT_CODE_POINT");
803
+
804
+ let zero_width = normalize_stage_row(
805
+ StageRow {
806
+ entity_id: None,
807
+ schema_key: DIRECTORY_DESCRIPTOR_SCHEMA_KEY.to_string(),
808
+ snapshot_content: Some(
809
+ json!({
810
+ "id": "dir-zero-width",
811
+ "parent_id": null,
812
+ "name": "zero\u{200D}width",
813
+ })
814
+ .to_string(),
815
+ ),
816
+ global: false,
817
+ ..base_stage_row()
818
+ },
819
+ &mut catalog,
820
+ functions(),
821
+ )
822
+ .expect_err("directory descriptor name should reject zero-width characters");
823
+ assert_eq!(zero_width.code, "LIX_ERROR_PATH_INVALID_SEGMENT_CODE_POINT");
824
+ }
825
+
826
+ #[test]
827
+ fn normalization_keeps_file_descriptor_name_opaque() {
828
+ let mut catalog = catalog_with(vec![builtin_schema(FILE_DESCRIPTOR_SCHEMA_KEY)]);
829
+
830
+ let row = normalize_stage_row(
831
+ StageRow {
832
+ entity_id: None,
833
+ schema_key: FILE_DESCRIPTOR_SCHEMA_KEY.to_string(),
834
+ snapshot_content: Some(
835
+ json!({
836
+ "id": "file-opaque-name",
837
+ "directory_id": null,
838
+ "name": "foo.bar",
839
+ })
840
+ .to_string(),
841
+ ),
842
+ global: false,
843
+ ..base_stage_row()
844
+ },
845
+ &mut catalog,
846
+ functions(),
847
+ )
848
+ .expect("file descriptor name should be an opaque basename");
849
+
850
+ let snapshot: JsonValue =
851
+ serde_json::from_str(row.snapshot_content.as_deref().unwrap()).unwrap();
852
+ assert_eq!(snapshot["name"], "foo.bar");
853
+ }
854
+
855
+ fn catalog_with(schemas: Vec<JsonValue>) -> TransactionSchemaCatalog {
856
+ TransactionSchemaCatalog::from_visible_schemas(&schemas).expect("catalog")
857
+ }
858
+
859
+ fn builtin_schema(schema_key: &str) -> JsonValue {
860
+ seed_schema_definition(schema_key)
861
+ .unwrap_or_else(|| panic!("{schema_key} builtin schema should exist"))
862
+ .clone()
863
+ }
864
+
865
+ fn base_stage_row() -> StageRow {
866
+ StageRow {
867
+ entity_id: Some(crate::entity_identity::EntityIdentity::single("entity-1")),
868
+ schema_key: "normalization_schema".to_string(),
869
+ file_id: None,
870
+ snapshot_content: Some(r#"{"id":"entity-1","value":"hello"}"#.to_string()),
871
+ metadata: None,
872
+ origin: None,
873
+ schema_version: "1".to_string(),
874
+ created_at: None,
875
+ updated_at: None,
876
+ global: true,
877
+ change_id: None,
878
+ commit_id: None,
879
+ untracked: false,
880
+ version_id: crate::GLOBAL_VERSION_ID.to_string(),
881
+ }
882
+ }
883
+
884
+ fn schema_with_default_id() -> JsonValue {
885
+ json!({
886
+ "x-lix-key": "normalization_schema",
887
+ "x-lix-version": "1",
888
+ "x-lix-primary-key": ["/id"],
889
+ "type": "object",
890
+ "properties": {
891
+ "id": { "type": "string", "x-lix-default": "lix_uuid_v7()" },
892
+ "value": { "type": "string", "default": "literal-default" }
893
+ },
894
+ "required": ["id", "value"],
895
+ "additionalProperties": false
896
+ })
897
+ }
898
+
899
+ fn schema_with_cel_field_default() -> JsonValue {
900
+ json!({
901
+ "x-lix-key": "cel_field_default_schema",
902
+ "x-lix-version": "1",
903
+ "x-lix-primary-key": ["/id"],
904
+ "type": "object",
905
+ "properties": {
906
+ "id": { "type": "string" },
907
+ "name": { "type": "string" },
908
+ "slug": { "type": "string", "x-lix-default": "name + '-slug'" }
909
+ },
910
+ "required": ["id", "name"],
911
+ "additionalProperties": false
912
+ })
913
+ }
914
+
915
+ fn schema_with_overridden_default() -> JsonValue {
916
+ json!({
917
+ "x-lix-key": "overridden_default_schema",
918
+ "x-lix-version": "1",
919
+ "x-lix-primary-key": ["/id"],
920
+ "type": "object",
921
+ "properties": {
922
+ "id": { "type": "string" },
923
+ "status": {
924
+ "type": "string",
925
+ "default": "literal",
926
+ "x-lix-default": "'computed'"
927
+ }
928
+ },
929
+ "required": ["id"],
930
+ "additionalProperties": false
931
+ })
932
+ }
933
+
934
+ fn schema_with_nullable_default() -> JsonValue {
935
+ json!({
936
+ "x-lix-key": "nullable_default_schema",
937
+ "x-lix-version": "1",
938
+ "x-lix-primary-key": ["/id"],
939
+ "type": "object",
940
+ "properties": {
941
+ "id": { "type": "string" },
942
+ "status": {
943
+ "anyOf": [{ "type": "string" }, { "type": "null" }],
944
+ "x-lix-default": "'computed'"
945
+ }
946
+ },
947
+ "required": ["id"],
948
+ "additionalProperties": false
949
+ })
950
+ }
951
+
952
+ fn schema_with_timestamp_default() -> JsonValue {
953
+ json!({
954
+ "x-lix-key": "timestamp_default_schema",
955
+ "x-lix-version": "1",
956
+ "x-lix-primary-key": ["/id"],
957
+ "type": "object",
958
+ "properties": {
959
+ "id": { "type": "string" },
960
+ "created_at": { "type": "string", "x-lix-default": "lix_timestamp()" }
961
+ },
962
+ "required": ["id"],
963
+ "additionalProperties": false
964
+ })
965
+ }
966
+
967
+ fn schema_with_unknown_cel_default() -> JsonValue {
968
+ json!({
969
+ "x-lix-key": "unknown_cel_default_schema",
970
+ "x-lix-version": "1",
971
+ "x-lix-primary-key": ["/id"],
972
+ "type": "object",
973
+ "properties": {
974
+ "id": { "type": "string" },
975
+ "slug": { "type": "string", "x-lix-default": "missing_var + '-slug'" }
976
+ },
977
+ "required": ["id"],
978
+ "additionalProperties": false
979
+ })
980
+ }
981
+
982
+ fn composite_key_schema() -> JsonValue {
983
+ json!({
984
+ "x-lix-key": "composite_key_schema",
985
+ "x-lix-version": "1",
986
+ "x-lix-primary-key": ["/namespace", "/key"],
987
+ "type": "object",
988
+ "properties": {
989
+ "namespace": { "type": "string" },
990
+ "key": { "type": "string" }
991
+ },
992
+ "required": ["namespace", "key"],
993
+ "additionalProperties": false
994
+ })
995
+ }
996
+
997
+ fn dynamic_schema_definition() -> JsonValue {
998
+ json!({
999
+ "x-lix-key": "dynamic_schema",
1000
+ "x-lix-version": "1",
1001
+ "x-lix-primary-key": ["/id"],
1002
+ "type": "object",
1003
+ "properties": {
1004
+ "id": { "type": "string" }
1005
+ },
1006
+ "required": ["id"],
1007
+ "additionalProperties": false
1008
+ })
1009
+ }
1010
+
1011
+ fn functions() -> FunctionProviderHandle {
1012
+ SharedFunctionProvider::new(Box::new(FixedFunctions) as Box<dyn FunctionProvider + Send>)
1013
+ }
1014
+
1015
+ struct FixedFunctions;
1016
+
1017
+ impl FunctionProvider for FixedFunctions {
1018
+ fn uuid_v7(&mut self) -> String {
1019
+ "uuid-default".to_string()
1020
+ }
1021
+
1022
+ fn timestamp(&mut self) -> String {
1023
+ "1970-01-01T00:00:00.000Z".to_string()
1024
+ }
1025
+ }
1026
+ }