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}