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

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 (169) hide show
  1. package/SKILL.md +46 -8
  2. package/dist/engine-wasm/wasm/lix_engine.d.ts +25 -1
  3. package/dist/engine-wasm/wasm/lix_engine.js +60 -2
  4. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  5. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +5 -0
  6. package/dist/generated/builtin-schemas.d.ts +87 -162
  7. package/dist/generated/builtin-schemas.js +139 -236
  8. package/dist/open-lix.d.ts +10 -3
  9. package/dist/open-lix.js +39 -0
  10. package/dist-engine-src/src/binary_cas/types.rs +0 -6
  11. package/dist-engine-src/src/catalog/context.rs +412 -0
  12. package/dist-engine-src/src/catalog/mod.rs +10 -0
  13. package/dist-engine-src/src/catalog/schema.rs +4 -0
  14. package/dist-engine-src/src/catalog/snapshot.rs +1114 -0
  15. package/dist-engine-src/src/cel/mod.rs +1 -1
  16. package/dist-engine-src/src/cel/provider.rs +1 -1
  17. package/dist-engine-src/src/commit_graph/context.rs +328 -1015
  18. package/dist-engine-src/src/commit_graph/mod.rs +2 -3
  19. package/dist-engine-src/src/commit_graph/types.rs +7 -43
  20. package/dist-engine-src/src/commit_graph/walker.rs +57 -81
  21. package/dist-engine-src/src/commit_store/codec.rs +887 -0
  22. package/dist-engine-src/src/commit_store/context.rs +944 -0
  23. package/dist-engine-src/src/commit_store/materialization.rs +84 -0
  24. package/dist-engine-src/src/commit_store/mod.rs +16 -0
  25. package/dist-engine-src/src/commit_store/storage.rs +600 -0
  26. package/dist-engine-src/src/commit_store/types.rs +215 -0
  27. package/dist-engine-src/src/common/identity.rs +15 -5
  28. package/dist-engine-src/src/common/json_pointer.rs +67 -0
  29. package/dist-engine-src/src/common/metadata.rs +17 -12
  30. package/dist-engine-src/src/common/mod.rs +5 -5
  31. package/dist-engine-src/src/domain.rs +324 -0
  32. package/dist-engine-src/src/engine.rs +29 -43
  33. package/dist-engine-src/src/entity_identity.rs +238 -118
  34. package/dist-engine-src/src/functions/context.rs +17 -52
  35. package/dist-engine-src/src/functions/deterministic.rs +1 -1
  36. package/dist-engine-src/src/functions/mod.rs +1 -1
  37. package/dist-engine-src/src/functions/provider.rs +4 -4
  38. package/dist-engine-src/src/functions/state.rs +39 -66
  39. package/dist-engine-src/src/functions/types.rs +1 -1
  40. package/dist-engine-src/src/init.rs +204 -151
  41. package/dist-engine-src/src/json_store/context.rs +354 -60
  42. package/dist-engine-src/src/json_store/encoded.rs +6 -6
  43. package/dist-engine-src/src/json_store/mod.rs +4 -1
  44. package/dist-engine-src/src/json_store/store.rs +884 -11
  45. package/dist-engine-src/src/json_store/types.rs +166 -1
  46. package/dist-engine-src/src/lib.rs +11 -10
  47. package/dist-engine-src/src/live_state/context.rs +608 -830
  48. package/dist-engine-src/src/live_state/mod.rs +3 -3
  49. package/dist-engine-src/src/live_state/overlay.rs +7 -7
  50. package/dist-engine-src/src/live_state/reader.rs +5 -5
  51. package/dist-engine-src/src/live_state/types.rs +19 -36
  52. package/dist-engine-src/src/live_state/visibility.rs +19 -14
  53. package/dist-engine-src/src/plugin/archive.rs +3 -6
  54. package/dist-engine-src/src/plugin/install.rs +0 -18
  55. package/dist-engine-src/src/plugin/plugin_manifest.json +0 -1
  56. package/dist-engine-src/src/schema/annotations/defaults.rs +2 -7
  57. package/dist-engine-src/src/schema/builtin/lix_account.json +0 -1
  58. package/dist-engine-src/src/schema/builtin/lix_active_account.json +0 -1
  59. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +0 -1
  60. package/dist-engine-src/src/schema/builtin/lix_change.json +11 -10
  61. package/dist-engine-src/src/schema/builtin/lix_change_author.json +0 -1
  62. package/dist-engine-src/src/schema/builtin/lix_commit.json +8 -46
  63. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +29 -22
  64. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +0 -1
  65. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +0 -1
  66. package/dist-engine-src/src/schema/builtin/lix_key_value.json +0 -1
  67. package/dist-engine-src/src/schema/builtin/lix_label.json +10 -3
  68. package/dist-engine-src/src/schema/builtin/lix_label_assignment.json +74 -0
  69. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +2 -8
  70. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +0 -1
  71. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +0 -1
  72. package/dist-engine-src/src/schema/builtin/mod.rs +10 -59
  73. package/dist-engine-src/src/schema/compatibility.rs +787 -0
  74. package/dist-engine-src/src/schema/definition.json +47 -17
  75. package/dist-engine-src/src/schema/definition.rs +202 -96
  76. package/dist-engine-src/src/schema/key.rs +9 -77
  77. package/dist-engine-src/src/schema/mod.rs +4 -4
  78. package/dist-engine-src/src/schema/tests.rs +133 -92
  79. package/dist-engine-src/src/session/context.rs +86 -48
  80. package/dist-engine-src/src/session/create_version.rs +22 -14
  81. package/dist-engine-src/src/session/execute.rs +117 -23
  82. package/dist-engine-src/src/session/merge/apply.rs +4 -4
  83. package/dist-engine-src/src/session/merge/conflicts.rs +3 -2
  84. package/dist-engine-src/src/session/merge/stats.rs +1 -1
  85. package/dist-engine-src/src/session/merge/version.rs +35 -45
  86. package/dist-engine-src/src/session/mod.rs +9 -7
  87. package/dist-engine-src/src/session/optimization9_sql2_bench.rs +100 -0
  88. package/dist-engine-src/src/session/switch_version.rs +17 -28
  89. package/dist-engine-src/src/session/transaction.rs +76 -0
  90. package/dist-engine-src/src/sql2/change_provider.rs +14 -20
  91. package/dist-engine-src/src/sql2/classify.rs +75 -48
  92. package/dist-engine-src/src/sql2/context.rs +22 -18
  93. package/dist-engine-src/src/sql2/directory_history_provider.rs +28 -20
  94. package/dist-engine-src/src/sql2/directory_provider.rs +131 -83
  95. package/dist-engine-src/src/sql2/entity_history_provider.rs +10 -14
  96. package/dist-engine-src/src/sql2/entity_provider.rs +680 -169
  97. package/dist-engine-src/src/sql2/error.rs +24 -5
  98. package/dist-engine-src/src/sql2/execute.rs +426 -272
  99. package/dist-engine-src/src/sql2/file_history_provider.rs +29 -21
  100. package/dist-engine-src/src/sql2/file_provider.rs +533 -108
  101. package/dist-engine-src/src/sql2/filesystem_planner.rs +58 -94
  102. package/dist-engine-src/src/sql2/filesystem_visibility.rs +37 -23
  103. package/dist-engine-src/src/sql2/history_projection.rs +3 -27
  104. package/dist-engine-src/src/sql2/history_provider.rs +11 -17
  105. package/dist-engine-src/src/sql2/history_route.rs +22 -8
  106. package/dist-engine-src/src/sql2/lix_state_provider.rs +178 -96
  107. package/dist-engine-src/src/sql2/mod.rs +8 -4
  108. package/dist-engine-src/src/sql2/predicate_typecheck.rs +246 -0
  109. package/dist-engine-src/src/sql2/public_bind/assignment.rs +46 -0
  110. package/dist-engine-src/src/sql2/public_bind/capability.rs +41 -0
  111. package/dist-engine-src/src/sql2/public_bind/dml.rs +172 -0
  112. package/dist-engine-src/src/sql2/public_bind/mod.rs +26 -0
  113. package/dist-engine-src/src/sql2/public_bind/table.rs +168 -0
  114. package/dist-engine-src/src/sql2/read_only.rs +10 -12
  115. package/dist-engine-src/src/sql2/session.rs +7 -10
  116. package/dist-engine-src/src/sql2/udfs/lix_timestamp.rs +76 -0
  117. package/dist-engine-src/src/sql2/udfs/mod.rs +8 -1
  118. package/dist-engine-src/src/sql2/udfs/public_call.rs +238 -0
  119. package/dist-engine-src/src/sql2/version_provider.rs +46 -31
  120. package/dist-engine-src/src/sql2/version_scope.rs +4 -4
  121. package/dist-engine-src/src/storage_bench.rs +1782 -325
  122. package/dist-engine-src/src/test_support.rs +183 -36
  123. package/dist-engine-src/src/tracked_state/by_file_index.rs +20 -24
  124. package/dist-engine-src/src/tracked_state/codec.rs +1519 -181
  125. package/dist-engine-src/src/tracked_state/context.rs +1155 -271
  126. package/dist-engine-src/src/tracked_state/diff.rs +249 -57
  127. package/dist-engine-src/src/tracked_state/materialization.rs +365 -103
  128. package/dist-engine-src/src/tracked_state/materializer.rs +488 -0
  129. package/dist-engine-src/src/tracked_state/merge.rs +37 -19
  130. package/dist-engine-src/src/tracked_state/mod.rs +8 -7
  131. package/dist-engine-src/src/tracked_state/storage.rs +138 -6
  132. package/dist-engine-src/src/tracked_state/tree.rs +695 -252
  133. package/dist-engine-src/src/tracked_state/types.rs +176 -6
  134. package/dist-engine-src/src/transaction/commit.rs +695 -435
  135. package/dist-engine-src/src/transaction/context.rs +551 -310
  136. package/dist-engine-src/src/transaction/live_state_overlay.rs +9 -8
  137. package/dist-engine-src/src/transaction/mod.rs +2 -0
  138. package/dist-engine-src/src/transaction/normalization.rs +311 -447
  139. package/dist-engine-src/src/transaction/prep.rs +37 -0
  140. package/dist-engine-src/src/transaction/schema_resolver.rs +93 -71
  141. package/dist-engine-src/src/transaction/staging.rs +701 -406
  142. package/dist-engine-src/src/transaction/types.rs +231 -122
  143. package/dist-engine-src/src/transaction/validation.rs +2717 -1698
  144. package/dist-engine-src/src/untracked_state/codec.rs +40 -96
  145. package/dist-engine-src/src/untracked_state/context.rs +21 -5
  146. package/dist-engine-src/src/untracked_state/materialization.rs +10 -104
  147. package/dist-engine-src/src/untracked_state/mod.rs +3 -5
  148. package/dist-engine-src/src/untracked_state/storage.rs +105 -57
  149. package/dist-engine-src/src/untracked_state/types.rs +63 -13
  150. package/dist-engine-src/src/version/context.rs +1 -13
  151. package/dist-engine-src/src/version/lifecycle.rs +221 -0
  152. package/dist-engine-src/src/version/mod.rs +3 -2
  153. package/dist-engine-src/src/version/refs.rs +12 -103
  154. package/dist-engine-src/src/version/stage_rows.rs +15 -19
  155. package/package.json +1 -1
  156. package/dist-engine-src/src/changelog/codec.rs +0 -321
  157. package/dist-engine-src/src/changelog/context.rs +0 -92
  158. package/dist-engine-src/src/changelog/materialization.rs +0 -121
  159. package/dist-engine-src/src/changelog/mod.rs +0 -13
  160. package/dist-engine-src/src/changelog/reader.rs +0 -20
  161. package/dist-engine-src/src/changelog/storage.rs +0 -220
  162. package/dist-engine-src/src/changelog/types.rs +0 -38
  163. package/dist-engine-src/src/schema/builtin/lix_change_set.json +0 -18
  164. package/dist-engine-src/src/schema/builtin/lix_change_set_element.json +0 -75
  165. package/dist-engine-src/src/schema/builtin/lix_entity_label.json +0 -63
  166. package/dist-engine-src/src/schema_registry.rs +0 -294
  167. package/dist-engine-src/src/sql2/commit_derived_provider.rs +0 -591
  168. package/dist-engine-src/src/tracked_state/rebuild.rs +0 -771
  169. package/dist-engine-src/src/tracked_state/tree_types.rs +0 -176
@@ -1,244 +1,200 @@
1
- use std::collections::BTreeMap;
1
+ use std::sync::Arc;
2
2
 
3
3
  use serde_json::{Map as JsonMap, Value as JsonValue};
4
4
 
5
+ use crate::catalog::{CatalogSnapshot, SchemaPlan, SchemaPlanId};
6
+ use crate::common::format_json_pointer;
5
7
  use crate::common::normalize_path_segment;
8
+ use crate::domain::Domain;
6
9
  use crate::entity_identity::{EntityIdentity, EntityIdentityError};
7
10
  use crate::functions::FunctionProviderHandle;
8
11
  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
+ is_seed_schema_key, schema_from_registered_snapshot, validate_lix_schema,
13
+ validate_lix_schema_definition,
12
14
  };
13
- use crate::transaction::types::StageRow;
15
+ use crate::transaction::types::{PreparedRowFacts, TransactionJson, TransactionWriteRow};
14
16
  use crate::LixError;
15
17
 
16
18
  pub(crate) const REGISTERED_SCHEMA_KEY: &str = "lix_registered_schema";
17
19
  const DIRECTORY_DESCRIPTOR_SCHEMA_KEY: &str = "lix_directory_descriptor";
18
20
  const FILE_DESCRIPTOR_SCHEMA_KEY: &str = "lix_file_descriptor";
19
21
 
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
- }
22
+ #[derive(Debug, Clone, PartialEq, Eq)]
23
+ pub(crate) struct NormalizedTransactionWriteRow {
24
+ pub(crate) row: TransactionWriteRow,
25
+ pub(crate) snapshot: Option<TransactionJson>,
26
+ pub(crate) schema_plan_id: SchemaPlanId,
27
+ pub(crate) facts: PreparedRowFacts,
82
28
  }
83
29
 
84
30
  /// Normalizes one incoming row into a row with final snapshot/entity identity.
85
31
  ///
86
- /// This is the canonical schema-semantics boundary for staged writes. It owns
32
+ /// This is the canonical schema-semantics boundary for transaction writes. It owns
87
33
  /// schema default application, primary-key identity derivation, and explicit
88
34
  /// identity mismatch validation. SQL providers should not pre-derive primary
89
- /// keys for schemas that can be normalized here; they should stage decoded
35
+ /// keys for schemas that can be normalized here; they should pass decoded
90
36
  /// snapshots and let this layer complete them.
91
37
  ///
92
38
  /// 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,
39
+ /// commit ids; those are prepared-row fields assigned after semantic
40
+ /// normalization has produced the final identity.
41
+ pub(crate) fn normalize_transaction_write_row(
42
+ mut row: TransactionWriteRow,
43
+ schema_catalog: &mut CatalogSnapshot,
98
44
  functions: FunctionProviderHandle,
99
- ) -> Result<StageRow, LixError> {
100
- validate_stage_row_schema_identity(&row)?;
45
+ ) -> Result<NormalizedTransactionWriteRow, LixError> {
46
+ validate_transaction_write_row_schema_identity(&row)?;
101
47
 
102
- let Some(schema) = schema_catalog
103
- .schema(&row.schema_key, &row.schema_version)
104
- .cloned()
105
- else {
48
+ let Some((schema_plan_id, schema_plan)) = schema_catalog.plan_for_key(&row.schema_key) else {
106
49
  return Err(LixError::new(
107
50
  LixError::CODE_SCHEMA_DEFINITION,
108
51
  format!(
109
- "schema '{}' version '{}' is not visible to this transaction",
110
- row.schema_key, row.schema_version
52
+ "schema '{}' is not visible to this transaction",
53
+ row.schema_key
111
54
  ),
112
55
  ));
113
56
  };
114
57
 
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)?;
58
+ let normalized_snapshot = if let Some(snapshot) = row.snapshot.take() {
59
+ let (mut snapshot, normalized) = snapshot_object_from_transaction_json(snapshot, &row)?;
60
+ let defaults_changed = apply_defaults(&mut snapshot, schema_plan, &row, functions)?;
61
+ let descriptor_changed = normalize_filesystem_descriptor_snapshot(&row, &mut snapshot)?;
119
62
  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
- })?);
63
+ row.entity_id = Some(resolve_entity_id(&row, schema_plan, &snapshot)?);
64
+ if defaults_changed || descriptor_changed {
65
+ Some(TransactionJson::from_value(
66
+ snapshot,
67
+ "normalized transaction snapshot_content",
68
+ )?)
69
+ } else {
70
+ Some(TransactionJson::from_parts(Arc::new(snapshot), normalized))
71
+ }
130
72
  } else if row.entity_id.is_none() {
131
73
  return Err(LixError::new(
132
74
  LixError::CODE_SCHEMA_VALIDATION,
133
75
  format!(
134
- "tombstone for schema '{}' version '{}' requires entity_id",
135
- row.schema_key, row.schema_version
76
+ "tombstone for schema '{}' requires entity_id",
77
+ row.schema_key
136
78
  ),
137
79
  ));
138
- }
80
+ } else {
81
+ None
82
+ };
139
83
 
140
84
  if row.schema_key == REGISTERED_SCHEMA_KEY {
141
- remember_pending_registered_schema(row.snapshot_content.as_deref(), schema_catalog)?;
142
- }
143
-
144
- Ok(row)
85
+ if row.file_id.is_some() {
86
+ return Err(LixError::new(
87
+ LixError::CODE_SCHEMA_DEFINITION,
88
+ "lix_registered_schema rows must not be scoped to a file",
89
+ )
90
+ .with_hint("Schema definitions are scoped by version and durability only; write them with null file_id."));
91
+ }
92
+ let schema_domain =
93
+ Domain::schema_catalog(row.schema_scope_version_id().to_string(), row.untracked);
94
+ remember_pending_registered_schema(
95
+ normalized_snapshot.as_ref().map(TransactionJson::value),
96
+ schema_domain,
97
+ schema_catalog,
98
+ )?;
99
+ }
100
+
101
+ Ok(NormalizedTransactionWriteRow {
102
+ row,
103
+ snapshot: normalized_snapshot,
104
+ schema_plan_id,
105
+ facts: PreparedRowFacts::default(),
106
+ })
145
107
  }
146
108
 
147
- fn validate_stage_row_schema_identity(row: &StageRow) -> Result<(), LixError> {
109
+ fn validate_transaction_write_row_schema_identity(
110
+ row: &TransactionWriteRow,
111
+ ) -> Result<(), LixError> {
148
112
  if row.schema_key.is_empty() {
149
113
  return Err(LixError::new(
150
114
  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",
115
+ "engine transaction staging requires non-empty schema_key",
158
116
  ));
159
117
  }
160
118
  Ok(())
161
119
  }
162
120
 
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(
121
+ fn snapshot_object_from_transaction_json(
122
+ snapshot: TransactionJson,
123
+ row: &TransactionWriteRow,
124
+ ) -> Result<(JsonMap<String, JsonValue>, Arc<str>), LixError> {
125
+ let (snapshot, normalized) = snapshot.into_parts();
126
+ let snapshot = match Arc::try_unwrap(snapshot) {
127
+ Ok(snapshot) => snapshot,
128
+ Err(snapshot) => snapshot.as_ref().clone(),
129
+ };
130
+ match snapshot {
131
+ JsonValue::Object(snapshot) => Ok((snapshot, normalized)),
132
+ _ => Err(LixError::new(
178
133
  LixError::CODE_SCHEMA_VALIDATION,
179
134
  format!(
180
- "snapshot_content for schema '{}' version '{}' must be a JSON object",
181
- row.schema_key, row.schema_version
135
+ "snapshot_content for schema '{}' must be a JSON object",
136
+ row.schema_key
182
137
  ),
183
- )
184
- })
138
+ )),
139
+ }
185
140
  }
186
141
 
187
142
  fn apply_defaults(
188
143
  snapshot: &mut JsonMap<String, JsonValue>,
189
- schema: &JsonValue,
190
- row: &StageRow,
144
+ schema_plan: &SchemaPlan,
145
+ row: &TransactionWriteRow,
191
146
  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(())
147
+ ) -> Result<bool, LixError> {
148
+ schema_plan
149
+ .defaults
150
+ .apply(snapshot, functions, &row.schema_key)
201
151
  }
202
152
 
203
153
  fn normalize_filesystem_descriptor_snapshot(
204
- row: &StageRow,
154
+ row: &TransactionWriteRow,
205
155
  snapshot: &mut JsonMap<String, JsonValue>,
206
- ) -> Result<(), LixError> {
156
+ ) -> Result<bool, LixError> {
207
157
  match row.schema_key.as_str() {
208
158
  DIRECTORY_DESCRIPTOR_SCHEMA_KEY => normalize_directory_descriptor_snapshot(row, snapshot),
209
159
  FILE_DESCRIPTOR_SCHEMA_KEY => normalize_file_descriptor_snapshot(row, snapshot),
210
- _ => Ok(()),
160
+ _ => Ok(false),
211
161
  }
212
162
  }
213
163
 
214
164
  fn normalize_directory_descriptor_snapshot(
215
- row: &StageRow,
165
+ row: &TransactionWriteRow,
216
166
  snapshot: &mut JsonMap<String, JsonValue>,
217
- ) -> Result<(), LixError> {
167
+ ) -> Result<bool, LixError> {
218
168
  let Some(name) = optional_string_field(snapshot, "name", row)? else {
219
- return Ok(());
169
+ return Ok(false);
220
170
  };
221
171
  let normalized_name = normalize_path_segment(name)?;
172
+ if name == normalized_name {
173
+ return Ok(false);
174
+ }
222
175
  snapshot.insert("name".to_string(), JsonValue::String(normalized_name));
223
- Ok(())
176
+ Ok(true)
224
177
  }
225
178
 
226
179
  fn normalize_file_descriptor_snapshot(
227
- row: &StageRow,
180
+ row: &TransactionWriteRow,
228
181
  snapshot: &mut JsonMap<String, JsonValue>,
229
- ) -> Result<(), LixError> {
182
+ ) -> Result<bool, LixError> {
230
183
  let Some(name) = optional_string_field(snapshot, "name", row)? else {
231
- return Ok(());
184
+ return Ok(false);
232
185
  };
233
186
  let normalized_name = normalize_path_segment(name)?;
187
+ if name == normalized_name {
188
+ return Ok(false);
189
+ }
234
190
  snapshot.insert("name".to_string(), JsonValue::String(normalized_name));
235
- Ok(())
191
+ Ok(true)
236
192
  }
237
193
 
238
194
  fn optional_string_field<'a>(
239
195
  snapshot: &'a JsonMap<String, JsonValue>,
240
196
  field: &str,
241
- row: &StageRow,
197
+ row: &TransactionWriteRow,
242
198
  ) -> Result<Option<&'a str>, LixError> {
243
199
  let Some(value) = snapshot.get(field) else {
244
200
  return Ok(None);
@@ -247,38 +203,38 @@ fn optional_string_field<'a>(
247
203
  LixError::new(
248
204
  LixError::CODE_SCHEMA_VALIDATION,
249
205
  format!(
250
- "snapshot_content for schema '{}' version '{}' field '{}' must be a string",
251
- row.schema_key, row.schema_version, field
206
+ "snapshot_content for schema '{}' field '{}' must be a string",
207
+ row.schema_key, field
252
208
  ),
253
209
  )
254
210
  })
255
211
  }
256
212
 
257
213
  fn resolve_entity_id(
258
- row: &StageRow,
259
- schema: &JsonValue,
214
+ row: &TransactionWriteRow,
215
+ schema_plan: &SchemaPlan,
260
216
  snapshot: &JsonValue,
261
217
  ) -> Result<EntityIdentity, LixError> {
262
- let Some(primary_key_paths) = primary_key_paths(schema)? else {
218
+ let Some(primary_key_paths) = schema_plan.primary_key.as_ref() else {
263
219
  return row.entity_id.clone().ok_or_else(|| {
264
220
  LixError::new(
265
221
  LixError::CODE_SCHEMA_VALIDATION,
266
222
  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
223
+ "write for schema '{}' requires entity_id because the schema has no x-lix-primary-key",
224
+ row.schema_key
269
225
  ),
270
226
  )
271
227
  });
272
228
  };
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))?;
229
+ let derived = EntityIdentity::from_primary_key_paths(snapshot, primary_key_paths)
230
+ .map_err(|error| entity_id_derivation_error(row, primary_key_paths, error))?;
275
231
  if let Some(entity_id) = row.entity_id.as_ref() {
276
232
  if entity_id != &derived {
277
233
  return Err(LixError::new(
278
234
  LixError::CODE_SCHEMA_VALIDATION,
279
235
  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
236
+ "entity_id '{}' does not match x-lix-primary-key derived entity_id '{}' for schema '{}'",
237
+ entity_id.as_json_array_text()?, derived.as_json_array_text()?, row.schema_key
282
238
  ),
283
239
  ));
284
240
  }
@@ -286,72 +242,8 @@ fn resolve_entity_id(
286
242
  Ok(derived)
287
243
  }
288
244
 
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
245
  fn entity_id_derivation_error(
354
- row: &StageRow,
246
+ row: &TransactionWriteRow,
355
247
  primary_key_paths: &[Vec<String>],
356
248
  error: EntityIdentityError,
357
249
  ) -> LixError {
@@ -360,21 +252,20 @@ fn entity_id_derivation_error(
360
252
  EntityIdentityError::EmptyPrimaryKeyPath { index } => {
361
253
  format!("empty x-lix-primary-key pointer at index {index}")
362
254
  }
255
+ EntityIdentityError::EmptyPrimaryKeyValue { index } => {
256
+ let pointer = primary_key_paths
257
+ .get(index)
258
+ .map(|path| format_json_pointer(path))
259
+ .unwrap_or_else(|| format!("index {index}"));
260
+ format!("empty value at primary-key pointer '{pointer}'")
261
+ }
363
262
  EntityIdentityError::MissingPrimaryKeyValue { index } => {
364
263
  let pointer = format_json_pointer(&primary_key_paths[index]);
365
264
  format!("missing value at primary-key pointer '{pointer}'")
366
265
  }
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
266
  EntityIdentityError::UnsupportedPrimaryKeyValue { index } => {
376
267
  let pointer = format_json_pointer(&primary_key_paths[index]);
377
- format!("unsupported non-scalar value at primary-key pointer '{pointer}'")
268
+ format!("non-string value at primary-key pointer '{pointer}'")
378
269
  }
379
270
  EntityIdentityError::InvalidEncodedEntityIdentity => {
380
271
  "invalid encoded entity identity".to_string()
@@ -383,56 +274,38 @@ fn entity_id_derivation_error(
383
274
  LixError::new(
384
275
  LixError::CODE_SCHEMA_VALIDATION,
385
276
  format!(
386
- "failed to derive entity_id for schema '{}' version '{}': {detail}",
387
- row.schema_key, row.schema_version
277
+ "failed to derive entity_id for schema '{}': {detail}",
278
+ row.schema_key
388
279
  ),
389
280
  )
390
281
  }
391
282
 
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
283
  pub(crate) fn remember_pending_registered_schema(
407
- snapshot_content: Option<&str>,
408
- schema_catalog: &mut TransactionSchemaCatalog,
284
+ snapshot: Option<&JsonValue>,
285
+ domain: Domain,
286
+ schema_catalog: &mut CatalogSnapshot,
409
287
  ) -> Result<(), LixError> {
410
- let Some(snapshot_content) = snapshot_content else {
288
+ let Some(snapshot) = snapshot else {
411
289
  return Err(LixError::new(
412
290
  LixError::CODE_SCHEMA_DEFINITION,
413
291
  "lix_registered_schema rows cannot be deleted yet; schema deletion is not supported",
414
292
  ));
415
293
  };
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)?;
294
+ if let Some(schema) = snapshot.get("value") {
295
+ validate_lix_schema_definition(schema)?;
296
+ }
297
+ {
298
+ let registered_schema_definition = schema_catalog
299
+ .schema(REGISTERED_SCHEMA_KEY)
300
+ .ok_or_else(|| {
301
+ LixError::new(
302
+ LixError::CODE_SCHEMA_DEFINITION,
303
+ "lix_registered_schema schema is not visible to this transaction",
304
+ )
305
+ })?;
306
+ validate_lix_schema(registered_schema_definition, &snapshot)?;
433
307
  }
434
308
  let (key, schema) = schema_from_registered_snapshot(&snapshot)?;
435
- reject_unsupported_registered_schema_version(&key)?;
436
309
  if is_seed_schema_key(&key.schema_key) {
437
310
  return Err(LixError::new(
438
311
  LixError::CODE_SCHEMA_DEFINITION,
@@ -443,26 +316,10 @@ pub(crate) fn remember_pending_registered_schema(
443
316
  ));
444
317
  }
445
318
  validate_lix_schema_definition(&schema)?;
446
- validate_lix_schema(&registered_schema_definition, &snapshot)?;
447
- schema_catalog.insert_schema(key, schema);
319
+ schema_catalog.insert_schema_for_domain(domain, key, schema)?;
448
320
  Ok(())
449
321
  }
450
322
 
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
323
  #[cfg(test)]
467
324
  mod tests {
468
325
  use serde_json::json;
@@ -474,18 +331,20 @@ mod tests {
474
331
  #[test]
475
332
  fn normalization_derives_entity_id_from_primary_key() {
476
333
  let mut catalog = catalog_with(vec![schema_with_default_id()]);
477
- let row = StageRow {
334
+ let row = TransactionWriteRow {
478
335
  entity_id: None,
479
336
  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()),
337
+ snapshot: Some(snapshot_json(
338
+ r#"{"id":"entity-from-snapshot","value":"hello"}"#,
339
+ )),
482
340
  ..base_stage_row()
483
341
  };
484
342
 
485
- let row = normalize_stage_row(row, &mut catalog, functions()).expect("normalize row");
343
+ let row =
344
+ normalize_transaction_write_row(row, &mut catalog, functions()).expect("normalize row");
486
345
 
487
346
  assert_eq!(
488
- row.entity_id.as_ref(),
347
+ row.row.entity_id.as_ref(),
489
348
  Some(&crate::entity_identity::EntityIdentity::single(
490
349
  "entity-from-snapshot"
491
350
  ))
@@ -495,20 +354,19 @@ mod tests {
495
354
  #[test]
496
355
  fn normalization_applies_json_and_cel_defaults_before_identity_derivation() {
497
356
  let mut catalog = catalog_with(vec![schema_with_default_id()]);
498
- let row = StageRow {
357
+ let row = TransactionWriteRow {
499
358
  entity_id: None,
500
359
  schema_key: "normalization_schema".to_string(),
501
- schema_version: "1".to_string(),
502
- snapshot_content: Some(r#"{}"#.to_string()),
360
+ snapshot: Some(snapshot_json(r#"{}"#)),
503
361
  ..base_stage_row()
504
362
  };
505
363
 
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();
364
+ let row =
365
+ normalize_transaction_write_row(row, &mut catalog, functions()).expect("normalize row");
366
+ let snapshot = normalized_snapshot(&row);
509
367
 
510
368
  assert_eq!(
511
- row.entity_id.as_ref(),
369
+ row.row.entity_id.as_ref(),
512
370
  Some(&crate::entity_identity::EntityIdentity::single(
513
371
  "uuid-default"
514
372
  ))
@@ -520,17 +378,16 @@ mod tests {
520
378
  #[test]
521
379
  fn normalization_applies_cel_defaults_from_snapshot_context() {
522
380
  let mut catalog = catalog_with(vec![schema_with_cel_field_default()]);
523
- let row = StageRow {
381
+ let row = TransactionWriteRow {
524
382
  entity_id: None,
525
383
  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()),
384
+ snapshot: Some(snapshot_json(r#"{"id":"entity-1","name":"Sample"}"#)),
528
385
  ..base_stage_row()
529
386
  };
530
387
 
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();
388
+ let row =
389
+ normalize_transaction_write_row(row, &mut catalog, functions()).expect("normalize row");
390
+ let snapshot = normalized_snapshot(&row);
534
391
 
535
392
  assert_eq!(snapshot["slug"], "Sample-slug");
536
393
  }
@@ -538,17 +395,16 @@ mod tests {
538
395
  #[test]
539
396
  fn normalization_x_lix_default_overrides_json_default() {
540
397
  let mut catalog = catalog_with(vec![schema_with_overridden_default()]);
541
- let row = StageRow {
398
+ let row = TransactionWriteRow {
542
399
  entity_id: None,
543
400
  schema_key: "overridden_default_schema".to_string(),
544
- schema_version: "1".to_string(),
545
- snapshot_content: Some(r#"{"id":"entity-1"}"#.to_string()),
401
+ snapshot: Some(snapshot_json(r#"{"id":"entity-1"}"#)),
546
402
  ..base_stage_row()
547
403
  };
548
404
 
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();
405
+ let row =
406
+ normalize_transaction_write_row(row, &mut catalog, functions()).expect("normalize row");
407
+ let snapshot = normalized_snapshot(&row);
552
408
 
553
409
  assert_eq!(snapshot["status"], "computed");
554
410
  }
@@ -556,17 +412,16 @@ mod tests {
556
412
  #[test]
557
413
  fn normalization_does_not_overwrite_explicit_null_with_default() {
558
414
  let mut catalog = catalog_with(vec![schema_with_nullable_default()]);
559
- let row = StageRow {
415
+ let row = TransactionWriteRow {
560
416
  entity_id: None,
561
417
  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()),
418
+ snapshot: Some(snapshot_json(r#"{"id":"entity-1","status":null}"#)),
564
419
  ..base_stage_row()
565
420
  };
566
421
 
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();
422
+ let row =
423
+ normalize_transaction_write_row(row, &mut catalog, functions()).expect("normalize row");
424
+ let snapshot = normalized_snapshot(&row);
570
425
 
571
426
  assert_eq!(snapshot["status"], JsonValue::Null);
572
427
  }
@@ -574,17 +429,16 @@ mod tests {
574
429
  #[test]
575
430
  fn normalization_applies_timestamp_function_default() {
576
431
  let mut catalog = catalog_with(vec![schema_with_timestamp_default()]);
577
- let row = StageRow {
432
+ let row = TransactionWriteRow {
578
433
  entity_id: None,
579
434
  schema_key: "timestamp_default_schema".to_string(),
580
- schema_version: "1".to_string(),
581
- snapshot_content: Some(r#"{"id":"entity-1"}"#.to_string()),
435
+ snapshot: Some(snapshot_json(r#"{"id":"entity-1"}"#)),
582
436
  ..base_stage_row()
583
437
  };
584
438
 
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();
439
+ let row =
440
+ normalize_transaction_write_row(row, &mut catalog, functions()).expect("normalize row");
441
+ let snapshot = normalized_snapshot(&row);
588
442
 
589
443
  assert_eq!(snapshot["created_at"], "1970-01-01T00:00:00.000Z");
590
444
  }
@@ -592,16 +446,15 @@ mod tests {
592
446
  #[test]
593
447
  fn normalization_surfaces_cel_default_errors() {
594
448
  let mut catalog = catalog_with(vec![schema_with_unknown_cel_default()]);
595
- let row = StageRow {
449
+ let row = TransactionWriteRow {
596
450
  entity_id: None,
597
451
  schema_key: "unknown_cel_default_schema".to_string(),
598
- schema_version: "1".to_string(),
599
- snapshot_content: Some(r#"{"id":"entity-1"}"#.to_string()),
452
+ snapshot: Some(snapshot_json(r#"{"id":"entity-1"}"#)),
600
453
  ..base_stage_row()
601
454
  };
602
455
 
603
- let error =
604
- normalize_stage_row(row, &mut catalog, functions()).expect_err("default should fail");
456
+ let error = normalize_transaction_write_row(row, &mut catalog, functions())
457
+ .expect_err("default should fail");
605
458
 
606
459
  assert!(error.message.contains("failed to evaluate x-lix-default"));
607
460
  assert!(error.message.contains("unknown_cel_default_schema.slug"));
@@ -610,16 +463,15 @@ mod tests {
610
463
  #[test]
611
464
  fn normalization_rejects_entity_id_that_disagrees_with_primary_key() {
612
465
  let mut catalog = catalog_with(vec![schema_with_default_id()]);
613
- let row = StageRow {
466
+ let row = TransactionWriteRow {
614
467
  entity_id: Some(crate::entity_identity::EntityIdentity::single("wrong-id")),
615
468
  schema_key: "normalization_schema".to_string(),
616
- schema_version: "1".to_string(),
617
- snapshot_content: Some(r#"{"id":"right-id","value":"hello"}"#.to_string()),
469
+ snapshot: Some(snapshot_json(r#"{"id":"right-id","value":"hello"}"#)),
618
470
  ..base_stage_row()
619
471
  };
620
472
 
621
- let error =
622
- normalize_stage_row(row, &mut catalog, functions()).expect_err("id mismatch fails");
473
+ let error = normalize_transaction_write_row(row, &mut catalog, functions())
474
+ .expect_err("id mismatch fails");
623
475
 
624
476
  assert!(error
625
477
  .message
@@ -627,22 +479,42 @@ mod tests {
627
479
  }
628
480
 
629
481
  #[test]
630
- fn normalization_derives_opaque_entity_id_for_composite_primary_key() {
482
+ fn normalization_derives_json_array_entity_id_for_composite_primary_key() {
483
+ let mut catalog = catalog_with(vec![composite_key_schema()]);
484
+ let row = TransactionWriteRow {
485
+ entity_id: None,
486
+ schema_key: "composite_key_schema".to_string(),
487
+ snapshot: Some(snapshot_json(r#"{"namespace":"a~b","key":"1"}"#)),
488
+ ..base_stage_row()
489
+ };
490
+
491
+ let row =
492
+ normalize_transaction_write_row(row, &mut catalog, functions()).expect("normalize row");
493
+ let entity_id = row.row.entity_id.expect("composite entity id");
494
+ let projected_entity_id = entity_id
495
+ .as_json_array_text()
496
+ .expect("entity id should project");
497
+
498
+ assert_eq!(projected_entity_id, "[\"a~b\",\"1\"]");
499
+ }
500
+
501
+ #[test]
502
+ fn normalization_rejects_non_string_primary_key_values() {
631
503
  let mut catalog = catalog_with(vec![composite_key_schema()]);
632
- let row = StageRow {
504
+ let row = TransactionWriteRow {
633
505
  entity_id: None,
634
506
  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()),
507
+ snapshot: Some(snapshot_json(r#"{"namespace":"a~b","key":1}"#)),
637
508
  ..base_stage_row()
638
509
  };
639
510
 
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");
511
+ let error = normalize_transaction_write_row(row, &mut catalog, functions())
512
+ .expect_err("non-string primary key values should fail");
643
513
 
644
- assert!(projected_entity_id.starts_with("pk:v1:"));
645
- assert_ne!(projected_entity_id, "a~b~1");
514
+ assert_eq!(error.code, LixError::CODE_SCHEMA_VALIDATION);
515
+ assert!(error
516
+ .message
517
+ .contains("non-string value at primary-key pointer '/key'"));
646
518
  }
647
519
 
648
520
  #[test]
@@ -657,17 +529,17 @@ mod tests {
657
529
  &[vec!["namespace".to_string()], vec!["key".to_string()]],
658
530
  )
659
531
  .expect("identity should derive");
660
- let row = StageRow {
532
+ let row = TransactionWriteRow {
661
533
  entity_id: Some(derived.clone()),
662
534
  schema_key: "composite_key_schema".to_string(),
663
- schema_version: "1".to_string(),
664
- snapshot_content: Some(snapshot.to_string()),
535
+ snapshot: Some(transaction_json(snapshot.clone())),
665
536
  ..base_stage_row()
666
537
  };
667
538
 
668
- let row = normalize_stage_row(row, &mut catalog, functions()).expect("normalize row");
539
+ let row =
540
+ normalize_transaction_write_row(row, &mut catalog, functions()).expect("normalize row");
669
541
 
670
- assert_eq!(row.entity_id.as_ref(), Some(&derived));
542
+ assert_eq!(row.row.entity_id.as_ref(), Some(&derived));
671
543
  }
672
544
 
673
545
  #[test]
@@ -675,32 +547,29 @@ mod tests {
675
547
  let mut catalog = catalog_with(vec![seed_schema_definition(REGISTERED_SCHEMA_KEY)
676
548
  .expect("registered schema builtin")
677
549
  .clone()]);
678
- let registered = StageRow {
550
+ let registered = TransactionWriteRow {
679
551
  entity_id: None,
680
552
  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
- ),
553
+ snapshot: Some(transaction_json(json!({
554
+ "value": dynamic_schema_definition(),
555
+ }))),
688
556
  ..base_stage_row()
689
557
  };
690
558
 
691
- normalize_stage_row(registered, &mut catalog, functions()).expect("register schema");
559
+ normalize_transaction_write_row(registered, &mut catalog, functions())
560
+ .expect("register schema");
692
561
 
693
- let dynamic = StageRow {
562
+ let dynamic = TransactionWriteRow {
694
563
  entity_id: None,
695
564
  schema_key: "dynamic_schema".to_string(),
696
- schema_version: "1".to_string(),
697
- snapshot_content: Some(r#"{"id":"dynamic-1"}"#.to_string()),
565
+ snapshot: Some(snapshot_json(r#"{"id":"dynamic-1"}"#)),
698
566
  ..base_stage_row()
699
567
  };
700
- let dynamic = normalize_stage_row(dynamic, &mut catalog, functions()).expect("dynamic row");
568
+ let dynamic = normalize_transaction_write_row(dynamic, &mut catalog, functions())
569
+ .expect("dynamic row");
701
570
 
702
571
  assert_eq!(
703
- dynamic.entity_id.as_ref(),
572
+ dynamic.row.entity_id.as_ref(),
704
573
  Some(&crate::entity_identity::EntityIdentity::single("dynamic-1"))
705
574
  );
706
575
  }
@@ -712,43 +581,36 @@ mod tests {
712
581
  builtin_schema(DIRECTORY_DESCRIPTOR_SCHEMA_KEY),
713
582
  ]);
714
583
 
715
- let file = StageRow {
584
+ let file = TransactionWriteRow {
716
585
  entity_id: None,
717
586
  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
- ),
587
+ snapshot: Some(transaction_json(json!({
588
+ "id": "file-cafe",
589
+ "directory_id": null,
590
+ "name": "Cafe\u{301}.txt",
591
+ }))),
726
592
  global: false,
727
593
  ..base_stage_row()
728
594
  };
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();
595
+ let file = normalize_transaction_write_row(file, &mut catalog, functions())
596
+ .expect("normalize file");
597
+ let file_snapshot = normalized_snapshot(&file);
732
598
  assert_eq!(file_snapshot["name"], "Café.txt");
733
599
 
734
- let directory = StageRow {
600
+ let directory = TransactionWriteRow {
735
601
  entity_id: None,
736
602
  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
- ),
603
+ snapshot: Some(transaction_json(json!({
604
+ "id": "dir-cafe",
605
+ "parent_id": null,
606
+ "name": "Cafe\u{301}",
607
+ }))),
745
608
  global: false,
746
609
  ..base_stage_row()
747
610
  };
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();
611
+ let directory = normalize_transaction_write_row(directory, &mut catalog, functions())
612
+ .expect("normalize directory");
613
+ let directory_snapshot = normalized_snapshot(&directory);
752
614
  assert_eq!(directory_snapshot["name"], "Café");
753
615
  }
754
616
 
@@ -759,18 +621,15 @@ mod tests {
759
621
  builtin_schema(DIRECTORY_DESCRIPTOR_SCHEMA_KEY),
760
622
  ]);
761
623
 
762
- let dot_segment = normalize_stage_row(
763
- StageRow {
624
+ let dot_segment = normalize_transaction_write_row(
625
+ TransactionWriteRow {
764
626
  entity_id: None,
765
627
  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
- ),
628
+ snapshot: Some(transaction_json(json!({
629
+ "id": "file-dotdot",
630
+ "directory_id": null,
631
+ "name": "..",
632
+ }))),
774
633
  global: false,
775
634
  ..base_stage_row()
776
635
  },
@@ -780,18 +639,15 @@ mod tests {
780
639
  .expect_err("file descriptor name should reject dot segments");
781
640
  assert_eq!(dot_segment.code, "LIX_ERROR_PATH_DOT_SEGMENT");
782
641
 
783
- let bidi = normalize_stage_row(
784
- StageRow {
642
+ let bidi = normalize_transaction_write_row(
643
+ TransactionWriteRow {
785
644
  entity_id: None,
786
645
  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
- ),
646
+ snapshot: Some(transaction_json(json!({
647
+ "id": "file-bidi",
648
+ "directory_id": null,
649
+ "name": "safe\u{202E}txt",
650
+ }))),
795
651
  global: false,
796
652
  ..base_stage_row()
797
653
  },
@@ -801,18 +657,15 @@ mod tests {
801
657
  .expect_err("file descriptor name should reject bidi formatting characters");
802
658
  assert_eq!(bidi.code, "LIX_ERROR_PATH_INVALID_SEGMENT_CODE_POINT");
803
659
 
804
- let zero_width = normalize_stage_row(
805
- StageRow {
660
+ let zero_width = normalize_transaction_write_row(
661
+ TransactionWriteRow {
806
662
  entity_id: None,
807
663
  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
- ),
664
+ snapshot: Some(transaction_json(json!({
665
+ "id": "dir-zero-width",
666
+ "parent_id": null,
667
+ "name": "zero\u{200D}width",
668
+ }))),
816
669
  global: false,
817
670
  ..base_stage_row()
818
671
  },
@@ -827,18 +680,15 @@ mod tests {
827
680
  fn normalization_keeps_file_descriptor_name_opaque() {
828
681
  let mut catalog = catalog_with(vec![builtin_schema(FILE_DESCRIPTOR_SCHEMA_KEY)]);
829
682
 
830
- let row = normalize_stage_row(
831
- StageRow {
683
+ let row = normalize_transaction_write_row(
684
+ TransactionWriteRow {
832
685
  entity_id: None,
833
686
  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
- ),
687
+ snapshot: Some(transaction_json(json!({
688
+ "id": "file-opaque-name",
689
+ "directory_id": null,
690
+ "name": "foo.bar",
691
+ }))),
842
692
  global: false,
843
693
  ..base_stage_row()
844
694
  },
@@ -847,13 +697,28 @@ mod tests {
847
697
  )
848
698
  .expect("file descriptor name should be an opaque basename");
849
699
 
850
- let snapshot: JsonValue =
851
- serde_json::from_str(row.snapshot_content.as_deref().unwrap()).unwrap();
700
+ let snapshot = normalized_snapshot(&row);
852
701
  assert_eq!(snapshot["name"], "foo.bar");
853
702
  }
854
703
 
855
- fn catalog_with(schemas: Vec<JsonValue>) -> TransactionSchemaCatalog {
856
- TransactionSchemaCatalog::from_visible_schemas(&schemas).expect("catalog")
704
+ fn normalized_snapshot(row: &NormalizedTransactionWriteRow) -> &JsonValue {
705
+ row.snapshot
706
+ .as_ref()
707
+ .expect("normalized test row should have a snapshot")
708
+ .value()
709
+ }
710
+
711
+ fn catalog_with(schemas: Vec<JsonValue>) -> CatalogSnapshot {
712
+ let mut visible_schemas = schemas;
713
+ if visible_schemas.iter().any(|schema| {
714
+ schema.get("x-lix-key").and_then(JsonValue::as_str) == Some(FILE_DESCRIPTOR_SCHEMA_KEY)
715
+ }) && !visible_schemas.iter().any(|schema| {
716
+ schema.get("x-lix-key").and_then(JsonValue::as_str)
717
+ == Some(DIRECTORY_DESCRIPTOR_SCHEMA_KEY)
718
+ }) {
719
+ visible_schemas.push(builtin_schema(DIRECTORY_DESCRIPTOR_SCHEMA_KEY));
720
+ }
721
+ CatalogSnapshot::from_visible_schemas(&visible_schemas).expect("catalog")
857
722
  }
858
723
 
859
724
  fn builtin_schema(schema_key: &str) -> JsonValue {
@@ -862,15 +727,22 @@ mod tests {
862
727
  .clone()
863
728
  }
864
729
 
865
- fn base_stage_row() -> StageRow {
866
- StageRow {
730
+ fn transaction_json(value: JsonValue) -> TransactionJson {
731
+ TransactionJson::from_value_for_test(value)
732
+ }
733
+
734
+ fn snapshot_json(value: &str) -> TransactionJson {
735
+ transaction_json(serde_json::from_str(value).expect("test snapshot should parse"))
736
+ }
737
+
738
+ fn base_stage_row() -> TransactionWriteRow {
739
+ TransactionWriteRow {
867
740
  entity_id: Some(crate::entity_identity::EntityIdentity::single("entity-1")),
868
741
  schema_key: "normalization_schema".to_string(),
869
742
  file_id: None,
870
- snapshot_content: Some(r#"{"id":"entity-1","value":"hello"}"#.to_string()),
743
+ snapshot: Some(snapshot_json(r#"{"id":"entity-1","value":"hello"}"#)),
871
744
  metadata: None,
872
745
  origin: None,
873
- schema_version: "1".to_string(),
874
746
  created_at: None,
875
747
  updated_at: None,
876
748
  global: true,
@@ -884,7 +756,6 @@ mod tests {
884
756
  fn schema_with_default_id() -> JsonValue {
885
757
  json!({
886
758
  "x-lix-key": "normalization_schema",
887
- "x-lix-version": "1",
888
759
  "x-lix-primary-key": ["/id"],
889
760
  "type": "object",
890
761
  "properties": {
@@ -899,7 +770,6 @@ mod tests {
899
770
  fn schema_with_cel_field_default() -> JsonValue {
900
771
  json!({
901
772
  "x-lix-key": "cel_field_default_schema",
902
- "x-lix-version": "1",
903
773
  "x-lix-primary-key": ["/id"],
904
774
  "type": "object",
905
775
  "properties": {
@@ -915,7 +785,6 @@ mod tests {
915
785
  fn schema_with_overridden_default() -> JsonValue {
916
786
  json!({
917
787
  "x-lix-key": "overridden_default_schema",
918
- "x-lix-version": "1",
919
788
  "x-lix-primary-key": ["/id"],
920
789
  "type": "object",
921
790
  "properties": {
@@ -934,7 +803,6 @@ mod tests {
934
803
  fn schema_with_nullable_default() -> JsonValue {
935
804
  json!({
936
805
  "x-lix-key": "nullable_default_schema",
937
- "x-lix-version": "1",
938
806
  "x-lix-primary-key": ["/id"],
939
807
  "type": "object",
940
808
  "properties": {
@@ -952,7 +820,6 @@ mod tests {
952
820
  fn schema_with_timestamp_default() -> JsonValue {
953
821
  json!({
954
822
  "x-lix-key": "timestamp_default_schema",
955
- "x-lix-version": "1",
956
823
  "x-lix-primary-key": ["/id"],
957
824
  "type": "object",
958
825
  "properties": {
@@ -967,7 +834,6 @@ mod tests {
967
834
  fn schema_with_unknown_cel_default() -> JsonValue {
968
835
  json!({
969
836
  "x-lix-key": "unknown_cel_default_schema",
970
- "x-lix-version": "1",
971
837
  "x-lix-primary-key": ["/id"],
972
838
  "type": "object",
973
839
  "properties": {
@@ -982,7 +848,6 @@ mod tests {
982
848
  fn composite_key_schema() -> JsonValue {
983
849
  json!({
984
850
  "x-lix-key": "composite_key_schema",
985
- "x-lix-version": "1",
986
851
  "x-lix-primary-key": ["/namespace", "/key"],
987
852
  "type": "object",
988
853
  "properties": {
@@ -997,7 +862,6 @@ mod tests {
997
862
  fn dynamic_schema_definition() -> JsonValue {
998
863
  json!({
999
864
  "x-lix-key": "dynamic_schema",
1000
- "x-lix-version": "1",
1001
865
  "x-lix-primary-key": ["/id"],
1002
866
  "type": "object",
1003
867
  "properties": {