Skip to content

Commit 0bd7e6a

Browse files
committed
feat(wasm,runtime): complete WASM runtime stubs and add crate-level documentation
- Wire `wasm_bindgen_futures::spawn_local` into `runtime::spawn` (was a compile-gate placeholder). - Wire `gloo_timers::future::sleep` into `runtime::sleep` (was a placeholder). - Add `gloo-timers`, `wasm-bindgen-futures`, and `js-sys` as unconditional dependencies in `nodedb-lite/Cargo.toml` so the WASM target compiles without feature flags. - Add crate-level documentation to `lib.rs` with a quick-start snippet, durability contract, and `Encryption` re-export. - Minor cleanups in columnar store, CRDT engine, FTS checkpoint/manager, timeseries sync, and vector lazy-load (comment accuracy, import hygiene).
1 parent 70135e3 commit 0bd7e6a

9 files changed

Lines changed: 226 additions & 56 deletions

File tree

nodedb-lite/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ getrandom = { workspace = true }
5151
[target.'cfg(target_arch = "wasm32")'.dependencies]
5252
pagedb = { workspace = true, features = ["opfs"] }
5353
tokio = { workspace = true, features = ["sync", "rt", "macros"] }
54+
gloo-timers = { version = "0.3", features = ["futures"] }
55+
wasm-bindgen-futures = "0.4"
56+
js-sys = { workspace = true }
5457

5558
# Async runtime (native only — WASM uses wasm-bindgen-futures)
5659
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]

nodedb-lite/src/engine/columnar/store.rs

Lines changed: 96 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use crate::error::LiteError;
2727
use crate::runtime::now_millis_i64;
2828
use crate::storage::engine::{StorageEngine, WriteOp};
2929
#[cfg(not(target_arch = "wasm32"))]
30-
use crate::sync::ColumnarOutbound;
30+
use crate::sync::outbound::columnar::ColumnarOutbound;
3131
#[cfg(not(target_arch = "wasm32"))]
3232
use crate::sync::outbound::timeseries::TimeseriesOutbound;
3333

@@ -138,7 +138,7 @@ pub struct ColumnarEngine<S: StorageEngine> {
138138
/// Optional outbound queue for plain columnar insert sync.
139139
/// `None` when sync is disabled or not yet configured.
140140
#[cfg(not(target_arch = "wasm32"))]
141-
outbound: Option<Arc<ColumnarOutbound>>,
141+
outbound: Option<Arc<ColumnarOutbound<S>>>,
142142
/// Optional outbound queue for timeseries-profile insert sync.
143143
///
144144
/// Timeseries collections must use `TimeseriesPush` frames on Origin
@@ -147,7 +147,7 @@ pub struct ColumnarEngine<S: StorageEngine> {
147147
/// collections with `ColumnarProfile::Timeseries` are enqueued here
148148
/// instead of `outbound`.
149149
#[cfg(not(target_arch = "wasm32"))]
150-
timeseries_outbound: Option<Arc<TimeseriesOutbound>>,
150+
timeseries_outbound: Option<Arc<TimeseriesOutbound<S>>>,
151151
}
152152

153153
impl<S: StorageEngine> ColumnarEngine<S> {
@@ -167,7 +167,7 @@ impl<S: StorageEngine> ColumnarEngine<S> {
167167
///
168168
/// Must be called before any inserts if columnar sync is desired.
169169
#[cfg(not(target_arch = "wasm32"))]
170-
pub fn set_outbound(&mut self, outbound: Arc<ColumnarOutbound>) {
170+
pub fn set_outbound(&mut self, outbound: Arc<ColumnarOutbound<S>>) {
171171
self.outbound = Some(outbound);
172172
}
173173

@@ -177,7 +177,7 @@ impl<S: StorageEngine> ColumnarEngine<S> {
177177
/// are routed here instead of `outbound`, so the transport can send them
178178
/// as `TimeseriesPush` frames to Origin's timeseries engine.
179179
#[cfg(not(target_arch = "wasm32"))]
180-
pub fn set_timeseries_outbound(&mut self, outbound: Arc<TimeseriesOutbound>) {
180+
pub fn set_timeseries_outbound(&mut self, outbound: Arc<TimeseriesOutbound<S>>) {
181181
self.timeseries_outbound = Some(outbound);
182182
}
183183

@@ -518,32 +518,103 @@ impl<S: StorageEngine> ColumnarEngine<S> {
518518

519519
/// Insert a row into a columnar collection's memtable.
520520
///
521-
/// When a sync outbound queue is attached the row is also enqueued for
522-
/// replication to Origin. Timeseries-profile collections use the
523-
/// `timeseries_outbound` queue (→ `TimeseriesPush` frames); all other
524-
/// columnar collections use `outbound` (→ `ColumnarInsert` frames).
521+
/// This is a pure in-memory operation. Call [`enqueue_outbound`] from
522+
/// an async context after inserting to durably enqueue the row for
523+
/// replication to Origin.
525524
pub fn insert(&self, collection: &str, values: &[Value]) -> Result<(), LiteError> {
526525
let state_arc = self.lookup(collection)?;
527526
let mut s = Self::lock_state(&state_arc)?;
528527
s.mutation.insert(values).map_err(columnar_err_to_lite)?;
528+
Ok(())
529+
}
529530

530-
#[cfg(not(target_arch = "wasm32"))]
531-
if matches!(s.profile, ColumnarProfile::Timeseries { .. }) {
532-
// Timeseries rows must replicate via TimeseriesPush so that Origin
533-
// stores them in its timeseries engine, not the columnar MutationEngine.
534-
if let Some(ts_out) = &self.timeseries_outbound {
535-
let column_names: Vec<String> = s
536-
.mutation
537-
.schema()
538-
.columns
539-
.iter()
540-
.map(|c| c.name.clone())
541-
.collect();
542-
ts_out.enqueue_row(collection, column_names, values.to_vec());
531+
/// Durably enqueue a batch of inserted rows for replication to Origin.
532+
///
533+
/// Must be called from an async context after one or more successful
534+
/// [`insert`] calls. Timeseries-profile collections are routed to the
535+
/// `timeseries_outbound` queue; all other columnar collections use the
536+
/// plain `outbound` queue.
537+
///
538+
/// Returns [`LiteError::Backpressure`] when the queue is at cap so the
539+
/// caller can propagate back-pressure. Other enqueue errors are logged as
540+
/// warnings (the local insert already succeeded) and `Ok(())` is returned.
541+
#[cfg(not(target_arch = "wasm32"))]
542+
pub async fn enqueue_outbound(
543+
&self,
544+
collection: &str,
545+
rows: &[Vec<Value>],
546+
) -> Result<(), LiteError> {
547+
if rows.is_empty() {
548+
return Ok(());
549+
}
550+
551+
// Read the profile and schema metadata under the lock, then drop it
552+
// before any await point to satisfy the no-lock-across-await rule.
553+
enum OutboundRoute {
554+
Timeseries { column_names: Vec<String> },
555+
Columnar { schema_bytes: Vec<u8> },
556+
None,
557+
}
558+
559+
let route: OutboundRoute = {
560+
let state_arc = self.lookup(collection)?;
561+
let s = Self::lock_state(&state_arc)?;
562+
if matches!(s.profile, ColumnarProfile::Timeseries { .. }) {
563+
if self.timeseries_outbound.is_some() {
564+
let column_names: Vec<String> = s
565+
.mutation
566+
.schema()
567+
.columns
568+
.iter()
569+
.map(|c| c.name.clone())
570+
.collect();
571+
OutboundRoute::Timeseries { column_names }
572+
} else {
573+
OutboundRoute::None
574+
}
575+
} else if self.outbound.is_some() {
576+
let schema_bytes = zerompk::to_msgpack_vec(s.mutation.schema()).unwrap_or_default();
577+
OutboundRoute::Columnar { schema_bytes }
578+
} else {
579+
OutboundRoute::None
580+
}
581+
// lock `s` is dropped here at end of block
582+
};
583+
584+
match route {
585+
OutboundRoute::Timeseries { column_names } => {
586+
let queue = match &self.timeseries_outbound {
587+
Some(q) => Arc::clone(q),
588+
None => return Ok(()),
589+
};
590+
for row in rows {
591+
crate::sync::reconcile_outbound_enqueue(
592+
queue
593+
.enqueue_row(collection, column_names.clone(), row.clone())
594+
.await,
595+
"timeseries insert",
596+
collection,
597+
"",
598+
)?;
599+
}
600+
}
601+
OutboundRoute::Columnar { schema_bytes } => {
602+
let queue = match &self.outbound {
603+
Some(q) => Arc::clone(q),
604+
None => return Ok(()),
605+
};
606+
for row in rows {
607+
crate::sync::reconcile_outbound_enqueue(
608+
queue
609+
.enqueue_row(collection, row.clone(), schema_bytes.clone())
610+
.await,
611+
"columnar insert",
612+
collection,
613+
"",
614+
)?;
615+
}
543616
}
544-
} else if let Some(outbound) = &self.outbound {
545-
let schema_bytes = zerompk::to_msgpack_vec(s.mutation.schema()).unwrap_or_default();
546-
outbound.enqueue_row(collection, values.to_vec(), schema_bytes);
617+
OutboundRoute::None => {}
547618
}
548619

549620
Ok(())

nodedb-lite/src/engine/crdt/engine.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ pub struct PendingDelta {
8080
pub document_id: String,
8181
/// Loro delta bytes (compact binary).
8282
pub delta_bytes: Vec<u8>,
83+
/// Stable idempotent-producer seq for this delta. 0 = unassigned;
84+
/// assigned at first send and reused on reconnect re-send so Origin
85+
/// dedups instead of double-applying.
86+
#[serde(default)]
87+
pub seq: u64,
8388
}
8489

8590
impl CrdtEngine {
@@ -162,6 +167,7 @@ impl CrdtEngine {
162167
collection: collection.to_string(),
163168
document_id: doc_id.to_string(),
164169
delta_bytes,
170+
seq: 0,
165171
});
166172

167173
Ok(mutation_id)
@@ -191,6 +197,7 @@ impl CrdtEngine {
191197
collection: collection.to_string(),
192198
document_id: doc_id.to_string(),
193199
delta_bytes,
200+
seq: 0,
194201
});
195202

196203
Ok(mutation_id)
@@ -240,6 +247,7 @@ impl CrdtEngine {
240247
collection: collection_name,
241248
document_id: format!("{}_ops", ops.len()),
242249
delta_bytes,
250+
seq: 0,
243251
});
244252

245253
Ok(mutation_id)
@@ -317,6 +325,7 @@ impl CrdtEngine {
317325
collection: "deferred".to_string(),
318326
document_id: format!("{count}_ops"),
319327
delta_bytes,
328+
seq: 0,
320329
});
321330

322331
self.deferred_count = 0;
@@ -375,6 +384,7 @@ impl CrdtEngine {
375384
collection: collection.to_string(),
376385
document_id: "*".to_string(),
377386
delta_bytes,
387+
seq: 0,
378388
});
379389
}
380390

@@ -444,6 +454,22 @@ impl CrdtEngine {
444454
self.pending_deltas.retain(|d| d.mutation_id != mutation_id);
445455
}
446456

457+
/// Assign a stable stream seq to a pending delta the first time it is sent.
458+
///
459+
/// If the delta already has a non-zero seq (assigned on a previous send)
460+
/// the call is a no-op — the existing seq is reused on reconnect re-sends
461+
/// so Origin can deduplicate rather than double-apply.
462+
pub fn set_pending_delta_seq(&mut self, mutation_id: u64, seq: u64) {
463+
if let Some(d) = self
464+
.pending_deltas
465+
.iter_mut()
466+
.find(|d| d.mutation_id == mutation_id)
467+
&& d.seq == 0
468+
{
469+
d.seq = seq;
470+
}
471+
}
472+
447473
/// Mark deltas as acknowledged by Origin (after DeltaAck received).
448474
///
449475
/// Removes all pending deltas with `mutation_id <= acked_id`.
@@ -685,6 +711,7 @@ impl CrdtEngine {
685711
collection: collection.to_string(),
686712
document_id: document_id.to_string(),
687713
delta_bytes,
714+
seq: 0,
688715
});
689716
Ok(())
690717
}

nodedb-lite/src/engine/fts/checkpoint.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,8 @@ where
338338
let idx = FtsIndex::new(backend);
339339

340340
// ── Posting data: try pagedb segment path first, fall back to KV ─────
341+
// Flipped only by the native segment path, compiled out on wasm32.
342+
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
341343
let mut restored_from_segment = false;
342344

343345
#[cfg(not(target_arch = "wasm32"))]

nodedb-lite/src/engine/fts/manager.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -362,17 +362,17 @@ impl FtsCollectionManager {
362362
&mut self,
363363
collection: &str,
364364
origin_surrogate: Surrogate,
365-
) -> bool {
365+
) -> Option<String> {
366366
let Some(doc_id) = self.origin_surrogate_to_doc_id.remove(&origin_surrogate.0) else {
367367
tracing::debug!(
368368
collection,
369369
sur = origin_surrogate.0,
370370
"FtsDeleteDoc: no Lite mapping for Origin surrogate — document was never indexed here"
371371
);
372-
return false;
372+
return None;
373373
};
374374
self.remove_document(collection, &doc_id);
375-
true
375+
Some(doc_id)
376376
}
377377

378378
// ── Per-field indexing (used by strict collections via index_integration) ─
@@ -562,7 +562,7 @@ mod tests {
562562

563563
// Delete via origin surrogate.
564564
let removed = mgr.remove_by_origin_surrogate("col", Surrogate(42));
565-
assert!(removed, "doc2 must be found and removed");
565+
assert!(removed.is_some(), "doc2 must be found and removed");
566566

567567
// doc1 and doc3 still searchable, doc2 not.
568568
let results = mgr.search("col", "rust", 10, &default_params());
@@ -612,7 +612,7 @@ mod tests {
612612
mgr.index_document("col", "doc1", "hello world");
613613

614614
let removed = mgr.remove_by_origin_surrogate("col", Surrogate(99));
615-
assert!(!removed, "unknown surrogate must return false");
615+
assert!(removed.is_none(), "unknown surrogate must return None");
616616

617617
// doc1 unaffected.
618618
let results = mgr.search("col", "hello", 10, &default_params());

nodedb-lite/src/engine/timeseries/engine/sync.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ impl TimeseriesEngine {
128128
min_ts,
129129
max_ts,
130130
watermarks,
131+
producer_id: 0,
132+
epoch: 0,
133+
seq: 0,
131134
})
132135
}
133136

nodedb-lite/src/engine/vector/search/lazy_load.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ pub(super) async fn ensure_index_loaded<S: StorageEngine>(
4949
return Ok(());
5050
};
5151

52+
// `index` is mutated only by the native segment-backing path (wasm32: none).
53+
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
5254
let Ok(Some(mut index)) = HnswIndex::from_checkpoint(&checkpoint) else {
5355
return Ok(());
5456
};

nodedb-lite/src/lib.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,33 @@
1+
//! # NodeDB-Lite
2+
//!
3+
//! Embedded, offline-first build of NodeDB for phones, browsers (WASM), and
4+
//! desktops. A single in-process library exposing the same [`NodeDb`] trait as
5+
//! the Origin server — document, key-value, vector, graph, full-text, spatial,
6+
//! columnar, timeseries, and array engines over one storage core — with CRDT
7+
//! sync to an Origin server over WebSocket.
8+
//!
9+
//! ## Quick start
10+
//!
11+
//! ```no_run
12+
//! use nodedb_lite::{NodeDbLite, PagedbStorageMem};
13+
//! use nodedb_client::NodeDb;
14+
//!
15+
//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
16+
//! let storage = PagedbStorageMem::open_in_memory().await?;
17+
//! let db = NodeDbLite::open(storage, 1u64).await?;
18+
//! db.execute_sql("CREATE COLLECTION notes", &[]).await?;
19+
//! # Ok(())
20+
//! # }
21+
//! ```
22+
//!
23+
//! ## Durability
24+
//!
25+
//! Writes are buffered for batching; `await` returning `Ok` does **not** by
26+
//! itself guarantee on-disk durability. Durability is bounded by the
27+
//! [`config::LiteConfig::auto_flush_ms`] background flush interval, or forced
28+
//! by an explicit [`NodeDbLite::flush`]. For at-rest encryption see
29+
//! [`Encryption`]. [`NodeDb`]: nodedb_client::NodeDb
30+
131
pub mod config;
232
pub mod engine;
333
pub mod error;
@@ -17,6 +47,7 @@ pub use memory::MemoryGovernor;
1747
pub use nodedb::{BatchItem, NodeDbLite, SyncGate};
1848
pub use nodedb_query;
1949
pub use nodedb_types::id_gen;
50+
pub use storage::encryption::Encryption;
2051
pub use storage::engine::{StorageEngine, WriteOp};
2152
#[cfg(not(target_arch = "wasm32"))]
2253
pub use storage::pagedb_storage::PagedbStorageDefault;

0 commit comments

Comments
 (0)