Skip to main content

arete_interpreter/
lib.rs

1//! # arete-interpreter
2//!
3//! AST transformation runtime and VM for Arete streaming pipelines.
4//!
5//! This crate provides the core components for processing Solana blockchain
6//! events into typed state projections:
7//!
8//! - **AST Definition** - Type-safe schemas for state and event handlers
9//! - **Bytecode Compiler** - Compiles specs into optimized bytecode  
10//! - **Virtual Machine** - Executes bytecode to process events
11//! - **TypeScript Generation** - Generate client SDKs automatically
12//!
13//! ## Example
14//!
15//! ```rust,ignore
16//! use arete_interpreter::{TypeScriptCompiler, TypeScriptConfig};
17//!
18//! let config = TypeScriptConfig::default();
19//! let compiler = TypeScriptCompiler::new(config);
20//! let typescript = compiler.compile(&spec)?;
21//! ```
22//!
23//! ## Feature Flags
24//!
25//! - `otel` - OpenTelemetry integration for distributed tracing and metrics
26
27pub mod ast;
28pub mod canonical_log;
29pub mod compiler;
30pub mod debugger;
31pub mod event_type_helpers;
32pub mod identifiers;
33mod idl_models;
34pub mod metrics_context;
35mod packed_row;
36pub mod program_sdk;
37pub mod proto_router;
38pub mod public_artifacts;
39pub mod python;
40pub mod resolvers;
41pub mod runtime_resolvers;
42pub mod runtime_resolvers_factory;
43pub mod rust;
44mod rust_doc;
45pub mod scheduler;
46pub mod slot_hash_cache;
47pub mod snapshot;
48pub mod spec_trait;
49mod stack_types;
50pub mod transaction_metadata;
51pub mod typescript;
52pub mod typescript_instructions;
53pub mod versioned;
54pub mod vm;
55pub mod vm_metrics;
56
57// Re-export slot hash cache functions
58pub use slot_hash_cache::{get_slot_hash, record_slot_hash};
59
60pub use canonical_log::{CanonicalLog, LogLevel};
61pub use debugger::{VmDebugEvent, VmDebugger, VmLookupHop};
62pub use metrics_context::{FieldAccessor, FieldRef, MetricsContext};
63pub use resolvers::{
64    InstructionContext, KeyResolution, ResolveContext, ReverseLookupUpdater, TokenMetadata,
65};
66pub use runtime_resolvers::{
67    InProcessResolver, ResolverApplyFuture, ResolverBatchFuture, ResolverBatchResult,
68    RuntimeResolver, RuntimeResolverBatchRequest, RuntimeResolverBatchResponse,
69    RuntimeResolverRequest, RuntimeResolverResponse, SharedRuntimeResolver,
70};
71pub use transaction_metadata::{
72    SolanaTransactionConfig, SolanaTransactionMetadata, SolanaTransactionVersion,
73    SOLANA_TRANSACTION_METADATA_KEY,
74};
75pub use typescript::{write_typescript_to_file, TypeScriptCompiler, TypeScriptConfig};
76pub use vm::{
77    CapacityWarning, CleanupResult, DirtyTracker, FieldChange, PendingAccountUpdate,
78    PendingQueueStats, QueuedAccountUpdate, ResolverRequest, ResolverTarget, ScheduledCallback,
79    StateTableConfig, UpdateContext, VmMemoryStats,
80};
81
82// Re-export macros for convenient use
83// The field! macro is the new recommended way to create field references
84// The field_accessor! macro is kept for backward compatibility
85
86use serde::{Deserialize, Serialize};
87use serde_json::Value;
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Mutation {
91    pub export: String,
92    pub key: Value,
93    pub patch: Value,
94    #[serde(skip_serializing_if = "Vec::is_empty", default)]
95    pub append: Vec<String>,
96}
97
98/// Field that marks a mutation's `patch` as the whole entity rather than only
99/// the fields that changed; its value says which kind ([`WholeEntity`]). Set
100/// with [`Mutation::mark_whole_entity`] or [`Mutation::mark_created`] and
101/// removed with [`Mutation::take_whole_entity_mark`] before the patch goes
102/// anywhere else; it is never part of an entity.
103pub const WHOLE_ENTITY_MARKER: &str = "__arete_whole_entity";
104
105/// Reserved mutation metadata for an explicit source deletion. Never an entity field.
106pub const ENTITY_DELETE_MARKER: &str = "__arete_entity_delete";
107
108/// Reserved mutation metadata for an authoritative Solana account position.
109///
110/// This is deliberately separate from a mutation batch's `_seq`: account
111/// `write_version` and instruction `txn_index` are different ordering domains
112/// and must never be compared to decide which account lifetime is current.
113pub const ACCOUNT_POSITION_MARKER: &str = "__arete_account_position";
114
115/// The authoritative order of an account write within the Solana stream.
116///
117/// Producers attach this only to mutations owned by an account update or
118/// account deletion. Instruction and resolver mutations must not invent one.
119/// Decimal strings keep both components exact across JSON boundaries.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase", deny_unknown_fields)]
122pub struct AccountPosition {
123    #[serde(with = "decimal_u64")]
124    pub slot: u64,
125    #[serde(with = "decimal_u64")]
126    pub write_version: u64,
127}
128
129impl AccountPosition {
130    pub const fn new(slot: u64, write_version: u64) -> Self {
131        Self {
132            slot,
133            write_version,
134        }
135    }
136}
137
138mod decimal_u64 {
139    use serde::{Deserialize, Deserializer, Serializer};
140
141    pub fn serialize<S>(value: &u64, serializer: S) -> Result<S::Ok, S::Error>
142    where
143        S: Serializer,
144    {
145        serializer.serialize_str(&value.to_string())
146    }
147
148    pub fn deserialize<'de, D>(deserializer: D) -> Result<u64, D::Error>
149    where
150        D: Deserializer<'de>,
151    {
152        let value = String::deserialize(deserializer)?;
153        value.parse().map_err(serde::de::Error::custom)
154    }
155}
156
157/// [`WHOLE_ENTITY_MARKER`]'s value for [`WholeEntity::Created`]. The value for
158/// [`WholeEntity::Resent`] is `true`.
159const CREATED_MARK: &str = "created";
160
161/// Why a marked mutation's patch is the whole entity.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum WholeEntity {
164    /// The entity's first mutation since its source created it: everything it
165    /// has is new, so the patch is a change like any other and all of the
166    /// entity at once.
167    Created,
168    /// The entity again, right after a mutation that carried the change: state
169    /// to restore, not a change of its own.
170    Resent,
171}
172
173impl Mutation {
174    /// End an entity's lifetime. Submit in the same ordered stream as its updates,
175    /// and call [`Self::mark_account_position`] for an account-owned entity. The
176    /// batch position still supplies `_seq`, but does not order lifetimes. This
177    /// is distinct from view eviction. VM-backed sources must use
178    /// [`vm::VmContext::delete_entity`] to remove state too.
179    pub fn delete(export: impl Into<String>, key: Value) -> Self {
180        Self {
181            export: export.into(),
182            key,
183            patch: serde_json::json!({ ENTITY_DELETE_MARKER: true }),
184            append: Vec::new(),
185        }
186    }
187
188    pub fn is_delete(&self) -> bool {
189        self.patch.get(ENTITY_DELETE_MARKER) == Some(&Value::Bool(true))
190    }
191
192    /// Attach the authoritative account-write position that owns this
193    /// mutation. This metadata is consumed before projection and never becomes
194    /// an entity field.
195    pub fn mark_account_position(&mut self, position: AccountPosition) {
196        if let Value::Object(fields) = &mut self.patch {
197            fields.insert(
198                ACCOUNT_POSITION_MARKER.to_string(),
199                serde_json::to_value(position).expect("AccountPosition is serializable"),
200            );
201        }
202    }
203
204    /// Remove and decode authoritative account-write metadata.
205    ///
206    /// Invalid metadata is an ingestion error rather than an unmarked
207    /// mutation: silently accepting it would disable stale-lifetime guards.
208    pub fn take_account_position(&mut self) -> Result<Option<AccountPosition>, String> {
209        let Value::Object(fields) = &mut self.patch else {
210            return Ok(None);
211        };
212        let Some(value) = fields.remove(ACCOUNT_POSITION_MARKER) else {
213            return Ok(None);
214        };
215        serde_json::from_value(value)
216            .map(Some)
217            .map_err(|error| format!("invalid {ACCOUNT_POSITION_MARKER}: {error}"))
218    }
219
220    /// Declare that `patch` holds the whole entity again, after a mutation
221    /// that carried the change ([`WholeEntity::Resent`]).
222    ///
223    /// A consumer that bounds how many entities it keeps (arete-server's
224    /// entity cache) cannot rebuild an entity it dropped from later patches,
225    /// which carry only changed fields; a mutation marked whole restores it.
226    /// The VM marks the mutations it emits for keys requested through
227    /// [`vm::WholeEntityRequests`]. A custom mutation source that emits whole
228    /// entities can mark them itself. Only an object patch can carry the mark;
229    /// on anything else this does nothing.
230    pub fn mark_whole_entity(&mut self) {
231        self.set_whole_entity_mark(Value::Bool(true));
232    }
233
234    /// Declare that this mutation creates its entity, so `patch` is all of it
235    /// ([`WholeEntity::Created`]).
236    ///
237    /// A VM given a [`vm::WholeEntityRequests`] marks every entity's first
238    /// mutation this way, which lets a consumer tell a new entity from a later
239    /// patch for one it does not hold. Only an object patch can carry the mark;
240    /// on anything else this does nothing.
241    pub fn mark_created(&mut self) {
242        self.set_whole_entity_mark(Value::String(CREATED_MARK.to_string()));
243    }
244
245    fn set_whole_entity_mark(&mut self, mark: Value) {
246        if let Value::Object(fields) = &mut self.patch {
247            fields.insert(WHOLE_ENTITY_MARKER.to_string(), mark);
248        }
249    }
250
251    /// Which whole-entity mark `patch` carries, if any.
252    pub fn whole_entity_mark(&self) -> Option<WholeEntity> {
253        Self::read_whole_entity_mark(self.patch.get(WHOLE_ENTITY_MARKER)?)
254    }
255
256    /// Whether `patch` is marked as the whole entity, of either kind.
257    pub fn is_whole_entity(&self) -> bool {
258        self.whole_entity_mark().is_some()
259    }
260
261    /// Remove the whole-entity mark, returning which one was set.
262    pub fn take_whole_entity_mark(&mut self) -> Option<WholeEntity> {
263        match &mut self.patch {
264            Value::Object(fields) => {
265                Self::read_whole_entity_mark(&fields.remove(WHOLE_ENTITY_MARKER)?)
266            }
267            _ => None,
268        }
269    }
270
271    fn read_whole_entity_mark(mark: &Value) -> Option<WholeEntity> {
272        match mark {
273            Value::Bool(true) => Some(WholeEntity::Resent),
274            Value::String(kind) if kind == CREATED_MARK => Some(WholeEntity::Created),
275            _ => None,
276        }
277    }
278}
279
280/// Generic wrapper for event data that includes context metadata
281/// This ensures type safety for events captured in entity specs
282///
283/// # Runtime Structure
284/// Events captured with `#[event]` are automatically wrapped in this structure:
285/// ```json
286/// {
287///   "timestamp": 1234567890,
288///   "data": { /* event-specific data */ },
289///   "slot": 381471241,
290///   "signature": "4xNEYTVL8DB28W87...",
291///   "event_index": 3,
292///   "ix_path": "0.1"
293/// }
294/// ```
295///
296/// `ix_path` is always present for a transaction-sourced event and identifies
297/// the instruction it came from, so two same-type events under one signature
298/// stay distinct. `event_index` is the absolute log-line index and appears
299/// only for events decoded from a log: it is what makes one log line decoded
300/// by both an outer instruction and its inner CPI resolve to a single
301/// occurrence. An `emit_cpi!` event arrives as its own instruction and is
302/// therefore identified by `ix_path` alone.
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct EventWrapper<T = Value> {
305    /// Unix timestamp when the event was processed
306    pub timestamp: i64,
307    /// The event-specific data
308    pub data: T,
309    /// Optional slot number from UpdateContext
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub slot: Option<u64>,
312    /// Optional transaction signature from UpdateContext
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub signature: Option<String>,
315    /// Absolute log-line index, for events decoded from a transaction log
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub event_index: Option<u64>,
318    /// 0-based instruction path within the transaction (e.g. `"0.1"`)
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub ix_path: Option<String>,
321}
322
323/// Generic wrapper for account capture data that includes context metadata
324/// This ensures type safety for accounts captured with `#[capture]` in entity specs
325///
326/// # Runtime Structure
327/// Accounts captured with `#[capture]` are automatically wrapped in this structure:
328/// ```json
329/// {
330///   "timestamp": 1234567890,
331///   "account_address": "C6P5CpJnYHgpGvCGuXYAWL6guKH5LApn3QwTAZmNUPCj",
332///   "data": { /* account-specific data (filtered, no __ fields) */ },
333///   "slot": 381471241,
334///   "signature": "4xNEYTVL8DB28W87..."
335/// }
336/// ```
337#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct CaptureWrapper<T = Value> {
339    /// Unix timestamp when the account was captured
340    pub timestamp: i64,
341    /// The account address (base58 encoded public key)
342    pub account_address: String,
343    /// The account data (already filtered to remove internal __ fields)
344    pub data: T,
345    /// Optional slot number from UpdateContext
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub slot: Option<u64>,
348    /// Optional transaction signature from UpdateContext
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub signature: Option<String>,
351}
352
353#[cfg(test)]
354mod mutation_metadata_tests {
355    use super::*;
356    use serde_json::json;
357
358    #[test]
359    fn account_position_is_exact_reserved_metadata() {
360        let mut mutation = Mutation {
361            export: "Account".into(),
362            key: json!("address"),
363            patch: json!({"balance": 1}),
364            append: vec![],
365        };
366        let position = AccountPosition::new(9_007_199_254_740_993, u64::MAX);
367        mutation.mark_account_position(position);
368        assert_eq!(
369            mutation.patch[ACCOUNT_POSITION_MARKER],
370            json!({
371                "slot": "9007199254740993",
372                "writeVersion": "18446744073709551615"
373            })
374        );
375        assert_eq!(mutation.take_account_position().unwrap(), Some(position));
376        assert!(mutation.patch.get(ACCOUNT_POSITION_MARKER).is_none());
377        assert_eq!(mutation.patch["balance"], 1);
378    }
379}