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}