brep_kernel/feature_pipeline/parts_library.rs
1//! The per-document PARTS LIBRARY — assemblies build-spec §2.1 / §10 item 3.
2//!
3//! Each unique part is stored ONCE per document: `{ sourceKey, sourceSignature,
4//! document (the embedded full sub-part history JSON), snapshot (the io/snapshot
5//! exact-BREP payload) }`, keyed by a document-unique part name. ACOMP instance
6//! features (`features/assembly_component.rs`) reference entries by name and
7//! carry NO payload of their own.
8//!
9//! # Residency and round-trip
10//!
11//! The library is KERNEL-RESIDENT state (thread-local, like the history cache):
12//! it is seeded through [`ingest`] (the `partsLibrary` block of a history
13//! request) or [`install_parts_library`] (the app → runner channel), mutated
14//! through [`add_part_to_library`] / [`refresh_library_entry`], and serialized
15//! for SAVE via [`parts_library_json`] — save must serialize THIS, never echo
16//! the loaded block, or healed snapshots and refreshes are lost. It clears
17//! with `clear_history_cache` (document-switch semantics).
18//!
19//! Ingest rule (stale request blocks must never undo kernel-side state): a
20//! RESIDENT entry always wins — a request echoing the block the document
21//! LOADED with predates any heal/refresh, so the block only FILLS names
22//! missing from the store.
23//!
24//! # Two seeding doors, and why they differ
25//!
26//! [`ingest`] is fill-only because it is fed by a *possibly stale echo*. The
27//! app's history runner runs OFF the caller's thread (a native thread or a
28//! browser worker), so it owns a SEPARATE store that must be kept in step with
29//! the app's — and re-sending the whole block on every run is what made a
30//! large assembly freeze the browser UI (each run stringified megabytes of
31//! payload on the main thread). The app therefore sends the library only when
32//! it CHANGES, over [`install_parts_library`], which is a *replace by content
33//! identity*: it may drop and replace, not merely fill. Two guards keep that
34//! safe — [`parts_library_revision`] tells the sender when to re-send, and
35//! [`missing_library_parts`] lets the receiver refuse a run whose parts it
36//! cannot resolve (the orphan GC below can drop entries the sender still
37//! believes are resident).
38//!
39//! # Orphan GC
40//!
41//! At the end of every `execute_history` run, entries whose part name is
42//! referenced by NO ACOMP feature in the request are dropped — orphaned
43//! payloads never accumulate in the file. (The insert flow must therefore add
44//! the ACOMP feature to the history before triggering another rebuild.)
45//!
46//! # Isolated sub-part execution
47//!
48//! [`rebuild_snapshot`] executes an embedded part document HEADLESSLY to
49//! (re)produce its snapshot — the [`add_part_to_library`] insert path and the
50//! ACOMP self-heal lane. It deliberately does NOT recurse into
51//! `execute_history`: the history cache's `retain(request_ids)` prologue would
52//! free every PARENT feature's cached handles mid-run. Instead it walks the
53//! sub-document's features through `execute_feature` with a local scene,
54//! brackets the scene-metadata store (the sub-part's un-namespaced records must
55//! never touch the parent document's), pushes the sub-document's OWN
56//! `partsLibrary` as the active library (parent and child libraries are
57//! independent, spec §2.2), and frees every handle it registered before
58//! returning.
59
60use serde::{Deserialize, Serialize};
61use std::cell::RefCell;
62use std::collections::BTreeMap;
63
64use crate::feature_pipeline::{
65 execute_feature, scene_metadata, sheet_metal, Env, FeatureDescriptor, HistoryRequest, SceneMap,
66};
67use crate::BrepSolid;
68use wasm_bindgen::prelude::*;
69
70// ===========================================================================
71// The entry + the resident stores
72// ===========================================================================
73
74/// One unique part payload (spec §2.1). `dirty` is RESIDENT-ONLY state: set by
75/// [`refresh_library_entry`] (edit-in-context / update-components), it forces
76/// the ACOMP self-heal lane on the next run even with a readable snapshot, and
77/// clears when the heal rewrites the snapshot.
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct PartsLibraryEntry {
80 #[serde(rename = "sourceKey", default)]
81 pub source_key: String,
82 #[serde(rename = "sourceSignature", default)]
83 pub source_signature: String,
84 /// The embedded full sub-part history JSON — the durable source the
85 /// self-heal lane re-executes when the snapshot cache fails.
86 #[serde(default)]
87 pub document: serde_json::Value,
88 /// The evaluated part as an `io/snapshot` payload (the fast lane).
89 #[serde(default)]
90 pub snapshot: String,
91 #[serde(skip)]
92 pub dirty: bool,
93 /// Stable content hash of `document`, precomputed at insert/refresh/ingest
94 /// so the per-feature cache hook never re-hashes a large document.
95 #[serde(skip)]
96 pub doc_hash: u64,
97}
98
99/// The library map shape as it travels in the history request / save file.
100pub type PartsLibraryMap = BTreeMap<String, PartsLibraryEntry>;
101
102thread_local! {
103 /// The open document's library (the ROOT store).
104 static ROOT: RefCell<PartsLibraryMap> = RefCell::new(BTreeMap::new());
105 /// Active-library stack for isolated sub-document runs: a nested ACOMP
106 /// resolves against ITS document's library, never the parent's.
107 static STACK: RefCell<Vec<PartsLibraryMap>> = const { RefCell::new(Vec::new()) };
108 /// Monotonic REVISION of the ROOT store, bumped by every mutation that
109 /// actually CHANGES it (insert, refresh, heal, GC drop, install, clear).
110 /// The app polls it to decide whether a background runner's resident copy
111 /// is stale — the library no longer rides on every history request, so
112 /// this counter is what tells the app when to re-send it. Thread-local
113 /// like the store itself: main and each runner count their own store.
114 static REVISION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
115}
116
117/// Record that the ROOT store changed. Call it from EVERY mutation that
118/// actually alters content — never speculatively: `ingest` and
119/// `gc_after_rebuild` run on every history run, so an unconditional bump there
120/// would make the app re-send the whole library every edit, which is the cost
121/// this counter exists to avoid.
122fn bump_revision() {
123 REVISION.with(|revision| revision.set(revision.get().wrapping_add(1)));
124}
125
126/// The ROOT store's current revision (see [`REVISION`]). Two reads that differ
127/// mean the library changed in between; two that agree mean it did not.
128pub fn parts_library_revision() -> u64 {
129 REVISION.with(std::cell::Cell::get)
130}
131
132/// Stable content hash of a JSON value: sorted-key walk (never trusts a
133/// serializer's map ordering), string leaves verbatim.
134fn stable_json_hash(value: &serde_json::Value) -> u64 {
135 use std::hash::{Hash, Hasher};
136 fn walk(value: &serde_json::Value, hasher: &mut impl Hasher) {
137 match value {
138 serde_json::Value::Null => 0u8.hash(hasher),
139 serde_json::Value::Bool(flag) => {
140 1u8.hash(hasher);
141 flag.hash(hasher);
142 }
143 serde_json::Value::Number(number) => {
144 2u8.hash(hasher);
145 number.to_string().hash(hasher);
146 }
147 serde_json::Value::String(text) => {
148 3u8.hash(hasher);
149 text.hash(hasher);
150 }
151 serde_json::Value::Array(items) => {
152 4u8.hash(hasher);
153 for item in items {
154 walk(item, hasher);
155 }
156 }
157 serde_json::Value::Object(map) => {
158 5u8.hash(hasher);
159 let mut keys: Vec<&String> = map.keys().collect();
160 keys.sort();
161 for key in keys {
162 key.hash(hasher);
163 walk(&map[key], hasher);
164 }
165 }
166 }
167 }
168 let mut hasher = std::collections::hash_map::DefaultHasher::new();
169 walk(value, &mut hasher);
170 hasher.finish()
171}
172
173/// Read the entry for a part name from the ACTIVE library: the innermost
174/// isolated-run library when one is pushed, else the root store.
175pub(crate) fn active_entry(part_name: &str) -> Option<PartsLibraryEntry> {
176 STACK.with(|stack| {
177 let stack = stack.borrow();
178 if let Some(top) = stack.last() {
179 return top.get(part_name).cloned();
180 }
181 ROOT.with(|root| root.borrow().get(part_name).cloned())
182 })
183}
184
185/// Write a healed snapshot back into the ACTIVE library entry and clear its
186/// dirty flag. A heal inside an isolated run updates the pushed (temporary)
187/// library only — the parent entry's embedded document is NOT rewritten (its
188/// re-snapshot bakes the healed geometry anyway; the stale inner snapshot heals
189/// again on the next full sub-document run).
190pub(crate) fn heal_entry(part_name: &str, snapshot: String) {
191 let heal = |map: &mut PartsLibraryMap| {
192 if let Some(entry) = map.get_mut(part_name) {
193 entry.snapshot = snapshot.clone();
194 entry.dirty = false;
195 }
196 };
197 STACK.with(|stack| {
198 let mut stack = stack.borrow_mut();
199 if let Some(top) = stack.last_mut() {
200 heal(top);
201 } else {
202 ROOT.with(|root| heal(&mut root.borrow_mut()));
203 // A heal rewrites a ROOT entry's snapshot, so the store changed.
204 // (It does NOT change the entry's content IDENTITY — see
205 // `install` — so re-sending a healed library never clobbers a
206 // runner's own, independently-derived heal.)
207 bump_revision();
208 }
209 });
210}
211
212/// Drop the whole library (document switch — wired into `clear_history_cache`).
213pub(crate) fn clear_all() {
214 let had_entries = ROOT.with(|root| {
215 let mut root = root.borrow_mut();
216 let had = !root.is_empty();
217 root.clear();
218 had
219 });
220 STACK.with(|stack| stack.borrow_mut().clear());
221 if had_entries {
222 bump_revision();
223 }
224}
225
226// ===========================================================================
227// execute_history hooks: ingest, cache fingerprint, GC
228// ===========================================================================
229
230/// The ACOMP dispatch predicate — shared by the fingerprint hook, the GC scan,
231/// and (as adjacent literals) the `execute_feature` match arm, so they can
232/// never disagree on what counts as a component instance.
233pub(crate) fn is_acomp_type(feature_type: &str) -> bool {
234 matches!(feature_type, "ACOMP" | "ASSEMBLY COMPONENT")
235}
236
237/// Merge a request's `partsLibrary` block into the root store (start of every
238/// `execute_history` run — this is how a LOADED document seeds the library).
239/// A RESIDENT entry always wins: mid-session the kernel store is the source of
240/// truth (it carries heals, refreshes, and pending-dirty state the request's
241/// echo of the loaded block predates); the block only ever FILLS missing names
242/// (document load starts from an empty store, cleared by `clear_history_cache`).
243pub(crate) fn ingest(request_map: &PartsLibraryMap) {
244 if request_map.is_empty() {
245 return;
246 }
247 let seeded = ROOT.with(|root| {
248 let mut root = root.borrow_mut();
249 let mut seeded = false;
250 for (name, incoming) in request_map {
251 if root.contains_key(name) {
252 continue;
253 }
254 let mut entry = incoming.clone();
255 entry.dirty = false;
256 // A loaded block's entry gets the same stamp as an inserted one, so a
257 // document saved before per-loop ids existed picks them up on open.
258 stamp_document_loop_ids(&mut entry.document);
259 entry.doc_hash = stable_json_hash(&entry.document);
260 root.insert(name.clone(), entry);
261 seeded = true;
262 }
263 seeded
264 });
265 if seeded {
266 bump_revision();
267 }
268}
269
270/// The CONTENT IDENTITY of an entry: what makes two entries the same PART.
271/// `snapshot` is deliberately excluded — a heal re-derives the snapshot from
272/// the same `document`, so it must not read as a different part. This is the
273/// same identity [`mix_descriptor_fingerprint`] mixes into an ACOMP's cache
274/// fingerprint, so "install kept the resident entry" and "the instance
275/// replayed from cache" can never disagree.
276fn identity(entry: &PartsLibraryEntry) -> (&str, &str, u64) {
277 (&entry.source_key, &entry.source_signature, entry.doc_hash)
278}
279
280/// INSTALL a library wholesale — the app → history-runner channel (the runner
281/// owns its own thread-local store, and the per-run request no longer carries
282/// the block, so this is how a background thread/worker learns the library).
283///
284/// Unlike [`ingest`] (fill-only, resident-wins) this makes the store MATCH
285/// `incoming` BY CONTENT IDENTITY, which is what a cache channel needs:
286///
287/// * a name missing from the store is inserted (stamped + hashed like ingest);
288/// * a name whose resident entry has the SAME identity is KEPT VERBATIM — that
289/// is what protects a runner-side heal, whose only trace is a rewritten
290/// `snapshot`, from being clobbered by a re-send;
291/// * a name whose resident entry has a DIFFERENT identity is REPLACED and
292/// marked `dirty`, so the ACOMP self-heal lane re-derives every instance
293/// (`dirty` is `#[serde(skip)]` and cannot ride the wire — it is derived
294/// HERE, from the identity mismatch);
295/// * a resident name ABSENT from `incoming` is dropped.
296///
297/// Returns whether anything changed (and bumps the revision if so).
298pub fn install_parts_library(incoming: &PartsLibraryMap) -> bool {
299 let mut changed = false;
300 ROOT.with(|root| {
301 let mut root = root.borrow_mut();
302 root.retain(|name, _| {
303 let keep = incoming.contains_key(name);
304 changed |= !keep;
305 keep
306 });
307 for (name, entry) in incoming {
308 let mut entry = entry.clone();
309 entry.dirty = false;
310 stamp_document_loop_ids(&mut entry.document);
311 entry.doc_hash = stable_json_hash(&entry.document);
312 match root.get(name) {
313 // Same part: keep the RESIDENT copy — it may carry a heal this
314 // side derived and the sender never saw.
315 Some(resident) if identity(resident) == identity(&entry) => continue,
316 // Genuinely different content: replace, and force the self-heal
317 // lane so every instance follows the new document.
318 Some(_) => entry.dirty = true,
319 None => {}
320 }
321 root.insert(name.clone(), entry);
322 changed = true;
323 }
324 });
325 if changed {
326 bump_revision();
327 }
328 changed
329}
330
331/// A CLONE of the root store — what the app hands a runner through
332/// [`install_parts_library`]. (`parts_library_json` is the SAVE door; this is
333/// the in-process one, with no JSON round trip on the native paths.)
334pub fn parts_library_map() -> PartsLibraryMap {
335 ROOT.with(|root| root.borrow().clone())
336}
337
338/// The PREFLIGHT for a run: every part name the request's ACOMP features
339/// reference that the root store cannot resolve, in request order.
340///
341/// A background runner calls this BEFORE executing a run and refuses the run
342/// when it is non-empty. That is the drift-proof half of the library channel:
343/// the orphan GC at the end of every run can legitimately drop entries the
344/// sender still believes are resident (undo to zero components GCs the runner's
345/// store while the sender's store, which never ran, keeps them), and no
346/// revision bookkeeping can see that. Missing CONTENT is observable; that is
347/// what this checks.
348pub fn missing_library_parts(request: &HistoryRequest) -> Vec<String> {
349 let mut missing = Vec::new();
350 ROOT.with(|root| {
351 let root = root.borrow();
352 for descriptor in &request.features {
353 if !is_acomp_type(&descriptor.feature_type) {
354 continue;
355 }
356 let Some(name) = descriptor
357 .input_params
358 .get("partName")
359 .and_then(|value| value.as_str())
360 .map(str::trim)
361 .filter(|name| !name.is_empty())
362 else {
363 continue;
364 };
365 if !root.contains_key(name) && !missing.iter().any(|seen| seen == name) {
366 missing.push(name.to_string());
367 }
368 }
369 });
370 missing
371}
372
373/// The history-cache hook for one descriptor: mix the referenced library
374/// entry's CONTENT identity (source key/signature/document hash — snapshot
375/// deliberately excluded, a heal must not dirty the instance) into the
376/// feature fingerprint, and report whether a DIRTY entry forces re-execution.
377/// Non-ACOMP descriptors pass through untouched.
378pub(crate) fn mix_descriptor_fingerprint(
379 descriptor: &FeatureDescriptor,
380 fingerprint: u64,
381) -> (u64, bool) {
382 if !is_acomp_type(&descriptor.feature_type) {
383 return (fingerprint, false);
384 }
385 use std::hash::{Hash, Hasher};
386 let mut hasher = std::collections::hash_map::DefaultHasher::new();
387 fingerprint.hash(&mut hasher);
388 let part_name = descriptor
389 .input_params
390 .get("partName")
391 .and_then(|value| value.as_str())
392 .unwrap_or("");
393 part_name.hash(&mut hasher);
394 let mut force_dirty = false;
395 match active_entry(part_name) {
396 Some(entry) => {
397 1u8.hash(&mut hasher);
398 entry.source_key.hash(&mut hasher);
399 entry.source_signature.hash(&mut hasher);
400 entry.doc_hash.hash(&mut hasher);
401 force_dirty = entry.dirty;
402 }
403 None => 0u8.hash(&mut hasher),
404 }
405 (hasher.finish(), force_dirty)
406}
407
408/// Orphan GC (end of every `execute_history` run): retain only entries some
409/// ACOMP feature in the REQUEST references (the request always carries the full
410/// feature list — editor stop points truncate execution, not the list).
411pub(crate) fn gc_after_rebuild(request: &HistoryRequest) {
412 let referenced: std::collections::BTreeSet<String> = request
413 .features
414 .iter()
415 .filter(|descriptor| is_acomp_type(&descriptor.feature_type))
416 .filter_map(|descriptor| {
417 descriptor
418 .input_params
419 .get("partName")
420 .and_then(|value| value.as_str())
421 .map(|name| name.trim().to_string())
422 })
423 .collect();
424 let dropped = ROOT.with(|root| {
425 let mut root = root.borrow_mut();
426 let before = root.len();
427 root.retain(|name, _| referenced.contains(name));
428 before != root.len()
429 });
430 if dropped {
431 bump_revision();
432 }
433}
434
435// ===========================================================================
436// Isolated sub-part execution (the self-heal / insert lane)
437// ===========================================================================
438
439/// Execute an embedded part document headlessly and return its surviving
440/// solids as PART-LOCAL `(scene name, owned BREP)` pairs in history order.
441/// Never touches the history cache or the parent scene; every handle it
442/// registers is freed before returning. See the module doc for why this must
443/// not recurse into `execute_history`.
444fn run_isolated_document(document: &serde_json::Value) -> Result<Vec<(String, BrepSolid)>, String> {
445 let mut request: HistoryRequest = serde_json::from_value(document.clone())
446 .map_err(|error| format!("embedded part document does not parse: {error}"))?;
447 // A stale editor stop point saved into a library part must not silently
448 // truncate the part on every heal.
449 request.stop_at_id = None;
450 request.stop_before_id = None;
451
452 // The sub-document's own library is the active one for this run (a nested
453 // assembly's ACOMPs resolve against it; missing entries are clean errors,
454 // never a fallback to the parent's library).
455 STACK.with(|stack| stack.borrow_mut().push(std::mem::take(&mut request.parts_library)));
456 let outcome = run_isolated_features(&request);
457 STACK.with(|stack| {
458 stack.borrow_mut().pop();
459 });
460 outcome
461}
462
463/// The feature walk of [`run_isolated_document`], separated so the library
464/// stack push/pop brackets every exit path.
465fn run_isolated_features(request: &HistoryRequest) -> Result<Vec<(String, BrepSolid)>, String> {
466 let env = Env::build(&request.expressions, &request.configurator).unwrap_or_else(Env::poisoned);
467 let mut scene = SceneMap::default();
468 let mut results = Vec::with_capacity(request.features.len());
469 let mut all_handles: Vec<u32> = Vec::new();
470 let mut error: Option<String> = None;
471 for descriptor in &request.features {
472 let result = execute_feature(descriptor, &env, &scene);
473 scene.apply(&result);
474 all_handles.extend(result.added.iter().map(|added| added.handle));
475 // Replicate the loop's producer stamp so the snapshot carries the same
476 // topology metadata a real history run would have written.
477 if !result.id.is_empty() {
478 let mut seed = serde_json::Map::new();
479 seed.insert(
480 "sourceFeatureId".into(),
481 serde_json::Value::String(result.id.clone()),
482 );
483 for added in &result.added {
484 for (_, face_name) in &added.face_names {
485 scene_metadata::merge_record(face_name, &seed, false);
486 }
487 for (_, edge_name) in &added.edge_names {
488 scene_metadata::merge_record(edge_name, &seed, false);
489 }
490 }
491 }
492 if let Some(message) = &result.error {
493 error = Some(format!("feature '{}': {message}", result.id));
494 break;
495 }
496 results.push(result);
497 }
498
499 // Survivors: added solids still scene-resident under their own name, in
500 // history order (a consumed/superseded solid's name no longer maps to it).
501 let mut members = Vec::new();
502 if error.is_none() {
503 for result in &results {
504 for added in &result.added {
505 if scene.resolve_solid(&added.name) == Some(added.handle) {
506 let solid =
507 crate::with_registered_solid_str(added.handle, |solid| Ok(solid.clone()))?;
508 members.push((added.name.clone(), solid));
509 }
510 }
511 }
512 }
513 for handle in all_handles {
514 sheet_metal::remove_tree(handle);
515 crate::free_registered_solid(handle);
516 }
517 match error {
518 Some(message) => Err(message),
519 None if members.is_empty() => Err("embedded part document produced no solids".into()),
520 None => Ok(members),
521 }
522}
523
524/// Execute an embedded part document and snapshot the result — the ONE
525/// snapshot-(re)build lane ([`add_part_to_library`] insert + ACOMP self-heal).
526/// The scene-metadata store is bracketed around the run: the sub-part's
527/// un-namespaced records feed the snapshot capture and are then discarded, so
528/// the parent document's store is byte-identical afterwards on every path.
529pub(crate) fn rebuild_snapshot(document: &serde_json::Value) -> Result<String, String> {
530 let saved = scene_metadata::take_store();
531 let outcome = run_isolated_document(document).and_then(|members| {
532 let refs: Vec<(&str, &BrepSolid)> = members
533 .iter()
534 .map(|(name, solid)| (name.as_str(), solid))
535 .collect();
536 crate::snapshot_solids(&refs)
537 });
538 scene_metadata::restore_store(saved);
539 outcome
540}
541
542// ===========================================================================
543// The app-facing insert / update / save surface
544// ===========================================================================
545
546/// A part name not yet used in the root store: `requested`, else
547/// `requested-2`, `requested-3`, …
548fn unique_name(root: &PartsLibraryMap, requested: &str) -> String {
549 if !root.contains_key(requested) {
550 return requested.to_string();
551 }
552 for counter in 2.. {
553 let candidate = format!("{requested}-{counter}");
554 if !root.contains_key(&candidate) {
555 return candidate;
556 }
557 }
558 unreachable!("counter loop is unbounded")
559}
560
561/// Stamp per-loop sketch ids onto an entry's document, in place — run at EVERY
562/// door into the library (insert, refresh, and the loaded-block seeding), so a
563/// part gets durable loop ids no matter how it arrived.
564///
565/// The engine also stamps a document on LOAD
566/// (`EngineState::set_history_json`), but a part inserted or refreshed through
567/// the assembly lanes never passes through that: `add_part_to_library` and
568/// `refresh_library_entry` take a document STRING straight from a caller (the
569/// file panel inserts a saved `.BREP.json`; update-components commits an edited
570/// one). Stamping here rather than at those call sites means a future lane that
571/// installs a document cannot silently skip it.
572///
573/// This renames nothing — the kernel derives the same loop ids every run. What
574/// it adds is durability: a stored id survives deleting the edge it was derived
575/// from, which derivation alone cannot. See `features/sketch/loop_ids`.
576fn stamp_document_loop_ids(document: &mut serde_json::Value) {
577 let Some(features) = document
578 .get_mut("features")
579 .and_then(serde_json::Value::as_array_mut)
580 else {
581 return;
582 };
583 for feature in features {
584 if feature.get("type").and_then(serde_json::Value::as_str) != Some("S") {
585 continue;
586 }
587 let Some(sketch) = feature
588 .get_mut("persistentData")
589 .and_then(|data| data.get_mut("sketch"))
590 else {
591 continue;
592 };
593 crate::feature_pipeline::assign_sketch_loop_ids(sketch);
594 }
595}
596
597/// INSERT: hand the opened sub-part document over to the library. Executes the
598/// document once, snapshots, stores the entry, and returns the EFFECTIVE part
599/// name (the ACOMP `partName` the app must reference):
600///
601/// - a live entry with the same `sourceKey` AND `sourceSignature` is reused
602/// verbatim (no re-execution — instant re-insert, spec §2.1);
603/// - same `sourceKey` but a DIFFERENT signature gets a fresh entry under a
604/// disambiguated name (existing instances keep their version; the explicit
605/// refresh lane is [`refresh_library_entry`]);
606/// - a requested name taken by different content is disambiguated (`name-2`).
607#[wasm_bindgen]
608pub fn add_part_to_library(
609 name: &str,
610 source_key: &str,
611 source_signature: &str,
612 document_json: &str,
613) -> Result<String, JsValue> {
614 let requested = name.trim();
615 if requested.is_empty() {
616 return Err(JsValue::from_str("add_part_to_library: empty part name"));
617 }
618 let reused = ROOT.with(|root| {
619 root.borrow().iter().find_map(|(entry_name, entry)| {
620 (entry.source_key == source_key && entry.source_signature == source_signature)
621 .then(|| entry_name.clone())
622 })
623 });
624 if let Some(entry_name) = reused {
625 return Ok(entry_name);
626 }
627 let mut document: serde_json::Value = serde_json::from_str(document_json)
628 .map_err(|error| JsValue::from_str(&format!("add_part_to_library: {error}")))?;
629 // Stamp BEFORE the hash and the snapshot so both describe the stored form —
630 // a later re-stamp then finds nothing to change and cannot dirty the entry.
631 stamp_document_loop_ids(&mut document);
632 let snapshot = rebuild_snapshot(&document)
633 .map_err(|error| JsValue::from_str(&format!("add_part_to_library: {error}")))?;
634 let entry = PartsLibraryEntry {
635 source_key: source_key.to_string(),
636 source_signature: source_signature.to_string(),
637 doc_hash: stable_json_hash(&document),
638 document,
639 snapshot,
640 dirty: false,
641 };
642 let final_name = ROOT.with(|root| {
643 let mut root = root.borrow_mut();
644 let final_name = unique_name(&root, requested);
645 root.insert(final_name.clone(), entry);
646 final_name
647 });
648 bump_revision();
649 Ok(final_name)
650}
651
652/// UPDATE (update-components / edit-in-place commit): replace an existing
653/// entry's document + signature and mark it DIRTY. The next history run's
654/// ACOMP self-heal lane re-executes the document, re-snapshots, and every
655/// instance of the part follows.
656#[wasm_bindgen]
657pub fn refresh_library_entry(
658 name: &str,
659 source_signature: &str,
660 document_json: &str,
661) -> Result<(), JsValue> {
662 let mut document: serde_json::Value = serde_json::from_str(document_json)
663 .map_err(|error| JsValue::from_str(&format!("refresh_library_entry: {error}")))?;
664 // Stamped before the hash, as in `add_part_to_library`.
665 stamp_document_loop_ids(&mut document);
666 ROOT.with(|root| {
667 let mut root = root.borrow_mut();
668 let Some(entry) = root.get_mut(name) else {
669 return Err(JsValue::from_str(&format!(
670 "refresh_library_entry: no parts-library entry named '{name}'"
671 )));
672 };
673 entry.source_signature = source_signature.to_string();
674 entry.doc_hash = stable_json_hash(&document);
675 entry.document = document;
676 entry.dirty = true;
677 Ok(())
678 })?;
679 bump_revision();
680 Ok(())
681}
682
683/// The current library in the `partsLibrary` block shape (spec §2.1) — what
684/// SAVE must serialize into the document (never the loaded block: this copy
685/// carries healed snapshots, refreshes, and GC).
686#[wasm_bindgen]
687pub fn parts_library_json() -> String {
688 ROOT.with(|root| {
689 serde_json::to_string(&*root.borrow()).unwrap_or_else(|_| "{}".to_string())
690 })
691}