Skip to main content

uni_plugin_custom/
persistence.rs

1// Rust guideline compliant
2//! Persistence backends for declared-plugin records.
3//!
4//! M9 stores declarations in a [`DeclaredPluginStore`](super::DeclaredPluginStore)
5//! in memory, but the user-visible promise of `apoc.custom`-style
6//! `uni.plugin.declareFunction` is that declarations *survive restart*.
7//!
8//! Proposal §9.7 anchors the persistence schema in a Cypher-visible
9//! system label `_DeclaredPlugin`. That label requires write-enabled
10//! [`uni_plugin::traits::procedure::ProcedureHost`] execution, which
11//! does not exist yet (the host's `execute_inner_query` is read-only
12//! and does not bind parameters). Rather than block M9 on that
13//! infrastructure, this module ships a [`Persistence`] trait with two
14//! concrete implementations:
15//!
16//! - [`NullPersistence`] — drops declarations on the floor; used in
17//!   tests that exercise only the in-memory store.
18//! - [`JsonFilePersistence`] — round-trips the [`DeclaredPlugin`]
19//!   serde shape through a JSON sidecar file under the instance's
20//!   data directory.
21//!
22//! The schema matches proposal §9.7 field-for-field, so the eventual
23//! cutover to `_DeclaredPlugin` (when write-enabled host execution
24//! lands) is a drop-in `impl Persistence for SystemLabelPersistence`.
25
26use std::io;
27use std::path::PathBuf;
28use std::sync::Mutex;
29
30use thiserror::Error;
31use uni_sidecar::{SidecarIoError, SystemSidecar};
32
33use crate::DeclaredPlugin;
34
35/// Errors raised by [`Persistence`] backends.
36#[derive(Debug, Error)]
37#[non_exhaustive]
38pub enum PersistenceError {
39    /// I/O failure while reading or writing the sidecar.
40    #[error("persistence I/O: {0}")]
41    Io(#[from] io::Error),
42
43    /// JSON encode / decode failure.
44    #[error("persistence serde: {0}")]
45    Serde(#[from] serde_json::Error),
46}
47
48impl From<SidecarIoError> for PersistenceError {
49    fn from(e: SidecarIoError) -> Self {
50        // The sidecar's variants already carry path + cause in their Display;
51        // fold them into the I/O arm (the message preserves the detail).
52        PersistenceError::Io(io::Error::other(e.to_string()))
53    }
54}
55
56/// A persistence backend for declared-plugin records.
57///
58/// Implementations must be `Send + Sync` because the
59/// [`crate::CustomPlugin`] holds an `Arc<dyn Persistence>` shared
60/// across procedure invocations on every session thread.
61pub trait Persistence: Send + Sync + std::fmt::Debug {
62    /// Persist a freshly-declared plugin record.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`PersistenceError`] on I/O or serialization failure.
67    fn save(&self, plugin: &DeclaredPlugin) -> Result<(), PersistenceError>;
68
69    /// Remove a previously persisted record by qname.
70    ///
71    /// # Errors
72    ///
73    /// Returns [`PersistenceError`] on I/O or serialization failure.
74    fn delete(&self, qname: &str) -> Result<(), PersistenceError>;
75
76    /// Replay every persisted declaration (in any order — callers
77    /// must topologically sort if dependency ordering matters).
78    ///
79    /// # Errors
80    ///
81    /// Returns [`PersistenceError`] on I/O or deserialization failure.
82    fn load_all(&self) -> Result<Vec<DeclaredPlugin>, PersistenceError>;
83}
84
85/// In-memory persistence that drops every record on the floor.
86///
87/// Used by tests and by `CustomPlugin::new_in_memory()` when the host
88/// does not provide a data directory.
89#[derive(Debug, Default)]
90pub struct NullPersistence;
91
92impl Persistence for NullPersistence {
93    fn save(&self, _plugin: &DeclaredPlugin) -> Result<(), PersistenceError> {
94        Ok(())
95    }
96
97    fn delete(&self, _qname: &str) -> Result<(), PersistenceError> {
98        Ok(())
99    }
100
101    fn load_all(&self) -> Result<Vec<DeclaredPlugin>, PersistenceError> {
102        Ok(Vec::new())
103    }
104}
105
106/// On-disk JSON-sidecar persistence.
107///
108/// Records are stored as a JSON array on a single file under the
109/// configured path. Reads parse the whole file; writes serialize the
110/// whole array. This is intentionally simple — declared plugins are
111/// metadata, not throughput-sensitive.
112///
113/// File format (proposal §9.7 schema, JSON-encoded):
114///
115/// ```json
116/// [
117///   {
118///     "qname": "mycorp.fullName",
119///     "kind": "Function",
120///     "body": "$first + ' ' + $last",
121///     "signature_json": "{...}",
122///     "dependencies": [],
123///     "declared_by": "alice",
124///     "active": true
125///   }
126/// ]
127/// ```
128///
129/// The cutover to `_DeclaredPlugin` system-label persistence (proposal
130/// §9.7) leaves this struct unchanged — the wire schema is identical.
131#[derive(Debug)]
132pub struct JsonFilePersistence {
133    /// Atomic JSON IO (temp + fsync + rename + parent-dir fsync), shared with
134    /// the `uni-plugin-host` persisters via [`uni_sidecar`].
135    sidecar: SystemSidecar<Vec<DeclaredPlugin>>,
136    /// Serializes the read-modify-write in `save` / `delete`.
137    write_guard: Mutex<()>,
138}
139
140impl JsonFilePersistence {
141    /// Construct a persistence backend at the exact `path`.
142    ///
143    /// The file is created on first write. If it does not exist at
144    /// construction time, [`Self::load_all`] returns an empty vector. The path
145    /// is used verbatim (via [`SystemSidecar::at_path`]) so existing
146    /// `declared_plugins.json` files keep loading across an upgrade.
147    #[must_use]
148    pub fn new(path: PathBuf) -> Self {
149        Self {
150            sidecar: SystemSidecar::at_path(path),
151            write_guard: Mutex::new(()),
152        }
153    }
154}
155
156impl Persistence for JsonFilePersistence {
157    fn save(&self, plugin: &DeclaredPlugin) -> Result<(), PersistenceError> {
158        let _guard = self.write_guard.lock().expect("persistence mutex poisoned");
159        let mut plugins = self.sidecar.load()?;
160        if let Some(slot) = plugins.iter_mut().find(|p| p.qname == plugin.qname) {
161            *slot = plugin.clone();
162        } else {
163            plugins.push(plugin.clone());
164        }
165        self.sidecar.store(&plugins)?;
166        Ok(())
167    }
168
169    fn delete(&self, qname: &str) -> Result<(), PersistenceError> {
170        let _guard = self.write_guard.lock().expect("persistence mutex poisoned");
171        let mut plugins = self.sidecar.load()?;
172        plugins.retain(|p| p.qname != qname);
173        self.sidecar.store(&plugins)?;
174        Ok(())
175    }
176
177    fn load_all(&self) -> Result<Vec<DeclaredPlugin>, PersistenceError> {
178        let _guard = self.write_guard.lock().expect("persistence mutex poisoned");
179        Ok(self.sidecar.load()?)
180    }
181}