This issue consolidates design requirements and implementation direction for three key primitives that expand JODS into a full-stack reactive state platform:
- #5
jods.persist(...) – storage adapter to persist store state to disk, memory, or remote.
- #7
jods.sync(...) – bidirectional state replication across environments (client ↔ server, peer ↔ peer).
- #9
jods.stream(...) – one-way async feed of external data (SSE, HTTP streams, etc.) into a store.
Together with defineStore() and withJods() (Remix), these complete the lifecycle of: initialize → mutate → hydrate → persist → sync → stream.
✅ Shared Design Principles
-
Unified Sink Model: All incoming state updates—whether local mutations, synced diffs, streamed payloads, or restored snapshots—should flow through a common reconciliation pathway. This enables onUpdate, signals, and devtools to behave consistently regardless of source.
-
Schema-Driven Trust Boundary: If a defineStore() uses a Zod schema, it should act as the boundary for validating all incoming external data. Invalid updates should be ignored and optionally logged.
-
No Dropped State: Streaming or syncing should never drop updates by default. If necessary for performance (e.g. UI rendering), downstream systems can batch. But the reactive core should maintain every change for time-travel/debugging.
-
Pluggable, Imperative API: All primitives should be low-level, composable JS calls (persist(store, opts)) rather than builder chains (persist(...).andSync(...)) to preserve flexibility, composability, and Unix-style separation of concerns.
1. 🧠 jods.persist(store, opts?) - #5
Purpose: Store state to localStorage, IndexedDB, sessionStorage, or a remote adapter.
Core Behavior:
- On init, loads persisted state and merges into store.
- Subscribes to store updates and saves them (debounced).
- Should be opt-in per store or partial (with a filter).
- Can use schema versioning to manage breaking changes.
Key Features:
- Supports both sync and async storage engines.
- Optional
version field on defineStore() could seed a cache key (e.g., jods:cart:v1.2.0) and rotate on breaking schema changes.
- Default error behavior: catch + log invalid saves; never crash app.
- Hook support:
onLoad, onSaveError.
Example:
jods.persist(store, {
storage: localStorage,
key: 'cart',
version: '1.0.2',
filter: (key) => !key.startsWith('secure_'),
});
2. 🔄 jods.sync(socket, store, opts?) - #7
Purpose: Two-way syncing of state across network boundaries (WS, postMessage, devtools, etc.)
Core Behavior:
- Wraps a
SocketLike interface (with .send() + onmessage).
- Diffs outbound updates (via
onUpdate) and sends as patches.
- Receives remote patches, validates, and applies via unified sink.
- Optionally handshake on connect to align divergent stores.
Must Support:
- Partial syncing (via key allow-list).
- Conflict resolution via timestamps (even in v1).
- Error logging, not crashing (e.g., schema validation fails).
- Events:
onPatchRejected, onConnectionLost.
Example:
jods.sync(socket, store, {
allowKeys: ['items', 'note'],
mode: 'peer', // future: 'primary', 'readOnly'
});
Conflict Strategy:
- Each patch carries a timestamp.
- Local patches can queue if in-flight.
- Remote patches are ordered and applied only if newer.
3. 📡 jods.stream(readableStream | generator, store, opts?) - #9
Purpose: One-way push of streamed external data into a store (HTTP streams, SSE, etc.)
Core Behavior:
- Consumes JSON stream chunks or structured diffs.
- Applies them as patches (or replaces) on arrival.
- Default behavior: validate each chunk against schema.
Design Notes:
- Support
{ type: 'patch' | 'replace' } per chunk.
- Optionally batch in
requestAnimationFrame to avoid UI jank.
- Expose
jods.applyPatch() for manual patch usage.
Example:
await jods.stream(eventSource, store, {
validate: true,
batch: true,
onError: (err) => logger.warn('Stream parse failed', err),
});
** Expose jods.applyPatch() alludes to the design principle from earlier about exposing the internals for advanced usage.
🧪 Devtools Integration
Existing: [src/react/debugger.tsx](https://github.com/clamstew/jods/blob/main/src/react/debugger.tsx)
-
Expand debugger to visualize:
- Incoming patches (sync or stream)
- Source (local, persisted, streamed, remote)
- Rejected patches
- Versioning or hydration flows
-
Add dev command to simulate patches manually
-
Possibly: a debug transportLog for showing outbound and inbound event traffic
📚 Documentation Plan
New sections on the docs site should include:
🔬 Implementation & Testing Strategy
🚫 Not in Scope Yet
We anticipate building toward these capabilities, but they are not included in the initial implementation of persist, stream, or sync:
- CRDT/OT Conflict Resolution – Future work may implement true peer-to-peer conflict resolution via conflict-free replicated data types (CRDT) or operational transforms (OT).
- Chained API (
persist(...).andSync(...)) – While expressive, this approach limits composability and is harder to scale. We'll revisit if dev feedback demands it.
- Server-Pushed Transforms – Applying a patch or command that maps to a defined action handler or reducer (e.g., transform via
cart.addItem) is a more opinionated abstraction and could appear in v2+.
- Persistent Retry Queues – For now, we assume sync happens only when connected. An offline queue or sync-on-reconnect flow (e.g., local writes buffered until network resumes) could follow in a dedicated feature.
📌 TL;DR
This issue defines the shape of JODS' transport architecture. V1 of persist, stream, and sync aim to be composable primitives that respect schema boundaries, maintain signal-based updates, and push state coherently across runtime boundaries—without relying on last-write-wins or dropping updates.
This enables JODS to truly become the "active model" layer of a Remix app, with Remix as the router/controller and JODS as the data layer—coherent, reactive, and cross-context.
Informed by Issues #5, #7, #9, and PRs #20 and #22.
This issue consolidates design requirements and implementation direction for three key primitives that expand JODS into a full-stack reactive state platform:
jods.persist(...)– storage adapter to persist store state to disk, memory, or remote.jods.sync(...)– bidirectional state replication across environments (client ↔ server, peer ↔ peer).jods.stream(...)– one-way async feed of external data (SSE, HTTP streams, etc.) into a store.Together with
defineStore()andwithJods()(Remix), these complete the lifecycle of: initialize → mutate → hydrate → persist → sync → stream.✅ Shared Design Principles
Unified Sink Model: All incoming state updates—whether local mutations, synced diffs, streamed payloads, or restored snapshots—should flow through a common reconciliation pathway. This enables
onUpdate,signals, and devtools to behave consistently regardless of source.Schema-Driven Trust Boundary: If a
defineStore()uses a Zod schema, it should act as the boundary for validating all incoming external data. Invalid updates should be ignored and optionally logged.No Dropped State: Streaming or syncing should never drop updates by default. If necessary for performance (e.g. UI rendering), downstream systems can batch. But the reactive core should maintain every change for time-travel/debugging.
Pluggable, Imperative API: All primitives should be low-level, composable JS calls (
persist(store, opts)) rather than builder chains (persist(...).andSync(...)) to preserve flexibility, composability, and Unix-style separation of concerns.1. 🧠
jods.persist(store, opts?)- #5Purpose: Store state to localStorage, IndexedDB, sessionStorage, or a remote adapter.
Core Behavior:
Key Features:
versionfield ondefineStore()could seed a cache key (e.g.,jods:cart:v1.2.0) and rotate on breaking schema changes.onLoad,onSaveError.Example:
2. 🔄
jods.sync(socket, store, opts?)- #7Purpose: Two-way syncing of state across network boundaries (WS, postMessage, devtools, etc.)
Core Behavior:
SocketLikeinterface (with.send()+ onmessage).onUpdate) and sends as patches.Must Support:
onPatchRejected,onConnectionLost.Example:
Conflict Strategy:
3. 📡
jods.stream(readableStream | generator, store, opts?)- #9Purpose: One-way push of streamed external data into a store (HTTP streams, SSE, etc.)
Core Behavior:
Design Notes:
{ type: 'patch' | 'replace' }per chunk.requestAnimationFrameto avoid UI jank.jods.applyPatch()for manual patch usage.Example:
** Expose
jods.applyPatch()alludes to the design principle from earlier about exposing the internals for advanced usage.🧪 Devtools Integration
Expand debugger to visualize:
Add dev command to simulate patches manually
Possibly: a debug
transportLogfor showing outbound and inbound event traffic📚 Documentation Plan
New sections on the docs site should include:
Core Concepts: transport layer, schema validation, sync vs stream vs persist
How-To Guides:
jods.persistjods.syncExamples:
🔬 Implementation & Testing Strategy
Use
[jods-remix-starter](https://github.com/clamstew/jods-remix-starter)to test:Plan to build:
jods.transport.test.tsjods-remix.e2e.tsPlaywright flows (multi-tab sync, SSR → stream handoff)Cursor can be used to scaffold these workflows iteratively.
🚫 Not in Scope Yet
We anticipate building toward these capabilities, but they are not included in the initial implementation of
persist,stream, orsync:persist(...).andSync(...)) – While expressive, this approach limits composability and is harder to scale. We'll revisit if dev feedback demands it.cart.addItem) is a more opinionated abstraction and could appear in v2+.📌 TL;DR
This issue defines the shape of JODS' transport architecture. V1 of
persist,stream, andsyncaim to be composable primitives that respect schema boundaries, maintain signal-based updates, and push state coherently across runtime boundaries—without relying on last-write-wins or dropping updates.This enables JODS to truly become the "active model" layer of a Remix app, with Remix as the router/controller and JODS as the data layer—coherent, reactive, and cross-context.
Informed by Issues #5, #7, #9, and PRs #20 and #22.