@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,19 +1,30 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "Lix Schema Definition",
4
- "description": "A Lix schema is a JSON Schema draft 2020-12 document augmented with `x-lix-*` extensions that identify, version, and constrain an entity type. Every schema must declare `x-lix-key` (snake_case identifier, preferably prefixed with a plugin or domain namespace such as `library_book` to avoid collisions), `x-lix-version` (string-as-integer, e.g. `\"1\"`), and `additionalProperties: false`; add `x-lix-primary-key` (array of JSON Pointers) to make the schema writable Lix will auto-materialize a public virtual table named after `x-lix-key` with an `INSERT/UPDATE/DELETE` surface. See the `examples` field for a minimal working schema.",
4
+ "description": "A Lix schema is a JSON Schema draft 2020-12 document augmented with `x-lix-*` extensions that identify and constrain an entity type. Every schema must declare `x-lix-key` (snake_case identifier, preferably prefixed with a plugin or domain namespace such as `library_book` to avoid collisions) and `additionalProperties: false`; add `x-lix-primary-key` (array of JSON Pointers to required string properties) to make the schema writable. Lix will auto-materialize a public virtual table named after `x-lix-key` with an `INSERT/UPDATE/DELETE` surface. See the `examples` field for a minimal working schema.",
5
5
  "examples": [
6
6
  {
7
7
  "x-lix-key": "library_book",
8
- "x-lix-version": "1",
9
8
  "type": "object",
10
- "x-lix-primary-key": ["/id"],
9
+ "x-lix-primary-key": [
10
+ "/id"
11
+ ],
11
12
  "properties": {
12
- "id": { "type": "string", "x-lix-default": "lix_uuid_v7()" },
13
- "title": { "type": "string" },
14
- "author": { "type": "string" }
13
+ "id": {
14
+ "type": "string",
15
+ "x-lix-default": "lix_uuid_v7()"
16
+ },
17
+ "title": {
18
+ "type": "string"
19
+ },
20
+ "author": {
21
+ "type": "string"
22
+ }
15
23
  },
16
- "required": ["id", "title"],
24
+ "required": [
25
+ "id",
26
+ "title"
27
+ ],
17
28
  "additionalProperties": false
18
29
  }
19
30
  ],
@@ -47,7 +58,7 @@
47
58
  "type": "array",
48
59
  "minItems": 1,
49
60
  "uniqueItems": true,
50
- "description": "Primary-key fields as JSON Pointers (RFC 6901) into the entity, e.g. `[\"/id\"]` for a single-column key or `[\"/tenant_id\", \"/handle\"]` for a composite key. Note the leading slash; `\"id\"` without a slash is not a valid pointer.",
61
+ "description": "Primary-key fields as JSON Pointers (RFC 6901) into required string-valued entity properties, e.g. `[\"/id\"]` for a single-column key or `[\"/tenant_id\", \"/handle\"]` for a composite key. Note the leading slash; `\"id\"` without a slash is not a valid pointer.",
51
62
  "items": {
52
63
  "type": "string",
53
64
  "format": "json-pointer",
@@ -103,6 +114,34 @@
103
114
  }
104
115
  }
105
116
  },
117
+ "x-lix-state-foreign-keys": {
118
+ "type": "array",
119
+ "description": "Foreign keys from local fields to arbitrary live state rows. Each entry is exactly three required local JSON Pointers ordered as `[entity_id, schema_key, file_id]`: index 0 points to the local entity_id JSON array, index 1 points to the local schema_key string, and index 2 points to the local file_id string-or-null. Use explicit null for global file_id targets; omitted fields are invalid. The referenced state row is resolved in the same version.",
120
+ "items": {
121
+ "type": "array",
122
+ "minItems": 3,
123
+ "maxItems": 3,
124
+ "uniqueItems": true,
125
+ "prefixItems": [
126
+ {
127
+ "type": "string",
128
+ "format": "json-pointer",
129
+ "description": "[0] Local JSON Pointer for the target entity_id. The value must be a non-empty JSON array of strings."
130
+ },
131
+ {
132
+ "type": "string",
133
+ "format": "json-pointer",
134
+ "description": "[1] Local JSON Pointer for the target schema_key. The value must be a string."
135
+ },
136
+ {
137
+ "type": "string",
138
+ "format": "json-pointer",
139
+ "description": "[2] Local JSON Pointer for the target file_id. The value must be a string or null."
140
+ }
141
+ ],
142
+ "items": false
143
+ }
144
+ },
106
145
  "x-lix-key": {
107
146
  "type": "string",
108
147
  "pattern": "^[a-z][a-z0-9_]*$",
@@ -112,14 +151,6 @@
112
151
  "csv_plugin_cell"
113
152
  ]
114
153
  },
115
- "x-lix-version": {
116
- "type": "string",
117
- "pattern": "^[1-9]\\d*$",
118
- "description": "Schema version identifier. Deliberately constrained to a monotonic integer (as a string) without leading zeros to keep translation rules open while avoiding future breaking changes.",
119
- "examples": [
120
- "1"
121
- ]
122
- },
123
154
  "properties": {
124
155
  "type": "object",
125
156
  "additionalProperties": {
@@ -149,7 +180,6 @@
149
180
  },
150
181
  "required": [
151
182
  "x-lix-key",
152
- "x-lix-version",
153
183
  "additionalProperties"
154
184
  ]
155
185
  }
@@ -4,6 +4,7 @@ use serde_json::Value as JsonValue;
4
4
  use std::collections::BTreeSet;
5
5
  use std::sync::OnceLock;
6
6
 
7
+ use crate::common::parse_json_pointer;
7
8
  use crate::LixError;
8
9
 
9
10
  static LIX_SCHEMA_DEFINITION: OnceLock<JsonValue> = OnceLock::new();
@@ -11,9 +12,6 @@ static LIX_SCHEMA_VALIDATOR: OnceLock<Result<JSONSchema, LixError>> = OnceLock::
11
12
 
12
13
  pub fn lix_schema_definition() -> &'static JsonValue {
13
14
  LIX_SCHEMA_DEFINITION.get_or_init(|| {
14
- // NOTE: x-lix-version is intentionally constrained to a monotonic integer (as a string).
15
- // This keeps translation rules open while avoiding a future breaking change when versioning
16
- // semantics become concrete.
17
15
  let raw = include_str!("definition.json");
18
16
  serde_json::from_str(raw).expect("definition.json must be valid JSON")
19
17
  })
@@ -27,6 +25,9 @@ pub fn validate_lix_schema_definition(schema: &JsonValue) -> Result<(), LixError
27
25
  if let Some(err) = detect_missing_pointer_slash(schema) {
28
26
  return Err(err);
29
27
  }
28
+ if let Some(err) = detect_state_foreign_key_tuple_shape(schema) {
29
+ return Err(err);
30
+ }
30
31
 
31
32
  let validator = lix_schema_validator()?;
32
33
  if let Err(errors) = validator.validate(schema) {
@@ -41,13 +42,39 @@ pub fn validate_lix_schema_definition(schema: &JsonValue) -> Result<(), LixError
41
42
 
42
43
  assert_primary_key_pointers(schema)?;
43
44
  assert_unique_pointers(schema)?;
44
- assert_non_aliased_lix_foreign_key_references(schema)?;
45
+ assert_state_foreign_key_pointers(schema)?;
45
46
  assert_known_x_lix_top_level_fields(schema)?;
47
+ assert_entity_properties_do_not_use_reserved_lix_prefix(schema)?;
46
48
  assert_entity_properties_have_projectable_types(schema)?;
47
49
 
48
50
  Ok(())
49
51
  }
50
52
 
53
+ fn assert_entity_properties_do_not_use_reserved_lix_prefix(
54
+ schema: &JsonValue,
55
+ ) -> Result<(), LixError> {
56
+ let Some(schema_key) = schema.get("x-lix-key").and_then(JsonValue::as_str) else {
57
+ return Ok(());
58
+ };
59
+ let Some(properties) = schema.get("properties").and_then(JsonValue::as_object) else {
60
+ return Ok(());
61
+ };
62
+
63
+ for property_name in properties.keys() {
64
+ if property_name.starts_with("lix") {
65
+ return Err(LixError::new(
66
+ LixError::CODE_SCHEMA_DEFINITION,
67
+ format!(
68
+ "Invalid Lix schema definition: schema '{schema_key}' property '/{property_name}' uses reserved prefix 'lix'."
69
+ ),
70
+ )
71
+ .with_hint("Property names starting with 'lix' are reserved for Lix system fields."));
72
+ }
73
+ }
74
+
75
+ Ok(())
76
+ }
77
+
51
78
  fn assert_entity_properties_have_projectable_types(schema: &JsonValue) -> Result<(), LixError> {
52
79
  let Some(schema_key) = schema.get("x-lix-key").and_then(JsonValue::as_str) else {
53
80
  return Ok(());
@@ -57,9 +84,6 @@ fn assert_entity_properties_have_projectable_types(schema: &JsonValue) -> Result
57
84
  };
58
85
 
59
86
  for (property_name, property_schema) in properties {
60
- if property_name.starts_with("lixcol_") {
61
- continue;
62
- }
63
87
  if !schema_property_has_sql_projection_type(property_schema) {
64
88
  return Err(LixError::new(
65
89
  LixError::CODE_SCHEMA_DEFINITION,
@@ -110,7 +134,8 @@ fn collect_schema_type_kinds<'a>(schema: &'a JsonValue, out: &mut BTreeSet<&'a s
110
134
 
111
135
  /// Detect the common no-leading-slash mistake in JSON-Pointer-valued fields
112
136
  /// (`x-lix-primary-key`, `x-lix-unique`, `x-lix-foreign-keys[].properties`,
113
- /// `x-lix-foreign-keys[].references.properties`) and return a targeted
137
+ /// `x-lix-foreign-keys[].references.properties`,
138
+ /// `x-lix-state-foreign-keys[]`) and return a targeted
114
139
  /// error + hint suggesting the fix.
115
140
  ///
116
141
  /// Surfacing this before the meta-schema validator runs replaces the
@@ -166,6 +191,15 @@ fn detect_missing_pointer_slash(schema: &JsonValue) -> Option<LixError> {
166
191
  }
167
192
  }
168
193
 
194
+ if let Some(fks) = schema
195
+ .get("x-lix-state-foreign-keys")
196
+ .and_then(JsonValue::as_array)
197
+ {
198
+ for fk in fks {
199
+ collect(fk.as_array(), "x-lix-state-foreign-keys", &mut offenders);
200
+ }
201
+ }
202
+
169
203
  if offenders.is_empty() {
170
204
  return None;
171
205
  }
@@ -194,6 +228,26 @@ fn detect_missing_pointer_slash(schema: &JsonValue) -> Option<LixError> {
194
228
  )
195
229
  }
196
230
 
231
+ fn detect_state_foreign_key_tuple_shape(schema: &JsonValue) -> Option<LixError> {
232
+ let foreign_keys = schema
233
+ .get("x-lix-state-foreign-keys")
234
+ .and_then(JsonValue::as_array)?;
235
+ for (index, foreign_key) in foreign_keys.iter().enumerate() {
236
+ let Some(local_pointers) = foreign_key.as_array() else {
237
+ continue;
238
+ };
239
+ if local_pointers.len() != 3 {
240
+ return Some(LixError::new(
241
+ LixError::CODE_SCHEMA_DEFINITION,
242
+ format!(
243
+ "Invalid Lix schema definition: x-lix-state-foreign-keys[{index}] must contain exactly three JSON Pointers ordered as [entity_id, schema_key, file_id]; [0] entity_id, [1] schema_key, [2] file_id."
244
+ ),
245
+ ));
246
+ }
247
+ }
248
+ None
249
+ }
250
+
197
251
  pub fn validate_lix_schema(schema: &JsonValue, data: &JsonValue) -> Result<(), LixError> {
198
252
  validate_lix_schema_definition(schema)?;
199
253
 
@@ -257,50 +311,6 @@ fn is_cel_expression(value: &str) -> bool {
257
311
  Program::compile(value).is_ok()
258
312
  }
259
313
 
260
- fn parse_json_pointer(pointer: &str) -> Result<Vec<String>, LixError> {
261
- if pointer.is_empty() {
262
- return Ok(Vec::new());
263
- }
264
- if !pointer.starts_with('/') {
265
- return Err(LixError {
266
- code: LixError::CODE_SCHEMA_DEFINITION.to_string(),
267
- message: "Invalid JSON pointer".to_string(),
268
- hint: None,
269
- details: None,
270
- });
271
- }
272
-
273
- let mut segments = Vec::new();
274
- for raw in pointer[1..].split('/') {
275
- segments.push(unescape_pointer_segment(raw)?);
276
- }
277
- Ok(segments)
278
- }
279
-
280
- fn unescape_pointer_segment(segment: &str) -> Result<String, LixError> {
281
- let mut out = String::new();
282
- let mut chars = segment.chars();
283
- while let Some(ch) = chars.next() {
284
- if ch == '~' {
285
- match chars.next() {
286
- Some('0') => out.push('~'),
287
- Some('1') => out.push('/'),
288
- _ => {
289
- return Err(LixError {
290
- code: LixError::CODE_SCHEMA_DEFINITION.to_string(),
291
- message: "Invalid JSON pointer".to_string(),
292
- hint: None,
293
- details: None,
294
- })
295
- }
296
- }
297
- } else {
298
- out.push(ch);
299
- }
300
- }
301
- Ok(out)
302
- }
303
-
304
314
  fn assert_primary_key_pointers(schema: &JsonValue) -> Result<(), LixError> {
305
315
  let Some(primary_key) = schema
306
316
  .get("x-lix-primary-key")
@@ -314,7 +324,10 @@ fn assert_primary_key_pointers(schema: &JsonValue) -> Result<(), LixError> {
314
324
  continue;
315
325
  };
316
326
  let segments = parse_json_pointer(pointer)?;
317
- if segments.is_empty() || !schema_has_property(schema, &segments) {
327
+ let Some(property_schema) = (!segments.is_empty())
328
+ .then(|| schema_property(schema, &segments))
329
+ .flatten()
330
+ else {
318
331
  return Err(LixError { code: LixError::CODE_SCHEMA_DEFINITION.to_string(), message: format!(
319
332
  "Invalid Lix schema definition: x-lix-primary-key references missing property \"{}\".",
320
333
  pointer
@@ -322,6 +335,22 @@ fn assert_primary_key_pointers(schema: &JsonValue) -> Result<(), LixError> {
322
335
  hint: None,
323
336
  details: None,
324
337
  });
338
+ };
339
+ if !schema_property_is_string_only(property_schema) {
340
+ return Err(LixError::new(
341
+ LixError::CODE_SCHEMA_DEFINITION,
342
+ format!(
343
+ "Invalid Lix schema definition: x-lix-primary-key property \"{pointer}\" must have type \"string\"."
344
+ ),
345
+ ));
346
+ }
347
+ if !schema_pointer_is_required(schema, &segments) {
348
+ return Err(LixError::new(
349
+ LixError::CODE_SCHEMA_DEFINITION,
350
+ format!(
351
+ "Invalid Lix schema definition: x-lix-primary-key property \"{pointer}\" must be required."
352
+ ),
353
+ ));
325
354
  }
326
355
  }
327
356
 
@@ -360,33 +389,67 @@ fn assert_unique_pointers(schema: &JsonValue) -> Result<(), LixError> {
360
389
  Ok(())
361
390
  }
362
391
 
363
- fn assert_non_aliased_lix_foreign_key_references(schema: &JsonValue) -> Result<(), LixError> {
392
+ fn assert_state_foreign_key_pointers(schema: &JsonValue) -> Result<(), LixError> {
364
393
  let Some(foreign_keys) = schema
365
- .get("x-lix-foreign-keys")
394
+ .get("x-lix-state-foreign-keys")
366
395
  .and_then(|value| value.as_array())
367
396
  else {
368
397
  return Ok(());
369
398
  };
370
399
 
371
- for foreign_key in foreign_keys {
372
- let Some(schema_key) = foreign_key
373
- .get("references")
374
- .and_then(|value| value.get("schemaKey"))
375
- .and_then(|value| value.as_str())
376
- else {
400
+ for (index, foreign_key) in foreign_keys.iter().enumerate() {
401
+ let Some(local_pointers) = foreign_key.as_array() else {
377
402
  continue;
378
403
  };
379
-
380
- let Some(replacement) = preferred_lix_schema_key_alias(schema_key) else {
404
+ if local_pointers.len() != 3 {
381
405
  continue;
382
- };
406
+ }
383
407
 
384
- return Err(LixError { code: LixError::CODE_SCHEMA_DEFINITION.to_string(), message: format!(
385
- "Invalid Lix schema definition: x-lix-foreign-keys references.schemaKey uses deprecated alias \"{schema_key}\"; use \"{replacement}\"."
386
- ),
387
- hint: None,
388
- details: None,
389
- });
408
+ let roles = [
409
+ ("entity_id", "a non-empty JSON array of strings"),
410
+ ("schema_key", "a string"),
411
+ ("file_id", "a string or null"),
412
+ ];
413
+ for (slot, (role, expected)) in roles.iter().enumerate() {
414
+ let Some(pointer) = local_pointers[slot].as_str() else {
415
+ continue;
416
+ };
417
+ let segments = parse_json_pointer(pointer)?;
418
+ let Some(property_schema) = (!segments.is_empty())
419
+ .then(|| schema_property(schema, &segments))
420
+ .flatten()
421
+ else {
422
+ return Err(LixError::new(
423
+ LixError::CODE_SCHEMA_DEFINITION,
424
+ format!(
425
+ "Invalid Lix schema definition: x-lix-state-foreign-keys[{index}][{slot}] ({role}) references missing property \"{pointer}\"."
426
+ ),
427
+ ));
428
+ };
429
+ if !schema_pointer_is_required(schema, &segments) {
430
+ return Err(LixError::new(
431
+ LixError::CODE_SCHEMA_DEFINITION,
432
+ format!(
433
+ "Invalid Lix schema definition: x-lix-state-foreign-keys[{index}][{slot}] ({role}) property \"{pointer}\" must be required. Tuple order is [entity_id, schema_key, file_id]."
434
+ ),
435
+ ));
436
+ }
437
+
438
+ let valid = match *role {
439
+ "entity_id" => schema_property_is_string_array(property_schema),
440
+ "schema_key" => schema_property_is_string_only(property_schema),
441
+ "file_id" => schema_property_is_string_or_null(property_schema),
442
+ _ => unreachable!("state foreign key roles are exhaustive"),
443
+ };
444
+ if !valid {
445
+ return Err(LixError::new(
446
+ LixError::CODE_SCHEMA_DEFINITION,
447
+ format!(
448
+ "Invalid Lix schema definition: x-lix-state-foreign-keys[{index}][{slot}] ({role}) property \"{pointer}\" must be {expected}. Tuple order is [entity_id, schema_key, file_id]."
449
+ ),
450
+ ));
451
+ }
452
+ }
390
453
  }
391
454
 
392
455
  Ok(())
@@ -405,10 +468,10 @@ fn assert_known_x_lix_top_level_fields(schema: &JsonValue) -> Result<(), LixErro
405
468
  let known = matches!(
406
469
  key.as_str(),
407
470
  "x-lix-key"
408
- | "x-lix-version"
409
471
  | "x-lix-primary-key"
410
472
  | "x-lix-unique"
411
473
  | "x-lix-foreign-keys"
474
+ | "x-lix-state-foreign-keys"
412
475
  );
413
476
 
414
477
  if !known {
@@ -427,40 +490,84 @@ fn assert_known_x_lix_top_level_fields(schema: &JsonValue) -> Result<(), LixErro
427
490
  Ok(())
428
491
  }
429
492
 
430
- fn preferred_lix_schema_key_alias(schema_key: &str) -> Option<&'static str> {
431
- match schema_key {
432
- "state" => Some("lix_state"),
433
- "state_by_version" => Some("lix_state_by_version"),
434
- "state_history" => Some("lix_state_history"),
435
- "state_history_by_version" => Some("lix_state_history_by_version"),
436
- "label" => Some("lix_label"),
437
- "entity_label" => Some("lix_entity_label"),
438
- "conversation" => Some("lix_conversation"),
439
- "entity_conversation" => Some("lix_entity_conversation"),
440
- _ => None,
441
- }
493
+ fn schema_has_property(schema: &JsonValue, segments: &[String]) -> bool {
494
+ schema_property(schema, segments).is_some()
442
495
  }
443
496
 
444
- fn schema_has_property(schema: &JsonValue, segments: &[String]) -> bool {
497
+ fn schema_pointer_is_required(schema: &JsonValue, segments: &[String]) -> bool {
498
+ if segments.is_empty() {
499
+ return false;
500
+ }
501
+
445
502
  let mut node = schema;
446
503
  for segment in segments {
447
- let properties = match node.get("properties") {
448
- Some(properties) => properties,
449
- None => return false,
450
- };
451
- let properties = match properties.as_object() {
452
- Some(properties) => properties,
453
- None => return false,
454
- };
455
- let next = match properties.get(segment) {
456
- Some(next) => next,
457
- None => return false,
504
+ let required = node
505
+ .get("required")
506
+ .and_then(JsonValue::as_array)
507
+ .map(|required| {
508
+ required
509
+ .iter()
510
+ .any(|required_property| required_property.as_str() == Some(segment))
511
+ })
512
+ .unwrap_or(false);
513
+ if !required {
514
+ return false;
515
+ }
516
+
517
+ let Some(next) = node
518
+ .get("properties")
519
+ .and_then(JsonValue::as_object)
520
+ .and_then(|properties| properties.get(segment))
521
+ else {
522
+ return false;
458
523
  };
459
524
  node = next;
460
525
  }
526
+
461
527
  true
462
528
  }
463
529
 
530
+ fn schema_property<'a>(schema: &'a JsonValue, segments: &[String]) -> Option<&'a JsonValue> {
531
+ let mut node = schema;
532
+ for segment in segments {
533
+ let properties = node.get("properties")?.as_object()?;
534
+ let next = properties.get(segment)?;
535
+ node = next;
536
+ }
537
+ Some(node)
538
+ }
539
+
540
+ fn schema_property_is_string_only(schema: &JsonValue) -> bool {
541
+ let mut kinds = BTreeSet::new();
542
+ collect_schema_type_kinds(schema, &mut kinds);
543
+ kinds.len() == 1 && kinds.contains("string")
544
+ }
545
+
546
+ fn schema_property_is_string_or_null(schema: &JsonValue) -> bool {
547
+ let mut kinds = BTreeSet::new();
548
+ collect_schema_type_kinds(schema, &mut kinds);
549
+ kinds.remove("null");
550
+ kinds.len() == 1 && kinds.contains("string")
551
+ }
552
+
553
+ fn schema_property_is_string_array(schema: &JsonValue) -> bool {
554
+ let mut kinds = BTreeSet::new();
555
+ collect_schema_type_kinds(schema, &mut kinds);
556
+ if kinds.len() != 1 || !kinds.contains("array") {
557
+ return false;
558
+ }
559
+ let Some(items) = schema.get("items") else {
560
+ return false;
561
+ };
562
+ if !schema_property_is_string_only(items) {
563
+ return false;
564
+ }
565
+ schema
566
+ .get("minItems")
567
+ .and_then(JsonValue::as_u64)
568
+ .is_some_and(|min_items| min_items >= 1)
569
+ }
570
+
464
571
  pub(crate) fn format_lix_schema_validation_errors<'a>(
465
572
  errors: impl Iterator<Item = jsonschema::ValidationError<'a>>,
466
573
  ) -> String {
@@ -490,7 +597,6 @@ mod pointer_slash_detection_tests {
490
597
  let mut obj = json!({
491
598
  "type": "object",
492
599
  "x-lix-key": "book",
493
- "x-lix-version": "1",
494
600
  "properties": {
495
601
  "id": { "type": "string" },
496
602
  "author_id": { "type": "string" },