Skip to main content

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}