Skip to main content

graphrefly/
checkpoint.rs

1//! Fresh graph checkpoint/restore (R-snapshot/R-restore, D83/D94/D116).
2//!
3//! Checkpoint-owned storage integration is deliberately absent here: callers
4//! load/decode checkpoint JSON outside the sync core, then pass the already-loaded
5//! value to [`restore_graph`].
6
7use std::any::{Any, TypeId};
8use std::cell::RefCell;
9use std::cmp::Ordering as CmpOrdering;
10use std::collections::{BTreeMap, BTreeSet, HashMap};
11use std::error::Error;
12use std::fmt;
13use std::rc::Rc;
14use std::sync::{Arc, Mutex, OnceLock};
15
16use serde::{Deserialize, Serialize};
17use serde_json::{Number, Value};
18
19use crate::ctx::Ctx;
20use crate::dispatcher::NodeFn;
21use crate::graph::{Graph, GraphNodeOpts, GraphOptions, RestoreFactoryMeta};
22use crate::json::validate_strict_json_value;
23use crate::node::{Core, NodeRestoreRuntime, Status};
24use crate::protocol::AnyValue;
25use crate::versioning::{
26    node_version_to_json, validate_node_version_json, verify_restored_node_version,
27};
28
29/// `constant` constant.
30pub const GRAPH_CHECKPOINT_VERSION: &str = "graphrefly.checkpoint.v1";
31
32/// `GraphCheckpointJson` type alias.
33pub type GraphCheckpointJson = Value;
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(tag = "kind")]
37/// `GraphCheckpointValue` variants.
38pub enum GraphCheckpointValue {
39    #[serde(rename = "SENTINEL")]
40    /// `Sentinel` variant.
41    Sentinel,
42    #[serde(rename = "DATA")]
43    /// `Data` variant.
44    Data {
45        /// `data` field for `Data`.
46        data: GraphCheckpointJson,
47    },
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(tag = "kind")]
52/// `GraphCheckpointTerminal` variants.
53pub enum GraphCheckpointTerminal {
54    #[serde(rename = "none")]
55    /// `None` variant.
56    None,
57    #[serde(rename = "COMPLETE")]
58    /// `Complete` variant.
59    Complete,
60    #[serde(rename = "ERROR")]
61    /// `Error` variant.
62    Error {
63        /// `error` field for `Error`.
64        error: GraphCheckpointJson,
65    },
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69#[serde(tag = "kind")]
70/// `GraphCheckpointFactory` variants.
71pub enum GraphCheckpointFactory {
72    #[serde(rename = "registry-ref")]
73    /// `RegistryRef` variant.
74    RegistryRef {
75        #[serde(rename = "ref")]
76        /// `ref_` field for ref.
77        ref_: String,
78        #[serde(skip_serializing_if = "Option::is_none")]
79        /// `config` field for config.
80        config: Option<GraphCheckpointJson>,
81        #[serde(rename = "configVersion", skip_serializing_if = "Option::is_none")]
82        /// `config_version` field for config version.
83        config_version: Option<GraphCheckpointJson>,
84    },
85    #[serde(rename = "local-only")]
86    /// `LocalOnly` variant.
87    LocalOnly {
88        /// `name` field for `LocalOnly`.
89        name: String,
90        /// `reason` field for `LocalOnly`.
91        reason: String,
92    },
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96/// `GraphCheckpointNode` data container.
97pub struct GraphCheckpointNode {
98    /// `id` field for id.
99    pub id: String,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    /// `name` field for name.
102    pub name: Option<String>,
103    /// `factory` field for factory.
104    pub factory: GraphCheckpointFactory,
105    /// `status` field for status.
106    pub status: String,
107    /// `deps` field for deps.
108    pub deps: Vec<String>,
109    /// `value` field for value.
110    pub value: GraphCheckpointValue,
111    #[serde(rename = "backendState", skip_serializing_if = "Option::is_none")]
112    /// `backend_state` field for backend state.
113    pub backend_state: Option<GraphCheckpointJson>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    /// `version` field for version.
116    pub version: Option<GraphCheckpointJson>,
117    /// `terminal` field for terminal.
118    pub terminal: GraphCheckpointTerminal,
119    /// `lifecycle` field for lifecycle.
120    pub lifecycle: GraphCheckpointLifecycle,
121    #[serde(rename = "ctxState")]
122    /// `ctx_state` field for ctx state.
123    pub ctx_state: GraphCheckpointCtxState,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    /// `meta` field for meta.
126    pub meta: Option<BTreeMap<String, GraphCheckpointJson>>,
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130/// `GraphCheckpointLifecycle` data container.
131pub struct GraphCheckpointLifecycle {
132    /// `activated` field for activated.
133    pub activated: bool,
134    #[serde(rename = "hasCalledFnOnce")]
135    /// `has_called_fn_once` field for has called fn once.
136    pub has_called_fn_once: bool,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140/// `GraphCheckpointCtxState` data container.
141pub struct GraphCheckpointCtxState {
142    /// `persist` field for persist.
143    pub persist: bool,
144    /// `value` field for value.
145    pub value: GraphCheckpointValue,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149/// `GraphCheckpointEdge` data container.
150pub struct GraphCheckpointEdge {
151    /// `from` field for from.
152    pub from: String,
153    /// `to` field for to.
154    pub to: String,
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158/// `GraphCheckpointMount` data container.
159pub struct GraphCheckpointMount {
160    /// `at` field for at.
161    pub at: String,
162    /// `checkpoint` field for checkpoint.
163    pub checkpoint: GraphCheckpoint,
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167/// `GraphCheckpoint` data container.
168pub struct GraphCheckpoint {
169    /// `version` field for version.
170    pub version: String,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    /// `name` field for name.
173    pub name: Option<String>,
174    /// `nodes` field for nodes.
175    pub nodes: Vec<GraphCheckpointNode>,
176    /// `edges` field for edges.
177    pub edges: Vec<GraphCheckpointEdge>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    /// `mounts` field for mounts.
180    pub mounts: Option<Vec<GraphCheckpointMount>>,
181}
182
183#[derive(Clone)]
184pub(crate) struct CheckpointEntry {
185    pub id: String,
186    pub name: Option<String>,
187    pub factory: String,
188    pub meta: BTreeMap<String, String>,
189    pub restore: Option<RestoreFactoryMeta>,
190    pub core: Core,
191    pub unregistered: bool,
192}
193
194#[derive(Debug, Clone)]
195/// `GraphRestoreError` data container.
196pub struct GraphRestoreError(String);
197
198impl GraphRestoreError {
199    /// Creates or computes `new`.
200    pub fn new(msg: impl Into<String>) -> Self {
201        Self(msg.into())
202    }
203}
204
205impl fmt::Display for GraphRestoreError {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        f.write_str(&self.0)
208    }
209}
210
211impl Error for GraphRestoreError {}
212
213/// `GraphRestoreResult` type alias.
214pub type GraphRestoreResult<T> = Result<T, GraphRestoreError>;
215type RestoreResult<T> = GraphRestoreResult<T>;
216type JsonDefinitionFn = dyn Fn(&GraphCheckpointJson) -> RestoreResult<GraphCheckpointJson>;
217type BackendStateContributor = dyn Fn(&str) -> Result<GraphCheckpointJson, String>;
218type CustomRestoreFn = dyn Fn(&Graph) -> RestoreResult<Core>;
219type CheckpointJsonEncoder =
220    dyn Fn(&dyn Any, &str) -> RestoreResult<GraphCheckpointJson> + Send + Sync;
221
222static CHECKPOINT_JSON_ENCODERS: OnceLock<Mutex<HashMap<TypeId, Arc<CheckpointJsonEncoder>>>> =
223    OnceLock::new();
224
225thread_local! {
226    static BACKEND_STATE_CONTRIBUTORS: RefCell<HashMap<(usize, usize, u64), Rc<BackendStateContributor>>> =
227        RefCell::new(HashMap::new());
228}
229
230/// Register a host-owned strict-JSON checkpoint encoder for an opaque DATA type.
231///
232/// This keeps host-language values out of the Rust core while letting a binding
233/// such as PyO3 make its own payload wrapper checkpointable under D90.
234#[doc(hidden)]
235pub fn register_checkpoint_json_encoder<T: 'static>(
236    encoder: impl Fn(&T, &str) -> RestoreResult<GraphCheckpointJson> + Send + Sync + 'static,
237) {
238    let encoders = CHECKPOINT_JSON_ENCODERS.get_or_init(|| Mutex::new(HashMap::new()));
239    encoders
240        .lock()
241        .expect("checkpoint JSON encoder registry poisoned")
242        .insert(
243            TypeId::of::<T>(),
244            Arc::new(move |value, path| {
245                let typed = value.downcast_ref::<T>().ok_or_else(|| {
246                    GraphRestoreError::new(format!(
247                        "checkpoint: value at {path} did not match registered checkpoint encoder type"
248                    ))
249                })?;
250                encoder(typed, path)
251            }),
252        );
253}
254
255/// D160 collection-owned backend checkpoint contributor for graph checkpoint.
256pub(crate) fn register_backend_state_contributor(
257    core: &Core,
258    contributor: Rc<BackendStateContributor>,
259) {
260    BACKEND_STATE_CONTRIBUTORS.with(|contributors| {
261        contributors
262            .borrow_mut()
263            .insert(core.identity_key(), contributor);
264    });
265}
266
267pub(crate) fn unregister_backend_state_contributor(core: &Core) {
268    unregister_backend_state_contributor_key(core.identity_key());
269}
270
271pub(crate) fn unregister_backend_state_contributor_key(key: (usize, usize, u64)) {
272    BACKEND_STATE_CONTRIBUTORS.with(|contributors| {
273        contributors.borrow_mut().remove(&key);
274    });
275}
276
277fn checkpoint_backend_state(core: &Core, path: &str) -> RestoreResult<Option<GraphCheckpointJson>> {
278    let contributor = BACKEND_STATE_CONTRIBUTORS
279        .with(|contributors| contributors.borrow().get(&core.identity_key()).cloned());
280    contributor
281        .map(|contributor| {
282            let value = contributor(path).map_err(|err| {
283                GraphRestoreError::new(format!("checkpoint: backendState at {path}: {err}"))
284            })?;
285            validate_checkpoint_json(&value, path)?;
286            Ok(value)
287        })
288        .transpose()
289}
290
291#[derive(Clone)]
292/// `GraphRestoreDefinition` data container.
293pub struct GraphRestoreDefinition {
294    /// `ref_` field for ref.
295    pub ref_: String,
296    f: Rc<JsonDefinitionFn>,
297}
298
299impl GraphRestoreDefinition {
300    /// Creates or computes `json`.
301    pub fn json(
302        ref_: impl Into<String>,
303        f: impl Fn(&GraphCheckpointJson) -> RestoreResult<GraphCheckpointJson> + 'static,
304    ) -> Self {
305        Self {
306            ref_: ref_.into(),
307            f: Rc::new(f),
308        }
309    }
310
311    fn call(&self, value: &GraphCheckpointJson) -> RestoreResult<GraphCheckpointJson> {
312        (self.f)(value)
313    }
314}
315
316/// `GraphRestoreDescriptor` behavior contract.
317pub trait GraphRestoreDescriptor {
318    /// Updates or reads `ref_`.
319    fn ref_(&self) -> &str;
320    /// Updates or reads `define`.
321    fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition>;
322}
323
324#[derive(Clone)]
325/// `GraphRestoreEntry` variants.
326pub enum GraphRestoreEntry {
327    /// `Descriptor` variant.
328    Descriptor(Rc<dyn GraphRestoreDescriptor>),
329    /// `Definition` variant.
330    Definition(GraphRestoreDefinition),
331}
332
333impl GraphRestoreEntry {
334    /// Creates or computes `descriptor`.
335    pub fn descriptor(d: impl GraphRestoreDescriptor + 'static) -> Self {
336        Self::Descriptor(Rc::new(d))
337    }
338
339    /// Creates or computes `definition`.
340    pub fn definition(d: GraphRestoreDefinition) -> Self {
341        Self::Definition(d)
342    }
343}
344
345#[derive(Clone, Default)]
346/// `GraphRestoreRegistry` data container.
347pub struct GraphRestoreRegistry {
348    entries: BTreeMap<String, GraphRestoreEntry>,
349}
350
351impl GraphRestoreRegistry {
352    /// Creates or computes `try_new`.
353    pub fn try_new(entries: impl IntoIterator<Item = GraphRestoreEntry>) -> RestoreResult<Self> {
354        let mut out = Self::default();
355        for entry in entries {
356            let key = match &entry {
357                GraphRestoreEntry::Descriptor(d) => d.ref_().to_owned(),
358                GraphRestoreEntry::Definition(d) => d.ref_.clone(),
359            };
360            if out.entries.insert(key.clone(), entry).is_some() {
361                return Err(GraphRestoreError::new(format!(
362                    "duplicate restore registry ref '{key}'"
363                )));
364            }
365        }
366        Ok(out)
367    }
368
369    /// Creates or computes `new`.
370    pub fn new(entries: impl IntoIterator<Item = GraphRestoreEntry>) -> Self {
371        Self::try_new(entries).expect("restore registry must not contain duplicate refs")
372    }
373
374    fn descriptor(&self, ref_: &str) -> Option<Rc<dyn GraphRestoreDescriptor>> {
375        match self.entries.get(ref_) {
376            Some(GraphRestoreEntry::Descriptor(d)) => Some(d.clone()),
377            _ => None,
378        }
379    }
380
381    fn definition(&self, ref_: &str) -> Option<GraphRestoreDefinition> {
382        match self.entries.get(ref_) {
383            Some(GraphRestoreEntry::Definition(d)) => Some(d.clone()),
384            _ => None,
385        }
386    }
387}
388
389/// Creates or computes `restore_registry`.
390pub fn restore_registry(
391    entries: impl IntoIterator<Item = GraphRestoreEntry>,
392) -> GraphRestoreRegistry {
393    GraphRestoreRegistry::new(entries)
394}
395
396/// Creates or computes `default_restore_registry`.
397pub fn default_restore_registry() -> GraphRestoreRegistry {
398    GraphRestoreRegistry::new([
399        GraphRestoreEntry::descriptor(StateRestoreDescriptor),
400        GraphRestoreEntry::descriptor(MapJsonRestoreDescriptor),
401        GraphRestoreEntry::descriptor(ReactiveListDeltaRestoreDescriptor),
402        GraphRestoreEntry::descriptor(ReactiveListSnapshotRestoreDescriptor),
403        GraphRestoreEntry::descriptor(ReactiveLogDeltaRestoreDescriptor),
404        GraphRestoreEntry::descriptor(ReactiveLogSnapshotRestoreDescriptor),
405        GraphRestoreEntry::descriptor(ReactiveMapDeltaRestoreDescriptor),
406        GraphRestoreEntry::descriptor(ReactiveMapSnapshotRestoreDescriptor),
407        GraphRestoreEntry::descriptor(ReactiveIndexDeltaRestoreDescriptor),
408        GraphRestoreEntry::descriptor(ReactiveIndexSnapshotRestoreDescriptor),
409    ])
410}
411
412/// `RestoreDefineCtx` data container.
413pub struct RestoreDefineCtx<'a> {
414    /// `id` field for id.
415    pub id: &'a str,
416    /// `deps` field for deps.
417    pub deps: &'a [String],
418    /// `config` field for config.
419    pub config: Option<&'a GraphCheckpointJson>,
420    /// `config_version` field for config version.
421    pub config_version: Option<&'a GraphCheckpointJson>,
422    /// `checkpoint` field for checkpoint.
423    pub checkpoint: &'a GraphCheckpointNode,
424    registry: &'a GraphRestoreRegistry,
425}
426
427impl RestoreDefineCtx<'_> {
428    /// Updates or reads `resolve_definition`.
429    pub fn resolve_definition(&self, ref_: &str) -> RestoreResult<GraphRestoreDefinition> {
430        self.registry.definition(ref_).ok_or_else(|| {
431            GraphRestoreError::new(format!(
432                "restore_graph: missing function definition for '{ref_}' (node '{}')",
433                self.id
434            ))
435        })
436    }
437}
438
439/// `RestoreNodeKind` variants.
440pub enum RestoreNodeKind {
441    /// `StateJson` variant.
442    StateJson,
443    /// `NodeJson` variant.
444    NodeJson(NodeFn),
445    /// `Custom` variant.
446    Custom(Rc<CustomRestoreFn>),
447}
448
449/// `RestoreNodeDefinition` data container.
450pub struct RestoreNodeDefinition {
451    /// `factory` field for factory.
452    pub factory: String,
453    /// `kind` field for kind.
454    pub kind: RestoreNodeKind,
455    /// `opts` field for opts.
456    pub opts: GraphNodeOpts,
457}
458
459/// `StateRestoreDescriptor` data container.
460pub struct StateRestoreDescriptor;
461
462impl GraphRestoreDescriptor for StateRestoreDescriptor {
463    fn ref_(&self) -> &str {
464        "state"
465    }
466
467    fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
468        if !ctx.deps.is_empty() {
469            return Err(GraphRestoreError::new(format!(
470                "restore_graph: state node '{}' cannot restore deps",
471                ctx.id
472            )));
473        }
474        if ctx.config.is_some() {
475            return Err(GraphRestoreError::new(
476                "restore_graph: built-in state descriptor does not accept config",
477            ));
478        }
479        if ctx.config_version.is_some() {
480            return Err(GraphRestoreError::new(
481                "restore_graph: built-in state descriptor does not accept configVersion",
482            ));
483        }
484        Ok(RestoreNodeDefinition {
485            factory: "state".to_owned(),
486            kind: RestoreNodeKind::StateJson,
487            opts: restored_opts(ctx.checkpoint)?,
488        })
489    }
490}
491
492/// `MapJsonRestoreDescriptor` data container.
493pub struct MapJsonRestoreDescriptor;
494
495impl GraphRestoreDescriptor for MapJsonRestoreDescriptor {
496    fn ref_(&self) -> &str {
497        "map"
498    }
499
500    fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
501        if ctx.deps.len() != 1 {
502            return Err(GraphRestoreError::new(format!(
503                "restore_graph: map node '{}' requires exactly one dep",
504                ctx.id
505            )));
506        }
507        let config = ctx.config.and_then(Value::as_object).ok_or_else(|| {
508            GraphRestoreError::new("restore_graph: 'map' descriptor requires object config")
509        })?;
510        if ctx.config_version.is_some() {
511            return Err(GraphRestoreError::new(
512                "restore_graph: built-in map descriptor does not accept configVersion",
513            ));
514        }
515        let fn_ref = config.get("fn").and_then(Value::as_str).ok_or_else(|| {
516            GraphRestoreError::new("restore_graph: 'map' config.fn must be a string definition ref")
517        })?;
518        let definition = ctx.resolve_definition(fn_ref)?;
519        let body: NodeFn = Rc::new(move |ctx: &Ctx| {
520            for value in ctx.batch::<GraphCheckpointJson>(0) {
521                match definition.call(value.as_ref()) {
522                    Ok(out) => ctx.emit(out),
523                    Err(err) => panic!("{err}"),
524                }
525            }
526        });
527        Ok(RestoreNodeDefinition {
528            factory: "map".to_owned(),
529            kind: RestoreNodeKind::NodeJson(body),
530            opts: restored_opts(ctx.checkpoint)?,
531        })
532    }
533}
534
535#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
536#[serde(transparent)]
537struct CheckpointJsonOrd(GraphCheckpointJson);
538
539impl Ord for CheckpointJsonOrd {
540    fn cmp(&self, other: &Self) -> CmpOrdering {
541        let left = strict_canonical_json_bytes_for_ord(&self.0);
542        let right = strict_canonical_json_bytes_for_ord(&other.0);
543        left.cmp(&right)
544    }
545}
546
547impl PartialOrd for CheckpointJsonOrd {
548    fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
549        Some(self.cmp(other))
550    }
551}
552
553fn strict_canonical_json_bytes_for_ord(value: &GraphCheckpointJson) -> Vec<u8> {
554    crate::json::strict_canonical_json_bytes(value)
555        .expect("CheckpointJsonOrd is constructed only after strict JSON validation")
556}
557
558fn collection_base_id(id: &str, suffix: &str, ref_: &str) -> RestoreResult<String> {
559    let Some(base) = id.strip_suffix(suffix) else {
560        return Err(GraphRestoreError::new(format!(
561            "restore_graph: '{ref_}' node '{id}' must end with '{suffix}'"
562        )));
563    };
564    if base.is_empty() {
565        return Err(GraphRestoreError::new(format!(
566            "restore_graph: '{ref_}' node '{id}' has no collection name"
567        )));
568    }
569    Ok(base.to_owned())
570}
571
572fn reject_collection_config(
573    config: Option<&GraphCheckpointJson>,
574    config_version: Option<&GraphCheckpointJson>,
575    ref_: &str,
576) -> RestoreResult<()> {
577    if config.is_some() || config_version.is_some() {
578        return Err(GraphRestoreError::new(format!(
579            "restore_graph: '{ref_}' descriptor does not accept config"
580        )));
581    }
582    Ok(())
583}
584
585fn collection_log_max_size_config(
586    config: Option<&GraphCheckpointJson>,
587    config_version: Option<&GraphCheckpointJson>,
588    ref_: &str,
589) -> RestoreResult<Option<usize>> {
590    if config_version.is_some() {
591        return Err(GraphRestoreError::new(format!(
592            "restore_graph: '{ref_}' descriptor does not accept configVersion"
593        )));
594    }
595    let Some(config) = config else {
596        return Ok(None);
597    };
598    let GraphCheckpointJson::Object(config) = config else {
599        return Err(GraphRestoreError::new(format!(
600            "restore_graph: '{ref_}' descriptor requires object config"
601        )));
602    };
603    if config.is_empty() {
604        return Ok(None);
605    }
606    if config.len() != 1 || !config.contains_key("maxSize") {
607        return Err(GraphRestoreError::new(format!(
608            "restore_graph: '{ref_}' config only accepts maxSize"
609        )));
610    }
611    let max_size = config
612        .get("maxSize")
613        .expect("checked")
614        .as_u64()
615        .ok_or_else(|| {
616            GraphRestoreError::new(format!(
617                "restore_graph: '{ref_}' config.maxSize must be a positive integer"
618            ))
619        })?;
620    if max_size == 0 {
621        return Err(GraphRestoreError::new(format!(
622            "restore_graph: '{ref_}' config.maxSize must be a positive integer"
623        )));
624    }
625    usize::try_from(max_size).map(Some).map_err(|_| {
626        GraphRestoreError::new(format!(
627            "restore_graph: '{ref_}' config.maxSize exceeds usize"
628        ))
629    })
630}
631
632fn backend_array(
633    node: &GraphCheckpointNode,
634    ref_: &str,
635) -> RestoreResult<Vec<GraphCheckpointJson>> {
636    let Some(GraphCheckpointJson::Array(items)) = &node.backend_state else {
637        return Err(GraphRestoreError::new(format!(
638            "restore_graph: '{ref_}' node '{}' requires array backendState",
639            node.id
640        )));
641    };
642    Ok(items.clone())
643}
644
645fn map_entries(
646    node: &GraphCheckpointNode,
647    ref_: &str,
648) -> RestoreResult<Vec<(CheckpointJsonOrd, GraphCheckpointJson)>> {
649    let mut out = Vec::new();
650    let mut seen = BTreeSet::new();
651    for (i, entry) in backend_array(node, ref_)?.into_iter().enumerate() {
652        let GraphCheckpointJson::Array(pair) = entry else {
653            return Err(GraphRestoreError::new(format!(
654                "restore_graph: {}.backendState[{i}] must be a [key,value] map entry",
655                node.id
656            )));
657        };
658        if pair.len() != 2 {
659            return Err(GraphRestoreError::new(format!(
660                "restore_graph: {}.backendState[{i}] must be a [key,value] map entry",
661                node.id
662            )));
663        }
664        let key = CheckpointJsonOrd(pair[0].clone());
665        if !seen.insert(key.clone()) {
666            return Err(GraphRestoreError::new(format!(
667                "restore_graph: {}.backendState[{i}][0] duplicates an earlier map key",
668                node.id
669            )));
670        }
671        out.push((key, pair[1].clone()));
672    }
673    Ok(out)
674}
675
676fn index_rows(
677    node: &GraphCheckpointNode,
678    ref_: &str,
679) -> RestoreResult<
680    Vec<
681        crate::data_structures::IndexRow<CheckpointJsonOrd, CheckpointJsonOrd, GraphCheckpointJson>,
682    >,
683> {
684    let mut out = Vec::new();
685    let mut seen = BTreeSet::new();
686    for (i, row) in backend_array(node, ref_)?.into_iter().enumerate() {
687        let GraphCheckpointJson::Object(obj) = row else {
688            return Err(GraphRestoreError::new(format!(
689                "restore_graph: {}.backendState[{i}] must be an index row object",
690                node.id
691            )));
692        };
693        if obj.len() != 3
694            || !obj.contains_key("primary")
695            || !obj.contains_key("secondary")
696            || !obj.contains_key("value")
697        {
698            return Err(GraphRestoreError::new(format!(
699                "restore_graph: {}.backendState[{i}] must contain primary, secondary, and value",
700                node.id
701            )));
702        }
703        let primary = CheckpointJsonOrd(obj.get("primary").expect("checked").clone());
704        if !seen.insert(primary.clone()) {
705            return Err(GraphRestoreError::new(format!(
706                "restore_graph: {}.backendState[{i}].primary duplicates an earlier index row",
707                node.id
708            )));
709        }
710        out.push(crate::data_structures::IndexRow {
711            primary,
712            secondary: CheckpointJsonOrd(obj.get("secondary").expect("checked").clone()),
713            value: obj.get("value").expect("checked").clone(),
714        });
715    }
716    Ok(out)
717}
718
719/// `ReactiveListDeltaRestoreDescriptor` data container.
720pub struct ReactiveListDeltaRestoreDescriptor;
721/// `ReactiveListSnapshotRestoreDescriptor` data container.
722pub struct ReactiveListSnapshotRestoreDescriptor;
723/// `ReactiveLogDeltaRestoreDescriptor` data container.
724pub struct ReactiveLogDeltaRestoreDescriptor;
725/// `ReactiveLogSnapshotRestoreDescriptor` data container.
726pub struct ReactiveLogSnapshotRestoreDescriptor;
727/// `ReactiveMapDeltaRestoreDescriptor` data container.
728pub struct ReactiveMapDeltaRestoreDescriptor;
729/// `ReactiveMapSnapshotRestoreDescriptor` data container.
730pub struct ReactiveMapSnapshotRestoreDescriptor;
731/// `ReactiveIndexDeltaRestoreDescriptor` data container.
732pub struct ReactiveIndexDeltaRestoreDescriptor;
733/// `ReactiveIndexSnapshotRestoreDescriptor` data container.
734pub struct ReactiveIndexSnapshotRestoreDescriptor;
735
736impl GraphRestoreDescriptor for ReactiveListDeltaRestoreDescriptor {
737    fn ref_(&self) -> &str {
738        "reactiveList.delta"
739    }
740
741    fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
742        collection_delta_definition(
743            ctx,
744            "reactiveList.delta",
745            ".delta",
746            false,
747            |graph: &Graph, base: String, node: &GraphCheckpointNode| {
748                let collection = crate::data_structures::reactive_list::<GraphCheckpointJson>(
749                    backend_array(node, "reactiveList.delta")?,
750                    crate::data_structures::ReactiveListOptions::named(base).graph(graph.clone()),
751                );
752                Ok(collection.delta.erased())
753            },
754        )
755    }
756}
757
758impl GraphRestoreDescriptor for ReactiveListSnapshotRestoreDescriptor {
759    fn ref_(&self) -> &str {
760        "reactiveList.snapshot"
761    }
762
763    fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
764        collection_existing_definition(ctx, "reactiveList.snapshot", ".snapshot")
765    }
766}
767
768impl GraphRestoreDescriptor for ReactiveLogDeltaRestoreDescriptor {
769    fn ref_(&self) -> &str {
770        "reactiveLog.delta"
771    }
772
773    fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
774        let max_size = collection_log_max_size_config(ctx.config, ctx.config_version, self.ref_())?;
775        collection_delta_definition(
776            ctx,
777            "reactiveLog.delta",
778            ".delta",
779            true,
780            move |graph: &Graph, base: String, node: &GraphCheckpointNode| {
781                let state = backend_array(node, "reactiveLog.delta")?;
782                if let Some(max_size) = max_size {
783                    if state.len() > max_size {
784                        return Err(GraphRestoreError::new(format!(
785                        "restore_graph: 'reactiveLog.delta' node '{}' backendState exceeds config.maxSize",
786                        node.id
787                    )));
788                    }
789                }
790                let mut options =
791                    crate::data_structures::ReactiveLogOptions::named(base).graph(graph.clone());
792                if let Some(max_size) = max_size {
793                    options = options.max_size(max_size);
794                }
795                let collection =
796                    crate::data_structures::reactive_log::<GraphCheckpointJson>(state, options);
797                Ok(collection.delta.erased())
798            },
799        )
800    }
801}
802
803impl GraphRestoreDescriptor for ReactiveLogSnapshotRestoreDescriptor {
804    fn ref_(&self) -> &str {
805        "reactiveLog.snapshot"
806    }
807
808    fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
809        collection_existing_definition(ctx, "reactiveLog.snapshot", ".snapshot")
810    }
811}
812
813impl GraphRestoreDescriptor for ReactiveMapDeltaRestoreDescriptor {
814    fn ref_(&self) -> &str {
815        "reactiveMap.delta"
816    }
817
818    fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
819        collection_delta_definition(
820            ctx,
821            "reactiveMap.delta",
822            ".delta",
823            false,
824            |graph: &Graph, base: String, node: &GraphCheckpointNode| {
825                let collection =
826                    crate::data_structures::reactive_map::<CheckpointJsonOrd, GraphCheckpointJson>(
827                        map_entries(node, "reactiveMap.delta")?,
828                        crate::data_structures::ReactiveMapOptions::named(base)
829                            .graph(graph.clone()),
830                    );
831                Ok(collection.delta.erased())
832            },
833        )
834    }
835}
836
837impl GraphRestoreDescriptor for ReactiveMapSnapshotRestoreDescriptor {
838    fn ref_(&self) -> &str {
839        "reactiveMap.snapshot"
840    }
841
842    fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
843        collection_existing_definition(ctx, "reactiveMap.snapshot", ".snapshot")
844    }
845}
846
847impl GraphRestoreDescriptor for ReactiveIndexDeltaRestoreDescriptor {
848    fn ref_(&self) -> &str {
849        "reactiveIndex.delta"
850    }
851
852    fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
853        collection_delta_definition(
854            ctx,
855            "reactiveIndex.delta",
856            ".delta",
857            false,
858            |graph: &Graph, base: String, node: &GraphCheckpointNode| {
859                let collection = crate::data_structures::reactive_index::<
860                    CheckpointJsonOrd,
861                    CheckpointJsonOrd,
862                    GraphCheckpointJson,
863                >(
864                    index_rows(node, "reactiveIndex.delta")?,
865                    crate::data_structures::ReactiveIndexOptions::named(base).graph(graph.clone()),
866                );
867                Ok(collection.delta.erased())
868            },
869        )
870    }
871}
872
873impl GraphRestoreDescriptor for ReactiveIndexSnapshotRestoreDescriptor {
874    fn ref_(&self) -> &str {
875        "reactiveIndex.snapshot"
876    }
877
878    fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
879        collection_existing_definition(ctx, "reactiveIndex.snapshot", ".snapshot")
880    }
881}
882
883fn collection_delta_definition(
884    ctx: RestoreDefineCtx<'_>,
885    ref_: &'static str,
886    suffix: &str,
887    allow_config: bool,
888    restore: impl Fn(&Graph, String, &GraphCheckpointNode) -> RestoreResult<Core> + 'static,
889) -> RestoreResult<RestoreNodeDefinition> {
890    if !ctx.deps.is_empty() {
891        return Err(GraphRestoreError::new(format!(
892            "restore_graph: '{ref_}' node '{}' cannot restore deps",
893            ctx.id
894        )));
895    }
896    if !allow_config {
897        reject_collection_config(ctx.config, ctx.config_version, ref_)?;
898    }
899    let base = collection_base_id(ctx.id, suffix, ref_)?;
900    let checkpoint = ctx.checkpoint.clone();
901    Ok(RestoreNodeDefinition {
902        factory: ref_.to_owned(),
903        kind: RestoreNodeKind::Custom(Rc::new(move |graph| {
904            restore(graph, base.clone(), &checkpoint)
905        })),
906        opts: restored_opts(ctx.checkpoint)?,
907    })
908}
909
910fn collection_existing_definition(
911    ctx: RestoreDefineCtx<'_>,
912    ref_: &'static str,
913    suffix: &str,
914) -> RestoreResult<RestoreNodeDefinition> {
915    reject_collection_config(ctx.config, ctx.config_version, ref_)?;
916    let id = ctx.id.to_owned();
917    let _base = collection_base_id(ctx.id, suffix, ref_)?;
918    Ok(RestoreNodeDefinition {
919        factory: ref_.to_owned(),
920        kind: RestoreNodeKind::Custom(Rc::new(move |graph| {
921            graph.find(&id).map(|node| node.core()).ok_or_else(|| {
922                GraphRestoreError::new(format!(
923                    "restore_graph: '{ref_}' node '{id}' was not restored with its collection"
924                ))
925            })
926        })),
927        opts: restored_opts(ctx.checkpoint)?,
928    })
929}
930
931#[derive(Clone)]
932/// `RestoreGraphOptions` data container.
933pub struct RestoreGraphOptions {
934    /// `registry` field for registry.
935    pub registry: GraphRestoreRegistry,
936    /// `graph` field for graph.
937    pub graph: GraphOptions,
938}
939
940impl RestoreGraphOptions {
941    /// Creates or computes `new`.
942    pub fn new(registry: GraphRestoreRegistry) -> Self {
943        Self {
944            registry,
945            graph: GraphOptions::default(),
946        }
947    }
948}
949
950impl Graph {
951    /// Updates or reads `checkpoint`.
952    pub fn checkpoint(&self) -> RestoreResult<GraphCheckpoint> {
953        checkpoint_graph(self)
954    }
955}
956
957/// Creates or computes `restore_graph`.
958pub fn restore_graph(
959    checkpoint: GraphCheckpoint,
960    options: RestoreGraphOptions,
961) -> RestoreResult<Graph> {
962    let prepared = prepare_checkpoint(&checkpoint, &options.registry, "checkpoint", None)?;
963    construct_prepared(&prepared, &options)
964}
965
966fn checkpoint_graph(graph: &Graph) -> RestoreResult<GraphCheckpoint> {
967    let mut entries = graph.checkpoint_entries();
968    let mut i = 0;
969    while i < entries.len() {
970        let deps = entries[i].core.deps();
971        for dep in deps {
972            if entries.iter().any(|candidate| candidate.core.ptr_eq(&dep)) {
973                continue;
974            }
975            entries.push(CheckpointEntry {
976                id: graph.checkpoint_synthetic_id_for_core(&dep),
977                name: None,
978                factory: dep.factory().unwrap_or_else(|| "?".to_owned()),
979                meta: BTreeMap::new(),
980                restore: None,
981                core: dep,
982                unregistered: true,
983            });
984        }
985        i += 1;
986    }
987    let mut nodes = Vec::with_capacity(entries.len());
988    let mut edges = Vec::new();
989    for entry in &entries {
990        let runtime = entry.core.checkpoint_runtime();
991        ensure_quiescent_status(runtime.status, &entry.id, "checkpoint")?;
992        let deps = entry.core.deps();
993        let dep_ids = deps
994            .iter()
995            .map(|dep| {
996                entries
997                    .iter()
998                    .find(|candidate| candidate.core.ptr_eq(dep))
999                    .map(|candidate| candidate.id.clone())
1000                    .ok_or_else(|| {
1001                        GraphRestoreError::new(format!(
1002                            "checkpoint: node '{}' has an unregistered dep; local-only auto-discovery restore is not implemented",
1003                            entry.id
1004                        ))
1005                    })
1006            })
1007            .collect::<RestoreResult<Vec<_>>>()?;
1008        edges.extend(dep_ids.iter().map(|dep| GraphCheckpointEdge {
1009            from: dep.clone(),
1010            to: entry.id.clone(),
1011        }));
1012        let backend_state =
1013            checkpoint_backend_state(&entry.core, &format!("{}.backendState", entry.id))?;
1014        let non_authoritative_collection_helper =
1015            is_non_authoritative_collection_helper(entry.meta.get("kind").map(String::as_str));
1016        nodes.push(GraphCheckpointNode {
1017            id: entry.id.clone(),
1018            name: entry.name.clone(),
1019            factory: checkpoint_factory(entry),
1020            status: status_to_str(runtime.status).to_owned(),
1021            deps: dep_ids,
1022            value: if non_authoritative_collection_helper {
1023                GraphCheckpointValue::Sentinel
1024            } else {
1025                checkpoint_value(runtime.cache.as_ref(), runtime.has_data, &entry.id)?
1026            },
1027            backend_state,
1028            version: runtime.version.as_ref().map(node_version_to_json),
1029            terminal: if runtime.terminal {
1030                match runtime.status {
1031                    Status::Completed => GraphCheckpointTerminal::Complete,
1032                    Status::Errored => {
1033                        return Err(GraphRestoreError::new(format!(
1034                            "checkpoint: ERROR payload for node '{}' is unavailable; cannot serialize a placeholder",
1035                            entry.id
1036                        )));
1037                    }
1038                    _ => GraphCheckpointTerminal::None,
1039                }
1040            } else {
1041                GraphCheckpointTerminal::None
1042            },
1043            lifecycle: GraphCheckpointLifecycle {
1044                activated: runtime.activated,
1045                has_called_fn_once: runtime.has_called_fn_once,
1046            },
1047            ctx_state: GraphCheckpointCtxState {
1048                persist: runtime.ctx_state_persist,
1049                value: if non_authoritative_collection_helper {
1050                    GraphCheckpointValue::Sentinel
1051                } else {
1052                    checkpoint_value(
1053                        runtime.ctx_state.as_ref(),
1054                        runtime.ctx_state.is_some(),
1055                        &format!("{}.ctxState", entry.id),
1056                    )?
1057                },
1058            },
1059            meta: if entry.meta.is_empty() {
1060                None
1061            } else {
1062                Some(
1063                    entry
1064                        .meta
1065                        .iter()
1066                        .map(|(k, v)| (k.clone(), GraphCheckpointJson::String(v.clone())))
1067                        .collect(),
1068                )
1069            },
1070        });
1071    }
1072    let mounts = graph
1073        .checkpoint_mounts()
1074        .into_iter()
1075        .map(|(at, child)| {
1076            Ok(GraphCheckpointMount {
1077                at,
1078                checkpoint: checkpoint_graph(&child)?,
1079            })
1080        })
1081        .collect::<RestoreResult<Vec<_>>>()?;
1082    Ok(GraphCheckpoint {
1083        version: GRAPH_CHECKPOINT_VERSION.to_owned(),
1084        name: graph.name().map(str::to_owned),
1085        nodes,
1086        edges,
1087        mounts: if mounts.is_empty() {
1088            None
1089        } else {
1090            Some(mounts)
1091        },
1092    })
1093}
1094
1095fn is_non_authoritative_collection_helper(kind: Option<&str>) -> bool {
1096    matches!(
1097        kind,
1098        Some(
1099            "collection_delta"
1100                | "collection_snapshot"
1101                | "collection_view_delta"
1102                | "collection_view_snapshot"
1103        )
1104    )
1105}
1106
1107fn checkpoint_factory(entry: &CheckpointEntry) -> GraphCheckpointFactory {
1108    if entry.unregistered {
1109        return GraphCheckpointFactory::LocalOnly {
1110            name: entry.factory.clone(),
1111            reason: "node is an unregistered live dependency auto-discovered from topology"
1112                .to_owned(),
1113        };
1114    }
1115    if let Some(restore) = &entry.restore {
1116        return GraphCheckpointFactory::RegistryRef {
1117            ref_: restore.ref_.clone(),
1118            config: restore.config.clone(),
1119            config_version: restore.config_version.clone(),
1120        };
1121    }
1122    if entry.factory == "state" {
1123        return GraphCheckpointFactory::RegistryRef {
1124            ref_: "state".to_owned(),
1125            config: None,
1126            config_version: None,
1127        };
1128    }
1129    GraphCheckpointFactory::LocalOnly {
1130        name: entry.factory.clone(),
1131        reason: "node was not constructed with restore metadata".to_owned(),
1132    }
1133}
1134
1135fn checkpoint_value(
1136    value: Option<&AnyValue>,
1137    has_data: bool,
1138    path: &str,
1139) -> RestoreResult<GraphCheckpointValue> {
1140    if !has_data {
1141        return Ok(GraphCheckpointValue::Sentinel);
1142    }
1143    let value = value.ok_or_else(|| {
1144        GraphRestoreError::new(format!(
1145            "checkpoint: value at {path} is marked DATA but absent"
1146        ))
1147    })?;
1148    Ok(GraphCheckpointValue::Data {
1149        data: any_to_json(value, path)?,
1150    })
1151}
1152
1153fn any_to_json(value: &AnyValue, path: &str) -> RestoreResult<GraphCheckpointJson> {
1154    let encoder = CHECKPOINT_JSON_ENCODERS
1155        .get_or_init(|| Mutex::new(HashMap::new()))
1156        .lock()
1157        .map_err(|_| GraphRestoreError::new("checkpoint JSON encoder registry poisoned"))?
1158        .get(&value.as_ref().type_id())
1159        .cloned();
1160    if let Some(encoder) = encoder {
1161        let out = encoder(value.as_ref(), path)?;
1162        validate_checkpoint_json(&out, path)?;
1163        return Ok(out);
1164    }
1165    if let Some(v) = value.downcast_ref::<GraphCheckpointJson>() {
1166        validate_checkpoint_json(v, path)?;
1167        return Ok(v.clone());
1168    }
1169    if let Some(v) = value.downcast_ref::<String>() {
1170        return Ok(Value::String(v.clone()));
1171    }
1172    if let Some(v) = value.downcast_ref::<bool>() {
1173        return Ok(Value::Bool(*v));
1174    }
1175    if let Some(v) = value.downcast_ref::<i32>() {
1176        return Ok(Value::Number(Number::from(*v)));
1177    }
1178    if let Some(v) = value.downcast_ref::<i64>() {
1179        return Ok(Value::Number(Number::from(*v)));
1180    }
1181    if let Some(v) = value.downcast_ref::<u32>() {
1182        return Ok(Value::Number(Number::from(*v)));
1183    }
1184    if let Some(v) = value.downcast_ref::<u64>() {
1185        return Ok(Value::Number(Number::from(*v)));
1186    }
1187    if let Some(v) = value.downcast_ref::<usize>() {
1188        return Ok(Value::Number(Number::from(*v as u64)));
1189    }
1190    if let Some(v) = value.downcast_ref::<f64>() {
1191        if let Some(n) = Number::from_f64(*v) {
1192            let out = Value::Number(n);
1193            validate_checkpoint_json(&out, path)?;
1194            return Ok(out);
1195        }
1196    }
1197    Err(GraphRestoreError::new(format!(
1198        "checkpoint: value at {path} is not strict JSON compatible"
1199    )))
1200}
1201
1202fn validate_checkpoint_json(value: &GraphCheckpointJson, path: &str) -> RestoreResult<()> {
1203    validate_strict_json_value(value, path)
1204        .map_err(|err| GraphRestoreError::new(format!("checkpoint: {err}")))
1205}
1206
1207/// Creates or computes `restored_opts`.
1208pub fn restored_opts(node: &GraphCheckpointNode) -> RestoreResult<GraphNodeOpts> {
1209    let meta = node
1210        .meta
1211        .clone()
1212        .unwrap_or_default()
1213        .into_iter()
1214        .map(|(k, v)| {
1215            let value = v.as_str().ok_or_else(|| {
1216                GraphRestoreError::new(format!(
1217                    "restore_graph: node '{}' meta field '{}' must be a string in Rust",
1218                    node.id, k
1219                ))
1220            })?;
1221            Ok((k, value.to_owned()))
1222        })
1223        .collect::<RestoreResult<BTreeMap<_, _>>>()?;
1224    let restore = match &node.factory {
1225        GraphCheckpointFactory::RegistryRef {
1226            ref_,
1227            config,
1228            config_version,
1229        } => Some(RestoreFactoryMeta {
1230            ref_: ref_.clone(),
1231            config: config.clone(),
1232            config_version: config_version.clone(),
1233        }),
1234        GraphCheckpointFactory::LocalOnly { .. } => None,
1235    };
1236    Ok(GraphNodeOpts {
1237        name: node.name.clone(),
1238        meta,
1239        restore,
1240        ..GraphNodeOpts::default()
1241    })
1242}
1243
1244struct PreparedNode {
1245    checkpoint: GraphCheckpointNode,
1246    descriptor: Rc<dyn GraphRestoreDescriptor>,
1247    deps: Vec<String>,
1248}
1249
1250struct PreparedCheckpoint {
1251    checkpoint: GraphCheckpoint,
1252    nodes: BTreeMap<String, PreparedNode>,
1253    mounts: Vec<(String, PreparedCheckpoint)>,
1254}
1255
1256fn prepare_checkpoint(
1257    checkpoint: &GraphCheckpoint,
1258    registry: &GraphRestoreRegistry,
1259    path: &str,
1260    mount_at: Option<&str>,
1261) -> RestoreResult<PreparedCheckpoint> {
1262    if checkpoint.version != GRAPH_CHECKPOINT_VERSION {
1263        return Err(GraphRestoreError::new(format!(
1264            "restore_graph: unsupported checkpoint version at {path}"
1265        )));
1266    }
1267    let mut nodes = BTreeMap::new();
1268    for node in &checkpoint.nodes {
1269        if nodes.contains_key(&node.id) {
1270            return Err(GraphRestoreError::new(format!(
1271                "restore_graph: duplicate node id '{}'",
1272                node.id
1273            )));
1274        }
1275        if let Some(at) = mount_at {
1276            let prefix = format!("{at}::");
1277            if node.id.starts_with(&prefix) {
1278                return Err(GraphRestoreError::new(format!(
1279                    "restore_graph: mounted checkpoint at '{at}' must use child-local node id '{}', not '{}'",
1280                    node.id.trim_start_matches(&prefix),
1281                    node.id
1282                )));
1283            }
1284        }
1285        let descriptor = match &node.factory {
1286            GraphCheckpointFactory::LocalOnly { name, reason } => {
1287                return Err(GraphRestoreError::new(format!(
1288                    "restore_graph: node '{}' uses local-only factory '{}' ({reason})",
1289                    node.id, name
1290                )));
1291            }
1292            GraphCheckpointFactory::RegistryRef { ref_, .. } => {
1293                registry.descriptor(ref_).ok_or_else(|| {
1294                    GraphRestoreError::new(format!(
1295                        "restore_graph: missing registry descriptor for '{ref_}' (node '{}')",
1296                        node.id
1297                    ))
1298                })?
1299            }
1300        };
1301        let status = status_from_str(&node.status, &node.id)?;
1302        ensure_quiescent_status(status, &node.id, "restore_graph")?;
1303        validate_checkpoint_node_json(node)?;
1304        if let Some(version) = &node.version {
1305            validate_node_version_json(version, &format!("{}.version", node.id))
1306                .map_err(|err| GraphRestoreError::new(err.to_string()))?;
1307        }
1308        nodes.insert(
1309            node.id.clone(),
1310            PreparedNode {
1311                checkpoint: node.clone(),
1312                descriptor,
1313                deps: node.deps.clone(),
1314            },
1315        );
1316    }
1317    for node in nodes.values() {
1318        for dep in &node.deps {
1319            if !nodes.contains_key(dep) {
1320                return Err(GraphRestoreError::new(format!(
1321                    "restore_graph: node '{}' has missing dep '{dep}'",
1322                    node.checkpoint.id
1323                )));
1324            }
1325        }
1326    }
1327    let mut edge_keys = BTreeSet::new();
1328    for edge in &checkpoint.edges {
1329        if !edge_keys.insert((edge.from.clone(), edge.to.clone())) {
1330            return Err(GraphRestoreError::new(format!(
1331                "restore_graph: duplicate edge '{}' -> '{}'",
1332                edge.from, edge.to
1333            )));
1334        }
1335        let Some(target) = nodes.get(&edge.to) else {
1336            return Err(GraphRestoreError::new(format!(
1337                "restore_graph: edge '{}' -> '{}' references a missing node",
1338                edge.from, edge.to
1339            )));
1340        };
1341        if !nodes.contains_key(&edge.from) || !target.deps.contains(&edge.from) {
1342            return Err(GraphRestoreError::new(format!(
1343                "restore_graph: edge '{}' -> '{}' is not present in target deps",
1344                edge.from, edge.to
1345            )));
1346        }
1347    }
1348    for node in nodes.values() {
1349        for dep in &node.deps {
1350            if !edge_keys.contains(&(dep.clone(), node.checkpoint.id.clone())) {
1351                return Err(GraphRestoreError::new(format!(
1352                    "restore_graph: node '{}' dep '{dep}' is missing its edge",
1353                    node.checkpoint.id
1354                )));
1355            }
1356        }
1357    }
1358    let mut mount_paths = BTreeSet::new();
1359    let mut mounts = Vec::new();
1360    for mount in checkpoint.mounts.clone().unwrap_or_default() {
1361        if mount.at.is_empty() {
1362            return Err(GraphRestoreError::new(format!(
1363                "restore_graph: mount path at {path}.mounts[] must not be empty"
1364            )));
1365        }
1366        if !mount_paths.insert(mount.at.clone()) {
1367            return Err(GraphRestoreError::new(format!(
1368                "restore_graph: duplicate mount path '{}'",
1369                mount.at
1370            )));
1371        }
1372        mounts.push((
1373            mount.at.clone(),
1374            prepare_checkpoint(
1375                &mount.checkpoint,
1376                registry,
1377                &format!("{path}.mounts.{}", mount.at),
1378                Some(&mount.at),
1379            )?,
1380        ));
1381    }
1382    Ok(PreparedCheckpoint {
1383        checkpoint: checkpoint.clone(),
1384        nodes,
1385        mounts,
1386    })
1387}
1388
1389fn validate_checkpoint_node_json(node: &GraphCheckpointNode) -> RestoreResult<()> {
1390    if let GraphCheckpointFactory::RegistryRef {
1391        config,
1392        config_version,
1393        ..
1394    } = &node.factory
1395    {
1396        if let Some(config) = config {
1397            validate_checkpoint_json(config, &format!("{}.factory.config", node.id))?;
1398        }
1399        if let Some(config_version) = config_version {
1400            validate_checkpoint_json(
1401                config_version,
1402                &format!("{}.factory.configVersion", node.id),
1403            )?;
1404        }
1405    }
1406    if let GraphCheckpointValue::Data { data } = &node.value {
1407        validate_checkpoint_json(data, &format!("{}.value", node.id))?;
1408    }
1409    if let GraphCheckpointValue::Data { data } = &node.ctx_state.value {
1410        validate_checkpoint_json(data, &format!("{}.ctxState", node.id))?;
1411    }
1412    if let Some(backend_state) = &node.backend_state {
1413        validate_checkpoint_json(backend_state, &format!("{}.backendState", node.id))?;
1414    }
1415    if let GraphCheckpointTerminal::Error { error } = &node.terminal {
1416        validate_checkpoint_json(error, &format!("{}.terminal.error", node.id))?;
1417    }
1418    if let Some(meta) = &node.meta {
1419        for (key, value) in meta {
1420            validate_checkpoint_json(value, &format!("{}.meta.{key}", node.id))?;
1421        }
1422    }
1423    Ok(())
1424}
1425
1426fn construct_prepared(
1427    prepared: &PreparedCheckpoint,
1428    options: &RestoreGraphOptions,
1429) -> RestoreResult<Graph> {
1430    let mut opts = options.graph.clone();
1431    opts.name = prepared.checkpoint.name.clone();
1432    let graph = crate::graph::graph_opts(opts);
1433    let mut built = BTreeMap::<String, Core>::new();
1434    let mut visiting = BTreeSet::<String>::new();
1435    for id in prepared.nodes.keys() {
1436        build_node(
1437            id,
1438            prepared,
1439            &options.registry,
1440            &graph,
1441            &mut built,
1442            &mut visiting,
1443        )?;
1444    }
1445    for (at, child) in &prepared.mounts {
1446        graph.mount_restored(construct_prepared(child, options)?, at.clone());
1447    }
1448    for (id, core) in &built {
1449        let node = &prepared
1450            .nodes
1451            .get(id)
1452            .expect("built map only contains prepared nodes")
1453            .checkpoint;
1454        let (cache, has_data) = restore_value(&node.value);
1455        let (terminal, status) = restore_terminal(node)?;
1456        let (ctx_state, ctx_state_persist) = restore_ctx_state(&node.ctx_state);
1457        let restored_version = verify_restored_node_version(
1458            &core.versioning_policy(),
1459            node.version.as_ref(),
1460            has_data,
1461            cache.as_ref(),
1462            &format!("{}.version", node.id),
1463        )
1464        .map_err(|err| GraphRestoreError::new(err.to_string()))?;
1465        core.restore_runtime(NodeRestoreRuntime {
1466            cache,
1467            has_data,
1468            version: restored_version,
1469            status,
1470            terminal,
1471            activated: node.lifecycle.activated,
1472            has_called_fn_once: node.lifecycle.has_called_fn_once,
1473            ctx_state,
1474            ctx_state_persist,
1475        });
1476    }
1477    Ok(graph)
1478}
1479
1480fn build_node(
1481    id: &str,
1482    prepared: &PreparedCheckpoint,
1483    registry: &GraphRestoreRegistry,
1484    graph: &Graph,
1485    built: &mut BTreeMap<String, Core>,
1486    visiting: &mut BTreeSet<String>,
1487) -> RestoreResult<Core> {
1488    if let Some(core) = built.get(id) {
1489        return Ok(core.clone());
1490    }
1491    if !visiting.insert(id.to_owned()) {
1492        return Err(GraphRestoreError::new(format!(
1493            "restore_graph: dependency cycle at '{id}'"
1494        )));
1495    }
1496    let item = prepared.nodes.get(id).ok_or_else(|| {
1497        GraphRestoreError::new(format!("restore_graph: missing prepared node '{id}'"))
1498    })?;
1499    let deps = item
1500        .deps
1501        .iter()
1502        .map(|dep| build_node(dep, prepared, registry, graph, built, visiting))
1503        .collect::<RestoreResult<Vec<_>>>()?;
1504    let (config, config_version) = match &item.checkpoint.factory {
1505        GraphCheckpointFactory::RegistryRef {
1506            config,
1507            config_version,
1508            ..
1509        } => (config.as_ref(), config_version.as_ref()),
1510        GraphCheckpointFactory::LocalOnly { .. } => (None, None),
1511    };
1512    let definition = item.descriptor.define(RestoreDefineCtx {
1513        id,
1514        deps: &item.deps,
1515        config,
1516        config_version,
1517        checkpoint: &item.checkpoint,
1518        registry,
1519    })?;
1520    let core = match definition.kind {
1521        RestoreNodeKind::StateJson => graph
1522            .restore_state_json_with_id(id.to_owned(), definition.opts)
1523            .erased(),
1524        RestoreNodeKind::NodeJson(body) => graph
1525            .restore_node_with_id(
1526                id.to_owned(),
1527                definition.factory,
1528                deps.clone(),
1529                body,
1530                definition.opts,
1531            )
1532            .erased(),
1533        RestoreNodeKind::Custom(restore) => restore(graph)?,
1534    };
1535    let restored_deps = core.deps();
1536    if restored_deps.len() != deps.len()
1537        || restored_deps
1538            .iter()
1539            .zip(deps.iter())
1540            .any(|(restored, expected)| !restored.ptr_eq(expected))
1541    {
1542        return Err(GraphRestoreError::new(format!(
1543            "restore_graph: descriptor '{}' registered node '{}' with deps that do not match the checkpoint",
1544            item.descriptor.ref_(),
1545            id
1546        )));
1547    }
1548    built.insert(id.to_owned(), core.clone());
1549    visiting.remove(id);
1550    Ok(core)
1551}
1552
1553fn restore_value(value: &GraphCheckpointValue) -> (Option<AnyValue>, bool) {
1554    match value {
1555        GraphCheckpointValue::Sentinel => (None, false),
1556        GraphCheckpointValue::Data { data } => (Some(Rc::new(data.clone()) as AnyValue), true),
1557    }
1558}
1559
1560fn restore_ctx_state(state: &GraphCheckpointCtxState) -> (Option<AnyValue>, bool) {
1561    let (value, has_data) = restore_value(&state.value);
1562    (if has_data { value } else { None }, state.persist)
1563}
1564
1565fn restore_terminal(node: &GraphCheckpointNode) -> RestoreResult<(bool, Status)> {
1566    let status = status_from_str(&node.status, &node.id)?;
1567    match &node.terminal {
1568        GraphCheckpointTerminal::None => {
1569            if status.is_terminal() {
1570                return Err(GraphRestoreError::new(format!(
1571                    "restore_graph: node '{}' terminal status requires terminal state",
1572                    node.id
1573                )));
1574            }
1575            Ok((false, status))
1576        }
1577        GraphCheckpointTerminal::Complete => {
1578            if status != Status::Completed {
1579                return Err(GraphRestoreError::new(format!(
1580                    "restore_graph: node '{}' COMPLETE terminal requires completed status",
1581                    node.id
1582                )));
1583            }
1584            Ok((true, status))
1585        }
1586        GraphCheckpointTerminal::Error { .. } => Err(GraphRestoreError::new(format!(
1587            "restore_graph: node '{}' carries ERROR terminal payload, which Rust restore cannot preserve yet",
1588            node.id
1589        ))),
1590    }
1591}
1592
1593fn status_to_str(status: Status) -> &'static str {
1594    match status {
1595        Status::Sentinel => "sentinel",
1596        Status::Pending => "pending",
1597        Status::Dirty => "dirty",
1598        Status::Settled => "settled",
1599        Status::Resolved => "resolved",
1600        Status::Completed => "completed",
1601        Status::Errored => "errored",
1602    }
1603}
1604
1605fn status_from_str(status: &str, id: &str) -> RestoreResult<Status> {
1606    match status {
1607        "sentinel" => Ok(Status::Sentinel),
1608        "pending" => Ok(Status::Pending),
1609        "dirty" => Ok(Status::Dirty),
1610        "settled" => Ok(Status::Settled),
1611        "resolved" => Ok(Status::Resolved),
1612        "completed" => Ok(Status::Completed),
1613        "errored" => Ok(Status::Errored),
1614        _ => Err(GraphRestoreError::new(format!(
1615            "restore_graph: node '{id}' has invalid status '{status}'"
1616        ))),
1617    }
1618}
1619
1620fn ensure_quiescent_status(status: Status, id: &str, op: &str) -> RestoreResult<()> {
1621    if matches!(status, Status::Pending | Status::Dirty) {
1622        return Err(GraphRestoreError::new(format!(
1623            "{op}: node '{id}' has non-quiescent status '{status}' that cannot be checkpoint-restored yet",
1624            status = status_to_str(status)
1625        )));
1626    }
1627    Ok(())
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632    use super::*;
1633    use crate::graph::RestoreFactoryMeta;
1634    use crate::node::Node;
1635    use crate::protocol::Message;
1636    use crate::versioning::{NodeVersion, NodeVersioningPolicy};
1637    use serde_json::json;
1638
1639    struct StatefulJsonDescriptor;
1640
1641    impl GraphRestoreDescriptor for StatefulJsonDescriptor {
1642        fn ref_(&self) -> &str {
1643            "stateful-json"
1644        }
1645
1646        fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
1647            assert_eq!(ctx.deps.len(), 1);
1648            Ok(RestoreNodeDefinition {
1649                factory: "stateful-json".to_owned(),
1650                kind: RestoreNodeKind::NodeJson(Rc::new(|ctx: &Ctx| {
1651                    let mut acc = ctx
1652                        .state_get::<GraphCheckpointJson>()
1653                        .and_then(|v| v.as_i64())
1654                        .unwrap_or(40);
1655                    for _ in ctx.batch::<GraphCheckpointJson>(0) {
1656                        acc += 1;
1657                        ctx.emit(json!(acc));
1658                    }
1659                    ctx.state_set(json!(acc));
1660                })),
1661                opts: restored_opts(ctx.checkpoint)?,
1662            })
1663        }
1664    }
1665
1666    #[test]
1667    fn restore_preserves_ctx_state_for_later_runs() {
1668        let g = crate::graph::graph();
1669        let source = g.state_opts(json!(1), GraphNodeOpts::named("source"));
1670        let mut opts = GraphNodeOpts::named("memo");
1671        opts.restore = Some(RestoreFactoryMeta::registry_ref("stateful-json"));
1672        let memo = g.node_opts::<GraphCheckpointJson, _>(
1673            vec![source.erased()],
1674            |ctx| {
1675                let mut acc = ctx
1676                    .state_get::<GraphCheckpointJson>()
1677                    .and_then(|v| v.as_i64())
1678                    .unwrap_or(40);
1679                for _ in ctx.batch::<GraphCheckpointJson>(0) {
1680                    acc += 1;
1681                    ctx.emit(json!(acc));
1682                }
1683                ctx.state_set(json!(acc));
1684            },
1685            opts,
1686        );
1687        let _keep_active = memo.subscribe(|_| {});
1688        assert_eq!(memo.cache(), Some(json!(41)));
1689
1690        let checkpoint = g.checkpoint().expect("checkpoint succeeds");
1691        let registry = restore_registry([
1692            GraphRestoreEntry::descriptor(StateRestoreDescriptor),
1693            GraphRestoreEntry::descriptor(StatefulJsonDescriptor),
1694        ]);
1695        let restored = restore_graph(checkpoint, RestoreGraphOptions::new(registry))
1696            .expect("restore succeeds");
1697        let restored_memo = Node::<GraphCheckpointJson>::from_core(
1698            restored.find("memo").expect("memo restored").core(),
1699        );
1700        let restored_source = Node::<GraphCheckpointJson>::from_core(
1701            restored.find("source").expect("source restored").core(),
1702        );
1703
1704        assert_eq!(restored_memo.cache(), Some(json!(41)));
1705        let restored_checkpoint = restored.checkpoint().expect("re-checkpoint succeeds");
1706        let memo_checkpoint = restored_checkpoint
1707            .nodes
1708            .iter()
1709            .find(|node| node.id == "memo")
1710            .expect("memo checkpoint exists");
1711        assert_eq!(
1712            memo_checkpoint.lifecycle,
1713            GraphCheckpointLifecycle {
1714                activated: true,
1715                has_called_fn_once: true
1716            }
1717        );
1718        let runtime = restored_memo.erased().checkpoint_runtime();
1719        assert_eq!(
1720            runtime
1721                .ctx_state
1722                .and_then(|v| v.downcast::<GraphCheckpointJson>().ok())
1723                .as_deref(),
1724            Some(&json!(41))
1725        );
1726        restored_source.set(json!(2));
1727        assert_eq!(restored_memo.cache(), Some(json!(42)));
1728        let runtime = restored_memo.erased().checkpoint_runtime();
1729        assert_eq!(
1730            runtime
1731                .ctx_state
1732                .and_then(|v| v.downcast::<GraphCheckpointJson>().ok())
1733                .as_deref(),
1734            Some(&json!(42))
1735        );
1736    }
1737
1738    #[test]
1739    fn checkpoints_and_restores_node_runtime_versions() {
1740        let g = crate::graph::graph();
1741        let source = g.state_opts(json!(1), GraphNodeOpts::named("source"));
1742        source.set(json!(2));
1743        let checkpoint = g.checkpoint().expect("checkpoint succeeds");
1744        assert_eq!(
1745            checkpoint.nodes[0].version,
1746            Some(json!({ "level": 0, "counter": 1 }))
1747        );
1748
1749        let restored = restore_graph(
1750            checkpoint,
1751            RestoreGraphOptions::new(default_restore_registry()),
1752        )
1753        .expect("V0 restore succeeds");
1754        let restored_source = restored.find("source").expect("source restored");
1755        assert_eq!(
1756            restored_source.version(),
1757            Some(NodeVersion::V0 { counter: 1 })
1758        );
1759
1760        restored_source.down(vec![Message::Data(Rc::new(json!(3)))]);
1761        assert_eq!(
1762            restored_source.version(),
1763            Some(NodeVersion::V0 { counter: 2 })
1764        );
1765    }
1766
1767    #[test]
1768    fn restore_rejects_v0_metadata_when_selected_policy_is_not_v0() {
1769        let g = crate::graph::graph();
1770        let source = g.state_opts(json!(1), GraphNodeOpts::named("source"));
1771        source.set(json!(2));
1772        let checkpoint = g.checkpoint().expect("checkpoint succeeds");
1773
1774        let disabled_err = match restore_graph(
1775            checkpoint.clone(),
1776            RestoreGraphOptions {
1777                registry: default_restore_registry(),
1778                graph: GraphOptions {
1779                    versioning: Some(NodeVersioningPolicy::Disabled),
1780                    ..GraphOptions::default()
1781                },
1782            },
1783        ) {
1784            Ok(_) => panic!("V0 metadata must not restore into disabled versioning"),
1785            Err(err) => err,
1786        };
1787        assert!(disabled_err.to_string().contains("versioning is disabled"));
1788
1789        let level1_err = match restore_graph(
1790            checkpoint,
1791            RestoreGraphOptions {
1792                registry: default_restore_registry(),
1793                graph: GraphOptions {
1794                    versioning: Some(NodeVersioningPolicy::Level1 {
1795                        hash: Some(Rc::new(|bytes: &[u8]| {
1796                            format!(
1797                                "h:{}",
1798                                std::str::from_utf8(bytes).expect("canonical JSON is UTF-8")
1799                            )
1800                        })),
1801                    }),
1802                    ..GraphOptions::default()
1803                },
1804            },
1805        ) {
1806            Ok(_) => panic!("V0 metadata must not restore into a V1 lane"),
1807            Err(err) => err,
1808        };
1809        assert!(level1_err
1810            .to_string()
1811            .contains("level 0 requires matching node versioning policy"));
1812    }
1813
1814    #[test]
1815    fn restores_v1_only_with_matching_hash_lane() {
1816        let hash = Rc::new(|bytes: &[u8]| {
1817            format!(
1818                "h:{}",
1819                std::str::from_utf8(bytes).expect("canonical JSON is UTF-8")
1820            )
1821        });
1822        let graph = crate::graph::graph_opts(GraphOptions {
1823            versioning: Some(NodeVersioningPolicy::Level1 {
1824                hash: Some(hash.clone()),
1825            }),
1826            ..GraphOptions::default()
1827        });
1828        let source = graph.state_opts(json!(1), GraphNodeOpts::named("source"));
1829        source.set(json!(2));
1830        let checkpoint = graph.checkpoint().expect("checkpoint succeeds");
1831        assert_eq!(
1832            checkpoint.nodes[0].version,
1833            Some(json!({ "level": 1, "counter": 1, "cid": "h:2", "prev": "h:1" }))
1834        );
1835
1836        let err = match restore_graph(
1837            checkpoint.clone(),
1838            RestoreGraphOptions::new(default_restore_registry()),
1839        ) {
1840            Ok(_) => panic!("V1 restore without a matching lane must fail"),
1841            Err(err) => err,
1842        };
1843        assert!(err.to_string().contains("matching node versioning policy"));
1844
1845        let err = match restore_graph(
1846            checkpoint.clone(),
1847            RestoreGraphOptions {
1848                registry: default_restore_registry(),
1849                graph: GraphOptions {
1850                    versioning: Some(NodeVersioningPolicy::Level1 {
1851                        hash: Some(Rc::new(|bytes: &[u8]| {
1852                            format!(
1853                                "other:{}",
1854                                std::str::from_utf8(bytes).expect("canonical JSON is UTF-8")
1855                            )
1856                        })),
1857                    }),
1858                    ..GraphOptions::default()
1859                },
1860            },
1861        ) {
1862            Ok(_) => panic!("wrong hash lane must fail"),
1863            Err(err) => err,
1864        };
1865        assert!(err.to_string().contains("hash policy"));
1866
1867        let restored = restore_graph(
1868            checkpoint,
1869            RestoreGraphOptions {
1870                registry: default_restore_registry(),
1871                graph: GraphOptions {
1872                    versioning: Some(NodeVersioningPolicy::Level1 { hash: Some(hash) }),
1873                    ..GraphOptions::default()
1874                },
1875            },
1876        )
1877        .expect("matching V1 lane restores");
1878        let restored_source = restored.find("source").expect("source restored");
1879        assert_eq!(
1880            restored_source.version(),
1881            Some(NodeVersion::V1 {
1882                counter: 1,
1883                cid: "h:2".to_owned(),
1884                prev: Some("h:1".to_owned()),
1885            })
1886        );
1887        restored_source.down(vec![Message::Data(Rc::new(json!(3)))]);
1888        assert_eq!(
1889            restored_source.version(),
1890            Some(NodeVersion::V1 {
1891                counter: 2,
1892                cid: "h:3".to_owned(),
1893                prev: Some("h:2".to_owned()),
1894            })
1895        );
1896    }
1897
1898    #[test]
1899    fn restore_requires_version_metadata_when_versioning_is_enabled() {
1900        let graph = crate::graph::graph();
1901        graph.state_opts(json!(1), GraphNodeOpts::named("source"));
1902        let mut checkpoint = graph.checkpoint().expect("checkpoint succeeds");
1903        checkpoint.nodes[0].version = None;
1904
1905        let err = match restore_graph(
1906            checkpoint,
1907            RestoreGraphOptions::new(default_restore_registry()),
1908        ) {
1909            Ok(_) => panic!("missing version metadata must fail under default V0 versioning"),
1910            Err(err) => err,
1911        };
1912        assert!(err.to_string().contains("version metadata is required"));
1913    }
1914
1915    #[test]
1916    fn restore_rejects_invalid_node_version_metadata_shape() {
1917        let graph = crate::graph::graph();
1918        graph.state_opts(json!(1), GraphNodeOpts::named("source"));
1919        let checkpoint = graph.checkpoint().expect("checkpoint succeeds");
1920
1921        let mut extra = checkpoint.clone();
1922        extra.nodes[0].version = Some(json!({ "level": 0, "counter": 1, "extra": true }));
1923        let err = match restore_graph(extra, RestoreGraphOptions::new(default_restore_registry())) {
1924            Ok(_) => panic!("extra node version fields must fail honestly"),
1925            Err(err) => err,
1926        };
1927        assert!(err.to_string().contains("unexpected node version fields"));
1928
1929        let mut unsafe_counter = checkpoint;
1930        unsafe_counter.nodes[0].version =
1931            Some(json!({ "level": 0, "counter": 9_007_199_254_740_992u64 }));
1932        let err = match restore_graph(
1933            unsafe_counter,
1934            RestoreGraphOptions::new(default_restore_registry()),
1935        ) {
1936            Ok(_) => panic!("unsafe node version counters must fail honestly"),
1937            Err(err) => err,
1938        };
1939        assert!(err.to_string().contains("safe integer"));
1940    }
1941
1942    #[test]
1943    fn checkpoint_and_restore_reject_noncanonical_json_numbers() {
1944        let g = crate::graph::graph();
1945        let source = g.state_opts(-0.0_f64, GraphNodeOpts::named("source"));
1946        let _keep_active = source.subscribe(|_| {});
1947        let err = g
1948            .checkpoint()
1949            .expect_err("negative zero is not strict canonical JSON");
1950        assert!(err.to_string().contains("strict canonical JSON"));
1951
1952        let g = crate::graph::graph();
1953        g.state_opts(json!(1), GraphNodeOpts::named("source"));
1954        let mut checkpoint = g.checkpoint().expect("checkpoint succeeds");
1955        checkpoint.nodes[0].value = GraphCheckpointValue::Data {
1956            data: Value::Number(Number::from_f64(f64::MIN_POSITIVE / 2.0).unwrap()),
1957        };
1958        let err = match restore_graph(
1959            checkpoint,
1960            RestoreGraphOptions::new(default_restore_registry()),
1961        ) {
1962            Ok(_) => panic!("subnormal checkpoint number must fail restore validation"),
1963            Err(err) => err,
1964        };
1965        assert!(err.to_string().contains("subnormal"));
1966    }
1967
1968    #[test]
1969    fn restore_rejects_error_terminal_payload_until_preservation_lands() {
1970        let g = crate::graph::graph();
1971        g.state_opts(json!(1), GraphNodeOpts::named("source"));
1972        let mut checkpoint = g.checkpoint().expect("checkpoint succeeds");
1973        checkpoint.nodes[0].status = "errored".to_owned();
1974        checkpoint.nodes[0].terminal = GraphCheckpointTerminal::Error {
1975            error: json!("boom"),
1976        };
1977
1978        let err = match restore_graph(
1979            checkpoint,
1980            RestoreGraphOptions::new(default_restore_registry()),
1981        ) {
1982            Ok(_) => panic!("ERROR terminal payload restore must fail honestly"),
1983            Err(err) => err,
1984        };
1985        assert!(err.to_string().contains("cannot preserve yet"));
1986    }
1987
1988    #[test]
1989    fn restore_descriptors_reject_unsupported_config_version() {
1990        let g = crate::graph::graph();
1991        g.state_opts(json!(1), GraphNodeOpts::named("source"));
1992        let mut checkpoint = g.checkpoint().expect("checkpoint succeeds");
1993        if let GraphCheckpointFactory::RegistryRef { config_version, .. } =
1994            &mut checkpoint.nodes[0].factory
1995        {
1996            *config_version = Some(json!("v2"));
1997        }
1998
1999        let err = match restore_graph(
2000            checkpoint,
2001            RestoreGraphOptions::new(default_restore_registry()),
2002        ) {
2003            Ok(_) => panic!("built-in state rejects configVersion"),
2004            Err(err) => err,
2005        };
2006        assert!(err.to_string().contains("configVersion"));
2007    }
2008
2009    #[test]
2010    fn restore_preserves_registry_metadata_for_later_checkpoints() {
2011        let g = crate::graph::graph();
2012        let source = g.state_opts(json!(3), GraphNodeOpts::named("source"));
2013        let mut opts = GraphNodeOpts::named("double");
2014        opts.restore = Some(
2015            RestoreFactoryMeta::registry_ref("map").with_config(json!({ "fn": "double-json" })),
2016        );
2017        let doubled = g.node_opts::<GraphCheckpointJson, _>(
2018            vec![source.erased()],
2019            |ctx| {
2020                for value in ctx.batch::<GraphCheckpointJson>(0) {
2021                    ctx.emit(json!(value.as_i64().expect("number input") * 2));
2022                }
2023            },
2024            opts,
2025        );
2026        let _keep_active = doubled.subscribe(|_| {});
2027        let checkpoint = g.checkpoint().expect("checkpoint succeeds");
2028        let registry = restore_registry([
2029            GraphRestoreEntry::descriptor(StateRestoreDescriptor),
2030            GraphRestoreEntry::descriptor(MapJsonRestoreDescriptor),
2031            GraphRestoreEntry::definition(GraphRestoreDefinition::json("double-json", |value| {
2032                Ok(json!(value.as_i64().expect("number input") * 2))
2033            })),
2034        ]);
2035
2036        let restored = restore_graph(checkpoint, RestoreGraphOptions::new(registry))
2037            .expect("restore succeeds");
2038        let restored_checkpoint = restored.checkpoint().expect("re-checkpoint succeeds");
2039        let restored_node = restored_checkpoint
2040            .nodes
2041            .iter()
2042            .find(|node| node.id == "double")
2043            .expect("double checkpoint exists");
2044
2045        assert_eq!(
2046            restored_node.factory,
2047            GraphCheckpointFactory::RegistryRef {
2048                ref_: "map".to_owned(),
2049                config: Some(json!({ "fn": "double-json" })),
2050                config_version: None,
2051            }
2052        );
2053    }
2054
2055    #[test]
2056    fn restore_rejects_rust_unsupported_non_string_meta() {
2057        let g = crate::graph::graph();
2058        g.state_opts(json!(1), GraphNodeOpts::named("source"));
2059        let mut checkpoint = g.checkpoint().expect("checkpoint succeeds");
2060        checkpoint.nodes[0].meta = Some(BTreeMap::from([("json".to_owned(), json!({ "x": 1 }))]));
2061
2062        let err = match restore_graph(
2063            checkpoint,
2064            RestoreGraphOptions::new(default_restore_registry()),
2065        ) {
2066            Ok(_) => panic!("Rust graph metadata is string-only"),
2067            Err(err) => err,
2068        };
2069        assert!(err.to_string().contains("meta field 'json'"));
2070    }
2071
2072    #[test]
2073    fn restore_rejects_duplicate_edges() {
2074        let g = crate::graph::graph();
2075        let source = g.state_opts(json!(1), GraphNodeOpts::named("source"));
2076        let mut opts = GraphNodeOpts::named("memo");
2077        opts.restore = Some(RestoreFactoryMeta::registry_ref("stateful-json"));
2078        let memo = g.node_opts::<GraphCheckpointJson, _>(
2079            vec![source.erased()],
2080            |ctx| {
2081                for value in ctx.batch::<GraphCheckpointJson>(0) {
2082                    ctx.emit(value.as_ref().clone());
2083                }
2084            },
2085            opts,
2086        );
2087        let _keep_active = memo.subscribe(|_| {});
2088        let mut checkpoint = g.checkpoint().expect("checkpoint succeeds");
2089        let edge = checkpoint.edges[0].clone();
2090        checkpoint.edges.push(edge);
2091
2092        let registry = restore_registry([
2093            GraphRestoreEntry::descriptor(StateRestoreDescriptor),
2094            GraphRestoreEntry::descriptor(StatefulJsonDescriptor),
2095        ]);
2096        let err = match restore_graph(checkpoint, RestoreGraphOptions::new(registry)) {
2097            Ok(_) => panic!("duplicate checkpoint edges must fail"),
2098            Err(err) => err,
2099        };
2100        assert!(err.to_string().contains("duplicate edge"));
2101    }
2102
2103    #[test]
2104    fn checkpoint_and_restore_reject_non_quiescent_statuses() {
2105        let g = crate::graph::graph();
2106        let source = g.state_opts(json!(1), GraphNodeOpts::named("source"));
2107        source.down(vec![Message::Dirty]);
2108
2109        let err = g
2110            .checkpoint()
2111            .expect_err("checkpoint cannot preserve in-flight wave bookkeeping yet");
2112        assert!(err.to_string().contains("non-quiescent status 'dirty'"));
2113
2114        let g = crate::graph::graph();
2115        g.state_opts(json!(1), GraphNodeOpts::named("source"));
2116        let mut checkpoint = g.checkpoint().expect("checkpoint succeeds");
2117        checkpoint.nodes[0].status = "pending".to_owned();
2118
2119        let err = match restore_graph(
2120            checkpoint,
2121            RestoreGraphOptions::new(default_restore_registry()),
2122        ) {
2123            Ok(_) => panic!("restore must reject non-quiescent checkpoint status"),
2124            Err(err) => err,
2125        };
2126        assert!(err.to_string().contains("non-quiescent status 'pending'"));
2127    }
2128
2129    #[test]
2130    fn restore_rejects_empty_mount_path_without_panicking() {
2131        let parent = crate::graph::graph();
2132        let child = crate::graph::graph();
2133        child.state_opts(json!(1), GraphNodeOpts::named("leaf"));
2134        parent.mount(child, "child");
2135        let mut checkpoint = parent.checkpoint().expect("checkpoint succeeds");
2136        checkpoint.mounts.as_mut().expect("mount exists")[0]
2137            .at
2138            .clear();
2139
2140        let err = match restore_graph(
2141            checkpoint,
2142            RestoreGraphOptions::new(default_restore_registry()),
2143        ) {
2144            Ok(_) => panic!("empty mount path must fail validation"),
2145            Err(err) => err,
2146        };
2147        assert!(err.to_string().contains("must not be empty"));
2148    }
2149
2150    #[test]
2151    fn checkpoint_rejects_unavailable_error_payload_instead_of_inventing_one() {
2152        let g = crate::graph::graph();
2153        let source = g.state_opts(json!(1), GraphNodeOpts::named("source"));
2154        source.down(vec![Message::Error(Box::new(std::io::Error::other(
2155            "boom",
2156        )))]);
2157
2158        let err = g
2159            .checkpoint()
2160            .expect_err("Rust cannot serialize the original GraphError payload yet");
2161        assert!(err.to_string().contains("ERROR payload"));
2162    }
2163}