WIP prototype migrations

This commit is contained in:
Dennis Kobert
2026-07-14 13:54:41 +02:00
parent 97f8113fe4
commit 6c084fb954
17 changed files with 827 additions and 3 deletions
+21
View File
@@ -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 }
+82
View File
@@ -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>;
}
+11
View File
@@ -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),
}
+44
View File
@@ -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())
}
}
+41
View File
@@ -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>>,
}
+47
View File
@@ -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)
}
}