Replace the current layered store architecture with a unified offline-first builder where:
- SSR provides synchronous initial state for the first render
- IndexedDB stores confirmed snapshots and queued actions
- Tilia store renders the current optimistic state and derived values
- Mutations are strongly typed actions that are queued, acknowledged over the existing websocket, and reconciled asynchronously
- Stores may opt into sync; local-only stores use the same action model without websocket delivery
⚠️ Experimental: This is a prototype. APIs are not stable and subject to change.
- UUIDv7 for action IDs with embedded timestamps
- Strongly typed actions and mutation payloads everywhere; do not use string action identifiers
- Refactor the existing
RealtimeClientand websocket protocol; do not add a parallel websocket stack - Use JSON-only websocket frames for mutations, acknowledgements, patches, and snapshots
- Keep
hydrateStore(): unit => tsynchronous so SSR hydration stays server-first with no loading state - Recompute optimistic state from the last confirmed state plus remaining pending or acked actions
- Use
storeNameas the IndexedDB database identity, with ascopeKeyfor route/subscription-scoped stores - Use a timestamp-based compile-time contract
timestampOfState: state => float; stores without a natural timestamp must add store-level metadata so SSR, IndexedDB, and realtime snapshots can be compared - Synced actions must be idempotent under retry; prefer explicit state-setting mutations over toggle-style mutations
- Update all demos:
todo,todo-multiplayer,ecommerce - Follow
docs/DRAFT-PRINCIPALS.md(minimal setup, no loading states, server-first) - This is a hard refactor of the builders and demos, not a deprecation path
- Initial Render: SSR state -> hydrate the Tilia store synchronously.
- Background Reconcile:
- Open IndexedDB for
storeName - Load the confirmed snapshot for the current
scopeKey - Load pending or acked actions for the same
scopeKey - Choose the newer confirmed state between SSR and IndexedDB using the store timestamp contract
- Persist the winning confirmed state back to IndexedDB so SSR-only loads still establish a browser snapshot
- Replay pending or acked actions over that confirmed state to rebuild the optimistic state
- Open IndexedDB for
- Dispatch:
- Create
actionIdas UUIDv7 - Reduce optimistically and update the Tilia source immediately
- Persist the action ledger and confirmed snapshot metadata to IndexedDB
- If sync is configured, send a typed action envelope through the refactored
RealtimeClient
- Create
- Acknowledgement and Retry:
- Server sends a JSON acknowledgement frame with
actionId, statusok | error, and an optional error message okmarks the action asacked, but it is not pruned from the ledger until a newer confirmed snapshot, patch, or timestamp is observederrormarks the action as failed, then rebuilds optimistic state from the last confirmed state plus the remaining non-failed actions- Transport failures and missing acknowledgements use a simple retry policy: fixed retry delay, small max retry count, and the same
actionIdon every resend - Explicit
erroracknowledgements do not retry
- Server sends a JSON acknowledgement frame with
- Reconciliation:
- Patches and snapshots advance the confirmed state
- Once confirmed state has caught up, prune acked actions from the ledger
- Local-only Stores:
- If no sync config exists, dispatch still uses the same typed action pipeline
- Local actions are reduced locally and committed directly to confirmed state
- Local-only stores do not persist an action ledger
- Local-only stores broadcast newer confirmed snapshots across tabs for the same
storeName
- Queries: Read from the Tilia store, which always represents the current optimistic state.
Use one IndexedDB database per storeName, with per-scope records inside that database.
confirmed_state:{scopeKey: string, value: state, timestamp: float}actions:{id: UUIDv7, scopeKey: string, action: JSON, status: pending|syncing|acked|failed, enqueuedAt: float, retryCount: int, error: option(string)}
Notes:
storeNamealone is not enough for stores whose state is scoped by route or subscription.scopeKeyis the logical instance of a store inside onestoreNamedatabase.scopeKeyshould default to"default"for global stores and be derived for scoped stores such as todo list ids or premise ids.- For local-only stores, only
confirmed_stateis required.
Use JSON-only websocket frames.
Examples:
- Client mutation frame:
{type: "mutation", actionId, action} - Server acknowledgement frame:
{type: "ack", actionId, status: "ok" | "error", error: option(string)} - Server patch frame:
{type: "patch", ...} - Server snapshot frame:
{type: "snapshot", ...}
Typed actions still decode into strongly typed variants on both client and server; the JSON shape is only the transport encoding.
- Add or update the experimental warning in the relevant docs while the refactor is in progress.
- Add
UUID.rewith UUIDv7 generation and timestamp extraction helpers. - Refactor the websocket mutation flow in
RealtimeClient.reanddream-middlewareto support JSON mutation envelopes plus success or error acknowledgement frames. - Add a shared action codec pattern for synced stores so client and server can exchange typed actions instead of raw string mutation names.
- Define the compile-time timestamp contract for stores via
timestampOfState. - Add idempotency handling on the server for synced actions using
actionIdso retries are safe.
- Add
StoreIndexedDB.reas the browser storage engine, with native no-op implementations where needed. - Add
StoreActionLedger.refor reading, writing, pruning, and replaying queued actions. - Persist confirmed snapshots and action ledgers per
storeNameandscopeKey. - Add startup reconciliation that loads confirmed state plus queued actions and recomputes the optimistic state after SSR hydration.
- For local-only stores, skip
StoreActionLedgerand persist confirmed state directly.
Create StoreBuilder.Runtime.Make with a simplified schema centered around one state type and one typed action type.
Schema should include:
- State type
- Action type
reduce: (~state: state, ~action: action) => stateemptyStatestateElementIdstoreNamescopeKeyOfStateor equivalent scoped identity hooktimestampOfState- State and action JSON codecs
makeStorefor derived values and projections- Optional sync config with subscription resolution and websocket URLs
- Optional failure hook for UI feedback only; rollback itself is handled by replaying from the last confirmed state
Generated API should include:
dispatch(action): optimistic reduce plus persistence plus optional queueinghydrateStore(): synchronous SSR bootstrap- Background reconcile on mount
createStore(state)andserializeState(state)for SSR entrypoints
This builder replaces the current split between Runtime, Persisted, and Layered builders.
- todo: Migrate to offline-first local-only typed actions.
- ecommerce cart: Migrate to offline-first local-only typed actions; do not add sync yet.
- todo-multiplayer: Migrate to full offline-first sync with typed actions, acknowledgements, replay, retry, and server-side action decoding.
- todo-multiplayer mutation semantics: Replace non-idempotent toggle-style mutations with explicit state-setting actions so retry remains safe.
- ecommerce inventory: Migrate to the unified builder and realtime snapshot flow. The store must support the action pipeline even if no inventory mutations are used in the first pass.
- Server renders initial state plus whatever timestamp metadata the new builder requires.
- Client hydrates synchronously from SSR with no promise-gated boot path.
- IndexedDB reconciliation runs after mount and can replace the confirmed base state if it is newer.
- Websocket subscription starts after mount using the confirmed timestamp.
- Ack frames, snapshots, and patches all update the same captured store source.
- Stores without a natural server timestamp must add store metadata so SSR and realtime updates can be ordered correctly.
- Remove the old builder split once all demos have been migrated.
- Remove or replace
StoreLocal,StorePersist,StoreSync, andStoreRuntimeonce the unified builder is complete. - Update README and package docs to describe the new architecture and typed action flow.
All must build successfully:
dune build demos/todo/ui/src/.build_stampdune build demos/todo/server/src/server.exedune build demos/todo-multiplayer/ui/src/.build_stampdune build demos/todo-multiplayer/server/src/server.exedune build demos/ecommerce/ui/src/.build_stampdune build demos/ecommerce/server/src/server.exe
Manual or Playwright verification should cover:
- SSR render and hydration without a loading state
- Reload after an optimistic action before acknowledgement arrives
- Offline action queueing and replay after reconnect
- Retry after reconnect or acknowledgement timeout using the same
actionId - Ack error handling, including rebuilding from last confirmed state
- Route or subscription switches using distinct
scopeKeyvalues - Duplicate patch or acknowledgement delivery not duplicating state
- Server-side idempotency preventing duplicate writes for retried actions
-
Simple Retry Defaults: What acknowledgement timeout, fixed retry delay, and max retry count should V1 use?
-
Timestamp Source: For stores without a natural domain timestamp, should the timestamp live as explicit metadata on the store state, on the serialized payload, or both?
-
Server Idempotency Storage: Should synced demos use one shared processed-action table, or should each demo manage idempotency in its own schema?