mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-27 09:18:12 +08:00
WIP prototype migrations
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "migration-core"
|
||||
description = "Trait definitions and selectors for Graphite document migrations"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["typed"]
|
||||
# Content-tier migrations over the typed registry. Format-tier-only migration crates
|
||||
# (historic version steps, wasm builds) disable this to drop the graph-storage dependency.
|
||||
typed = ["dep:graph-storage"]
|
||||
|
||||
[dependencies]
|
||||
graph-storage = { workspace = true, optional = true }
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
rmp-serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,82 @@
|
||||
use crate::{MigrationError, MigrationId};
|
||||
use graph_storage::{Node, NodeId, Registry, ResourceId};
|
||||
|
||||
/// Identity of a proto-node declaration, as read from the declaration resource body.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DeclarationInfo {
|
||||
pub identifier: String,
|
||||
/// Explicit behavioral version; 0 for declarations predating versioning.
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
/// Which registry entities a content migration applies to.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Selector {
|
||||
/// Every node whose proto-node declaration matches.
|
||||
Node(NodeSelector),
|
||||
/// Every node carrying this `ui::reference` attribute value (how legacy wrapper networks
|
||||
/// like "Brush" are identified).
|
||||
Reference(&'static str),
|
||||
/// Once per document, gated by the [`crate::APPLIED_ATTRIBUTE`] provenance list.
|
||||
Document,
|
||||
}
|
||||
|
||||
/// Matches nodes by declaration identifier (with historic aliases) and version range.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NodeSelector {
|
||||
/// Current declaration identifier plus historic aliases.
|
||||
pub names: &'static [&'static str],
|
||||
/// Upgrade target: nodes with a declaration version strictly below this match.
|
||||
pub below_version: u32,
|
||||
}
|
||||
|
||||
impl NodeSelector {
|
||||
/// Whether a node with this declaration identity is matched.
|
||||
pub fn matches(&self, declaration: &DeclarationInfo) -> bool {
|
||||
// TODO(Dennis): Matching semantics to decide here:
|
||||
// - Legacy identifiers can carry generic type arguments (`...MemoNode<T>`); the old system
|
||||
// compared with `identifier.split('<').next()`. Strip them, or require exact matches?
|
||||
// - Should `below_version` also gate name-alias matches, or do aliases (which only appear in
|
||||
// pre-versioning documents, version 0) always match regardless?
|
||||
let _ = declaration;
|
||||
todo!("decide and implement declaration matching")
|
||||
}
|
||||
}
|
||||
|
||||
/// One matched entity, produced by scanning a [`Selector`] over the registry.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Target {
|
||||
Node(NodeId),
|
||||
Document,
|
||||
}
|
||||
|
||||
/// Byte-store and catalog services only the host (editor or CLI) can provide.
|
||||
pub trait MigrationHost {
|
||||
/// Read a declaration resource's identity (identifier and version).
|
||||
fn declaration_info(&self, id: ResourceId) -> Option<DeclarationInfo>;
|
||||
/// The decoded declaration body, for inspection beyond the identity.
|
||||
fn declaration(&self, id: ResourceId) -> Option<serde_json::Value>;
|
||||
/// Instantiate the current catalog default node for a declaration identifier.
|
||||
fn resolve_definition(&mut self, identifier: &str) -> Option<Node>;
|
||||
}
|
||||
|
||||
/// Everything a content migration can reach beyond the registry: host services plus ID minting.
|
||||
pub trait MigrationContext: MigrationHost {
|
||||
/// Mint a fresh peer-scoped node ID for inserted nodes.
|
||||
fn mint_node_id(&mut self) -> NodeId;
|
||||
}
|
||||
|
||||
/// One node-usage upgrade within the current format version.
|
||||
///
|
||||
/// `migrate` mutates a registry clone in place; the mutation is diffed into deltas and committed to
|
||||
/// history as one retired gesture, so implementations never construct deltas by hand. Timestamps
|
||||
/// written into the clone are placeholders the commit path re-stamps. Implementations must be
|
||||
/// idempotent: a document can round-trip through an editor build that lacks a later migration.
|
||||
pub trait ContentMigration {
|
||||
/// Stable identifier recorded in provenance.
|
||||
fn id(&self) -> MigrationId;
|
||||
/// Which registry entities to run on.
|
||||
fn selector(&self) -> Selector;
|
||||
/// Upgrade one matched target in place.
|
||||
fn migrate(&self, target: Target, registry: &mut Registry, context: &mut dyn MigrationContext) -> Result<(), MigrationError>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MigrationError {
|
||||
#[error("failed to decode payload: {0}")]
|
||||
Decode(String),
|
||||
#[error("failed to encode payload: {0}")]
|
||||
Encode(String),
|
||||
#[error("migration invariant violated: {0}")]
|
||||
Invariant(String),
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use crate::{MigrationError, MigrationId, Payload};
|
||||
|
||||
/// How stored history survives a format step.
|
||||
///
|
||||
/// Under `Rewrite`, the migration transforms each record's payload via
|
||||
/// [`FormatMigration::migrate_delta`]; the runner then recomputes `Rev`s in topological order and
|
||||
/// remaps parent links and session cursors. Because the rewrite is a pure function of content and
|
||||
/// `Rev`s are content-addressed, peers applying the same migration converge on identical rewritten
|
||||
/// history without coordination.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum HistoryPolicy {
|
||||
/// The shape change does not affect stored deltas.
|
||||
Untouched,
|
||||
/// Each history record is rewritten via `migrate_delta`.
|
||||
Rewrite,
|
||||
/// No faithful rewrite exists; the document becomes a state-only snapshot.
|
||||
Truncate,
|
||||
}
|
||||
|
||||
/// One whole-document format step, migrating version `migrates_from()` to `migrates_from() + 1`.
|
||||
///
|
||||
/// Implementations freeze whatever old struct shapes they need locally (deserialized from
|
||||
/// [`Payload`]s); the active codebase never carries them. Migrations only transform payload
|
||||
/// content, never identity fields (`id`, `parent`) — Merkle bookkeeping belongs to the runner.
|
||||
pub trait FormatMigration {
|
||||
/// Stable identifier recorded in provenance.
|
||||
fn id(&self) -> MigrationId;
|
||||
/// The format version this step upgrades from.
|
||||
fn migrates_from(&self) -> u32;
|
||||
/// Transform the serialized registry payload.
|
||||
fn migrate_registry(&self, registry: &Payload) -> Result<Payload, MigrationError>;
|
||||
/// How stored history survives this step.
|
||||
fn history_policy(&self) -> HistoryPolicy {
|
||||
HistoryPolicy::Untouched
|
||||
}
|
||||
/// Transform one history record. Called only under [`HistoryPolicy::Rewrite`].
|
||||
fn migrate_delta(&self, delta: &Payload) -> Result<Payload, MigrationError> {
|
||||
Ok(delta.clone())
|
||||
}
|
||||
/// Transform the per-peer session payload.
|
||||
fn migrate_session(&self, session: &Payload) -> Result<Payload, MigrationError> {
|
||||
Ok(session.clone())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Trait definitions for `.gdd` document migrations.
|
||||
//!
|
||||
//! Two tiers: a [`FormatMigration`] steps a document's serialized payloads from one format version
|
||||
//! to the next, and a [`ContentMigration`] (feature `typed`) upgrades node usages on the typed
|
||||
//! `Registry` within the current version. Migration crates export a [`MigrationSet`] via a plain
|
||||
//! constructor function; the `migration-runner` crate aggregates and dispatches them. Design
|
||||
//! rationale lives in `node-graph/rfcs/document-format-migrations.md`.
|
||||
|
||||
#[cfg(feature = "typed")]
|
||||
pub mod content;
|
||||
pub mod error;
|
||||
pub mod format;
|
||||
pub mod payload;
|
||||
|
||||
#[cfg(feature = "typed")]
|
||||
pub use content::{ContentMigration, DeclarationInfo, MigrationContext, MigrationHost, NodeSelector, Selector, Target};
|
||||
pub use error::MigrationError;
|
||||
pub use format::{FormatMigration, HistoryPolicy};
|
||||
pub use payload::{Payload, PayloadCodec};
|
||||
|
||||
/// Document attribute holding the list of applied `Document`-selector migration IDs.
|
||||
pub const APPLIED_ATTRIBUTE: &str = "migrations::applied";
|
||||
|
||||
/// Stable identifier for one migration, recorded in provenance and used for skip checks.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct MigrationId(pub &'static str);
|
||||
|
||||
impl std::fmt::Display for MigrationId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// What one migration crate exports: its migrations, in application order.
|
||||
/// Staged node upgrades must be registered in ascending target-version order.
|
||||
#[derive(Default)]
|
||||
pub struct MigrationSet {
|
||||
pub format: Vec<Box<dyn FormatMigration>>,
|
||||
#[cfg(feature = "typed")]
|
||||
pub content: Vec<Box<dyn ContentMigration>>,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use crate::MigrationError;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
/// Codec of a serialized payload. Mirrors the `document-format` codec table entries relevant to
|
||||
/// migrations so migration crates don't depend on `document-format`; the runner maps between the two.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PayloadCodec {
|
||||
Json,
|
||||
MessagePack,
|
||||
}
|
||||
|
||||
/// One serialized document payload (the registry, a single history record, the session) plus its
|
||||
/// codec. History framing is the runner's concern; migrations always see one record per payload.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Payload {
|
||||
pub bytes: Vec<u8>,
|
||||
pub codec: PayloadCodec,
|
||||
}
|
||||
|
||||
impl Payload {
|
||||
pub fn new(bytes: Vec<u8>, codec: PayloadCodec) -> Self {
|
||||
Self { bytes, codec }
|
||||
}
|
||||
|
||||
/// Decode into a typed shape, usually a frozen mirror struct owned by the migration crate.
|
||||
pub fn decode<T: DeserializeOwned>(&self) -> Result<T, MigrationError> {
|
||||
match self.codec {
|
||||
PayloadCodec::Json => serde_json::from_slice(&self.bytes).map_err(|e| MigrationError::Decode(e.to_string())),
|
||||
PayloadCodec::MessagePack => rmp_serde::from_slice(&self.bytes).map_err(|e| MigrationError::Decode(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a typed shape with the given codec.
|
||||
pub fn encode<T: Serialize>(value: &T, codec: PayloadCodec) -> Result<Self, MigrationError> {
|
||||
let bytes = match codec {
|
||||
PayloadCodec::Json => serde_json::to_vec(value).map_err(|e| MigrationError::Encode(e.to_string()))?,
|
||||
PayloadCodec::MessagePack => rmp_serde::to_vec(value).map_err(|e| MigrationError::Encode(e.to_string()))?,
|
||||
};
|
||||
Ok(Self { bytes, codec })
|
||||
}
|
||||
|
||||
/// Encode a typed shape, keeping this payload's codec.
|
||||
pub fn encode_as<T: Serialize>(&self, value: &T) -> Result<Self, MigrationError> {
|
||||
Self::encode(value, self.codec)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user