Skip to main content

kevy_wasm/
lib.rs

1//! kevy-wasm — kevy's embedded KV engine behind a hand-written C ABI.
2//!
3//! Compiled to `wasm32-unknown-unknown`, this crate exports a flat
4//! `extern "C"` surface (no binding generator, zero dependencies beyond
5//! the kevy workspace) that a small hand-written ES-module loader
6//! (`pkg/kevy.js`) wraps into an idiomatic JavaScript API. The same
7//! functions are plain Rust functions on native targets, which is how
8//! the unit tests drive them.
9//!
10//! # ABI conventions
11//!
12//! - **Instances** are `u32` handles from [`kevy_open`]; every other
13//!   call takes the handle first. `0` is never a valid handle.
14//! - **Bytes in** cross as `(ptr, len)` pairs pointing into linear
15//!   memory the caller obtained from [`kevy_alloc`] (and returns with
16//!   [`kevy_free`]).
17//! - **Bytes out** land in a per-instance result buffer read via
18//!   [`kevy_out_ptr`] / [`kevy_out_len`]; the buffer is valid until the
19//!   next call on the same handle, so callers copy out immediately.
20//! - **Status codes**: `>= 0` is success (meaning is per-function),
21//!   `-1` is an operation error (UTF-8 message in the result buffer),
22//!   `-2` is an invalid handle.
23//! - **Numbers** cross as `f64` where the JS side works with plain
24//!   `Number` values (clocks, TTLs, counts). All are well inside the
25//!   2^53 exact-integer range.
26//!
27//! # Threading and clocks
28//!
29//! The browser target has no threads and no OS clock: instances open
30//! with the manual TTL reaper, the host calls [`kevy_tick`] on its own
31//! cadence, and feeds `Date.now()` through [`kevy_set_clock`] first.
32//!
33//! # Persistence
34//!
35//! The browser has no filesystem, so durability is host-mediated: with
36//! frame capture enabled, every write also encodes the same RESP frame
37//! a kevy AOF stores on disk. The host pumps [`kevy_aof_frames_out`]
38//! into its own storage (OPFS, IndexedDB, anything append-capable) and
39//! feeds the log back through [`kevy_aof_frame_in`] on the next open.
40//! [`kevy_aof_dump`] produces a compacted image for log rewriting. The
41//! byte format is exactly `kevy-persist`'s AOF format, so a log written
42//! by a browser tab replays in a native kevy just as well.
43
44pub mod abi_aof;
45pub mod abi_cmd;
46pub mod abi_core;
47pub mod abi_kv;
48pub mod abi_pubsub;
49
50#[cfg(test)]
51#[path = "abi_tests.rs"]
52mod tests;
53
54use std::collections::BTreeMap;
55use std::io::Write;
56use std::sync::Mutex;
57use std::sync::atomic::{AtomicU32, Ordering};
58
59use kevy_embedded::{Store, Subscription};
60use kevy_store::{KevyError, StoreError};
61
62/// ABI contract version reported by [`abi_core::kevy_abi_version`].
63/// Bumped on any incompatible change to the export surface or the
64/// packed byte formats, so loaders can refuse a mismatched module.
65pub const ABI_VERSION: u32 = 1;
66
67/// Success status.
68pub(crate) const OK: i32 = 0;
69/// Operation failed; the result buffer holds a UTF-8 error message.
70pub(crate) const ERR: i32 = -1;
71/// The handle does not name a live instance.
72pub(crate) const BAD_HANDLE: i32 = -2;
73
74/// One open store plus its ABI-side state.
75pub(crate) struct Instance {
76    pub(crate) store: Store,
77    /// Live subscriptions by subscription id (ids are per-instance).
78    pub(crate) subs: BTreeMap<u32, Subscription>,
79    pub(crate) next_sub: u32,
80    /// Whether writes also encode AOF frames into `aof_out`.
81    pub(crate) capture_aof: bool,
82    /// Pending AOF frames awaiting a `kevy_aof_frames_out` drain.
83    pub(crate) aof_out: Vec<u8>,
84    /// Unparsed tail carried between `kevy_aof_frame_in` chunks.
85    pub(crate) aof_in_carry: Vec<u8>,
86    /// Whether the inbound AOF stream is past its optional magic header.
87    pub(crate) aof_in_started: bool,
88    /// Format of the host's stored log — set from the magic when the
89    /// host feeds its log back (v1 read-forever contract), flipped to
90    /// V2 by `kevy_aof_dump` (the image replaces the log). Outbound
91    /// frames encode in THIS format so the host's verbatim appends
92    /// never mix formats within one log. Fresh logs are V2.
93    pub(crate) aof_format: kevy_persist::AofFormat,
94    /// Whether the host's log already carries its format marker — true
95    /// once the host fed any log bytes or a dump image replaced the
96    /// log. While false, the first captured V2 frame is preceded by
97    /// the `KEVYAOF2` magic so a fresh log is self-describing.
98    pub(crate) aof_out_started: bool,
99    /// Reusable payload buffer for v2 record encoding.
100    pub(crate) aof_scratch: Vec<u8>,
101    /// Result buffer exposed through `kevy_out_ptr` / `kevy_out_len`.
102    pub(crate) out: Vec<u8>,
103}
104
105impl Instance {
106    pub(crate) fn new(store: Store, capture_aof: bool) -> Self {
107        Instance {
108            store,
109            subs: BTreeMap::new(),
110            next_sub: 1,
111            capture_aof,
112            aof_out: Vec::new(),
113            aof_in_carry: Vec::new(),
114            aof_in_started: false,
115            aof_format: kevy_persist::AofFormat::V2,
116            aof_out_started: false,
117            aof_scratch: Vec::new(),
118            out: Vec::new(),
119        }
120    }
121
122    /// Set the result buffer to `bytes`.
123    pub(crate) fn put_out(&mut self, bytes: &[u8]) {
124        self.out.clear();
125        self.out.extend_from_slice(bytes);
126    }
127
128    /// Record an error message in the result buffer and return [`ERR`].
129    pub(crate) fn fail(&mut self, msg: impl std::fmt::Display) -> i32 {
130        self.out.clear();
131        // Writing into a Vec cannot fail.
132        let _ = write!(self.out, "{msg}");
133        ERR
134    }
135
136    /// Record a [`KevyError`] using the Redis-canonical wording for
137    /// store-semantic errors, then return [`ERR`].
138    ///
139    /// The engine keeps errors structured (`KevyError::Store(WrongType)`),
140    /// and its `Display` prints the internal Debug spelling
141    /// (`store error: WrongType`) — an implementation detail that must not
142    /// leak to a JS caller. At the door boundary we translate store
143    /// variants to the exact messages a real Redis emits (`WRONGTYPE …`),
144    /// so the JS-visible error reads like a genuine server error. Non-store
145    /// variants keep their `Display` text.
146    pub(crate) fn fail_kevy(&mut self, e: &KevyError) -> i32 {
147        match e {
148            KevyError::Store(se) => self.fail(store_err_canonical(se)),
149            other => self.fail(other),
150        }
151    }
152
153    /// Append one command as an AOF frame to the pending pump buffer
154    /// (no-op unless frame capture was requested at open). Encodes in
155    /// the host log's format — a bare RESP frame for a v1-era log, a
156    /// checksummed v2 record otherwise; a fresh v2 log gets its
157    /// `KEVYAOF2` magic ahead of the first frame so the stored bytes
158    /// are self-describing on the next open.
159    pub(crate) fn log_frame(&mut self, parts: &[&[u8]]) {
160        if !self.capture_aof {
161            return;
162        }
163        let argv = kevy_persist::Argv::from(parts.iter().map(|p| p.to_vec()).collect::<Vec<_>>());
164        // Vec is an infallible Write.
165        match self.aof_format {
166            kevy_persist::AofFormat::V1 => {
167                let _ = kevy_persist::write_multibulk(&mut self.aof_out, &argv);
168            }
169            kevy_persist::AofFormat::V2 => {
170                if !self.aof_out_started {
171                    self.aof_out.extend_from_slice(kevy_persist::AOF2_MAGIC);
172                    self.aof_out_started = true;
173                }
174                let _ = kevy_persist::write_record_multibulk(
175                    &mut self.aof_out,
176                    &argv,
177                    &mut self.aof_scratch,
178                );
179            }
180        }
181    }
182}
183
184/// Redis-canonical message for a store-semantic error.
185///
186/// Mirrors the strings kevy-embedded's full RESP dispatcher emits
187/// (`dispatch::util`), duplicated here because that table is `pub(super)`
188/// and unreachable from this crate. The `dispatch_oracle` parity test in
189/// kevy-embedded holds those strings against the server byte for byte, so
190/// this door surfaces exactly the wording a native kevy would.
191fn store_err_canonical(e: &StoreError) -> &'static str {
192    match e {
193        StoreError::WrongType => {
194            "WRONGTYPE Operation against a key holding the wrong kind of value"
195        }
196        StoreError::NotInteger => "ERR value is not an integer or out of range",
197        StoreError::Overflow => "ERR increment or decrement would overflow",
198        StoreError::OutOfRange => "ERR index out of range",
199        StoreError::NoSuchKey => "ERR no such key",
200        StoreError::NotFloat => "ERR value is not a valid float",
201        StoreError::OutOfMemory => "OOM command not allowed when used memory > 'maxmemory'.",
202    }
203}
204
205/// Handle allocator. Starts at 1 so 0 stays "no instance".
206pub(crate) static NEXT_ID: AtomicU32 = AtomicU32::new(1);
207
208/// The live instance table. A `Mutex` (never contended on the
209/// single-threaded wasm target) keeps the native test builds sound.
210pub(crate) static REG: Mutex<BTreeMap<u32, Instance>> = Mutex::new(BTreeMap::new());
211
212/// Allocate a fresh handle id.
213pub(crate) fn next_id() -> u32 {
214    NEXT_ID.fetch_add(1, Ordering::Relaxed)
215}
216
217/// Run `f` against the instance behind `h`, or return `missing` when the
218/// handle is not live.
219pub(crate) fn with<R>(h: u32, missing: R, f: impl FnOnce(&mut Instance) -> R) -> R {
220    let mut reg = REG.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
221    match reg.get_mut(&h) {
222        Some(inst) => f(inst),
223        None => missing,
224    }
225}
226
227/// View a caller-provided `(ptr, len)` pair as a byte slice for the
228/// duration of the current call.
229///
230/// # Safety
231///
232/// `ptr` must point to `len` readable bytes that stay valid (and are not
233/// written) for the whole ABI call — the loader guarantees this by only
234/// passing buffers it obtained from [`abi_core::kevy_alloc`] and not
235/// touching them until the call returns. `len == 0` is always safe and
236/// yields the empty slice.
237pub(crate) unsafe fn arg<'a>(ptr: *const u8, len: u32) -> &'a [u8] {
238    if len == 0 {
239        return &[];
240    }
241    // SAFETY: contract above — caller passes a live, non-aliased buffer.
242    unsafe { std::slice::from_raw_parts(ptr, len as usize) }
243}