@hecto/db
v2.1.0
Published
Typed local collections, sync, optimistic transactions, and live queries.
Readme
@hecto/db
Collections give data rows one canonical local home. A collection’s visible
rows are always synced + pending patches: sync and rollback rebuild that
view, so a server refresh cannot silently overwrite an optimistic edit.
import { createCollection, localOnlyCollectionOptions, q, liveQuery } from '@hecto/db'
type Todo = { id: string; title: string; done: boolean }
const todos = createCollection<Todo>({
id: 'todos',
getKey: (todo) => todo.id,
sync: localOnlyCollectionOptions(),
})
todos.insert({ id: 'one', title: 'Ship query', done: false })
todos.update('one', (todo) => { todo.done = true })
const complete = liveQuery(q.from(todos).where((todo) => todo.done).build())queryCollectionOptions adds eager or on-demand loads. A load stores only
key order; rows live once in the collection, so the same optimistic row is
visible to every load and live query. patchCollectionOptions applies inbound
RFC 6902 SSE patches through the normal sync/rebase path.
Sync and conflicts
Sync providers use begin, write, commit, markReady, and truncate.
A subset commit owns only the supplied keys. Incoming data rebases pending
patches; choose server, local, or a callback policy for conflicts. Outbound
handler mutations expose jsonPatch() for the same RFC 6902 wire format.
Set text: 'splice' on a collection whose rows hold editable text fields. A
remote change to a string then arrives as one @hecto/diff splice (diffed
from the previous synced value) instead of a whole-value replace, so a
pending local edit on the same field rebases against it and survives instead
of conflicting. The wire format is unchanged either way — a pending splice
still lowers to a whole-string replace in jsonPatch() — this only changes
how incoming synced data is diffed for rebasing. Default 'replace' keeps
existing behaviour.
Use @hecto/query alone for opaque responses. Use a collection once rows
have stable identity, can be edited optimistically, or feed a live query.
Live queries
liveQuery runs on @hecto/dataflow: a change to a source collection
becomes a small diff through the query, not a full recompute.
const byStatus = liveGroupBy(
q.from(todos).build(),
{ total: { kind: 'count' }, openMinutes: { kind: 'sum', by: (todo) => todo.minutes } },
)groupBywith real incremental aggregates:count,sum,avg,min,max, driven throughliveGroupBy.jointakeskind: 'inner' | 'left' | 'right' | 'full'on the live engine, in addition tocompile()'s inner and left.- A live query can itself be a source for another query:
liveQueryCollectionOptions(query)keeps a collection's rows equal to a live query's result, so.from()/.join()it like any other collection. wherealso accepts aneq/gt/and/… expression (seeexpr.ts) instead of a closure: it compiles to the same predicate function, and its tree is carried onquery.filters— a sync adapter'sloadSubsetcan read that array, narrow each entry withisExpr, translate what it recognises into a query string or SQL fragment, and letpredicateshandle the rest locally. A single-source live query with.load(...)uses that filter membership as part of its load identity. Narrowed, disjoint and unfiltered memberships do not share captured filters, andcollection.refetch(...params)refreshes every active membership for those params.
Undo
collectionHistory(collection) records mutations as they commit and exposes
undo()/redo(). Undoing a transaction still pending or persisting aborts
it; otherwise undo() issues the inverse mutation through the collection's
normal onInsert/onUpdate/onDelete handler path, so a server sees it
like any other write.
Persistence
persistCollection({ collection, storage, key }) rehydrates a collection
from a QueryStorage synchronously on start and keeps it saved, including
pending (unsent optimistic) mutations by default so they resume next
session. localStorageCollectionOptions({ key }) is a sync source, not a
cache: rows live entirely in Storage, survive a reload, and stay in sync
across tabs through the DOM storage event.
Sync adapters
electricCollectionOptions({ stream }) syncs from an Electric
ShapeStream, buffering change messages per burst and committing once per
up-to-date. powerSyncCollectionOptions({ db, table, getKey }) wraps a
PowerSync watch query, diffing each full result against the previous one
by key so an unrelated write elsewhere in the same tables produces a commit
of only the rows that changed.
batchToPatch(changes, key) (entry ./sync, alongside patchCollectionOptions,
queryCollectionOptions and localOnlyCollectionOptions) converts a dataflow-shaped change
batch into the RFC 6902 operations patchCollectionOptions already consumes, so a server-side
MaterializedView/Batch can feed a browser collection without db learning about batches.
Differences from TanStack DB
Not yet done:
- An expression DSL for
where/orderBythat compiles to both a predicate and aloadSubsetfilter.where/orderByhere take plain predicate and selector functions. findOne, infinite live queries,isReady/preload.- Indexes for equality lookups.
Also worth knowing before reaching for a join:
- Predicate joins are not supported on the live engine.
join(b, (l, r) => boolean)throws a clear error onliveQuery, because the dataflowjoinoperator is equi-join-only by contract. The non-incrementalcompile()path still supports a predicate join — it is a nested loop, evaluated fresh each time. - A windowed query with no
orderByuses a key tiebreak, not insertion order, to give every row one stable position.
JSON Patch stream ownership
patchCollectionOptions({ stream: signal => connect(signal) }) passes an
adapter-owned AbortSignal to each connection attempt. Existing zero-argument
factories remain compatible. stopSync() and destroy() abort the connection,
cancel/unlock an active Web stream, and cancel reconnect timers. If a factory
ignores its signal and resolves after disposal, the adapter closes the late stream.
Both failures and clean EOF wait reconnectDelay (default 1,000 ms) before retrying.
Each reconnect must start with the authoritative root snapshot so disconnected
clients recover without discarding dependent JSON patches.
For custom async iterables, disposal requests return() once. A borrowed iterator
that ignores cancellation cannot be forcibly terminated; its pending work belongs
to the factory. Prefer a signal-aware factory returning an owned ReadableStream.
Native keyed consumers
The optional @hecto/db/view entry retains useCollection(collection) and its immutable array
snapshots. useCollectionKey(collection, () => selectedKey.get()) reads a single row through the
existing subscribeKey hook; key changes release the old subscription. Unrelated row edits do not
invalidate this binding. useCollectionChanges(collection, listener) owns one sparse change listener;
its payload is changed keys, or null for a reset.
Call the new helpers inside a View owner. They subscribe after mount, acquire nothing during SSR, and release their subscriptions on disposal without stopping sync or destroying the borrowed collection. Reads before mount reflect the supplied collection snapshot. These are keyed primitives, not a new ordered-window source protocol.
