# 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](../../examples) and the root [README](../../README.md).
## Add It To Your Crate
In your `Cargo.toml`:
```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`):
```toml
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:
```rust
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:
```rust
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:
```rust,ignore
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`](./src/engine/store.rs) (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/`:
```bash
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:
```bash
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/`:
```bash
cargo test
```
To run benches (if enabled for your environment):
```bash
cargo bench
```
## License
Dual-licensed under MIT OR Apache-2.0. See [LICENSE](./LICENSE).