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}