@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,516 @@
1
+ use std::sync::OnceLock;
2
+
3
+ use globset::{Glob, GlobBuilder};
4
+ use jsonschema::{Draft, JSONSchema};
5
+ use serde::{Deserialize, Serialize};
6
+ use serde_json::Value as JsonValue;
7
+
8
+ use crate::LixError;
9
+
10
+ static PLUGIN_MANIFEST_SCHEMA: OnceLock<JsonValue> = OnceLock::new();
11
+ static PLUGIN_MANIFEST_VALIDATOR: OnceLock<Result<JSONSchema, LixError>> = OnceLock::new();
12
+
13
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14
+ #[serde(rename_all = "kebab-case")]
15
+ pub enum PluginRuntime {
16
+ WasmComponentV1,
17
+ }
18
+
19
+ #[allow(dead_code)]
20
+ impl PluginRuntime {
21
+ pub fn as_str(self) -> &'static str {
22
+ match self {
23
+ Self::WasmComponentV1 => "wasm-component-v1",
24
+ }
25
+ }
26
+
27
+ pub fn from_str(value: &str) -> Option<Self> {
28
+ match value {
29
+ "wasm-component-v1" => Some(Self::WasmComponentV1),
30
+ _ => None,
31
+ }
32
+ }
33
+ }
34
+
35
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36
+ pub struct PluginManifest {
37
+ pub key: String,
38
+ pub runtime: PluginRuntime,
39
+ pub api_version: String,
40
+ #[serde(rename = "match")]
41
+ pub file_match: PluginMatch,
42
+ #[serde(default)]
43
+ pub detect_changes: Option<DetectChangesConfig>,
44
+ pub entry: String,
45
+ pub schemas: Vec<String>,
46
+ }
47
+
48
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49
+ pub struct PluginMatch {
50
+ pub path_glob: String,
51
+ #[serde(default)]
52
+ pub content_type: Option<PluginContentType>,
53
+ }
54
+
55
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56
+ #[serde(rename_all = "snake_case")]
57
+ pub enum PluginContentType {
58
+ Text,
59
+ Binary,
60
+ }
61
+
62
+ #[derive(Debug, Clone, PartialEq, Eq)]
63
+ pub struct ValidatedPluginManifest {
64
+ pub manifest: PluginManifest,
65
+ pub normalized_json: String,
66
+ }
67
+
68
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69
+ pub struct DetectChangesConfig {
70
+ #[serde(default)]
71
+ pub state_context: Option<DetectStateContextConfig>,
72
+ }
73
+
74
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75
+ pub struct DetectStateContextConfig {
76
+ #[serde(default)]
77
+ pub include_active_state: Option<bool>,
78
+ #[serde(default)]
79
+ pub columns: Option<Vec<StateContextColumn>>,
80
+ }
81
+
82
+ #[allow(dead_code)]
83
+ impl DetectStateContextConfig {
84
+ pub fn includes_active_state(&self) -> bool {
85
+ self.include_active_state.unwrap_or(false)
86
+ }
87
+
88
+ pub fn resolved_columns_or_default(&self) -> Option<Vec<StateContextColumn>> {
89
+ if !self.includes_active_state() {
90
+ return None;
91
+ }
92
+ Some(
93
+ self.columns
94
+ .clone()
95
+ .unwrap_or_else(|| StateContextColumn::default_active_state_columns().to_vec()),
96
+ )
97
+ }
98
+ }
99
+
100
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101
+ #[serde(rename_all = "snake_case")]
102
+ pub enum StateContextColumn {
103
+ EntityId,
104
+ SchemaKey,
105
+ SchemaVersion,
106
+ SnapshotContent,
107
+ FileId,
108
+ PluginKey,
109
+ VersionId,
110
+ ChangeId,
111
+ Metadata,
112
+ CreatedAt,
113
+ UpdatedAt,
114
+ }
115
+
116
+ #[allow(dead_code)]
117
+ impl StateContextColumn {
118
+ pub const fn default_active_state_columns() -> &'static [StateContextColumn] {
119
+ &[
120
+ StateContextColumn::EntityId,
121
+ StateContextColumn::SchemaKey,
122
+ StateContextColumn::SchemaVersion,
123
+ StateContextColumn::SnapshotContent,
124
+ ]
125
+ }
126
+ }
127
+
128
+ pub fn parse_plugin_manifest_json(raw: &str) -> Result<ValidatedPluginManifest, LixError> {
129
+ let manifest_json: JsonValue = serde_json::from_str(raw).map_err(|error| LixError {
130
+ code: "LIX_ERROR_UNKNOWN".to_string(),
131
+ message: format!("Plugin manifest must be valid JSON: {error}"),
132
+ hint: None,
133
+ details: None,
134
+ })?;
135
+
136
+ validate_plugin_manifest_json(&manifest_json)?;
137
+
138
+ let manifest: PluginManifest =
139
+ serde_json::from_value(manifest_json.clone()).map_err(|error| LixError {
140
+ code: "LIX_ERROR_UNKNOWN".to_string(),
141
+ message: format!("Plugin manifest does not match expected shape: {error}"),
142
+ hint: None,
143
+ details: None,
144
+ })?;
145
+ validate_path_glob(&manifest.file_match.path_glob)?;
146
+
147
+ let normalized_json = serde_json::to_string(&manifest_json).map_err(|error| LixError {
148
+ code: "LIX_ERROR_UNKNOWN".to_string(),
149
+ message: format!("Failed to normalize plugin manifest JSON: {error}"),
150
+ hint: None,
151
+ details: None,
152
+ })?;
153
+
154
+ Ok(ValidatedPluginManifest {
155
+ manifest,
156
+ normalized_json,
157
+ })
158
+ }
159
+
160
+ pub fn select_best_glob_match<'a, T, C: Copy + PartialEq>(
161
+ path: &str,
162
+ file_content_type: Option<C>,
163
+ candidates: &'a [T],
164
+ glob: impl Fn(&T) -> &str,
165
+ required_content_type: impl Fn(&T) -> Option<C>,
166
+ ) -> Option<&'a T> {
167
+ let mut selected: Option<&T> = None;
168
+ let mut selected_rank: Option<(u8, i32)> = None;
169
+
170
+ for candidate in candidates {
171
+ let pattern = glob(candidate);
172
+ if !glob_matches_path(pattern, path) {
173
+ continue;
174
+ }
175
+ if let (Some(actual_type), Some(required_type)) =
176
+ (file_content_type, required_content_type(candidate))
177
+ {
178
+ if actual_type != required_type {
179
+ continue;
180
+ }
181
+ }
182
+
183
+ let rank = glob_specificity_rank(pattern);
184
+ match selected_rank {
185
+ None => {
186
+ selected = Some(candidate);
187
+ selected_rank = Some(rank);
188
+ }
189
+ Some(existing_rank) if rank > existing_rank => {
190
+ selected = Some(candidate);
191
+ selected_rank = Some(rank);
192
+ }
193
+ _ => {}
194
+ }
195
+ }
196
+
197
+ selected
198
+ }
199
+
200
+ pub fn glob_matches_path(glob: &str, path: &str) -> bool {
201
+ let normalized_glob = glob.trim();
202
+ let normalized_path = path.trim();
203
+ if normalized_glob.is_empty() || normalized_path.is_empty() {
204
+ return false;
205
+ }
206
+ if is_catch_all_glob(normalized_glob) {
207
+ return true;
208
+ }
209
+
210
+ GlobBuilder::new(normalized_glob)
211
+ .literal_separator(false)
212
+ .case_insensitive(true)
213
+ .build()
214
+ .map(|compiled| compiled.compile_matcher().is_match(normalized_path))
215
+ .unwrap_or(false)
216
+ }
217
+
218
+ fn validate_path_glob(glob: &str) -> Result<(), LixError> {
219
+ Glob::new(glob).map_err(|error| LixError {
220
+ code: "LIX_ERROR_UNKNOWN".to_string(),
221
+ message: format!("Invalid plugin manifest: match.path_glob is invalid: {error}"),
222
+ hint: None,
223
+ details: None,
224
+ })?;
225
+ Ok(())
226
+ }
227
+
228
+ fn validate_plugin_manifest_json(manifest: &JsonValue) -> Result<(), LixError> {
229
+ let validator = plugin_manifest_validator()?;
230
+ if let Err(errors) = validator.validate(manifest) {
231
+ let details = format_validation_errors(errors);
232
+ return Err(LixError {
233
+ code: "LIX_ERROR_UNKNOWN".to_string(),
234
+ message: format!("Invalid plugin manifest: {details}"),
235
+ hint: None,
236
+ details: None,
237
+ });
238
+ }
239
+ Ok(())
240
+ }
241
+
242
+ fn glob_specificity_rank(glob: &str) -> (u8, i32) {
243
+ let normalized = glob.trim();
244
+ if is_catch_all_glob(normalized) {
245
+ return (0, i32::MIN);
246
+ }
247
+ (1, glob_specificity_score(normalized))
248
+ }
249
+
250
+ fn glob_specificity_score(glob: &str) -> i32 {
251
+ let mut literal_chars = 0i32;
252
+ let mut wildcard_chars = 0i32;
253
+ for ch in glob.chars() {
254
+ match ch {
255
+ '*' | '?' | '[' | ']' | '{' | '}' => wildcard_chars += 1,
256
+ _ => literal_chars += 1,
257
+ }
258
+ }
259
+ literal_chars - wildcard_chars
260
+ }
261
+
262
+ fn is_catch_all_glob(glob: &str) -> bool {
263
+ glob == "*" || glob == "**/*" || glob == "**"
264
+ }
265
+
266
+ fn plugin_manifest_validator() -> Result<&'static JSONSchema, LixError> {
267
+ let result = PLUGIN_MANIFEST_VALIDATOR.get_or_init(|| {
268
+ let mut options = JSONSchema::options();
269
+ options.with_meta_schemas();
270
+ if plugin_manifest_schema()
271
+ .get("$schema")
272
+ .and_then(JsonValue::as_str)
273
+ .is_some_and(|url| url == "https://json-schema.org/draft/2020-12/schema")
274
+ {
275
+ options.with_draft(Draft::Draft202012);
276
+ }
277
+
278
+ options
279
+ .compile(plugin_manifest_schema())
280
+ .map_err(|error| LixError {
281
+ code: "LIX_ERROR_UNKNOWN".to_string(),
282
+ message: format!("Failed to compile plugin manifest schema: {error}"),
283
+ hint: None,
284
+ details: None,
285
+ })
286
+ });
287
+
288
+ match result {
289
+ Ok(schema) => Ok(schema),
290
+ Err(error) => Err(LixError {
291
+ code: "LIX_ERROR_UNKNOWN".to_string(),
292
+ message: error.message.clone(),
293
+ hint: None,
294
+ details: None,
295
+ }),
296
+ }
297
+ }
298
+
299
+ fn plugin_manifest_schema() -> &'static JsonValue {
300
+ PLUGIN_MANIFEST_SCHEMA.get_or_init(|| {
301
+ let raw = include_str!("./plugin_manifest.schema.json");
302
+ serde_json::from_str(raw).expect("plugin_manifest.schema.json must be valid JSON")
303
+ })
304
+ }
305
+
306
+ fn format_validation_errors<'a>(
307
+ errors: impl Iterator<Item = jsonschema::ValidationError<'a>>,
308
+ ) -> String {
309
+ let mut parts = Vec::new();
310
+ for error in errors {
311
+ let path = error.instance_path.to_string();
312
+ let message = error.to_string();
313
+ if path.is_empty() {
314
+ parts.push(message);
315
+ } else {
316
+ parts.push(format!("{path} {message}"));
317
+ }
318
+ }
319
+ if parts.is_empty() {
320
+ "Unknown validation error".to_string()
321
+ } else {
322
+ parts.join("; ")
323
+ }
324
+ }
325
+
326
+ #[cfg(test)]
327
+ mod tests {
328
+ use super::{
329
+ parse_plugin_manifest_json, DetectStateContextConfig, PluginContentType, StateContextColumn,
330
+ };
331
+
332
+ #[test]
333
+ fn resolved_columns_returns_none_when_active_state_is_not_enabled() {
334
+ let config = DetectStateContextConfig {
335
+ include_active_state: None,
336
+ columns: None,
337
+ };
338
+
339
+ assert_eq!(config.resolved_columns_or_default(), None);
340
+ }
341
+
342
+ #[test]
343
+ fn resolved_columns_uses_defaults_when_columns_are_omitted() {
344
+ let config = DetectStateContextConfig {
345
+ include_active_state: Some(true),
346
+ columns: None,
347
+ };
348
+
349
+ assert_eq!(
350
+ config.resolved_columns_or_default(),
351
+ Some(StateContextColumn::default_active_state_columns().to_vec())
352
+ );
353
+ }
354
+
355
+ #[test]
356
+ fn resolved_columns_uses_explicit_column_selection() {
357
+ let config = DetectStateContextConfig {
358
+ include_active_state: Some(true),
359
+ columns: Some(vec![
360
+ StateContextColumn::EntityId,
361
+ StateContextColumn::SchemaKey,
362
+ ]),
363
+ };
364
+
365
+ assert_eq!(
366
+ config.resolved_columns_or_default(),
367
+ Some(vec![
368
+ StateContextColumn::EntityId,
369
+ StateContextColumn::SchemaKey
370
+ ])
371
+ );
372
+ }
373
+
374
+ #[test]
375
+ fn parses_valid_manifest() {
376
+ let validated = parse_plugin_manifest_json(
377
+ r#"{
378
+ "key":"plugin_json",
379
+ "runtime":"wasm-component-v1",
380
+ "api_version":"0.1.0",
381
+ "match":{"path_glob":"*.json"},
382
+ "entry":"plugin.wasm",
383
+ "schemas":["schema/default.json"]
384
+ }"#,
385
+ )
386
+ .expect("manifest should parse");
387
+
388
+ assert_eq!(validated.manifest.key, "plugin_json");
389
+ assert_eq!(validated.manifest.runtime.as_str(), "wasm-component-v1");
390
+ assert_eq!(validated.manifest.entry, "plugin.wasm");
391
+ }
392
+
393
+ #[test]
394
+ fn rejects_invalid_manifest() {
395
+ let err = parse_plugin_manifest_json(
396
+ r#"{
397
+ "runtime":"wasm-component-v1",
398
+ "api_version":"0.1.0",
399
+ "match":{"path_glob":"*.json"},
400
+ "entry":"plugin.wasm",
401
+ "schemas":["schema/default.json"]
402
+ }"#,
403
+ )
404
+ .expect_err("manifest should be invalid");
405
+
406
+ assert!(err.message.contains("Invalid plugin manifest"));
407
+ assert!(err.message.contains("key"));
408
+ }
409
+
410
+ #[test]
411
+ fn rejects_invalid_path_glob() {
412
+ let err = parse_plugin_manifest_json(
413
+ r#"{
414
+ "key":"plugin_markdown",
415
+ "runtime":"wasm-component-v1",
416
+ "api_version":"0.1.0",
417
+ "match":{"path_glob":"*.{md,mdx"},
418
+ "entry":"plugin.wasm",
419
+ "schemas":["schema/default.json"]
420
+ }"#,
421
+ )
422
+ .expect_err("invalid glob should fail");
423
+
424
+ assert!(err.message.contains("match.path_glob"));
425
+ }
426
+
427
+ #[test]
428
+ fn parses_manifest_with_content_type_match_filter() {
429
+ let validated = parse_plugin_manifest_json(
430
+ r#"{
431
+ "key":"plugin_text",
432
+ "runtime":"wasm-component-v1",
433
+ "api_version":"0.1.0",
434
+ "match":{"path_glob":"**/*", "content_type":"text"},
435
+ "entry":"plugin.wasm",
436
+ "schemas":["schema/default.json"]
437
+ }"#,
438
+ )
439
+ .expect("manifest should parse");
440
+
441
+ assert_eq!(
442
+ validated.manifest.file_match.content_type,
443
+ Some(PluginContentType::Text)
444
+ );
445
+ }
446
+
447
+ #[test]
448
+ fn parses_manifest_with_active_state_columns() {
449
+ let validated = parse_plugin_manifest_json(
450
+ r#"{
451
+ "key":"plugin_markdown",
452
+ "runtime":"wasm-component-v1",
453
+ "api_version":"0.1.0",
454
+ "match":{"path_glob":"*.{md,mdx}"},
455
+ "entry":"plugin.wasm",
456
+ "schemas":["schema/default.json"],
457
+ "detect_changes": {
458
+ "state_context": {
459
+ "include_active_state": true,
460
+ "columns": ["entity_id", "schema_key", "snapshot_content"]
461
+ }
462
+ }
463
+ }"#,
464
+ )
465
+ .expect("manifest should parse");
466
+
467
+ let state_context = validated
468
+ .manifest
469
+ .detect_changes
470
+ .expect("detect_changes should be present")
471
+ .state_context
472
+ .expect("state_context should be present");
473
+
474
+ assert_eq!(state_context.include_active_state, Some(true));
475
+ assert_eq!(
476
+ state_context.columns,
477
+ Some(vec![
478
+ StateContextColumn::EntityId,
479
+ StateContextColumn::SchemaKey,
480
+ StateContextColumn::SnapshotContent
481
+ ])
482
+ );
483
+ }
484
+
485
+ #[test]
486
+ fn parses_manifest_with_active_state_and_default_columns() {
487
+ let validated = parse_plugin_manifest_json(
488
+ r#"{
489
+ "key":"plugin_markdown",
490
+ "runtime":"wasm-component-v1",
491
+ "api_version":"0.1.0",
492
+ "match":{"path_glob":"*.md"},
493
+ "entry":"plugin.wasm",
494
+ "schemas":["schema/default.json"],
495
+ "detect_changes": {
496
+ "state_context": {
497
+ "include_active_state": true
498
+ }
499
+ }
500
+ }"#,
501
+ )
502
+ .expect("manifest should parse");
503
+
504
+ let state_context = validated
505
+ .manifest
506
+ .detect_changes
507
+ .expect("detect_changes should be present")
508
+ .state_context
509
+ .expect("state_context should be present");
510
+
511
+ assert_eq!(
512
+ state_context.resolved_columns_or_default(),
513
+ Some(StateContextColumn::default_active_state_columns().to_vec())
514
+ );
515
+ }
516
+ }