oxide_core 0.2.0

Rust engine primitives for Oxide (store, snapshot streams, error model, optional persistence).
Documentation

oxide_core

oxide_core is the Rust-side engine for Oxide. It provides the primitives for:

  • Defining reducers (Reducer)
  • Owning state in an engine (ReducerEngine)
  • Streaming revisioned snapshots (StateSnapshot<T>)
  • Optional persistence helpers (feature-gated)

This crate is intentionally usage-agnostic. For end-to-end Rust ↔ Flutter wiring, see the repository examples and the root README.

Add It To Your Crate

In your Cargo.toml:

[dependencies]
oxide_core = "0.2.0"

When working inside this repository, use a combined version + path dependency (Cargo prefers path locally, while published crates resolve by version):

oxide_core = { version = "0.2.0", path = "../rust/oxide_core" }

Core Concepts

Reducer

Reducers are stateful controllers that mutate state in response to actions and side-effects:

use oxide_core::{CoreResult, Reducer, StateChange};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CounterState {
  pub value: u64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CounterAction {
  Inc,
}

pub enum CounterSideEffect {}

#[derive(Default)]
pub struct CounterReducer;
impl Reducer for CounterReducer {
  type State = CounterState;
  type Action = CounterAction;
  type SideEffect = CounterSideEffect;

  async fn init(&mut self, _ctx: oxide_core::InitContext<Self::SideEffect>) {}

  fn reduce(&mut self, state: &mut Self::State, action: Self::Action) -> CoreResult<StateChange> {
    match action {
      CounterAction::Inc => state.value = state.value.saturating_add(1),
    }
    Ok(StateChange::Full)
  }

  fn effect(
    &mut self,
    _state: &mut Self::State,
    _effect: Self::SideEffect,
  ) -> CoreResult<StateChange> {
    Ok(StateChange::None)
  }
}

ReducerEngine

ReducerEngine<R> is the async-safe facade for dispatching actions and observing snapshots:

use oxide_core::ReducerEngine;

# use oxide_core::{CoreResult, Reducer, StateChange};
# use oxide_core::InitContext;
# #[derive(Debug, Clone, PartialEq, Eq)]
# pub struct CounterState {
#   pub value: u64,
# }
# #[derive(Debug, Clone, PartialEq, Eq)]
# pub enum CounterAction {
#   Inc,
# }
# pub enum CounterSideEffect {}
# #[derive(Default)]
# pub struct CounterReducer;
# impl Reducer for CounterReducer {
#   type State = CounterState;
#   type Action = CounterAction;
#   type SideEffect = CounterSideEffect;
#
#   async fn init(&mut self, _ctx: InitContext<Self::SideEffect>) {}
#
#   fn reduce(&mut self, state: &mut Self::State, action: Self::Action) -> CoreResult<StateChange> {
#     match action {
#       CounterAction::Inc => state.value = state.value.saturating_add(1),
#     }
#     Ok(StateChange::Full)
#   }
#
#   fn effect(
#     &mut self,
#     _state: &mut Self::State,
#     _effect: Self::SideEffect,
#   ) -> CoreResult<StateChange> {
#     Ok(StateChange::None)
#   }
# }
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
  fn thread_pool() -> &'static flutter_rust_bridge::SimpleThreadPool {
    static POOL: std::sync::OnceLock<flutter_rust_bridge::SimpleThreadPool> = std::sync::OnceLock::new();
    POOL.get_or_init(flutter_rust_bridge::SimpleThreadPool::default)
  }
  let _ = oxide_core::runtime::init(thread_pool);

  let engine = ReducerEngine::<CounterReducer>::new(CounterReducer::default(), CounterState { value: 0 })
    .await
    .unwrap();
  let snap = engine.dispatch(CounterAction::Inc).await.unwrap();
  let current = engine.current().await;
  assert_eq!(snap.revision, current.revision);
});

Async Runtime Behavior

ReducerEngine starts an internal async loop to process side-effects. Engine creation is safe even when there is no ambient Tokio runtime (for example, when entered from a synchronous FFI boundary):

  • If a Tokio runtime is currently available, Oxide spawns tasks onto it.
  • On native targets, if no runtime is available and internal-runtime is enabled (default), Oxide falls back to an internal global Tokio runtime for background work.
  • On web WASM (wasm32-unknown-unknown), Oxide uses wasm-bindgen-futures to spawn background work without requiring a Tokio runtime.

In a normal Rust binary, the simplest approach is to run inside a Tokio runtime:

use oxide_core::ReducerEngine;

#[tokio::main(flavor = "multi_thread")]
async fn main() {
  let engine = ReducerEngine::<CounterReducer>::new(CounterReducer::default(), CounterState { value: 0 });
  let _ = engine.dispatch(CounterAction::Inc).await.unwrap();
}

Invariant: No Partial Mutation On Error

Dispatch uses clone-first semantics:

  • The current state is cloned.
  • The reducer runs against the clone.
  • If the reducer returns StateChange::None, the clone is discarded and no snapshot is emitted.
  • If the reducer returns an error, the live state is not updated and no snapshot is emitted.

This makes it safe to write reducers as normal &mut State code while preserving strong error semantics.

Sliced Updates (Optional)

Sliced updates allow snapshots to carry lightweight metadata describing which top-level parts of state changed, so binding layers (like Flutter) can avoid rebuilding when irrelevant fields update.

  • Opt-in: slicing is only meaningful when the state type implements SlicedState (typically generated by oxide_generator_rs via #[state(sliced = true)]).
  • Reducer signaling:
    • Return StateChange::Infer to ask the engine to call Reducer::infer_slices(before, after).
    • Return StateChange::Slices(&[...]) to explicitly declare which slices changed.
    • Return StateChange::Full for a full update.
  • Snapshot semantics:
    • snapshot.state is always the full state.
    • snapshot.slices.is_empty() is treated as a full update.

If you are not using sliced updates, you can ignore snapshot.slices (it will remain empty).

Feature Flags

  • frb-spawn (default): enables FRB’s spawn helper for cross-platform task spawning
  • state-persistence: enables bincode encode/decode helpers in oxide_core::persistence
  • persistence-json: adds JSON encode/decode helpers (requires state-persistence)
  • full: enables all persistence features
  • internal-runtime (default): enables a global Tokio runtime fallback on native targets

Web / WASM Support

oxide_core supports compiling for WebAssembly. The supported targets differ in what persistence backend is available:

  • wasm32-unknown-unknown (web): background tasks are spawned with wasm-bindgen-futures. When state-persistence is enabled, persisted snapshots are stored in localStorage using a stable key derived from PersistenceConfig.key.
  • wasm32-wasip1 (WASI): compilation is supported. When state-persistence is enabled, snapshots are persisted to the filesystem.

Build Commands

From rust/:

rustup target add wasm32-unknown-unknown wasm32-wasip1
cargo check -p oxide_core --target wasm32-unknown-unknown --all-features
cargo check -p oxide_core --target wasm32-wasip1 --all-features

WASM Tests

oxide_core includes WASM-focused regression tests:

  • wasm_web_compat (web): validates ReducerEngine dispatch in browser WASM and (when enabled) persistence restore using localStorage.
  • wasm_wasi_compat (WASI): compile-level compatibility check for wasm32-wasip1.

To compile the web test crate:

cargo test -p oxide_core --target wasm32-unknown-unknown --all-features --no-run --test wasm_web_compat

Troubleshooting

  • #[tokio::test] does not work on wasm32-unknown-unknown. Use wasm-bindgen-test for web WASM tests and keep Tokio-based tests for native targets.
  • Web persistence uses localStorage and therefore requires a browser environment. If you run WASM tests under Node.js, window/localStorage may not be available.
  • Persistence is debounced: writes are throttled to at most once per PersistenceConfig.min_interval, always persisting the latest snapshot payload observed during the interval.

Commands

From rust/:

cargo test

To run benches (if enabled for your environment):

cargo bench

License

Dual-licensed under MIT OR Apache-2.0. See LICENSE.