@alizarin/napi 0.2.1-alpha.83

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.
@@ -0,0 +1,1699 @@
1
+ /// NAPI bindings for ResourceInstanceWrapperCore
2
+ ///
3
+ /// Provides the same public interface as WASMResourceInstanceWrapper so that
4
+ /// the TypeScript ViewModel layer (ResourceInstanceWrapper / PseudoValue / PseudoList)
5
+ /// can work with either backend.
6
+ use std::collections::HashMap;
7
+ use std::sync::Arc;
8
+
9
+ use napi::bindgen_prelude::*;
10
+ use napi_derive::napi;
11
+
12
+ use std::collections::HashSet;
13
+
14
+ use alizarin_core::extension_type_registry::ExtensionTypeRegistry;
15
+ use alizarin_core::graph::{
16
+ StaticEdge, StaticGraph, StaticNode, StaticNodegroup, StaticResource, StaticTile,
17
+ };
18
+ use alizarin_core::instance_wrapper_core::ModelAccess;
19
+ use alizarin_core::instance_wrapper_core::{
20
+ LoadState, ResourceInstanceWrapperCore, SemanticChildError, SemanticChildResult,
21
+ };
22
+ use alizarin_core::node_config::NodeConfigManager;
23
+ use alizarin_core::permissions::PermissionRule;
24
+ use alizarin_core::pseudo_value_core::{PseudoListCore, PseudoValueCore, VisitorContext};
25
+ use alizarin_core::rdm_cache::RdmCache;
26
+ use alizarin_core::type_serialization::{SerializationContext, SerializationOptions};
27
+ use alizarin_core::GraphModelAccess;
28
+
29
+ // =============================================================================
30
+ // Helpers
31
+ // =============================================================================
32
+
33
+ fn ext_registry() -> &'static ExtensionTypeRegistry {
34
+ crate::extension_registry()
35
+ }
36
+
37
+ fn sc_err(e: SemanticChildError) -> napi::Error {
38
+ napi::Error::from_reason(e.to_string())
39
+ }
40
+
41
+ // =============================================================================
42
+ // NapiRdmCache
43
+ // =============================================================================
44
+
45
+ #[napi]
46
+ pub struct NapiRdmCache {
47
+ inner: RdmCache,
48
+ }
49
+
50
+ #[napi]
51
+ impl NapiRdmCache {
52
+ #[napi(constructor)]
53
+ pub fn new() -> Self {
54
+ NapiRdmCache {
55
+ inner: RdmCache::new(),
56
+ }
57
+ }
58
+
59
+ /// Load collections from a SKOS JSON string.
60
+ #[napi]
61
+ pub fn load_from_skos_json(&mut self, json_str: String) -> Result<()> {
62
+ let collections: Vec<alizarin_core::skos::SkosCollection> = serde_json::from_str(&json_str)
63
+ .map_err(|e| napi::Error::from_reason(format!("Invalid SKOS JSON: {e}")))?;
64
+ for collection in collections {
65
+ let rdm_col = alizarin_core::skos_to_rdm_collection(&collection);
66
+ self.inner.add_collection(rdm_col);
67
+ }
68
+ Ok(())
69
+ }
70
+
71
+ /// Load a single collection from JSON.
72
+ #[napi]
73
+ pub fn load_collection_json(&mut self, json_str: String) -> Result<()> {
74
+ let collection: alizarin_core::skos::SkosCollection = serde_json::from_str(&json_str)
75
+ .map_err(|e| napi::Error::from_reason(format!("Invalid collection JSON: {e}")))?;
76
+ let rdm_col = alizarin_core::skos_to_rdm_collection(&collection);
77
+ self.inner.add_collection(rdm_col);
78
+ Ok(())
79
+ }
80
+
81
+ #[napi(getter)]
82
+ pub fn collection_count(&self) -> u32 {
83
+ self.inner.len() as u32
84
+ }
85
+ }
86
+
87
+ // =============================================================================
88
+ // NapiNodeConfigManager
89
+ // =============================================================================
90
+
91
+ #[napi]
92
+ pub struct NapiNodeConfigManager {
93
+ inner: NodeConfigManager,
94
+ }
95
+
96
+ #[napi]
97
+ impl NapiNodeConfigManager {
98
+ #[napi(constructor)]
99
+ pub fn new() -> Self {
100
+ NapiNodeConfigManager {
101
+ inner: NodeConfigManager::new(),
102
+ }
103
+ }
104
+
105
+ /// Build node configs from a graph JSON string.
106
+ #[napi]
107
+ pub fn build_from_graph_json(&mut self, graph_json: String) -> Result<()> {
108
+ self.inner
109
+ .from_graph_json(&graph_json)
110
+ .map_err(napi::Error::from_reason)
111
+ }
112
+
113
+ /// Build node configs from a NapiStaticGraph.
114
+ #[napi]
115
+ pub fn build_from_graph(&mut self, graph: &crate::NapiStaticGraph) -> Result<()> {
116
+ self.inner.build_from_graph(graph.inner_ref());
117
+ Ok(())
118
+ }
119
+ }
120
+
121
+ // =============================================================================
122
+ // NapiPseudoValue
123
+ // =============================================================================
124
+
125
+ #[napi]
126
+ pub struct NapiPseudoValue {
127
+ inner: PseudoValueCore,
128
+ }
129
+
130
+ #[napi]
131
+ impl NapiPseudoValue {
132
+ // -- Node metadata getters --
133
+
134
+ #[napi(getter)]
135
+ pub fn node(&self) -> serde_json::Value {
136
+ serde_json::to_value(&*self.inner.node).unwrap_or(serde_json::Value::Null)
137
+ }
138
+
139
+ #[napi(getter)]
140
+ pub fn node_id(&self) -> Option<String> {
141
+ Some(self.inner.node.nodeid.clone())
142
+ }
143
+
144
+ #[napi(getter)]
145
+ pub fn node_alias(&self) -> Option<String> {
146
+ self.inner.node.alias.clone()
147
+ }
148
+
149
+ #[napi(getter)]
150
+ pub fn datatype(&self) -> String {
151
+ self.inner.node.datatype.clone()
152
+ }
153
+
154
+ #[napi(getter)]
155
+ pub fn nodegroup_id(&self) -> Option<String> {
156
+ self.inner.node.nodegroup_id.clone()
157
+ }
158
+
159
+ #[napi(getter)]
160
+ pub fn is_collector(&self) -> bool {
161
+ self.inner.is_collector
162
+ }
163
+
164
+ #[napi(getter)]
165
+ pub fn independent(&self) -> bool {
166
+ self.inner.independent
167
+ }
168
+
169
+ #[napi(getter)]
170
+ pub fn exportable(&self) -> bool {
171
+ self.inner.node.exportable
172
+ }
173
+
174
+ #[napi(getter)]
175
+ pub fn isrequired(&self) -> bool {
176
+ self.inner.node.isrequired
177
+ }
178
+
179
+ #[napi(getter)]
180
+ pub fn issearchable(&self) -> bool {
181
+ self.inner.node.issearchable
182
+ }
183
+
184
+ #[napi(getter)]
185
+ pub fn ontologyclass(&self) -> serde_json::Value {
186
+ serde_json::to_value(&self.inner.node.ontologyclass).unwrap_or(serde_json::Value::Null)
187
+ }
188
+
189
+ #[napi(getter)]
190
+ pub fn hascustomalias(&self) -> bool {
191
+ self.inner.node.hascustomalias
192
+ }
193
+
194
+ #[napi(getter)]
195
+ pub fn parentproperty(&self) -> Option<String> {
196
+ self.inner.node.parentproperty.clone()
197
+ }
198
+
199
+ #[napi(getter)]
200
+ pub fn description(&self) -> serde_json::Value {
201
+ serde_json::to_value(&self.inner.node.description).unwrap_or(serde_json::Value::Null)
202
+ }
203
+
204
+ #[napi(getter)]
205
+ pub fn name(&self) -> serde_json::Value {
206
+ serde_json::to_value(&self.inner.node.name).unwrap_or(serde_json::Value::Null)
207
+ }
208
+
209
+ // -- Tile data getters --
210
+
211
+ #[napi(getter)]
212
+ pub fn tile_id(&self) -> Option<String> {
213
+ self.inner.tile.as_ref().and_then(|t| t.tileid.clone())
214
+ }
215
+
216
+ #[napi(getter)]
217
+ pub fn tile_data(&self) -> serde_json::Value {
218
+ self.inner
219
+ .tile_data
220
+ .clone()
221
+ .unwrap_or(serde_json::Value::Null)
222
+ }
223
+
224
+ #[napi(getter)]
225
+ pub fn value_loaded(&self) -> bool {
226
+ self.inner.tile.is_some()
227
+ }
228
+
229
+ #[napi]
230
+ pub fn has_tile_data(&self) -> bool {
231
+ self.inner.tile_data.is_some()
232
+ }
233
+
234
+ #[napi]
235
+ pub fn set_tile_data(&mut self, value: serde_json::Value) {
236
+ self.inner.tile_data = Some(value);
237
+ }
238
+
239
+ // -- Relationship getters --
240
+
241
+ #[napi(getter)]
242
+ pub fn child_node_aliases(&self) -> serde_json::Value {
243
+ serde_json::to_value(&self.inner.child_node_ids).unwrap_or(serde_json::Value::Null)
244
+ }
245
+
246
+ #[napi]
247
+ pub fn get_child_node_id(&self, index: u32) -> Option<String> {
248
+ self.inner.child_node_ids.get(index as usize).cloned()
249
+ }
250
+
251
+ #[napi]
252
+ pub fn is_iterable(&self) -> bool {
253
+ alizarin_core::is_iterable_datatype(&self.inner.node.datatype)
254
+ }
255
+
256
+ /// Get sortorder from the tile
257
+ #[napi(getter)]
258
+ pub fn sortorder(&self) -> serde_json::Value {
259
+ self.inner
260
+ .tile
261
+ .as_ref()
262
+ .and_then(|t| t.sortorder)
263
+ .map(|s| serde_json::Value::Number(s.into()))
264
+ .unwrap_or(serde_json::Value::Null)
265
+ }
266
+
267
+ /// Snapshot all properties in one call (avoids multiple N-API boundary crossings)
268
+ #[napi]
269
+ pub fn to_snapshot(&self) -> serde_json::Value {
270
+ serde_json::json!({
271
+ "nodeId": self.inner.node.nodeid,
272
+ "alias": self.inner.node.alias,
273
+ "datatype": &self.inner.node.datatype,
274
+ "nodegroupId": self.inner.node.nodegroup_id,
275
+ "isCollector": self.inner.is_collector,
276
+ "independent": self.inner.independent,
277
+ "exportable": self.inner.node.exportable,
278
+ "isrequired": self.inner.node.isrequired,
279
+ "issearchable": self.inner.node.issearchable,
280
+ "hascustomalias": self.inner.node.hascustomalias,
281
+ "tileId": self.inner.tile.as_ref().and_then(|t| t.tileid.clone()),
282
+ "tileData": &self.inner.tile_data,
283
+ "valueLoaded": self.inner.tile.is_some(),
284
+ "childNodeIds": &self.inner.child_node_ids,
285
+ })
286
+ }
287
+
288
+ /// Clear tile data
289
+ #[napi]
290
+ pub fn clear(&mut self) {
291
+ self.inner.tile_data = None;
292
+ }
293
+ }
294
+
295
+ // =============================================================================
296
+ // NapiPseudoList
297
+ // =============================================================================
298
+
299
+ #[napi]
300
+ pub struct NapiPseudoList {
301
+ inner: PseudoListCore,
302
+ }
303
+
304
+ #[napi]
305
+ impl NapiPseudoList {
306
+ #[napi(getter)]
307
+ pub fn node_alias(&self) -> String {
308
+ self.inner.node_alias.clone()
309
+ }
310
+
311
+ #[napi(getter)]
312
+ pub fn total_values(&self) -> u32 {
313
+ self.inner.values.len() as u32
314
+ }
315
+
316
+ #[napi(getter)]
317
+ pub fn is_loaded(&self) -> bool {
318
+ self.inner.is_loaded
319
+ }
320
+
321
+ #[napi(getter)]
322
+ pub fn is_single(&self) -> bool {
323
+ self.inner.is_single
324
+ }
325
+
326
+ #[napi]
327
+ pub fn is_iterable(&self) -> bool {
328
+ !self.inner.is_single
329
+ }
330
+
331
+ #[napi]
332
+ pub fn get_value(&self, value_index: u32) -> Option<NapiPseudoValue> {
333
+ self.inner
334
+ .values
335
+ .get(value_index as usize)
336
+ .map(|v| NapiPseudoValue { inner: v.clone() })
337
+ }
338
+
339
+ #[napi]
340
+ pub fn get_all_values(&self) -> Vec<NapiPseudoValue> {
341
+ self.inner
342
+ .values
343
+ .iter()
344
+ .map(|v| NapiPseudoValue { inner: v.clone() })
345
+ .collect()
346
+ }
347
+ }
348
+
349
+ // =============================================================================
350
+ // NapiPopulateResult
351
+ // =============================================================================
352
+
353
+ #[napi]
354
+ pub struct NapiPopulateResult {
355
+ values: HashMap<String, PseudoListCore>,
356
+ all_values_map: HashMap<String, Option<bool>>,
357
+ all_nodegroups_map: HashMap<String, bool>,
358
+ }
359
+
360
+ #[napi]
361
+ impl NapiPopulateResult {
362
+ #[napi]
363
+ pub fn get_value_aliases(&self) -> Vec<String> {
364
+ self.values.keys().cloned().collect()
365
+ }
366
+
367
+ #[napi]
368
+ pub fn get_value(&self, alias: String) -> Option<NapiPseudoList> {
369
+ self.values
370
+ .get(&alias)
371
+ .map(|l| NapiPseudoList { inner: l.clone() })
372
+ }
373
+
374
+ /// Get all values as a JSON map (single boundary crossing).
375
+ #[napi]
376
+ pub fn get_all_values(&self) -> serde_json::Value {
377
+ let mut map = serde_json::Map::new();
378
+ for (alias, list) in &self.values {
379
+ map.insert(
380
+ alias.clone(),
381
+ serde_json::json!({
382
+ "nodeAlias": list.node_alias,
383
+ "isSingle": list.is_single,
384
+ "isLoaded": list.is_loaded,
385
+ "totalValues": list.values.len(),
386
+ }),
387
+ );
388
+ }
389
+ serde_json::Value::Object(map)
390
+ }
391
+
392
+ #[napi(getter)]
393
+ pub fn all_values_map(&self) -> serde_json::Value {
394
+ serde_json::to_value(&self.all_values_map).unwrap_or(serde_json::Value::Null)
395
+ }
396
+
397
+ #[napi(getter)]
398
+ pub fn all_nodegroups_map(&self) -> serde_json::Value {
399
+ serde_json::to_value(&self.all_nodegroups_map).unwrap_or(serde_json::Value::Null)
400
+ }
401
+ }
402
+
403
+ // =============================================================================
404
+ // NapiEnsureNodegroupResult
405
+ // =============================================================================
406
+
407
+ #[napi]
408
+ pub struct NapiEnsureNodegroupResult {
409
+ values: HashMap<String, PseudoListCore>,
410
+ implied_nodegroups: Vec<String>,
411
+ all_nodegroups_map: HashMap<String, bool>,
412
+ }
413
+
414
+ #[napi]
415
+ impl NapiEnsureNodegroupResult {
416
+ #[napi]
417
+ pub fn get_value_aliases(&self) -> Vec<String> {
418
+ self.values.keys().cloned().collect()
419
+ }
420
+
421
+ #[napi]
422
+ pub fn get_value(&self, alias: String) -> Option<NapiPseudoList> {
423
+ self.values
424
+ .get(&alias)
425
+ .map(|l| NapiPseudoList { inner: l.clone() })
426
+ }
427
+
428
+ #[napi]
429
+ pub fn get_all_values(&self) -> serde_json::Value {
430
+ let mut map = serde_json::Map::new();
431
+ for (alias, list) in &self.values {
432
+ map.insert(
433
+ alias.clone(),
434
+ serde_json::json!({
435
+ "nodeAlias": list.node_alias,
436
+ "isSingle": list.is_single,
437
+ "isLoaded": list.is_loaded,
438
+ "totalValues": list.values.len(),
439
+ }),
440
+ );
441
+ }
442
+ serde_json::Value::Object(map)
443
+ }
444
+
445
+ #[napi(getter)]
446
+ pub fn implied_nodegroups(&self) -> Vec<String> {
447
+ self.implied_nodegroups.clone()
448
+ }
449
+
450
+ #[napi(getter)]
451
+ pub fn all_nodegroups_map(&self) -> serde_json::Value {
452
+ serde_json::to_value(&self.all_nodegroups_map).unwrap_or(serde_json::Value::Null)
453
+ }
454
+ }
455
+
456
+ // =============================================================================
457
+ // NapiValuesFromNodegroupResult
458
+ // =============================================================================
459
+
460
+ #[napi]
461
+ pub struct NapiValuesFromNodegroupResult {
462
+ values: HashMap<String, PseudoListCore>,
463
+ implied_nodegroups: Vec<String>,
464
+ }
465
+
466
+ #[napi]
467
+ impl NapiValuesFromNodegroupResult {
468
+ #[napi]
469
+ pub fn get_all_values(&self) -> serde_json::Value {
470
+ let mut map = serde_json::Map::new();
471
+ for (alias, list) in &self.values {
472
+ map.insert(
473
+ alias.clone(),
474
+ serde_json::json!({
475
+ "nodeAlias": list.node_alias,
476
+ "isSingle": list.is_single,
477
+ "isLoaded": list.is_loaded,
478
+ "totalValues": list.values.len(),
479
+ }),
480
+ );
481
+ }
482
+ serde_json::Value::Object(map)
483
+ }
484
+
485
+ #[napi(getter)]
486
+ pub fn implied_nodegroups(&self) -> Vec<String> {
487
+ self.implied_nodegroups.clone()
488
+ }
489
+ }
490
+
491
+ // =============================================================================
492
+ // NapiResourceInstanceWrapper
493
+ // =============================================================================
494
+
495
+ #[napi]
496
+ pub struct NapiResourceInstanceWrapper {
497
+ inner: ResourceInstanceWrapperCore,
498
+ model_access: GraphModelAccess,
499
+ lazy: bool,
500
+ }
501
+
502
+ #[napi]
503
+ impl NapiResourceInstanceWrapper {
504
+ // =========================================================================
505
+ // Construction
506
+ // =========================================================================
507
+
508
+ /// Create a wrapper for a given graph (must be registered).
509
+ #[napi(constructor)]
510
+ pub fn new(graph_id: String) -> Result<Self> {
511
+ let graph = alizarin_core::get_graph(&graph_id).ok_or_else(|| {
512
+ napi::Error::from_reason(format!(
513
+ "Graph '{}' not registered. Call registerGraph() first.",
514
+ graph_id
515
+ ))
516
+ })?;
517
+
518
+ let model_access = GraphModelAccess::from_graph(&graph);
519
+ let mut core = ResourceInstanceWrapperCore::new(graph_id);
520
+ core.set_cached_indices(&model_access);
521
+
522
+ Ok(NapiResourceInstanceWrapper {
523
+ inner: core,
524
+ model_access,
525
+ lazy: false,
526
+ })
527
+ }
528
+
529
+ // =========================================================================
530
+ // Tile loading
531
+ // =========================================================================
532
+
533
+ /// Load tiles from a JSON array.
534
+ #[napi]
535
+ pub fn load_tiles(&mut self, tiles_js: serde_json::Value) -> Result<()> {
536
+ let tiles: Vec<StaticTile> = serde_json::from_value(tiles_js)
537
+ .map_err(|e| napi::Error::from_reason(format!("Invalid tiles JSON: {e}")))?;
538
+ self.inner.load_tiles(tiles);
539
+ Ok(())
540
+ }
541
+
542
+ /// Load tiles directly from a StaticResource JSON.
543
+ #[napi]
544
+ pub fn load_tiles_from_resource(&mut self, resource_js: serde_json::Value) -> Result<()> {
545
+ let resource: StaticResource = serde_json::from_value(resource_js)
546
+ .map_err(|e| napi::Error::from_reason(format!("Invalid resource JSON: {e}")))?;
547
+
548
+ self.inner.resource_instance = Some(resource.resourceinstance.clone());
549
+
550
+ if let Some(tiles_vec) = resource.tiles {
551
+ self.inner.load_tiles(tiles_vec);
552
+ }
553
+ Ok(())
554
+ }
555
+
556
+ /// Append tiles incrementally (for lazy loading).
557
+ #[napi]
558
+ pub fn append_tiles(&mut self, tiles_js: serde_json::Value) -> Result<()> {
559
+ let tiles: Vec<StaticTile> = serde_json::from_value(tiles_js)
560
+ .map_err(|e| napi::Error::from_reason(format!("Invalid tiles JSON: {e}")))?;
561
+
562
+ if self.inner.tiles.is_none() {
563
+ self.inner.tiles = Some(HashMap::new());
564
+ }
565
+
566
+ let tiles_map = self.inner.tiles.as_mut().unwrap();
567
+ for tile in tiles {
568
+ let tile_id = tile
569
+ .tileid
570
+ .clone()
571
+ .unwrap_or_else(|| format!("synthetic_{}", tiles_map.len()));
572
+ self.inner
573
+ .nodegroup_index
574
+ .entry(tile.nodegroup_id.clone())
575
+ .or_default()
576
+ .push(tile_id.clone());
577
+ tiles_map.insert(tile_id, tile);
578
+ }
579
+ Ok(())
580
+ }
581
+
582
+ #[napi]
583
+ pub fn get_tile_count(&self) -> u32 {
584
+ self.inner.tiles.as_ref().map(|t| t.len()).unwrap_or(0) as u32
585
+ }
586
+
587
+ /// Export all tiles as a JSON string.
588
+ /// Returns the wrapper's current tile state (including any mutations from setTileDataForNode).
589
+ /// Returned as a string for fast boundary crossing — call JSON.parse() on the JS side.
590
+ #[napi]
591
+ pub fn export_tiles_json(&self) -> Result<String> {
592
+ let tiles: Vec<&StaticTile> = self
593
+ .inner
594
+ .tiles
595
+ .as_ref()
596
+ .map(|t| t.values().collect())
597
+ .unwrap_or_default();
598
+ serde_json::to_string(&tiles).map_err(|e| napi::Error::from_reason(e.to_string()))
599
+ }
600
+
601
+ /// Prune tiles to only keep those in permitted nodegroups.
602
+ #[napi(js_name = "pruneResourceTiles")]
603
+ pub fn prune_resource_tiles(&mut self) -> Result<()> {
604
+ let tiles = self
605
+ .inner
606
+ .tiles
607
+ .take()
608
+ .ok_or_else(|| napi::Error::from_reason("Tiles not initialized".to_string()))?;
609
+ let pruned: HashMap<String, StaticTile> = tiles
610
+ .into_iter()
611
+ .filter(|(_id, tile)| self.model_access.is_nodegroup_permitted(&tile.nodegroup_id))
612
+ .collect();
613
+ self.inner.tiles = Some(pruned);
614
+ Ok(())
615
+ }
616
+
617
+ #[napi]
618
+ pub fn tiles_loaded(&self) -> bool {
619
+ self.inner
620
+ .tiles
621
+ .as_ref()
622
+ .map(|t| !self.lazy || !t.is_empty())
623
+ .unwrap_or(false)
624
+ }
625
+
626
+ #[napi]
627
+ pub fn get_all_tile_ids(&self) -> Vec<String> {
628
+ self.inner
629
+ .tiles
630
+ .as_ref()
631
+ .map(|t| t.keys().cloned().collect())
632
+ .unwrap_or_default()
633
+ }
634
+
635
+ #[napi]
636
+ pub fn get_tile_ids_by_nodegroup(&self, nodegroup_id: Option<String>) -> Vec<String> {
637
+ match nodegroup_id {
638
+ Some(ng_id) => self
639
+ .inner
640
+ .nodegroup_index
641
+ .get(&ng_id)
642
+ .cloned()
643
+ .unwrap_or_default(),
644
+ None => Vec::new(),
645
+ }
646
+ }
647
+
648
+ #[napi]
649
+ pub fn get_tile(&self, tile_id: String) -> Result<serde_json::Value> {
650
+ let tile = self
651
+ .inner
652
+ .get_tile(&tile_id)
653
+ .ok_or_else(|| napi::Error::from_reason(format!("Tile '{}' not found", tile_id)))?;
654
+ serde_json::to_value(tile).map_err(|e| napi::Error::from_reason(e.to_string()))
655
+ }
656
+
657
+ #[napi]
658
+ pub fn get_tile_data(&self, tile_id: String, node_id: String) -> serde_json::Value {
659
+ self.inner
660
+ .get_tile(&tile_id)
661
+ .and_then(|t| t.data.get(&node_id).cloned())
662
+ .unwrap_or(serde_json::Value::Null)
663
+ }
664
+
665
+ /// Set a single node's data in a tile, mutating in place.
666
+ /// Returns true if the tile was found and updated.
667
+ #[napi]
668
+ pub fn set_tile_data_for_node(
669
+ &mut self,
670
+ tile_id: String,
671
+ node_id: String,
672
+ value: serde_json::Value,
673
+ ) -> bool {
674
+ self.inner.set_tile_data_for_node(&tile_id, &node_id, value)
675
+ }
676
+
677
+ #[napi]
678
+ pub fn has_tiles_for_nodegroup(&self, nodegroup_id: String) -> bool {
679
+ if self.lazy {
680
+ self.inner.is_nodegroup_loaded(&nodegroup_id)
681
+ } else {
682
+ self.inner
683
+ .tiles
684
+ .as_ref()
685
+ .map(|t| !t.is_empty())
686
+ .unwrap_or(false)
687
+ }
688
+ }
689
+
690
+ #[napi]
691
+ pub fn get_nodegroup_count(&self) -> u32 {
692
+ self.inner.nodegroup_index.len() as u32
693
+ }
694
+
695
+ // =========================================================================
696
+ // Lazy loading state
697
+ // =========================================================================
698
+
699
+ #[napi]
700
+ pub fn set_lazy(&mut self, lazy: bool) {
701
+ self.lazy = lazy;
702
+ if lazy && self.inner.tiles.is_none() {
703
+ self.inner.tiles = Some(HashMap::new());
704
+ }
705
+ }
706
+
707
+ #[napi]
708
+ pub fn is_nodegroup_loaded(&self, nodegroup_id: Option<String>) -> bool {
709
+ match nodegroup_id {
710
+ Some(id) => self.inner.is_nodegroup_loaded(&id),
711
+ None => false,
712
+ }
713
+ }
714
+
715
+ #[napi]
716
+ pub fn is_nodegroup_loaded_or_loading(&self, nodegroup_id: Option<String>) -> bool {
717
+ match nodegroup_id {
718
+ Some(id) => {
719
+ if let Ok(loaded) = self.inner.loaded_nodegroups.lock() {
720
+ matches!(
721
+ loaded.get(&id),
722
+ Some(LoadState::Loaded) | Some(LoadState::Loading)
723
+ )
724
+ } else {
725
+ false
726
+ }
727
+ }
728
+ None => false,
729
+ }
730
+ }
731
+
732
+ #[napi]
733
+ pub fn try_acquire_nodegroup_lock(&self, nodegroup_id: String) -> bool {
734
+ if let Ok(mut loaded) = self.inner.loaded_nodegroups.lock() {
735
+ let state = loaded.entry(nodegroup_id).or_insert(LoadState::NotLoaded);
736
+ if *state == LoadState::NotLoaded {
737
+ *state = LoadState::Loading;
738
+ true
739
+ } else {
740
+ false
741
+ }
742
+ } else {
743
+ false
744
+ }
745
+ }
746
+
747
+ #[napi]
748
+ pub fn get_missing_nodegroups_for_children(&self, parent_node_id: String) -> Vec<String> {
749
+ let child_nodes = self.model_access.get_child_nodes(&parent_node_id);
750
+
751
+ if !self.lazy {
752
+ return Vec::new();
753
+ }
754
+
755
+ let mut missing = Vec::new();
756
+ for child in child_nodes.values() {
757
+ if let Some(ng_id) = &child.nodegroup_id {
758
+ if !self.inner.is_nodegroup_loaded(ng_id) {
759
+ missing.push(ng_id.to_string());
760
+ }
761
+ }
762
+ }
763
+ missing
764
+ }
765
+
766
+ // =========================================================================
767
+ // Resource metadata
768
+ // =========================================================================
769
+
770
+ #[napi]
771
+ pub fn get_resource_id(&self) -> Option<String> {
772
+ self.inner
773
+ .resource_instance
774
+ .as_ref()
775
+ .map(|ri| ri.resourceinstanceid.clone())
776
+ }
777
+
778
+ #[napi]
779
+ pub fn get_name(&self) -> Result<serde_json::Value> {
780
+ match self.inner.resource_instance.as_ref() {
781
+ Some(ri) => serde_json::to_value(&ri.name)
782
+ .map_err(|e| napi::Error::from_reason(format!("Serialization failed: {}", e))),
783
+ None => Ok(serde_json::Value::Null),
784
+ }
785
+ }
786
+
787
+ #[napi]
788
+ pub fn get_descriptors(&self, _recompute: bool) -> Result<serde_json::Value> {
789
+ // For now return cached descriptors; recompute=true could be supported later
790
+ match self.inner.resource_instance.as_ref() {
791
+ Some(ri) => serde_json::to_value(&ri.descriptors)
792
+ .map_err(|e| napi::Error::from_reason(format!("Serialization failed: {}", e))),
793
+ None => Ok(serde_json::Value::Null),
794
+ }
795
+ }
796
+
797
+ // =========================================================================
798
+ // Populate & tree building
799
+ // =========================================================================
800
+
801
+ /// Populate the pseudo cache for the given nodegroups.
802
+ #[napi]
803
+ pub fn populate(
804
+ &self,
805
+ lazy: bool,
806
+ nodegroup_ids: Vec<String>,
807
+ root_node_alias: String,
808
+ ) -> Result<NapiPopulateResult> {
809
+ let result = self
810
+ .inner
811
+ .populate(lazy, &nodegroup_ids, &root_node_alias, &self.model_access)
812
+ .map_err(sc_err)?;
813
+
814
+ Ok(NapiPopulateResult {
815
+ values: result.values,
816
+ all_values_map: result.all_values_map,
817
+ all_nodegroups_map: result.all_nodegroups_map,
818
+ })
819
+ }
820
+
821
+ /// Ensure a single nodegroup is loaded and return structured values.
822
+ #[napi]
823
+ pub fn ensure_nodegroup(
824
+ &self,
825
+ all_values_js: serde_json::Value,
826
+ all_nodegroups_js: serde_json::Value,
827
+ nodegroup_id: String,
828
+ add_if_missing: bool,
829
+ nodegroup_permissions_js: serde_json::Value,
830
+ do_implied_nodegroups: bool,
831
+ ) -> Result<NapiEnsureNodegroupResult> {
832
+ let all_values: HashMap<String, Option<bool>> = serde_json::from_value(all_values_js)
833
+ .map_err(|e| napi::Error::from_reason(format!("Invalid all_values: {e}")))?;
834
+ let mut all_nodegroups: HashMap<String, bool> =
835
+ serde_json::from_value(all_nodegroups_js)
836
+ .map_err(|e| napi::Error::from_reason(format!("Invalid all_nodegroups: {e}")))?;
837
+ // PermissionRule is currently used only for tile filtering, and may not be
838
+ // JSON-serializable. Use the model's permissions if available.
839
+ let _ = nodegroup_permissions_js;
840
+ let nodegroup_permissions = self.model_access.get_permitted_nodegroups();
841
+
842
+ let result = self
843
+ .inner
844
+ .ensure_nodegroup(
845
+ &all_values,
846
+ &mut all_nodegroups,
847
+ &nodegroup_id,
848
+ add_if_missing,
849
+ &nodegroup_permissions,
850
+ do_implied_nodegroups,
851
+ &self.model_access,
852
+ )
853
+ .map_err(sc_err)?;
854
+
855
+ Ok(NapiEnsureNodegroupResult {
856
+ values: result.values,
857
+ implied_nodegroups: result.implied_nodegroups,
858
+ all_nodegroups_map: result.all_nodegroups_map,
859
+ })
860
+ }
861
+
862
+ /// Build pseudo values from tiles for a specific nodegroup.
863
+ #[napi]
864
+ pub fn values_from_resource_nodegroup(
865
+ &self,
866
+ existing_values_js: serde_json::Value,
867
+ nodegroup_tile_ids: Vec<String>,
868
+ nodegroup_id: String,
869
+ ) -> Result<NapiValuesFromNodegroupResult> {
870
+ let existing_values: HashMap<String, Option<bool>> =
871
+ serde_json::from_value(existing_values_js)
872
+ .map_err(|e| napi::Error::from_reason(format!("Invalid existing_values: {e}")))?;
873
+
874
+ let result = self
875
+ .inner
876
+ .values_from_resource_nodegroup(
877
+ &existing_values,
878
+ &nodegroup_tile_ids,
879
+ &nodegroup_id,
880
+ &self.model_access,
881
+ )
882
+ .map_err(sc_err)?;
883
+
884
+ Ok(NapiValuesFromNodegroupResult {
885
+ values: result.values,
886
+ implied_nodegroups: result.implied_nodegroups,
887
+ })
888
+ }
889
+
890
+ // =========================================================================
891
+ // Path resolution
892
+ // =========================================================================
893
+
894
+ /// Resolve a dot-separated path and return a PseudoList.
895
+ #[napi]
896
+ pub fn get_values_at_path(&self, path: String) -> Result<NapiPseudoList> {
897
+ let result = self
898
+ .inner
899
+ .get_values_at_path(&path, &self.model_access, None)
900
+ .map_err(|e| napi::Error::from_reason(e.to_string()))?;
901
+ Ok(NapiPseudoList { inner: result })
902
+ }
903
+
904
+ // =========================================================================
905
+ // Semantic child traversal
906
+ // =========================================================================
907
+
908
+ /// Get semantic child value for a parent/child relationship.
909
+ #[napi]
910
+ pub fn get_semantic_child_value(
911
+ &self,
912
+ parent_tile_id: Option<String>,
913
+ parent_node_id: String,
914
+ parent_nodegroup_id: Option<String>,
915
+ child_alias: String,
916
+ ) -> Result<serde_json::Value> {
917
+ let result = self
918
+ .inner
919
+ .get_semantic_child_value(
920
+ parent_tile_id.as_ref(),
921
+ &parent_node_id,
922
+ parent_nodegroup_id.as_ref(),
923
+ &child_alias,
924
+ &self.model_access,
925
+ )
926
+ .map_err(sc_err)?;
927
+
928
+ match result {
929
+ SemanticChildResult::List(list) => {
930
+ Ok(serde_json::to_value(NapiPseudoListJson::from(&list))
931
+ .unwrap_or(serde_json::Value::Null))
932
+ }
933
+ SemanticChildResult::Single(val) => {
934
+ Ok(serde_json::to_value(NapiPseudoValueJson::from(&val))
935
+ .unwrap_or(serde_json::Value::Null))
936
+ }
937
+ SemanticChildResult::Empty => Ok(serde_json::Value::Null),
938
+ }
939
+ }
940
+
941
+ /// Get all semantic child values for a parent node.
942
+ #[napi]
943
+ pub fn get_all_semantic_child_values(
944
+ &self,
945
+ parent_tile_id: Option<String>,
946
+ parent_node_id: String,
947
+ parent_nodegroup_id: Option<String>,
948
+ ) -> Result<serde_json::Value> {
949
+ // Get child nodes
950
+ let child_nodes = self.model_access.get_child_nodes(&parent_node_id);
951
+
952
+ let mut results = serde_json::Map::new();
953
+ for child in child_nodes.values() {
954
+ if let Some(ref alias) = child.alias {
955
+ let result = self.inner.get_semantic_child_value(
956
+ parent_tile_id.as_ref(),
957
+ &parent_node_id,
958
+ parent_nodegroup_id.as_ref(),
959
+ alias,
960
+ &self.model_access,
961
+ );
962
+ match result {
963
+ Ok(SemanticChildResult::List(list)) => {
964
+ results.insert(
965
+ alias.clone(),
966
+ serde_json::to_value(NapiPseudoListJson::from(&list))
967
+ .unwrap_or(serde_json::Value::Null),
968
+ );
969
+ }
970
+ Ok(SemanticChildResult::Single(val)) => {
971
+ results.insert(
972
+ alias.clone(),
973
+ serde_json::to_value(NapiPseudoValueJson::from(&val))
974
+ .unwrap_or(serde_json::Value::Null),
975
+ );
976
+ }
977
+ Ok(SemanticChildResult::Empty) => {
978
+ results.insert(alias.clone(), serde_json::Value::Null);
979
+ }
980
+ Err(_) => {
981
+ results.insert(alias.clone(), serde_json::Value::Null);
982
+ }
983
+ }
984
+ }
985
+ }
986
+ Ok(serde_json::Value::Object(results))
987
+ }
988
+
989
+ /// Find all semantic children of a parent node (returns map of alias → tile IDs).
990
+ #[napi]
991
+ pub fn find_semantic_children(
992
+ &self,
993
+ parent_tile_id: Option<String>,
994
+ parent_node_id: String,
995
+ parent_nodegroup_id: Option<String>,
996
+ ) -> Result<serde_json::Value> {
997
+ let child_nodes = self.model_access.get_child_nodes(&parent_node_id);
998
+
999
+ let tiles = self
1000
+ .inner
1001
+ .tiles
1002
+ .as_ref()
1003
+ .ok_or_else(|| napi::Error::from_reason("Tiles not initialized"))?;
1004
+
1005
+ let mut results = serde_json::Map::new();
1006
+ for child in child_nodes.values() {
1007
+ if let Some(ref alias) = child.alias {
1008
+ let matching_tile_ids: Vec<String> = tiles
1009
+ .iter()
1010
+ .filter(|(_, tile)| {
1011
+ alizarin_core::matches_semantic_child(
1012
+ parent_tile_id.as_ref(),
1013
+ parent_nodegroup_id.as_ref(),
1014
+ child,
1015
+ tile,
1016
+ )
1017
+ })
1018
+ .filter_map(|(_, tile)| tile.tileid.clone())
1019
+ .collect();
1020
+ results.insert(
1021
+ alias.clone(),
1022
+ serde_json::to_value(&matching_tile_ids).unwrap(),
1023
+ );
1024
+ }
1025
+ }
1026
+ Ok(serde_json::Value::Object(results))
1027
+ }
1028
+
1029
+ // =========================================================================
1030
+ // Pseudo cache management
1031
+ // =========================================================================
1032
+
1033
+ #[napi]
1034
+ pub fn get_cached_pseudo(&self, alias: Option<String>) -> Option<NapiPseudoList> {
1035
+ let alias = alias?;
1036
+ self.inner
1037
+ .get_cached_pseudo(&alias)
1038
+ .map(|l| NapiPseudoList { inner: l })
1039
+ }
1040
+
1041
+ #[napi]
1042
+ pub fn get_root_pseudo(&self) -> Option<NapiPseudoValue> {
1043
+ let root = self.model_access.get_root_node().ok()?;
1044
+ let alias = root.alias.as_deref()?;
1045
+ let cached = self.inner.get_cached_pseudo(alias)?;
1046
+ cached
1047
+ .values
1048
+ .first()
1049
+ .map(|v| NapiPseudoValue { inner: v.clone() })
1050
+ }
1051
+
1052
+ #[napi]
1053
+ pub fn cache_pseudo_list(&self, alias: String, list: &NapiPseudoList) {
1054
+ self.inner.store_pseudo(alias, list.inner.clone());
1055
+ }
1056
+
1057
+ #[napi]
1058
+ pub fn cache_pseudo_value(&self, alias: String, value: &NapiPseudoValue) {
1059
+ let list = PseudoListCore::from_values_with_cardinality(
1060
+ alias.clone(),
1061
+ vec![value.inner.clone()],
1062
+ true,
1063
+ );
1064
+ self.inner.store_pseudo(alias, list);
1065
+ }
1066
+
1067
+ #[napi]
1068
+ pub fn clear_pseudo_cache(&self) {
1069
+ if let Ok(mut cache) = self.inner.pseudo_cache.lock() {
1070
+ cache.clear();
1071
+ }
1072
+ }
1073
+
1074
+ /// Construct a PseudoValue from node metadata (for TS wrapper).
1075
+ #[napi]
1076
+ pub fn make_pseudo_value(
1077
+ &self,
1078
+ alias: String,
1079
+ tile_id: Option<String>,
1080
+ is_permitted: bool,
1081
+ is_single: bool,
1082
+ ) -> Result<serde_json::Value> {
1083
+ // Find node by alias
1084
+ let nodes = self
1085
+ .model_access
1086
+ .get_nodes()
1087
+ .ok_or_else(|| napi::Error::from_reason("Nodes not initialized"))?;
1088
+
1089
+ let node = nodes
1090
+ .values()
1091
+ .find(|n| n.alias.as_deref() == Some(&alias))
1092
+ .ok_or_else(|| {
1093
+ napi::Error::from_reason(format!("Node with alias '{}' not found", alias))
1094
+ })?;
1095
+
1096
+ let edges = self
1097
+ .model_access
1098
+ .get_edges()
1099
+ .ok_or_else(|| napi::Error::from_reason("Edges not initialized"))?;
1100
+ let child_node_ids = edges.get(&node.nodeid).cloned().unwrap_or_default();
1101
+
1102
+ // Get tile if tile_id provided
1103
+ let tile = tile_id
1104
+ .as_ref()
1105
+ .and_then(|tid| self.inner.get_tile(tid))
1106
+ .map(|t| Arc::new(t.clone()));
1107
+
1108
+ let tile_data = tile
1109
+ .as_ref()
1110
+ .and_then(|t| t.data.get(&node.nodeid).cloned());
1111
+
1112
+ let value =
1113
+ PseudoValueCore::from_node_and_tile(Arc::clone(node), tile, tile_data, child_node_ids);
1114
+
1115
+ // Return as JSON with metadata about how to wrap it
1116
+ Ok(serde_json::json!({
1117
+ "alias": alias,
1118
+ "isSingle": is_single,
1119
+ "isPermitted": is_permitted,
1120
+ "value": NapiPseudoValueJson::from(&value),
1121
+ }))
1122
+ }
1123
+
1124
+ // =========================================================================
1125
+ // JSON serialization
1126
+ // =========================================================================
1127
+
1128
+ /// Serialize to JSON (tile_data mode — raw values).
1129
+ #[napi]
1130
+ pub fn to_json(&self) -> Result<serde_json::Value> {
1131
+ self.serialize_with_options(SerializationOptions::tile_data(), None, None, None)
1132
+ }
1133
+
1134
+ /// Serialize to display JSON (resolved labels).
1135
+ #[napi]
1136
+ pub fn to_display_json(
1137
+ &self,
1138
+ rdm_cache: &NapiRdmCache,
1139
+ node_config_manager: &NapiNodeConfigManager,
1140
+ language: Option<String>,
1141
+ ) -> Result<serde_json::Value> {
1142
+ let lang = language.unwrap_or_else(|| "en".to_string());
1143
+ self.serialize_with_options(
1144
+ SerializationOptions::display(&lang),
1145
+ Some(&rdm_cache.inner),
1146
+ Some(&node_config_manager.inner),
1147
+ None,
1148
+ )
1149
+ }
1150
+
1151
+ /// Serialize to display JSON without RDM/config (basic labels only).
1152
+ #[napi]
1153
+ pub fn to_display_json_simple(&self, language: Option<String>) -> Result<serde_json::Value> {
1154
+ let lang = language.unwrap_or_else(|| "en".to_string());
1155
+ self.serialize_with_options(SerializationOptions::display(&lang), None, None, None)
1156
+ }
1157
+
1158
+ // =========================================================================
1159
+ // Cleanup
1160
+ // =========================================================================
1161
+
1162
+ #[napi]
1163
+ pub fn release(&mut self) {
1164
+ self.inner.tiles = None;
1165
+ self.inner.nodegroup_index.clear();
1166
+ self.clear_pseudo_cache();
1167
+ if let Ok(mut loaded) = self.inner.loaded_nodegroups.lock() {
1168
+ loaded.clear();
1169
+ }
1170
+ }
1171
+ }
1172
+
1173
+ // =============================================================================
1174
+ // Private helpers
1175
+ // =============================================================================
1176
+
1177
+ impl NapiResourceInstanceWrapper {
1178
+ fn serialize_with_options(
1179
+ &self,
1180
+ options: SerializationOptions,
1181
+ rdm: Option<&RdmCache>,
1182
+ ncm: Option<&NodeConfigManager>,
1183
+ resource_registry: Option<&alizarin_core::StaticResourceRegistry>,
1184
+ ) -> Result<serde_json::Value> {
1185
+ let cache = self
1186
+ .inner
1187
+ .pseudo_cache
1188
+ .lock()
1189
+ .map_err(|_| napi::Error::from_reason("Failed to lock pseudo cache"))?;
1190
+
1191
+ // Get root alias
1192
+ let root_node = self
1193
+ .model_access
1194
+ .get_root_node()
1195
+ .map_err(napi::Error::from_reason)?;
1196
+ let root_alias = root_node
1197
+ .alias
1198
+ .as_deref()
1199
+ .ok_or_else(|| napi::Error::from_reason("Root node has no alias"))?;
1200
+
1201
+ let root_list = cache.get(root_alias).ok_or_else(|| {
1202
+ napi::Error::from_reason("Root pseudo not found — call populate() first")
1203
+ })?;
1204
+
1205
+ // Build graph indices for VisitorContext
1206
+ let nodes_by_alias = self
1207
+ .model_access
1208
+ .get_nodes_by_alias_arc()
1209
+ .ok_or_else(|| napi::Error::from_reason("Nodes by alias not built"))?;
1210
+ let edges = self
1211
+ .model_access
1212
+ .get_edges_arc()
1213
+ .ok_or_else(|| napi::Error::from_reason("Edges not built"))?;
1214
+
1215
+ let ext_reg = ext_registry();
1216
+
1217
+ let ser_ctx = SerializationContext {
1218
+ node_config: None,
1219
+ external_resolver: rdm
1220
+ .map(|r| r as &dyn alizarin_core::type_serialization::ExternalResolver),
1221
+ resource_resolver: resource_registry
1222
+ .map(|r| r as &dyn alizarin_core::type_serialization::ResourceDisplayResolver),
1223
+ extension_registry: Some(ext_reg),
1224
+ };
1225
+
1226
+ let ctx = VisitorContext {
1227
+ pseudo_cache: &*cache,
1228
+ nodes_by_alias: &nodes_by_alias,
1229
+ edges: &edges,
1230
+ depth: 0,
1231
+ max_depth: 50,
1232
+ serialization_options: options,
1233
+ serialization_context: ser_ctx,
1234
+ node_config_manager: ncm,
1235
+ };
1236
+
1237
+ Ok(root_list.to_json(&ctx))
1238
+ }
1239
+ }
1240
+
1241
+ // =============================================================================
1242
+ // JSON serialization helpers for SemanticChildResult
1243
+ // =============================================================================
1244
+
1245
+ #[derive(serde::Serialize)]
1246
+ struct NapiPseudoValueJson {
1247
+ node_id: String,
1248
+ alias: Option<String>,
1249
+ datatype: String,
1250
+ nodegroup_id: Option<String>,
1251
+ tile_id: Option<String>,
1252
+ tile_data: Option<serde_json::Value>,
1253
+ is_collector: bool,
1254
+ independent: bool,
1255
+ child_node_ids: Vec<String>,
1256
+ }
1257
+
1258
+ impl From<&PseudoValueCore> for NapiPseudoValueJson {
1259
+ fn from(v: &PseudoValueCore) -> Self {
1260
+ NapiPseudoValueJson {
1261
+ node_id: v.node.nodeid.clone(),
1262
+ alias: v.node.alias.clone(),
1263
+ datatype: v.node.datatype.clone(),
1264
+ nodegroup_id: v.node.nodegroup_id.clone(),
1265
+ tile_id: v.tile.as_ref().and_then(|t| t.tileid.clone()),
1266
+ tile_data: v.tile_data.clone(),
1267
+ is_collector: v.is_collector,
1268
+ independent: v.independent,
1269
+ child_node_ids: v.child_node_ids.clone(),
1270
+ }
1271
+ }
1272
+ }
1273
+
1274
+ #[derive(serde::Serialize)]
1275
+ struct NapiPseudoListJson {
1276
+ node_alias: String,
1277
+ is_single: bool,
1278
+ is_loaded: bool,
1279
+ values: Vec<NapiPseudoValueJson>,
1280
+ }
1281
+
1282
+ impl From<&PseudoListCore> for NapiPseudoListJson {
1283
+ fn from(l: &PseudoListCore) -> Self {
1284
+ NapiPseudoListJson {
1285
+ node_alias: l.node_alias.clone(),
1286
+ is_single: l.is_single,
1287
+ is_loaded: l.is_loaded,
1288
+ values: l.values.iter().map(NapiPseudoValueJson::from).collect(),
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ // =============================================================================
1294
+ // NapiResourceModelWrapper
1295
+ // =============================================================================
1296
+
1297
+ /// NAPI equivalent of WASMResourceModelWrapper.
1298
+ ///
1299
+ /// Provides graph schema access (nodes, edges, nodegroups, permissions, pruning)
1300
+ /// so that the TS ResourceModelWrapper can delegate to either this or the WASM
1301
+ /// model wrapper via the backend abstraction.
1302
+ #[napi]
1303
+ pub struct NapiResourceModelWrapper {
1304
+ model_access: GraphModelAccess,
1305
+ }
1306
+
1307
+ #[napi]
1308
+ impl NapiResourceModelWrapper {
1309
+ /// Create a model wrapper from a graph JSON string.
1310
+ ///
1311
+ /// The graph is also registered in the core GRAPH_REGISTRY so that
1312
+ /// NapiResourceInstanceWrapper can look it up by graph_id.
1313
+ #[napi(constructor)]
1314
+ pub fn new(graph_json: String, default_allow: bool) -> Result<Self> {
1315
+ let mut graph: StaticGraph = serde_json::from_str(&graph_json)
1316
+ .map_err(|e| napi::Error::from_reason(format!("Invalid graph JSON: {}", e)))?;
1317
+
1318
+ graph.build_indices();
1319
+ let graph_id = graph.graphid.clone();
1320
+ let graph_arc = Arc::new(graph);
1321
+
1322
+ // Register in core graph registry
1323
+ alizarin_core::register_graph(&graph_id, Arc::clone(&graph_arc));
1324
+
1325
+ let model_access = GraphModelAccess::new_eager(graph_arc, default_allow);
1326
+
1327
+ Ok(NapiResourceModelWrapper { model_access })
1328
+ }
1329
+
1330
+ /// Create from an already-loaded NapiStaticGraph.
1331
+ #[napi(factory)]
1332
+ pub fn from_graph(graph: &crate::NapiStaticGraph, default_allow: bool) -> Result<Self> {
1333
+ let inner = graph.inner_ref();
1334
+ let graph_id = inner.graphid.clone();
1335
+ let mut cloned = inner.clone();
1336
+ cloned.build_indices();
1337
+ let graph_arc = Arc::new(cloned);
1338
+
1339
+ alizarin_core::register_graph(&graph_id, Arc::clone(&graph_arc));
1340
+
1341
+ let model_access = GraphModelAccess::new_eager(graph_arc, default_allow);
1342
+
1343
+ Ok(NapiResourceModelWrapper { model_access })
1344
+ }
1345
+
1346
+ // =========================================================================
1347
+ // Graph ID
1348
+ // =========================================================================
1349
+
1350
+ #[napi(js_name = "getGraphId")]
1351
+ pub fn get_graph_id(&self) -> String {
1352
+ self.model_access.get_graph().graphid.clone()
1353
+ }
1354
+
1355
+ // =========================================================================
1356
+ // Graph getter/setter
1357
+ // =========================================================================
1358
+
1359
+ #[napi(getter)]
1360
+ pub fn graph(&self) -> Result<serde_json::Value> {
1361
+ serde_json::to_value(self.model_access.get_graph())
1362
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
1363
+ }
1364
+
1365
+ #[napi(setter)]
1366
+ pub fn set_graph(&mut self, graph_json: String) -> Result<()> {
1367
+ let graph: StaticGraph = serde_json::from_str(&graph_json)
1368
+ .map_err(|e| napi::Error::from_reason(format!("Invalid graph JSON: {}", e)))?;
1369
+ self.model_access.rebuild_from_graph(&graph);
1370
+ Ok(())
1371
+ }
1372
+
1373
+ // =========================================================================
1374
+ // Build nodes (no-op — caches are built eagerly in constructor)
1375
+ // =========================================================================
1376
+
1377
+ #[napi(js_name = "buildNodes")]
1378
+ pub fn build_nodes(&self) -> Result<()> {
1379
+ // GraphModelAccess builds caches in from_graph(), so this is a no-op.
1380
+ Ok(())
1381
+ }
1382
+
1383
+ #[napi(js_name = "buildNodesForGraph")]
1384
+ pub fn build_nodes_for_graph(&mut self, graph_json: String) -> Result<()> {
1385
+ let graph: StaticGraph = serde_json::from_str(&graph_json)
1386
+ .map_err(|e| napi::Error::from_reason(format!("Invalid graph JSON: {}", e)))?;
1387
+ self.model_access.rebuild_from_graph(&graph);
1388
+ Ok(())
1389
+ }
1390
+
1391
+ // =========================================================================
1392
+ // Node accessors
1393
+ // =========================================================================
1394
+
1395
+ /// Get the root node of the graph.
1396
+ #[napi(js_name = "getRootNode")]
1397
+ pub fn get_root_node(&self) -> Result<serde_json::Value> {
1398
+ let root = self
1399
+ .model_access
1400
+ .get_root_node()
1401
+ .map_err(napi::Error::from_reason)?;
1402
+ serde_json::to_value(&*root).map_err(|e| napi::Error::from_reason(e.to_string()))
1403
+ }
1404
+
1405
+ /// Get all nodes as a Map<string, StaticNode>.
1406
+ #[napi(js_name = "getNodeObjects")]
1407
+ pub fn get_node_objects(&self) -> Result<HashMap<String, serde_json::Value>> {
1408
+ let nodes = self
1409
+ .model_access
1410
+ .get_nodes()
1411
+ .ok_or_else(|| napi::Error::from_reason("Nodes not available"))?;
1412
+ let mut result = HashMap::new();
1413
+ for (k, v) in nodes {
1414
+ result.insert(
1415
+ k.clone(),
1416
+ serde_json::to_value(&**v).map_err(|e| napi::Error::from_reason(e.to_string()))?,
1417
+ );
1418
+ }
1419
+ Ok(result)
1420
+ }
1421
+
1422
+ /// Get all nodes indexed by alias.
1423
+ #[napi(js_name = "getNodeObjectsByAlias")]
1424
+ pub fn get_node_objects_by_alias(&self) -> Result<HashMap<String, serde_json::Value>> {
1425
+ let nodes = self
1426
+ .model_access
1427
+ .get_nodes_by_alias()
1428
+ .ok_or_else(|| napi::Error::from_reason("Nodes by alias not available"))?;
1429
+ let mut result = HashMap::new();
1430
+ for (k, v) in nodes {
1431
+ result.insert(
1432
+ k.clone(),
1433
+ serde_json::to_value(&**v).map_err(|e| napi::Error::from_reason(e.to_string()))?,
1434
+ );
1435
+ }
1436
+ Ok(result)
1437
+ }
1438
+
1439
+ /// Get a single node by alias.
1440
+ #[napi(js_name = "getNodeObjectFromAlias")]
1441
+ pub fn get_node_object_from_alias(&self, alias: String) -> Result<serde_json::Value> {
1442
+ let nodes = self
1443
+ .model_access
1444
+ .get_nodes_by_alias()
1445
+ .ok_or_else(|| napi::Error::from_reason("Nodes by alias not available"))?;
1446
+ let node = nodes.get(&alias).ok_or_else(|| {
1447
+ napi::Error::from_reason(format!("Node not found for alias: {}", alias))
1448
+ })?;
1449
+ serde_json::to_value(&**node).map_err(|e| napi::Error::from_reason(e.to_string()))
1450
+ }
1451
+
1452
+ /// Get a single node by node ID.
1453
+ #[napi(js_name = "getNodeObjectFromId")]
1454
+ pub fn get_node_object_from_id(&self, id: String) -> Result<serde_json::Value> {
1455
+ let nodes = self
1456
+ .model_access
1457
+ .get_nodes()
1458
+ .ok_or_else(|| napi::Error::from_reason("Nodes not available"))?;
1459
+ let node = nodes
1460
+ .get(&id)
1461
+ .ok_or_else(|| napi::Error::from_reason(format!("Node not found: {}", id)))?;
1462
+ serde_json::to_value(&**node).map_err(|e| napi::Error::from_reason(e.to_string()))
1463
+ }
1464
+
1465
+ /// Get child nodes for a parent node ID.
1466
+ #[napi(js_name = "getChildNodes")]
1467
+ pub fn get_child_nodes(&self, node_id: String) -> Result<HashMap<String, serde_json::Value>> {
1468
+ let children = self.model_access.get_child_nodes(&node_id);
1469
+ let mut result = HashMap::new();
1470
+ for (alias, node) in &children {
1471
+ result.insert(
1472
+ alias.clone(),
1473
+ serde_json::to_value(&**node)
1474
+ .map_err(|e| napi::Error::from_reason(e.to_string()))?,
1475
+ );
1476
+ }
1477
+ Ok(result)
1478
+ }
1479
+
1480
+ /// Get child node aliases (just strings, not full objects).
1481
+ #[napi(js_name = "getChildNodeAliases")]
1482
+ pub fn get_child_node_aliases(&self, node_id: String) -> Vec<String> {
1483
+ self.model_access
1484
+ .get_child_nodes(&node_id)
1485
+ .keys()
1486
+ .cloned()
1487
+ .collect()
1488
+ }
1489
+
1490
+ // =========================================================================
1491
+ // Edge accessors
1492
+ // =========================================================================
1493
+
1494
+ /// Get edges as Map<parent_id, [child_ids]>.
1495
+ #[napi(js_name = "getEdges")]
1496
+ pub fn get_edges(&self) -> Result<HashMap<String, Vec<String>>> {
1497
+ let edges = self
1498
+ .model_access
1499
+ .get_edges()
1500
+ .ok_or_else(|| napi::Error::from_reason("Edges not available"))?;
1501
+ Ok(edges.clone())
1502
+ }
1503
+
1504
+ // =========================================================================
1505
+ // Nodegroup accessors
1506
+ // =========================================================================
1507
+
1508
+ /// Get all nodegroups.
1509
+ #[napi(js_name = "getNodegroupObjects")]
1510
+ pub fn get_nodegroup_objects(&self) -> Result<HashMap<String, serde_json::Value>> {
1511
+ let nodegroups = self
1512
+ .model_access
1513
+ .get_nodegroups()
1514
+ .ok_or_else(|| napi::Error::from_reason("Nodegroups not available"))?;
1515
+ let mut result = HashMap::new();
1516
+ for (k, v) in nodegroups {
1517
+ result.insert(
1518
+ k.clone(),
1519
+ serde_json::to_value(&**v).map_err(|e| napi::Error::from_reason(e.to_string()))?,
1520
+ );
1521
+ }
1522
+ Ok(result)
1523
+ }
1524
+
1525
+ /// Get nodegroup IDs.
1526
+ #[napi(js_name = "getNodegroupIds")]
1527
+ pub fn get_nodegroup_ids(&self) -> Vec<String> {
1528
+ self.model_access
1529
+ .get_nodegroups()
1530
+ .map(|ng| ng.keys().cloned().collect())
1531
+ .unwrap_or_default()
1532
+ }
1533
+
1534
+ /// Get nodegroup name (actually the name of the root node for that nodegroup).
1535
+ #[napi(js_name = "getNodegroupName")]
1536
+ pub fn get_nodegroup_name(&self, nodegroup_id: String) -> Result<String> {
1537
+ let nodes = self
1538
+ .model_access
1539
+ .get_nodes()
1540
+ .ok_or_else(|| napi::Error::from_reason("Nodes not available"))?;
1541
+ let node = nodes
1542
+ .get(&nodegroup_id)
1543
+ .ok_or_else(|| napi::Error::from_reason(format!("Node not found: {}", nodegroup_id)))?;
1544
+ Ok(node.name.clone())
1545
+ }
1546
+
1547
+ /// Get node ID from alias.
1548
+ #[napi(js_name = "getNodeIdFromAlias")]
1549
+ pub fn get_node_id_from_alias(&self, alias: String) -> Result<String> {
1550
+ let nodes = self
1551
+ .model_access
1552
+ .get_nodes_by_alias()
1553
+ .ok_or_else(|| napi::Error::from_reason("Nodes by alias not available"))?;
1554
+ let node = nodes.get(&alias).ok_or_else(|| {
1555
+ napi::Error::from_reason(format!("Node not found for alias: {}", alias))
1556
+ })?;
1557
+ Ok(node.nodeid.clone())
1558
+ }
1559
+
1560
+ // =========================================================================
1561
+ // Property getters (matching WASM's getter properties)
1562
+ // =========================================================================
1563
+
1564
+ #[napi(getter)]
1565
+ pub fn nodes(&self) -> Result<HashMap<String, serde_json::Value>> {
1566
+ self.get_node_objects()
1567
+ }
1568
+
1569
+ #[napi(getter, js_name = "nodesByAlias")]
1570
+ pub fn nodes_by_alias(&self) -> Result<HashMap<String, serde_json::Value>> {
1571
+ self.get_node_objects_by_alias()
1572
+ }
1573
+
1574
+ #[napi(getter)]
1575
+ pub fn edges(&self) -> Result<HashMap<String, Vec<String>>> {
1576
+ self.get_edges()
1577
+ }
1578
+
1579
+ #[napi(getter)]
1580
+ pub fn nodegroups(&self) -> Result<HashMap<String, serde_json::Value>> {
1581
+ self.get_nodegroup_objects()
1582
+ }
1583
+
1584
+ // =========================================================================
1585
+ // Permission management
1586
+ // =========================================================================
1587
+
1588
+ /// Get permitted nodegroups as boolean map.
1589
+ #[napi(js_name = "getPermittedNodegroups")]
1590
+ pub fn get_permitted_nodegroups(&self) -> HashMap<String, bool> {
1591
+ self.model_access.get_permitted_nodegroups_bool()
1592
+ }
1593
+
1594
+ /// Check if a nodegroup is permitted.
1595
+ #[napi(js_name = "isNodegroupPermitted")]
1596
+ pub fn is_nodegroup_permitted(&self, nodegroup_id: String) -> bool {
1597
+ self.model_access.is_nodegroup_permitted(&nodegroup_id)
1598
+ }
1599
+
1600
+ /// Set permitted nodegroups. Accepts an object with boolean values or
1601
+ /// conditional rule objects { path, allowed }.
1602
+ #[napi(js_name = "setPermittedNodegroups")]
1603
+ pub fn set_permitted_nodegroups(&mut self, permissions: serde_json::Value) -> Result<()> {
1604
+ let perms = Self::parse_permission_value(&permissions)?;
1605
+ self.model_access.set_permitted_nodegroups_rules(perms);
1606
+ Ok(())
1607
+ }
1608
+
1609
+ #[napi(js_name = "setDefaultAllowAllNodegroups")]
1610
+ pub fn set_default_allow_all_nodegroups(&mut self, default_allow: bool) {
1611
+ self.model_access.set_default_allow(default_allow);
1612
+ }
1613
+
1614
+ // =========================================================================
1615
+ // Graph modification
1616
+ // =========================================================================
1617
+
1618
+ #[napi(js_name = "setGraphNodes")]
1619
+ pub fn set_graph_nodes(&mut self, nodes_json: String) -> Result<()> {
1620
+ let nodes: Vec<StaticNode> = serde_json::from_str(&nodes_json)
1621
+ .map_err(|e| napi::Error::from_reason(format!("Invalid nodes JSON: {}", e)))?;
1622
+ self.model_access.set_graph_nodes(nodes);
1623
+ Ok(())
1624
+ }
1625
+
1626
+ #[napi(js_name = "setGraphEdges")]
1627
+ pub fn set_graph_edges(&mut self, edges_json: String) -> Result<()> {
1628
+ let edges: Vec<StaticEdge> = serde_json::from_str(&edges_json)
1629
+ .map_err(|e| napi::Error::from_reason(format!("Invalid edges JSON: {}", e)))?;
1630
+ self.model_access.set_graph_edges(edges);
1631
+ Ok(())
1632
+ }
1633
+
1634
+ #[napi(js_name = "setGraphNodegroups")]
1635
+ pub fn set_graph_nodegroups(&mut self, nodegroups_json: String) -> Result<()> {
1636
+ let nodegroups: Vec<StaticNodegroup> = serde_json::from_str(&nodegroups_json)
1637
+ .map_err(|e| napi::Error::from_reason(format!("Invalid nodegroups JSON: {}", e)))?;
1638
+ self.model_access.set_graph_nodegroups(nodegroups);
1639
+ Ok(())
1640
+ }
1641
+
1642
+ // =========================================================================
1643
+ // Graph pruning
1644
+ // =========================================================================
1645
+
1646
+ /// Prune graph to only include permitted nodegroups and their dependencies.
1647
+ #[napi(js_name = "pruneGraph")]
1648
+ pub fn prune_graph(&mut self, keep_functions: Option<Vec<String>>) -> Result<()> {
1649
+ let keep_fns_ref = keep_functions.as_deref();
1650
+ self.model_access
1651
+ .prune_graph(keep_fns_ref)
1652
+ .map_err(napi::Error::from_reason)
1653
+ }
1654
+ }
1655
+
1656
+ // Private helpers for NapiResourceModelWrapper
1657
+ impl NapiResourceModelWrapper {
1658
+ /// Parse permission values from a JSON object.
1659
+ /// Supports booleans and conditional rules: { path: "...", allowed: [...] }
1660
+ fn parse_permission_value(val: &serde_json::Value) -> Result<HashMap<String, PermissionRule>> {
1661
+ let obj = val
1662
+ .as_object()
1663
+ .ok_or_else(|| napi::Error::from_reason("Permissions must be an object"))?;
1664
+ let mut perms = HashMap::new();
1665
+ for (key, value) in obj {
1666
+ if let Some(b) = value.as_bool() {
1667
+ perms.insert(key.clone(), PermissionRule::Boolean(b));
1668
+ } else if let Some(obj) = value.as_object() {
1669
+ let path = obj
1670
+ .get("path")
1671
+ .and_then(|v| v.as_str())
1672
+ .map(String::from)
1673
+ .unwrap_or_default();
1674
+ let allowed = obj
1675
+ .get("allowed")
1676
+ .and_then(|v| v.as_array())
1677
+ .map(|arr| {
1678
+ arr.iter()
1679
+ .filter_map(|v| v.as_str().map(String::from))
1680
+ .collect::<HashSet<String>>()
1681
+ })
1682
+ .unwrap_or_default();
1683
+ let rule = PermissionRule::conditional(path, allowed).map_err(|e| {
1684
+ napi::Error::from_reason(format!(
1685
+ "Invalid conditional rule for '{}': {}",
1686
+ key, e
1687
+ ))
1688
+ })?;
1689
+ perms.insert(key.clone(), rule);
1690
+ } else {
1691
+ return Err(napi::Error::from_reason(format!(
1692
+ "Invalid permission value for '{}': expected boolean or {{path, allowed}}",
1693
+ key
1694
+ )));
1695
+ }
1696
+ }
1697
+ Ok(perms)
1698
+ }
1699
+ }