@place-ts/persistence
v0.1.1
Published
Storage adapters for @place-ts/reactivity state. v0.1 ships a synchronous localStorage adapter and a persistedState helper. IndexedDB / sync server adapters land when reactivity Phase 5 introduces async-as-pending.
Maintainers
Readme
Persistence System
Storage adapters for @place-ts/reactivity state. The contract is plain (load, save, optional observe, optional refresh) so any backend — memory, browser storage, IndexedDB, remote sync — slots into the same shape and consumer code never changes.
Status: v0.3 shipping. localStorage + memory + cross-tab via BroadcastChannel + IndexedDB (async, sync surface). 31 tests green.
- docs/00-charter.md — scope and dependencies
- src/index.ts — runtime
- tests/unit/persistence.test.ts — full surface coverage
The claim
The platform's central architectural claim — "swap the impl, consumer code stays unchanged" — is most observable here. The commonplace book started with inMemoryNoteStore; switched to persistedNoteStore(localStorageAdapter(...)); switched to persistedNoteStore(crossTabAdapter(localStorageAdapter(...))) for cross-tab sync. None of: App.tsx, Sidebar, Editor, searchNotes, the keyed list, the routing wiring — touched.
Shipping API
import {
persistedState,
localStorageAdapter,
memoryAdapter,
crossTabAdapter,
type PersistenceAdapter,
} from '@place-ts/persistence'
// Sync localStorage round-trip:
const adapter = localStorageAdapter<Note[]>('notes:v1', [])
const { state, dispose } = persistedState(adapter)
state.write([...state.read(), newNote]) // saves automatically
// Cross-tab sync — wraps any adapter:
const synced = crossTabAdapter(localStorageAdapter('notes:v1', []), 'notes:v1')
const { state } = persistedState(synced)
// Edits in tab A propagate to tab B within ~1 frame.PersistenceAdapter<T>
interface PersistenceAdapter<T> {
load(): T
save(value: T): void
observe?(onChange: () => void): Disposer // external-change hook
refresh?(): void | Promise<void> // re-fetch backing store into cache
}Four things in scope, deliberate:
loadis sync. Returns the default if nothing's stored. Async backends (IndexedDB) keep a sync cache over async storage;loadreturns the cache.saveswallows recoverable errors (quota exceeded, security exception). A future cut surfaces them via a capability so apps can react.observeis optional. Its absence is meaningful — it tellspersistedStatethat nothing else can write to this store. memory and localStorage omit it; crossTab, IndexedDB, and (future) remote-sync provide it.refreshis optional. It re-reads the backing store and updates the cache thatload()returns, but does NOT fire observers — that's the caller's job. Used by wrappers likecrossTabAdapter: when a broadcast arrives, the wrapper awaitsinner.refresh?.()so the consumer's subsequentload()sees the fresh value, then fires its own observers.
persistedState(adapter, options?)
Wraps a state with auto-save. Returns { state, dispose }. The state is initialized from adapter.load(); every change triggers adapter.save(value).
When the adapter has observe, persistedState subscribes. On external change it re-loads and writes to the local state. The auto-save watch sees the write but skips saving (a closure-local applyingRemote flag breaks the cycle); without that, A's save → B's reload → B's save → A's reload would loop forever.
options.equals lets you pass a structural comparator for object-shaped state to avoid spurious save calls when the object is replaced with structurally identical content.
Adapters
localStorageAdapter(key, defaultValue, options?)— JSON-serializable values by default; passserialize/deserializefor richer types. Falls back to default on corrupt JSON. Customstoragebackend supported.memoryAdapter(initial)— in-memory, useful for tests and no-op fallbacks.crossTabAdapter(inner, channelName)— BroadcastChannel sync between same-origin tabs. Composes with any inner adapter; merges itsobservelisteners with the inner adapter's (if any). On broadcast, awaitsinner.refresh?.()before firing observers so consumers'load()reads see fresh data. Per spec,BroadcastChanneldoesn't echo to the sender, so the cycle break inpersistedStatecovers the receiver and the loop is fully closed.indexedDBAdapter(key, defaultValue, options?)— async storage with the same syncload()surface. Keeps a sync cache; the async load happens on construction and fires observers when it resolves with a real value, sopersistedStatere-runs and the local state catches up. Implementsrefreshfor crossTab composition (crossTabAdapter(indexedDBAdapter(...))is the cross-tab + async-storage stack). Saves are fire-and-forget.factoryoption lets tests pass a fake IDBFactory (used in our test suite viafake-indexeddb). One shared db ('place') and store ('kv') by default;keyis the IDB key inside the store.
Conflict policy
v0.2: last-write-wins. Concurrent edits in two tabs can lose keystrokes. CRDT or OT integration is the future sync-server adapter; deliberately deferred.
What's deferred
- AbortController integration in
indexedDBAdapter— cancel the in-flight load on dispose so a stale resolution can't write to a torn-down state. Add when a real workload demonstrates the issue. - Remote sync adapter — last-write-wins or CRDT. The
observe+refreshpattern is already the right shape; the work is the sync protocol itself. - Migration support across schema changes — version field on saved values; user-supplied migration functions per version step.
- Quota / error surfacing via a capability — rather than silent swallow.
- Structured queries on IndexedDB — currently the IDB adapter uses a single key-value store. Real IDB workloads often benefit from secondary indexes, ranges, cursors. Add when an app needs them.
