Skip to content

🚧 Proposal: Core Transport Layer Primitives – jods.persist, jods.sync, jods.stream #26

@clamstew

Description

@clamstew

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:

  • Core Concepts: transport layer, schema validation, sync vs stream vs persist

  • How-To Guides:

    • Offline caching with jods.persist
    • Real-time sync between tabs or clients with jods.sync
    • Streaming updates from an async function or HTTP response
  • Examples:

    • Chat app (sync)
    • Live dashboard (stream)
    • Offline cart (persist)
    • Remix integration walkthrough

🔬 Implementation & Testing Strategy

  • Use [jods-remix-starter](https://github.com/clamstew/jods-remix-starter) to test:

    • Persist + Sync together (ensure no feedback loops)
    • Sync + Stream on same store
    • Schema versioning / migration edge cases
  • Plan to build:

    • jods.transport.test.ts
    • jods-remix.e2e.ts Playwright 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, 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.

Sub-issues

Metadata

Metadata

Assignees

Labels

corepart of jods core state library (not an integration layer or tool)documentationImprovements or additions to documentationproposalplanning architecture and future work - discuss to make the right moves

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions