rship-entities-core 4.0.0-canary.53

Core entities for rship
Documentation
use std::sync::Arc;

#[cfg(not(target_arch = "wasm32"))]
use myko::report::export_tree::{EntityTreeExport, ExportEntityTree};
#[cfg(not(target_arch = "wasm32"))]
use myko::{
    command::{CommandContext, CommandError, CommandHandler},
    prelude::Uuid,
};
use myko::{inventory, myko_command, myko_item};
use serde_json::Value;

use crate::{Project, ProjectId};

// ─────────────────────────────────────────────────────────────────────────────
// Pre-capture contributor hook
// ─────────────────────────────────────────────────────────────────────────────

/// Signature for a pre-capture contributor. Invoked by `CreateSnapshot` before
/// `ExportEntityTree` runs, so downstream crates can do any preparatory work
/// whose result only becomes visible once the export runs (e.g. firing
/// `ResendPropertyValue` so live emitter pulses land in the store before the
/// post-export transform mines them).
///
/// Pre-capture contributors must NOT mutate persistent entity stores — the
/// snapshot is a read operation and side effects that outlive the command
/// surprise the caller. Persistent writes are an explicit hard no (see
/// the non-destructive design in `2026-04-22-action-emitter-property-pairing-design.md`).
///
/// `root_type` and `root_id` mirror the `CreateSnapshot` command fields. A
/// contributor should early-return for root types it does not recognise.
///
/// Returning an error aborts the snapshot.
#[cfg(not(target_arch = "wasm32"))]
pub type SnapshotPreCaptureFn =
    fn(&CommandContext, root_type: &str, root_id: &str) -> Result<(), CommandError>;

/// Registration record for a pre-capture contributor. See
/// [`SnapshotPreCaptureFn`].
#[cfg(not(target_arch = "wasm32"))]
pub struct SnapshotPreCaptureRegistration {
    pub contributor: SnapshotPreCaptureFn,
}

#[cfg(not(target_arch = "wasm32"))]
inventory::collect!(SnapshotPreCaptureRegistration);

// ─────────────────────────────────────────────────────────────────────────────
// Post-export contributor hook
// ─────────────────────────────────────────────────────────────────────────────

/// Signature for a post-export contributor. Invoked by `CreateSnapshot` AFTER
/// `ExportEntityTree` produces the [`EntityTreeExport`], so downstream crates
/// can transform the exported tree in-place (e.g. rewriting PropertyNode input
/// `BindingNodeValue` rows with live emitter pulse data) without mutating the
/// live entity stores.
///
/// The contributor receives the exported tree by mutable reference and may
/// modify each entry's `data` JSON. Returning an error aborts the snapshot.
/// Historical (`as_of`) captures bypass this hook — they are meant to reflect
/// a point-in-time read of the event log.
#[cfg(not(target_arch = "wasm32"))]
pub type SnapshotPostExportFn = fn(
    export: &mut EntityTreeExport,
    ctx: &CommandContext,
    root_type: &str,
    root_id: &str,
) -> Result<(), CommandError>;

/// Registration record for a post-export contributor. See
/// [`SnapshotPostExportFn`].
#[cfg(not(target_arch = "wasm32"))]
pub struct SnapshotPostExportRegistration {
    pub contributor: SnapshotPostExportFn,
}

#[cfg(not(target_arch = "wasm32"))]
inventory::collect!(SnapshotPostExportRegistration);

#[myko_item]
pub struct Snapshot {
    /// Display name for this snapshot
    #[searchable]
    pub name: String,

    /// Optional description
    #[serde(default)]
    pub description: Option<String>,

    /// The project this snapshot belongs to (cascade-deleted with project).
    /// Excluded from tree export to prevent recursive snapshot inclusion.
    #[belongs_to(Project)]
    #[exclude_from_tree]
    pub scope_id: ProjectId,

    /// The entity type of the snapshot root (e.g., "Project", "Scene")
    pub root_type: String,

    /// The ID of the snapshot root entity
    pub root_id: String,

    /// The full serialized EntityTreeExport payload
    pub data: Value,
}

// ─────────────────────────────────────────────────────────────────────────────
// Commands
// ─────────────────────────────────────────────────────────────────────────────

#[myko_command(SnapshotId)]
pub struct CreateSnapshot {
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    /// The owning project ID
    pub project_id: ProjectId,
    /// The entity type to snapshot (e.g., "Project", "Scene")
    pub root_type: String,
    /// The ID of the entity to snapshot
    pub root_id: String,
    /// ISO 8601 timestamp — when set, creates snapshot from historical state.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional = nullable)]
    pub as_of: Option<String>,
}

#[cfg(not(target_arch = "wasm32"))]
impl CommandHandler for CreateSnapshot {
    fn execute(self, ctx: CommandContext) -> Result<SnapshotId, CommandError> {
        let as_of = self.as_of.map(|s| Arc::from(s.as_str()));
        eprintln!(
            "[CreateSnapshot] root_type={} root_id={} as_of={:?}",
            self.root_type, self.root_id, as_of
        );

        // Invoke any pre-capture contributors registered by downstream crates.
        // These do preparatory work whose effect becomes visible to the tree
        // export (e.g. firing `ResendPropertyValue` so live pulses land in
        // the Pulse store before the post-export transform reads them).
        // Skipped for historical (`as_of`) snapshots — the user explicitly
        // asked for a point-in-time read of the event log.
        let is_live = as_of.is_none();
        if is_live {
            for registration in inventory::iter::<SnapshotPreCaptureRegistration> {
                (registration.contributor)(&ctx, &self.root_type, &self.root_id)?;
            }
        }

        // Capture the entity tree (historical if as_of is set)
        let mut tree = ctx.exec_report(ExportEntityTree {
            root_type: self.root_type.clone().into(),
            root_id: self.root_id.clone().into(),
            as_of,
        })?;

        eprintln!("[CreateSnapshot] exported {} entities", tree.entities.len());

        // Invoke any post-export contributors. These rewrite the exported
        // tree in-place (e.g. overlaying PropertyNode input values with the
        // paired emitter's live pulse) WITHOUT mutating the live stores —
        // the snapshot is a pure read.
        if is_live {
            for registration in inventory::iter::<SnapshotPostExportRegistration> {
                (registration.contributor)(&mut tree, &ctx, &self.root_type, &self.root_id)?;
            }
        }

        let id = SnapshotId(Uuid::new_v4().to_string().into());

        let data = serde_json::to_value(&tree).map_err(|e| CommandError {
            tx: ctx.tx().to_string(),
            command_id: ctx.command_id.to_string(),
            message: format!("Failed to serialize tree: {e}"),
        })?;

        let snapshot = Snapshot {
            id: id.clone(),
            name: self.name,
            description: self.description,
            scope_id: self.project_id,
            root_type: self.root_type,
            root_id: self.root_id,
            data,
        };

        ctx.emit_set(&snapshot)?;

        Ok(id)
    }
}