//! Typed handle for `.gdd` documents. //! //! [`Gdd`] owns a [`document_graph_storage::Session`] plus a working-copy [`document_container::AnyContainer`]. //! Mutations flow through `Gdd` to keep the session and the on-disk working copy mirrored. //! Export is a separate, explicit operation — see [`export::ExportFormat`]. //! //! See the "On-disk container" section of `node-graph/rfcs/document-format.md` for the format spec. use std::sync::Arc; // `Path` and `FolderBackend` are only used by the native-only path-based open/create, so they're // gated off wasm to avoid unused-import warnings. #[cfg(not(target_family = "wasm"))] use std::path::Path; #[cfg(not(target_family = "wasm"))] use document_container::backends::folder::FolderBackend; use document_container::{AnyContainer, AsyncContainer, ByteHolder, ContainerError}; #[cfg(feature = "conversion")] use document_graph_storage::{CommitError, NodeMetadataSource}; use document_graph_storage::{Delta, HotOp, PeerId, Registry, Session}; #[cfg(feature = "conversion")] use graphene_resource::LoadResource; use graphene_resource::ResourceHash; pub mod codec; pub mod error; pub mod export; pub mod io; pub mod layout; pub mod manifest; pub mod persist; pub mod resource; pub mod session_state; pub use codec::{Codec, CodecError}; pub use error::Error; pub use export::{ExportFormat, ExportOptions}; pub use io::ReadError; pub use layout::{GddV1Layout, Layout}; pub use manifest::{Manifest, PayloadCodecs}; pub use resource::ResourceProxy; pub use session_state::SessionState; /// The default [`Layout`], so callers write `GddV1` for the common `Gdd` handle. pub type GddV1 = Gdd; /// The manifest is always JSON: it is the bootstrap file, read before any other payload's codec is /// known, so its own codec cannot itself be configurable. pub const MANIFEST_CODEC: Codec = Codec::Json; /// Working-copy codecs. The working copy lives in appdata, not under VCS — these defaults /// optimize for size and write cost. MessagePack is self-describing, so it round-trips the /// type-erased `serde_json::Value` bodies that resource and attribute deltas carry (a non-self- /// describing format like postcard cannot). JSON/JSONL is opt-in via `ExportFormat::Folder` for /// users who want a diffable on-disk representation. Recorded in the manifest at create time and /// read back on open (see [`manifest::PayloadCodecs`]), so the persist path never probes the filesystem. pub const DEFAULT_SESSION_CODEC: Codec = Codec::Json; pub const DEFAULT_REGISTRY_CODEC: Codec = Codec::MessagePack; pub const DEFAULT_HISTORY_CODEC: Codec = Codec::MessagePackFrames; pub const DEFAULT_HOT_LOG_CODEC: Codec = Codec::MessagePackFrames; /// Editor-facing handle. Owns the `Session` and the working-copy container; mutations are mirrored /// to disk continuously (every retirement appends to the history file and re-snapshots the registry). /// /// The per-edit persist path (`commit_from_runtime`, `apply_hot_op`, `retire`) is synchronous and /// read-free: the manifest is cached in memory (so payload codecs need no disk read), and writes go /// through the container's sync write surface. Only `open` / `create` / `export` are async, since they /// read. /// `Clone` shares the working-copy container (`Arc`) so a cloned handle reads and writes /// the *same* on-disk/OPFS working copy — including any writes still queued on the OPFS backend. The /// `Session` is cloned (a snapshot copy); the container is shared. #[derive(Clone)] pub struct Gdd { pub(crate) session: Session, pub(crate) working: Arc, pub(crate) layout: L, /// In-memory copy of the manifest, kept authoritative since `Gdd` is its sole writer. Holds the /// per-payload codecs so the persist path never probes the filesystem, keeping it fully read-free /// and synchronous. pub(crate) manifest: Manifest, /// Per-peer view settings (PTZ, rulers, etc.), persisted in `session.json` not the registry, so /// they stay out of the CRDT/history. Opaque to the storage layer; the editor owns the keys/values. pub(crate) view_settings: std::collections::BTreeMap, /// Per-network view settings (node-graph nav + previewing), keyed by stable [`NetworkId`]. Same per-peer /// `session.json` treatment as [`view_settings`](Self::view_settings), but scoped per network. pub(crate) network_view_settings: std::collections::BTreeMap>, } /// Native folder-backed convenience constructors. On wasm the editor builds an OPFS-backed /// `AnyContainer` itself and uses [`Gdd::open_in`] / [`Gdd::create_in`] directly. #[cfg(not(target_family = "wasm"))] impl Gdd { /// Open an existing working copy at `path`. Validates the manifest, materializes the session /// from `registry.bin` (fast path) or by replaying `history.jsonl` (slow path), then applies /// the persisted hot log on top. pub async fn open(path: &Path) -> Result { let working = AnyContainer::Folder(FolderBackend::open(path)?); let layout = L::default(); Self::open_in(working, layout).await } /// Create a fresh, empty working copy at `path` bound to `peer`. Writes a default manifest /// and session state; the caller fills in editor metadata via [`Gdd::update_manifest`]. pub async fn create(path: &Path, peer: PeerId, document_uuid: u64, editor_version: String, stdlib_version: String) -> Result { let working = AnyContainer::Folder(FolderBackend::create(path)?); let layout = L::default(); Self::create_in(working, layout, peer, document_uuid, editor_version, stdlib_version).await } } impl Gdd { /// Open a `.gdd` from archive bytes (xz or zip, auto-detected) by materializing it into `working`, /// then opening it as a working copy. The archive is deserialized into an in-memory staging backend /// (the archive reader is synchronous), then each entry is written into `working` via the sync /// `write_non_blocking` surface — durable on folder/memory, eagerly enqueued on OPFS. `working` is /// expected to be a fresh per-document container; entries with colliding paths are overwritten. #[cfg(any(feature = "zip", feature = "xz"))] pub async fn open_from_archive(bytes: &[u8], mut working: AnyContainer, layout: L) -> Result { document_container::archive::open_auto(bytes, &mut working)?; Self::open_in(working, layout).await } /// Backend-agnostic open. Splits out so tests can supply a [`document_container::backends::memory::MemoryBackend`]. /// /// # Errors /// [`Error::WrongFormat`] / [`Error::UnsupportedVersion`] if the manifest fails validation, plus /// the usual [`Error::Read`] / [`Error::Codec`] / [`Error::Crdt`] if a payload is malformed. pub async fn open_in(working: AnyContainer, layout: L) -> Result { let manifest: Manifest = io::read_single(&working, layout.manifest_basename(), MANIFEST_CODEC).await?; validate_manifest(&manifest)?; let codecs = manifest.codecs; let session_state: SessionState = match io::exists(&working, layout.session_basename(), codecs.session).await { true => io::read_single(&working, layout.session_basename(), codecs.session).await?, false => SessionState::default(), }; let has_registry = io::exists(&working, layout.registry_basename(), codecs.registry).await; let has_history = io::exists(&working, layout.history_basename(), codecs.history).await; let peer = session_state.peer_id; let mut session = match (has_registry, has_history) { (true, true) => { let registry: Registry = io::read_single(&working, layout.registry_basename(), codecs.registry).await?; let history = load_history(&working, &layout, codecs.history).await?; Session::load(peer, registry, history, session_state.head_rev, session_state.redo_stack, session_state.next_node_counter) } (true, false) => { // Registry-only export: synthesize a history that reproduces this state. let registry: Registry = io::read_single(&working, layout.registry_basename(), codecs.registry).await?; Session::bootstrap_from_registry(peer, registry)? } (false, _) => Session::replay_from_history(peer, load_history(&working, &layout, codecs.history).await?, session_state.next_node_counter)?, }; // Restore the published frontier (silent/published undo boundary) regardless of which load arm ran. if let Some(rev) = session_state.last_broadcast_rev { session.publish_up_to(rev); } replay_hot_log(&working, &layout, codecs.hot_log, &mut session).await?; Ok(Self { session, working: Arc::new(working), layout, manifest, view_settings: session_state.view_settings, network_view_settings: session_state.network_view_settings, }) } /// Backend-agnostic create. Records the working-copy default codecs (see `DEFAULT_*_CODEC`) in /// the manifest and writes each payload with its recorded codec. pub async fn create_in(working: AnyContainer, layout: L, peer: PeerId, document_uuid: u64, editor_version: String, stdlib_version: String) -> Result { let manifest = Manifest::new(document_uuid, editor_version, stdlib_version); let codecs = manifest.codecs; io::write_single(&working, layout.manifest_basename(), MANIFEST_CODEC, &manifest)?; let session_state = SessionState { peer_id: peer, ..Default::default() }; io::write_single(&working, layout.session_basename(), codecs.session, &session_state)?; let session = Session::with_peer(peer); io::write_single(&working, layout.registry_basename(), codecs.registry, session.registry())?; Ok(Self { session, working: Arc::new(working), layout, manifest, view_settings: std::collections::BTreeMap::new(), network_view_settings: std::collections::BTreeMap::new(), }) } } fn validate_manifest(manifest: &Manifest) -> Result<(), Error> { if manifest.format != manifest::FORMAT_MAGIC { return Err(Error::WrongFormat { found: manifest.format.clone(), expected: manifest::FORMAT_MAGIC, }); } if manifest.format_version > manifest::SUPPORTED_FORMAT_VERSION { return Err(Error::UnsupportedVersion { found: manifest.format_version, max_supported: manifest::SUPPORTED_FORMAT_VERSION, }); } Ok(()) } async fn load_history(working: &AnyContainer, layout: &L, codec: Codec) -> Result, Error> { if !io::exists(working, layout.history_basename(), codec).await { return Ok(Vec::new()); } Ok(io::iter::(working, layout.history_basename(), codec).await?) } async fn replay_hot_log(working: &AnyContainer, layout: &L, codec: Codec, session: &mut Session) -> Result<(), Error> { if !io::exists(working, layout.hot_log_basename(), codec).await { return Ok(()); } for hot_op in io::iter::(working, layout.hot_log_basename(), codec).await? { session.replay_hot_op(hot_op)?; } Ok(()) } impl Gdd { pub fn session(&self) -> &Session { &self.session } pub fn can_undo(&self) -> bool { self.session.can_undo() } pub fn can_redo(&self) -> bool { self.session.can_redo() } pub fn registry(&self) -> &Registry { self.session.registry() } /// The in-memory manifest. `Gdd` is its sole writer, so this is authoritative without re-reading /// disk. pub fn manifest(&self) -> &Manifest { &self.manifest } pub fn layout(&self) -> &L { &self.layout } /// The per-peer view settings read from `session.json` (PTZ, rulers, overlays, snapping, collapse). /// Opaque `ui::doc::*` blobs; the editor decodes them. Empty for a fresh document. pub fn view_settings(&self) -> &std::collections::BTreeMap { &self.view_settings } /// The per-network view settings read from `session.json` (node-graph nav + previewing), keyed by /// [`NetworkId`](document_graph_storage::NetworkId). Opaque `ui::nav::*` / `ui::previewing` blobs the editor decodes. pub fn network_view_settings(&self) -> &std::collections::BTreeMap> { &self.network_view_settings } /// Resolve each runtime `network_path` to its stable [`NetworkId`](document_graph_storage::NetworkId), so the /// editor can key per-network, per-peer view state by a stable id. See [`Session::network_ids`]. #[cfg(feature = "conversion")] pub fn network_ids( &self, network: &graph_craft::document::NodeNetwork, metadata: &M, ) -> Result, document_graph_storage::NetworkId>, CommitError> { self.session.network_ids(network, metadata) } /// Every resource hash referenced by the current registry or anywhere in history, so resource GC keeps /// redoable/re-undoable interactions' resources (notably proto-node declaration bytes) alive even when an /// undo has dropped them from the current registry. pub fn all_referenced_resource_hashes(&self) -> std::collections::HashSet { self.session.all_referenced_resource_hashes() } /// Drop the session and return the working-copy container + layout. /// Intended for test code that needs to reopen against the same container; panics if the container /// is still shared by a `Gdd` clone (tests don't clone before calling this). pub fn into_storage(self) -> (AnyContainer, L) { let working = Arc::try_unwrap(self.working).unwrap_or_else(|_| panic!("into_storage called while the working-copy container is still shared by a Gdd clone")); (working, self.layout) } /// Resolve the proto-node declarations referenced by the registry into a [`document_graph_storage::Declarations`] /// map, loading each `ProtoNode`'s bytes from `byte_store` (the global cache in the editor, the /// working-copy container for standalone). Only resources referenced by `Implementation::ProtoNode` /// are visited, so image/font resources are skipped. Cold-path (open / `to_runtime`); async /// because resource loads are. #[cfg(feature = "conversion")] pub async fn declarations(&self, byte_store: &dyn LoadResource) -> document_graph_storage::Declarations { use document_graph_storage::Implementation; let registry = self.session.registry(); let mut declarations = document_graph_storage::Declarations::new(); for node in registry.node_instances.values() { let Implementation::ProtoNode(id) = node.implementation() else { continue }; if declarations.contains_key(id) { continue; } let Some(hash) = registry.resources.get(id).and_then(|entry| entry.hash) else { log::error!("Declaration resource {id} has no resolved hash; cannot load ProtoNode"); continue; }; let Some(resource) = byte_store.load(hash).await else { log::error!("Declaration bytes for {id} (hash {hash}) missing from byte store"); continue; }; match document_graph_storage::decode_declaration(resource.as_ref()) { Ok(proto) => { declarations.insert(*id, proto); } Err(error) => log::error!("Failed to deserialize ProtoNode for {id}: {error}"), } } declarations } /// Store the legacy `.graphite` document bytes verbatim inside the working copy (dual-write soak). /// Synchronous (hot-path safe via `write_non_blocking`): called at the autosave boundary alongside /// the registry snapshot. The bytes are opaque to `Gdd` — it never deserializes them. // TODO: Add feature gate for legacy embedding pub fn store_legacy_document(&self, bytes: &[u8]) -> Result<(), ContainerError> { self.working.write_non_blocking(self.layout.legacy_path(), bytes) } /// Read back the embedded legacy `.graphite` document, if present. The compare-on-open oracle and /// the recovery fallback both go through here. `None` when no legacy blob was ever written. pub async fn read_legacy_document(&self) -> Option { self.working.read(self.layout.legacy_path()).await.ok() } }