@asterql/store
npm install @asterql/store
Normalized entity store, scope cursors, and view-instance cache for AsterQL
state sync. This is the client-side Protocol B substrate: it ingests
ServerEventEnvelopes from @asterql/view-protocol and keeps one confirmed
copy of every entity, while a view-instance cache implements the Protocol A
staleness policy over invalidation classes.
The mental model (see the state-sync architecture doc): the server owns truth
and order; the client owns latency and intent; views own nothing.
API
State substrate:
ScopeCursors: monotonic per-scope sequence cursors —
beginScope(scope, lastSeq), scopeLastSeq(scope), and
ingestSequenced(envelope, apply) returning applied, duplicate, or
gap. The cursor advances even when apply throws (the envelope was
consumed at that sequence), and resetting a cursor is always explicit, so a
stale snapshot can never rewind a scope past sequences it already applied.
CoalescedEmitter<TTopic>: microtask-coalesced change notification with
global, per-topic, and per-key listeners. Any number of marks within one
task flush as a single notification per listener.
EntityStore: a normalized EntityRecord map keyed kind:id, fed by
ingest(envelope) for patch, snapshot, and invalidate envelope
kinds. Patches apply through applyEntityPatches; record versions come
from the envelope or hashRecordVersion. subscribeEntity,
subscribeKind, and subscribe notify precisely. snapshotHash()
produces a canonical h1_ hash for cross-surface divergence checks.
ViewInstanceCache: fresh | stale | loading | error entries keyed by
viewId.paramsHash.authScopeHash. An entry's recorded class versions
double as its dependency list; invalidate(classes, classVersions?) marks
only dependent entries stale, and only when a class actually advanced.
Example
import { EntityStore, ViewInstanceCache } from "@asterql/store";
const store = new EntityStore();
store.cursors.beginScope("chat:abc", 0);
const { result, changedKeys, invalidated } = store.ingest({
eventId: "ev1_…",
kind: "patch",
scope: "chat:abc",
seq: 1,
entities: [
{
key: "message:m1",
patches: [{ op: "appendText", path: "/text", delta: "Hello" }],
},
],
});
if (result.kind === "gap") {
}
const views = new ViewInstanceCache();
views.invalidate(invalidated, { "entityKind:message": Date.now() });
Fetching, transports, and optimistic mutations are deliberately out of scope:
this package is the storage and staleness policy that those layers share.