mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
* More consistent document crate names * Fix fmt * Fix ASCII art diagrams * rename document-graph to document-graph-storage
345 lines
16 KiB
Rust
345 lines
16 KiB
Rust
//! 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<GddV1Layout>` handle.
|
|
pub type GddV1 = Gdd<GddV1Layout>;
|
|
|
|
/// 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<AnyContainer>`) 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<L: Layout = GddV1Layout> {
|
|
pub(crate) session: Session,
|
|
pub(crate) working: Arc<AnyContainer>,
|
|
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<String, serde_json::Value>,
|
|
/// 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<document_graph_storage::NetworkId, std::collections::BTreeMap<String, serde_json::Value>>,
|
|
}
|
|
|
|
/// 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<L: Layout + Default> Gdd<L> {
|
|
/// 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<Self, Error> {
|
|
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<Self, Error> {
|
|
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<L: Layout> Gdd<L> {
|
|
/// 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<Self, Error> {
|
|
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<Self, Error> {
|
|
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<Self, Error> {
|
|
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<L: Layout>(working: &AnyContainer, layout: &L, codec: Codec) -> Result<Vec<Delta>, Error> {
|
|
if !io::exists(working, layout.history_basename(), codec).await {
|
|
return Ok(Vec::new());
|
|
}
|
|
Ok(io::iter::<Delta>(working, layout.history_basename(), codec).await?)
|
|
}
|
|
|
|
async fn replay_hot_log<L: Layout>(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::<HotOp>(working, layout.hot_log_basename(), codec).await? {
|
|
session.replay_hot_op(hot_op)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
impl<L: Layout> Gdd<L> {
|
|
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<String, serde_json::Value> {
|
|
&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<document_graph_storage::NetworkId, std::collections::BTreeMap<String, serde_json::Value>> {
|
|
&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<M: NodeMetadataSource>(
|
|
&self,
|
|
network: &graph_craft::document::NodeNetwork,
|
|
metadata: &M,
|
|
) -> Result<std::collections::HashMap<Vec<core_types::uuid::NodeId>, 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<ResourceHash> {
|
|
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<ByteHolder> {
|
|
self.working.read(self.layout.legacy_path()).await.ok()
|
|
}
|
|
}
|