API index and search · Build metadata
@rindle/optimistic
0.0.0 · Public export map; development manifest version (0.0.0).
attachLocalPersistence
FunctionDeclaration · Source: packages/optimistic/src/local-persist.ts:74 · Supporting declarations
Attach the persistence layer to a backend (standalone form — createRindleClient calls this
for you). The observer is wired SYNCHRONOUSLY, but the mirror starts empty: attach before the
first writeLocal (§5.2, a v1 constraint createRindleClient satisfies by construction).
Await handle.ready before first render if the app wants restored rows at first paint.
export declare function attachLocalPersistence<S extends ColsMap>(backend: OptimisticBackend<S>, schema: Schema<S>, opts: PersistLocalOptions): LocalPersistence;ClientMutator
TypeAliasDeclaration · Source: packages/optimistic/src/backend.ts:155 · Supporting declarations
A client mutator: optimistic, deterministic, replayable — a pure function of (base, args) (§5:
no clock, no randomness; it is RE-INVOKED on every rebase). Either shape is accepted:
- a plain synchronous function
(tx, args) => void(client-only), OR - a shared GENERATOR
(tx, args, ctx) => MutationGen(the isomorphic form: the SAME body the API server runs against a live async transaction — MUTATORS-ISOMORPHIC). The driver detects which.
export type ClientMutator = ((tx: MutationTx, args: never) => void) | ((tx: IsoTx, args: never, ctx: MutatorCtx) => MutationGen);ClientRegistry
TypeAliasDeclaration · Source: packages/optimistic/src/backend.ts:161 · Supporting declarations
The client registry (§4.2) — one of the two registries; the server's authoritative twin shares names (and possibly code), never the wire.
export type ClientRegistry = Record<string, ClientMutator>;createOptimisticStore
FunctionDeclaration · Source: packages/optimistic/src/index.ts:107 · Supporting declarations
A {@link Store} over an {@link OptimisticBackend}, plus the named-mutator entry
(mutate.createIssue(args) — the §9 dream shape) and the §6 lifecycle surface.
export declare function createOptimisticStore<S extends ColsMap, R extends ClientRegistry>(schema: Schema<S>, source: OptimisticSource, registry: R, opts: OptimisticBackendOptions): {
store: Store<S>;
backend: OptimisticBackend<S>;
mutate: {
[K in keyof R]: MutateFn<Parameters<R[K]>[1]>;
};
};createRindleClient
FunctionDeclaration · Source: packages/optimistic/src/client.ts:345 · Supporting declarations
Create a synced application client over the local WASM engine. Connects named query leases,
WebSocket subscriptions, and the optimistic mutation queue to the application's API routes.
Await construction before using the store; when configured, it awaits the local-table restore
attempt as well. Construction does not mean remote queries are synchronized: retain queries
or use ensure for the required readiness boundary.
Keep one client per application session. Call close() when the session ends, and create a
new client when its authenticated principal changes. The application server owns authorization.
export declare function createRindleClient<S extends ColsMap, R extends ClientRegistry>(opts: RindleClientOptions<S, R>): Promise<RindleClient<S, R>>;deleteLocalPersistence
FunctionDeclaration · Source: packages/optimistic/src/local-persist.ts:66 · Supporting declarations
Delete a user's local-persistence database — the sanctioned LOGOUT hook (§3.2). Never called
implicitly; the old database otherwise remains on disk (fast re-login, and a privacy decision
the app owns). Close this tab's live client for user first; a SIBLING tab's connection is
released automatically (it closes on versionchange and degrades to broadcast-only), so a
multi-tab logout completes instead of parking behind the other tab forever.
export declare function deleteLocalPersistence(user: string, env?: PersistEnv): Promise<void>;DowngradeStuckEvent
InterfaceDeclaration · Source: packages/optimistic/src/backend.ts:419 · Supporting declarations
The I-v stuck-downgrade event ({@link OptimisticBackend.onDowngradeStuck}): the ghost's fence is satisfied but these SENT room-domain mids never resolved (an entry that never reached the room — sent-but-undelivered when the socket died — is undecidable in general, §7.5). The ghost HOLDS (fail LOUD, never silent; no timeout-retire is invented) and the mids are surfaced once, actionably.
export interface DowngradeStuckEvent {
sourceKey: string;
doc: string;
mids: number[];
}EnsureQueryOptions
InterfaceDeclaration · Source: packages/client/src/ensure.ts:22 · Supporting declarations
export interface EnsureQueryOptions {
/** Readiness policy. Defaults to `complete`. */
until?: EnsureQueryUntil;
/** Cancel this caller's wait. The shared query may stay retained for another waiter/prefetch. */
signal?: AbortSignal;
}EnsureQueryUntil
TypeAliasDeclaration · Source: packages/client/src/ensure.ts:20 · Supporting declarations
When an {@link QueryEnsureCache.ensure} call may resolve.
completewaits for the server-authoritative result (the default).presentresolves as soon as the local view contains a result, while the remote retain keeps revalidating in the background. An authoritative empty result also resolves it, so a real not-found query never waits forever.
present deliberately does not add a partial {@link ResultType}: a locally useful answer and
server authority are independent facts. While it resolves early, the view's result type remains
unknown until the server says otherwise.
export type EnsureQueryUntil = "complete" | "present";FoldClock
InterfaceDeclaration · Source: packages/optimistic/src/backend.ts:290 · Supporting declarations
A virtual-clock seam for fold debounce timers and elapsed-time checks (FOLDED-MUTATIONS-DESIGN §9): the
oracle injects a deterministic scheduler; production defaults to real timers + Date.now.
export interface FoldClock {
setTimeout(cb: () => void, ms: number): unknown;
clearTimeout(handle: unknown): void;
now(): number;
}FoldHandle
InterfaceDeclaration · Source: packages/optimistic/src/backend.ts:320 · Supporting declarations
A folded mutation window. flush() assigns its mutation ID and queues the latest arguments
for delivery. mid resolves with that ID at flush; it does not wait for server acceptance
or confirming-stream progress. Each call in the same window shares this promise.
export interface FoldHandle {
flush(): void;
readonly mid: Promise<number>;
}FoldInspect
InterfaceDeclaration · Source: packages/optimistic/src/backend.ts:464 · Supporting declarations
One folded entry's debounce window, for the timeline's fold drill-down (§4.1).
export interface FoldInspect {
/** The fold key (`${name}\0${identityJSON}`) collapsing same-key invokes into one entry. */
foldKey: string;
debounceMs: number;
maxWaitMs?: number;
deferAcrossWrites: boolean;
/** Whether the window has flushed (a real `mid` was dealt); an un-flushed fold has `mid == null`. */
flushed: boolean;
}FoldOptions
InterfaceDeclaration · Source: packages/optimistic/src/backend.ts:298 · Supporting declarations
Options for a folded call site (FOLDED-MUTATIONS-DESIGN §3). key is the identity half of the
fold key (combined with the mutator name); the rest tune the debounce policy.
export interface FoldOptions {
/** The identity half of the fold key (typically the targeted primary key). Required. */
key: unknown;
/** Trailing debounce: the flush fires this long after the LAST invoke for `key`. Default 120ms. */
debounceMs?: number;
/** Elapsed-time threshold checked on each invocation. Once reached, that invocation flushes
* immediately. This does not arm a separate timer: without another invocation, the trailing
* debounce still controls the flush. Omit to use only the trailing debounce. */
maxWaitMs?: number;
/** For a declared room route, use this value for both `debounceMs` and `maxWaitMs`.
* The domain policy selects the route on the window's first invocation; it stays fixed
* for that window. `0` flushes each invocation. Omit to use the ordinary fold options.
* This does not detect collaborators or infer a route from the writes. */
roomDebounceMs?: number;
/** Keep deferring across overlapping non-fold writes for maximum economy, accepting the §4.2
* read-dependent reorder snap. Default `false` (flush-on-enqueue — correct-and-boring). */
deferAcrossWrites?: boolean;
}isoTx
VariableDeclaration · Source: packages/client/src/mutation-ops.ts:127 · Supporting declarations
The one shared effect factory (stateless — see {@link IsoTx}). Its methods just BUILD a
{@link MutationOp}, so the single instance serves every schema; the generic {@link IsoTx} view is
applied at the authoring site (a json<T> cell is a parsed object here and is stringified by the
funnels, {@link toCell }), hence the cast — the runtime shape is schema-agnostic.
export declare const isoTx: IsoTx;IsoTx
InterfaceDeclaration · Source: packages/client/src/mutation-ops.ts:102 · Supporting declarations
The tier-AGNOSTIC effect factory a generator mutator writes against. Every method just BUILDS an
effect to yield — it performs no I/O and holds no state, so the single {@link isoTx} instance is
shared by every mutator on both tiers; only the driver differs. insert/upsert/insertIgnore
require non-nullable columns and permit nullable omissions, which become null.
update/delete require the PK columns; update also names the columns to change.
export interface IsoTx<S extends ColsMap = ColsMap, P extends Record<string, string> = PkMap<S>> {
/** Insert a row. Omitted nullable columns become `null`; database defaults are not applied. */
insert<N extends keyof S & string>(table: N, row: InsertOf<S[N]>): MutationOp;
/** Update the row identified by its pk columns (REQUIRED); only the named non-pk columns change. */
update<N extends keyof S & string>(table: N, row: UpdateOf<S[N], PkColsOf<S, P, N>>): MutationOp;
/** Insert, or replace non-PK columns on PK conflict, with {@link IsoTx.insert}'s omission rules. */
upsert<N extends keyof S & string>(table: N, row: InsertOf<S[N]>): MutationOp;
/** Insert with {@link IsoTx.insert}'s omission rules, or do nothing on PK conflict. */
insertIgnore<N extends keyof S & string>(table: N, row: InsertOf<S[N]>): MutationOp;
/** Delete the row identified by its pk columns. */
delete<N extends keyof S & string>(table: N, pk: PkOf<S[N], PkColsOf<S, P, N>>): MutationOp;
/** Read one row by primary key (read-your-writes). */
row<N extends keyof S & string>(table: N, pk: PkOf<S[N], PkColsOf<S, P, N>>): ReadEffect;
/** Run a full query (`where`/`orderBy`/`limit`/join) over the state this mutator is mutating —
* read-your-writes, like {@link row} but for arbitrary shapes. Pass a query from the tier-agnostic
* builder, e.g. `tx.query(q.issue.where("ownerId", "=", ctx.user))` where `q = newQueryBuilder(schema)`.
* The `yield` evaluates to {@link QueryResultRow}`[]` (cast it — the generator's single next-type is a row). */
query(query: QueryArg): QueryEffect;
all(effects: readonly YieldEffect[]): BatchEffect;
}KeyedRow
TypeAliasDeclaration · Source: packages/client/src/mutation-ops.ts:13 · Supporting declarations
A keyed row: column name → cell. The ergonomic write shape (validated against the schema at runtime). JSON columns carry their raw JSON string (a {@link WireValue}), never a parsed object.
export type KeyedRow = Record<string, WireValue>;LIFECYCLE_QUERY_NAME
VariableDeclaration · Source: packages/optimistic/src/system-streams.ts:97 · Supporting declarations
The reserved name system retains subscribe under (never an app query — see the module doc).
Its args carry the {@link SystemStreamSpec} identity fields PLUS the parent labeled query's
(name, args), so a RE-resolution (reconnect / gate overflow) can re-lease the parent and pick
the matching lifecycle entry — renewal-as-reauthorization, the room-token precedent.
export declare const LIFECYCLE_QUERY_NAME = "_rindle/lifecycle";LIFECYCLE_TABLE_SCHEMAS
VariableDeclaration · Source: packages/optimistic/src/system-streams.ts:65 · Supporting declarations
export declare const LIFECYCLE_TABLE_SCHEMAS: readonly NormalizedTableSchema[];LifecycleLeaseBlock
InterfaceDeclaration · Source: packages/optimistic/src/client.ts:119 · Supporting declarations
The §4 lifecycle block on a query lease (mirror of the api-server's QueryLeaseLifecycle):
doorbell on every labeled lease under the opt-in server config, fence (watermark + ledger
- outcomes) only when the lease is ALSO room-served. Absent ⇒ this client behaves exactly as today — the whole plane is inert-until-fed.
export interface LifecycleLeaseBlock {
doorbell: LifecycleLeaseEntry;
fence?: LifecycleLeaseEntry[];
}LifecycleLeaseEntry
InterfaceDeclaration · Source: packages/optimistic/src/client.ts:103 · Supporting declarations
One minted SYSTEM-STREAM lease on the lifecycle block (mirror of the api-server's
QueryLeaseLifecycleLease; Slice I-iii): an ordinary daemon materialization over one of the
four _rindle_* lifecycle tables, presented on the wire exactly like the primary lease
(subscribe-with-leaseToken). The identity fields document the minted predicate — this client
keys its retains (idempotence per (table, scope/doc/clientId)) and the backend keys its
release-time row filters on them.
export interface LifecycleLeaseEntry {
table: string;
leaseToken: string;
wsEndpoint?: string;
/** DOORBELL only: the §4.1 occupancy scope (= the wire room doc, `"<profile>/<key>"`). */
scope?: string;
/** FENCE entries only: the room doc. */
doc?: string;
/** FENCE ledger/outcomes when the server could client-scope the predicate. */
clientId?: string;
}LocalPersistence
InterfaceDeclaration · Source: packages/optimistic/src/local-persist.ts:46 · Supporting declarations
The attached layer's handle. createRindleClient awaits {@link ready} before returning (§5.2).
export interface LocalPersistence {
/** Resolves after the initial restore attempt. It can resolve in a degraded mode when browser
* storage is unavailable or an operation fails; it is not a durability guarantee. */
readonly ready: Promise<void>;
/** Best-effort final persist/forward (the `pagehide` hook, §9): re-posts unacked ops (any
* role), then a leader drains its persist queue and retries P9-degraded batches. */
flush(): Promise<void>;
/** Release leadership (or abandon the queued lock request), close the channel + IDB (P10).
* A leader DRAINS its queued persist steps first (they carry already-committed writes) and
* releases the lock only after the last one lands; a follower re-posts its unacked ops. */
close(): void;
/** Introspection for tests/devtools: current role. */
role(): "leader" | "follower";
}MutateFn
TypeAliasDeclaration · Source: packages/optimistic/src/index.ts:20 · Supporting declarations
One entry of the {@link createOptimisticStore} mutate facade: call it for a normal optimistic
write (returns the mid), or .folded(opts, args) for a debounced, last-value-wins folded write
(returns a {@link FoldHandle} — the mid is assigned at flush, FOLDED-MUTATIONS-DESIGN §3).
export type MutateFn<Args> = ((args: Args) => number) & {
folded(opts: FoldOptions, args: Args): FoldHandle;
};MutationEnvelope
InterfaceDeclaration · Source: packages/client/src/types.ts:236 · Supporting declarations
The upstream named-mutator envelope (OPTIMISTIC-WRITES-DESIGN.md §8.1): the wire
carries the name + JSON args, never code; mid totally orders a client's mutations.
export interface MutationEnvelope {
clientID: string;
mid: number;
name: string;
args: unknown;
}MutationGen
TypeAliasDeclaration · Source: packages/client/src/mutation-ops.ts:148 · Supporting declarations
What a generator mutator IS: yield tx.<op>() on every side effect; a yield tx.row() expression
evaluates to the row. Neither sync nor async — the tier's driver decides, which is what lets one
body run synchronously on the client and against a live async transaction on the server.
export type MutationGen = Generator<YieldEffect, void, KeyedRow | undefined>;MutationTx
InterfaceDeclaration · Source: packages/optimistic/src/backend.ts:119 · Supporting declarations
The write handle a client mutator runs against (the client MutationTx, §4.2):
reads see the current base + this transaction's own staged writes (§4.1).
Prefer the KEYED methods (insert/update/upsert/delete/row) — named columns,
schema-checked. The positional methods (get/add/remove/edit) are the raw wire
shape: bare cells in schema column order, pk cells in primaryKey order.
export interface MutationTx {
/** Read one row by primary key (e.g. `tx.row("issue", { id: 1 })`). */
row(table: string, pk: KeyedRow): KeyedRow | undefined;
/** Insert a FULL row (every column named; missing or unknown columns throw). */
insert(table: string, row: KeyedRow): void;
/** Update the row identified by the pk columns; only the named non-pk columns change.
* A missing row is a NO-OP (rebase-friendly: the row may have vanished upstream). */
update(table: string, row: KeyedRow): void;
/** Insert, or fully replace when the pk already exists (a FULL row, like `insert`). */
upsert(table: string, row: KeyedRow): void;
/** Insert a FULL row, or do nothing if the pk already exists (the isomorphic form of the classic
* `if (!tx.row(pk)) tx.insert(row)` upsert-if-absent; renders `ON CONFLICT DO NOTHING` server-side). */
insertIgnore(table: string, row: KeyedRow): void;
/** Delete the row identified by the pk columns. A missing row is a NO-OP. */
delete(table: string, pk: KeyedRow): void;
/** Run a one-shot read query (`where`/`orderBy`/`limit`/join) over the state this
* mutator is mutating — it sees this transaction's own writes-so-far, the same
* read-your-writes contract as `get`/`row` (§4.1; 203-MUTATOR-READS-DESIGN.md §5.2).
* Synchronous; returns the matching rows in the query's order, each with its materialized
* relationship children nested by name (presented identically to a `view.data` row). Pass
* a query from the typed builder, e.g. `tx.query(q.issue.where("owner", "=", me))`.
* Refused inside a FOLDED mutator (a reading mutator is non-absorbing, §9.1). */
query(query: QueryArg): QueryResultRow[];
get(table: string, pk: WireValue[]): WireValue[] | undefined;
add(table: string, row: WireValue[]): void;
remove(table: string, row: WireValue[]): void;
edit(table: string, oldRow: WireValue[], newRow: WireValue[]): void;
}MutatorCtx
InterfaceDeclaration · Source: packages/client/src/mutation-ops.ts:141 · Supporting declarations
The minimal per-invocation context a shared mutator sees on BOTH tiers: the acting principal. The
server injects its AUTHENTICATED user; the client injects its local user. (The server's own
MutationContext is a superset of this.)
export interface MutatorCtx {
user: string;
}OptimisticBackend
ClassDeclaration · Source: packages/optimistic/src/backend.ts:532 · Supporting declarations
export declare class OptimisticBackend<S extends ColsMap> implements Backend {
private readonly local;
private readonly sync;
private readonly source;
private readonly registry;
private readonly clientID;
/** The acting principal provider for a shared mutator's `ctx.user` (§ shared mutators). */
private readonly user;
private readonly bufferCap;
/** Column order + pk indices per table, for the keyed `MutationTx` methods. */
private readonly specs;
/** Local-only table names (`201-LOCAL-ONLY-TABLES-DESIGN.md` §4). Drives: the agg-rewrite gate
* (L1 — a local-child count stays a native reduce), the mutator guard (M1 — a replayable
* mutator may not read/write one), and `writeLocal` (M2 — it accepts ONLY these). */
private readonly localTables;
/** Each table's full column count (union-row width) + column-name → base ColId — to learn a
* projected query's per-table projection off its `hello` and register it with the sync layer,
* so it scatters that query's narrower rows into the shared union (PROJECTION-SUPPORT-DESIGN
* §5.2). Without this a projected query's short rows reach the wasm `Db` un-scattered and fail
* its width check. */
private readonly colCounts;
private readonly colIndex;
/** Per-table pk column indices — held so `connectSource` can build a fresh per-source
* `NormalizedSync` with the same layout the daemon's uses. */
private readonly pkCols;
/** The client's OWN typed per-table schemas + the reserved lmid table — the fixed base
* of the expected-schema set (CRIT#4 validation). Synthetic agg tables are appended as
* queries arrive (`ensureSyntheticTables`). */
private readonly clientTablesBase;
/** Synthetic aggregate tables (`__agg_*`) registered so far, by name (AGGREGATE-SYNC-DESIGN
* §3.3). Per aggregate DEFINITION (not per query), so two queries over the same count
* share one table. */
private readonly synthetic;
/** Synthetic table name → how many registered LOCAL queries reference it. A table is
* materialized on the `0→1` transition and reclaimed (engine source + baseline + refcount
* layer + overlay def) on `1→0` — so aggregate state is not permanent (§4). */
private readonly syntheticRefs;
/** Local qid → the synthetic tables it referenced at registration, to decrement on teardown. */
private readonly queryAggTables;
/** The optimistic aggregate overlay (§4–§6): the per-aggregate definitions + the per-group
* pending delta `displayed = server_base ⊕ local_pending_delta` is applied from. */
private readonly overlay;
private handler;
private catchUpQids;
/** Newly-hydrated qids whose reconcile ACTUALLY emitted a (catch-up-stamped) batch — recorded by the
* local-event forwarder alongside {@link catchUpQids}. After the reconcile, any newly-hydrated qid
* NOT in here folded nothing (0 rows, or its result already present via a sibling → 0 net muts, or
* the reconcile was skipped), so `onProgress` sends it an explicit empty catch-up — else its SSR
* seed would never retire (the view freezes). Non-null only for the reconcile's duration. */
private catchUpEmitted;
/** The Store's commit-boundary handler ({@link Backend.onCommitBoundary}), forwarded from the
* local engine's `dispatch` brackets so the Store folds every affected view before notifying any
* subscriber (cross-view-atomic notification). All this backend's data deltas originate from the
* local engine, so its commit brackets are this backend's commit brackets. */
private boundaryHandler;
private readonly devObservers;
private pendingMutations;
/** The next mid to deal, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1): a client
* writing through room + daemon concurrently must not alias one lmid counter. Seeded with the
* `"daemon"` stream at 1; a domain absent from the map starts at 1. In the single-domain
* configuration only `"daemon"` is ever touched, so the sequence is byte-for-byte as before. */
private nextMid;
/** The client-global deal counter behind {@link PendingMutation.seq}: one sequence across ALL
* domains, bumped whenever any domain's mid is dealt. The replay order (mids are per-domain and
* incomparable across domains — see the `seq` field doc). */
private dealSeq;
/** The explicit confirming-stream override (§7.1/§3) — see
* {@link OptimisticBackendOptions.domainPolicy}. `undefined` from it ⇒ H-iii derivation. */
private readonly domainPolicy;
/** The final-rejection reason surface ({@link OptimisticBackendOptions.onRejected}). */
private readonly rejectedHandler;
/** Processed `(domain, mid)` outcome frames (H-v) — the deopt handshake's idempotence guard: a
* duplicate frame (the original plus a reconnect re-send's re-answer, or two re-answers across
* two reconnects) must not double-invoke. Needed precisely because a deopt frame can arrive for
* an ALREADY-RETIRED mid (the replay gotcha) — "no matching entry" alone cannot distinguish
* "handle it fresh" from "already handled". Per-domain FIFO, capped like the shell's
* recorded-outcome map ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}); past the cap a duplicate of
* an evicted mid would be re-processed — the same bounded-window trade the shell makes, and it
* takes 512 interleaving non-applied outcomes on one domain to open it. */
private readonly outcomesProcessed;
/** THE room-table registry (302 §2 — one source per table): per connected room `sourceKey`, the
* wire-table → engine-table map for the tables that room OWNS (its writable scope). Written by
* {@link registerRoomTables} (same breath as the engine registration); read by the gate's
* release rename/filter, the mutator staging map, the view swap ({@link processSwapIns}), and
* the client's `__realtimeInspect` bookkeeping. The record outlives a downgrade's disconnect —
* the ghost's views still read the engine tables — and drops at {@link dropGhost} (or the last
* clean release via {@link unregisterRoomTables}). */
private readonly roomTables;
/** Local view qids currently REGISTERED on a room's namespaced tables (302 §4 swap-in), →
* their sourceKey. Set by {@link processSwapIns}; cleared by the swap-back ({@link dropGhost})
* and view teardown. The original AST stays in {@link asts} throughout — the swap re-registers
* only the ENGINE query. */
private readonly roomSwappedViews;
/** Room subs whose FIRST snapshot released in the current release — their views swap onto the
* room tables at the release tail ({@link processSwapIns}), strictly AFTER the reconcile folded
* the snapshot into those tables (swapping earlier would hydrate the view EMPTY, a flash). */
private readonly pendingSwapIns;
/** The live fold entries, by fold key `${name}\0${identityJSON}` — at most one per key
* (FOLDED-MUTATIONS-DESIGN §8). Insertion order is creation order (the drain/flush tiebreak). */
private readonly folds;
/** The fold debounce clock (real timers by default; the oracle injects a virtual one). */
private readonly clock;
/** The high-water confirmed mutation id, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
* §7.2 per-domain confirm-drop): an entry with `mid <= watermark[entry.domain]` has been
* confirmed. The `"daemon"` domain is folded from the lmid system query's RELEASED ops
* (lmid-as-data) — never from a frame; a room domain will fold from its own lmid stream (later
* slice). Seeded with `"daemon"` at 0; the daemon scalar `confirmedLmid` (devtools) is
* `watermark.get("daemon")`. */
private watermark;
/** The per-source coherence gates (§5.1), by source key. Seeded with the daemon gate at
* construction; a room channel attaches later (`connectSource`). Single-domain: one entry,
* and every gate-generalized path degenerates to the old single-buffer code. NOT the same
* space as {@link watermark}/{@link nextMid}: a DOMAIN can confirm with no gate connected
* (the `__testRelease` seam); a gate's `key` names the domain its lmid stream folds into. */
private readonly gates;
/** The daemon's gate — the always-present channel (constructor-attached). The devtools
* scalars (`__inspect`) read it directly; its `sync` IS {@link sync} (the agg overlay and
* synthetic tables are daemon-tracked by design). */
private readonly daemonGate;
/** System retains by source qid ({@link retainSystemQuery}): a subscription with NO store view
* and NO user-visible table — its frames buffer on its gate exactly like {@link LMID_QID}'s and
* fold at RELEASE time ({@link foldSystemFrames}), never entering the sync layer or the local
* engine. The spec names which system table the qid serves and the scope/doc it was minted for
* (the fold's row filter). Empty on every non-lifecycle client — every partition below is then
* a structural no-op and the release path is byte-identical to before. */
private readonly systemQids;
/** The §4.2 fence state: room doc → highest `flush_seq` delivered through the daemon plane
* (monotone max-fold; a remove never regresses it). Slice I-v's ghost-drop consumer — I-iii
* only maintains + exposes it (`__inspectDomains().lifecycle`). */
private readonly roomWatermarks;
/** The §4.1 occupancy state: scope → (client_id → expires_at) from the doorbell stream. Slice
* I-iv's doorbell consumer (the 1→2 re-lease reaction) — I-iii only maintains + exposes it.
* A snapshot REPLACES the scope's map (authoritative re-hydrate); a batch folds add/edit/remove
* incrementally (the age-out sweep's deletes arrive as removes). */
private readonly scopeSessions;
/** The I-iv doorbell event sink ({@link onScopeSessions}) — fired once per scope a release's
* scope-session fold touched, AFTER the whole release applied. Default no-op: a client that
* never registers (no lifecycle plane) pays nothing. */
private scopeSessionsHandler;
/** Deferred old-channel row GC for in-flight upgrade retargets ({@link retargetRemoteQuery}):
* sub sourceQid → the channel it left. The rows the OLD gate's sync holds for the qid stay
* visible (merge: daemon tier) until the sub's first snapshot RELEASES on its new room channel
* ({@link flushRetargetGc}) — dropping them at retarget time would emit net removes ahead of
* the room's re-adds, the flicker the two-phase cutover exists to avoid. Doubles as the
* wrong-channel GRACE window in {@link onFrame}: a frame already in flight from the old
* channel when the sub moved is stale, not a wiring bug. Empty on every non-upgrade client —
* every consultation below is then a structural no-op. */
private readonly pendingRetargetGc;
/** The §4.2 GHOSTS (Slice I-v): demoted room sources awaiting their watermark fence, by
* sourceKey. Written only by {@link demoteRoomSource}; evaluated after every release
* ({@link evaluateGhosts}) and dropped by {@link dropGhost} once the fence clears with no
* sent room-domain pending left. Empty on every non-downgrade client — the per-release
* evaluation is then a structural no-op. */
private readonly ghosts;
/** The I-v stuck-downgrade surface ({@link onDowngradeStuck}) — fired AT MOST ONCE per ghost
* when its fence is satisfied but sent room-domain mids remain unresolved (§7.5: they retire
* only through outcome resolution; the ghost holds rather than inventing a timeout-retire).
* Default no-op. */
private downgradeStuckHandler;
private readonly asts;
/** Per query: the base tables its result can draw from (from the AST tree). */
private readonly queryTables;
private readonly remoteSubs;
private readonly sourceToRemote;
private readonly localToRemote;
private readonly remoteRetainToLocal;
private readonly resultTypes;
/** Local view qids that are server-authoritative: a query with no remote sub (purely local) is
* hydrated on registration; a remote query is hydrated when its sub's first snapshot releases.
* An un-hydrated query reports `unknown` (still loading) — the basis of `resultType`. */
private readonly hydrated;
private resultTypeHandler;
/** The pending AXIS (§7.2), split off `ResultType`: per query, whether any pending mutation
* touches its tables. Cached so `onPending` fires only on transitions (invoke ↔ confirm). */
private readonly pendingState;
private pendingHandler;
/** The local-persistence write-through tap (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §5.1):
* {@link writeLocal} invokes it post-commit; {@link applyLocalReplica} deliberately does not. */
private localWriteObserver;
constructor(schema: Schema<S>, source: OptimisticSource, registry: ClientRegistry, opts: OptimisticBackendOptions);
/** Wire one authority channel into its own coherence gate (§5.1): every frame the channel
* delivers buffers on THIS gate's cv timeline, its progress frames release THIS buffer, its
* restart resets THIS gate alone, and its reserved lmid stream folds into `watermark[key]`.
* Validates each server hello against our OWN typed schema → reject a schema skew (CRIT#4);
* the reserved lmid table is part of the expected set so the system query's hello passes, and
* synthetic agg tables join the set as queries register them. */
private attachGate;
/** Attach a SECOND authority channel (§5.1) — the seam Slice G's room upgrade calls with the
* ws-backed room feed. Rooms speak the daemon protocol verbatim (§2.4: the client cannot tell
* a room from the daemon), so the argument is a full {@link OptimisticSource} — exactly what
* `@rindle/remote` builds from `{roomUrl, leaseToken}`. The channel buffers/releases on its
* own cv timeline (an independent §5.1 gate: coherent within, eventual across) and its
* reserved lmid stream folds into `watermark[sourceKey]` — so `sourceKey` must equal the
* `domainPolicy` name for the mutations this authority confirms. The converse is NOT required:
* a domain may exist with no connected gate (`__testRelease` drives confirms gate-less); the
* live production path stays daemon-only until G calls this. */
connectSource(sourceKey: string, source: OptimisticSource): void;
/** Register the tables room `sourceKey` OWNS (its writable scope — 302 §2): each wire table
* gets its own namespaced ENGINE table (`{@link roomEngineTable}`), an ordinary tracked table
* whose sole authority is the room channel. From here on the channel's released deltas rename
* into these tables (wire tables outside the map are DROPPED — context stays daemon-owned,
* 302 §6), room-domain mutators stage onto them, and a room-homed view swaps onto them once
* the room sub hydrates ({@link processSwapIns}). Idempotent per (sourceKey, table); a wire
* table unknown to the schema is skipped (nothing to hold rows for). */
registerRoomTables(sourceKey: string, tables: readonly string[]): void;
/** The wire-table → engine-table map for room `sourceKey`'s owned tables (empty when none) —
* the client's idempotence check and `__realtimeInspect` read THIS record (one source of
* truth; the client keeps no shadow copy). */
roomTablesFor(sourceKey: string): ReadonlyMap<string, string>;
/** `channel` (G-iii registration-time routing) names the authority channel the remote sub
* registers on — a `connectSource`d gate key; default `"daemon"` (every existing caller is
* byte-identical). Slice G-v threads the lease's `realtime.sourceKey` here. Validated FIRST
* (like the E3 check below): a bad channel must throw before any per-query state is recorded. */
registerQuery(qid: QueryId, ast: Ast, remote?: RemoteQuery, channel?: string): void;
/** Register every synthetic aggregate table `ast` needs that we haven't seen yet: on the
* local engine (which auto-tracks it for the optimistic rebase loop), on `NormalizedSync`
* (so its rows refcount/GC by group key), and into the source's expected-schema set (so
* the server's `hello` — which advertises the same table — passes CRIT#4 validation).
* Idempotent across queries that share an aggregate definition. */
private ensureSyntheticTables;
/** Decrement the refcount of every synthetic table query `qid` referenced; for each one that
* reaches 0 (no live reader left), remove it from the engine, the refcount layer, and the
* overlay — so aggregate state is reclaimed, not permanent (§4). Must run AFTER
* `local.unregisterQuery(qid)` so the engine source has no live connection when
* `unregisterTable` frees it (the engine refuses otherwise). */
private releaseSyntheticTables;
unregisterQuery(qid: QueryId): void;
/** `channel` as in {@link registerQuery} (G-iii): the gate the remote sub registers on; default
* `"daemon"`. This is the split-retain seam G-v's resolve-then-register drives — resolve the
* lease, learn `realtime.sourceKey`, `connectSource` it, then retain the query on that channel.
* Validated FIRST so a bad channel throws before any synthetic-table refcount moves. */
retainRemoteQuery(qid: QueryId, remote: RemoteQuery, localQueryId?: QueryId, ast?: Ast, channel?: string): void;
releaseRemoteQuery(qid: QueryId): void;
/** The Slice I-iv upgrade retarget (§4.1 "Retarget" / the doorbell reaction): move a LIVE
* (name, args) sub — every retain of it and every local view it feeds, wholesale — from the
* channel it lives on onto `sourceKey`'s (already-`connectSource`d, already-promoted) room
* channel, WITHOUT the view ever dropping its rows. Returns the sub's wire `sourceQid` (the
* identity the client's renewal loop re-subscribes with).
*
* Why a dedicated primitive: the one-channel-per-(name,args) invariant ({@link retainRemote}'s
* loud throw) is correct — a sub's frames must never split across two cv timelines — so the
* upgrade cannot simply retain a second sub on the room and release the daemon one; and the
* naive release-then-retain order GCs the daemon sync's rows synchronously (net removes emit,
* the view flashes empty) a full ws round trip before the room's seq-0 snapshot refills it.
* The cutover is therefore TWO-PHASE around the room's first release:
*
* 1. NOW (here): unsubscribe the old channel's wire sub, sweep its still-buffered frames for
* this qid (their cv timeline continues without the sub — the hello-supersession
* precedent), flip `sub.channel`, re-arm `sub.hydrated` (the room's own snapshot is the
* cutover point), and register on the room source (its resolver presents the handed
* roomToken). The old gate's SYNC rows are deliberately NOT dropped: they keep the view's
* plain tables populated through the window — the view still reads them until the swap.
* 2. AT THE ROOM'S FIRST RELEASED SNAPSHOT: the reconcile folds the snapshot into the room's
* namespaced tables, the release tail SWAPS every local view onto them (302 §4.1,
* {@link processSwapIns} — the accepted-flash boundary), and {@link flushRetargetGc}'s
* deferred `dropQuery`+reconcile on the OLD gate then GCs the plain-table rows the sub
* alone referenced — invisible to the swapped views.
*
* Idempotent per target channel: a sub already on `sourceKey` returns immediately (the
* double-doorbell / re-entrancy guard — one retarget per (query, sourceKey)). Validates before
* mutating: a throw here leaves the sub fully daemon-attached (the client's fail-open). */
retargetRemoteQuery(remote: RemoteQuery, sourceKey: string): QueryId;
/** Phase 2 of {@link retargetRemoteQuery}, run at the end of every gate release: once a
* retargeted sub's first snapshot has RELEASED on its new channel (`sub.hydrated` re-armed at
* retarget, re-set by {@link markSubHydrated} inside this very release), drop the qid's rows
* from the OLD gate's sync and reconcile them out — after the room's rows are already applied,
* so the winner flip is value-equal (net-zero; see the phase table above). A sub torn down
* mid-window was already swept by `releaseRemoteQuery`/`unregisterQuery` (which delete the
* record); a vanished record here is pruned defensively. */
private flushRetargetGc;
/** The I-v downgrade orchestration primitive (§4.2/§7.4, re-expressed by 302 §4.2 as the
* SWAP-BACK GATE): retire room `sourceKey` behind the watermark fence. The caller has ALREADY
* retargeted every live sub off the channel ({@link retargetRemoteQuery} room→daemon —
* validated loudly below) and holds the fence from the api-server's downgrade response
* (`finalFlushSeq` = the room's last COMMITTED flush seq; `doc` keys the §4.2 watermark fold,
* {@link roomWatermarks}). Steps, in order:
*
* 1. **Disconnect** the channel ({@link disconnectSource}): handlers detached, gate + buffer
* dropped. `nextMid`/`watermark`/processed-outcomes for the domain are KEPT FOREVER (§7.1:
* an assigned mid pins its domain; a later re-upgrade of the same doc continues the
* sequence — {@link connectSource} attaches a fresh gate and the lmid snapshot max-folds
* into the surviving watermark). Disconnecting BEFORE the daemon sub's first release is
* load-bearing: it makes {@link flushRetargetGc}'s deferred old-channel GC a no-op (gate
* gone ⇒ record deleted, nothing dropped). The room's namespaced tables — and the views
* swapped onto them — deliberately stay: frozen at the room's last state, they keep the
* document visible while the falling-back follower may still lack the final flush.
* Swapping back earlier would show its pre-flush images — the regression §4.2 prevents.
* 2. **Ghost + first evaluation**: the record joins {@link ghosts} and is evaluated once
* immediately — `finalFlushSeq === 0` (a never-flushed room) with no room-domain pending
* drops on the spot, the single-daemon first-frame case.
*
* In-flight discipline (§7.5): entries with `mid !== null` on `sourceKey` stay PINNED (rule
* 2 — never re-route a sent mutation); their resolution arrives via the daemon-carried
* ledger+outcome folds (I-iii) and blocks the drop until then. Idempotent per sourceKey (a
* second labeled query sharing the room demotes into the existing ghost). */
demoteRoomSource(sourceKey: string, doc: string, finalFlushSeq: number): void;
/** Detach one connected room channel (Slice I-v step 3): the source's handlers are replaced
* with no-ops (the {@link OptimisticSource} handler seam is single-registration, so this IS
* the detach — a late frame from a dying socket can no longer touch any bookkeeping), its
* reserved lmid sub is unregistered, and the gate — buffer, per-source sync, cv watermark —
* is dropped from {@link gates}. The DOMAIN state deliberately survives forever:
* `nextMid[sourceKey]`, `watermark[sourceKey]`, and the processed-outcome set are untouched
* (§7.1 — an assigned mid pins its domain; a re-upgrade must continue, never restart, the mid
* sequence; {@link connectSource} then attaches a fresh gate whose lmid snapshot max-folds
* into the surviving watermark via {@link foldConfirm}). Closing the underlying transport is
* the caller's job. Idempotent (a missing gate is a no-op). */
disconnectSource(sourceKey: string): void;
/** Register the I-v stuck-downgrade sink — see {@link DowngradeStuckEvent}. One handler (a
* later registration replaces it, the {@link onScopeSessions} convention); client.ts maps it
* onto the loud anomaly surface. */
onDowngradeStuck(handler: (event: DowngradeStuckEvent) => void): void;
/** The I-v ghost-drop watcher (§4.2), run after every applied release ({@link applyRelease} —
* the seam where {@link roomWatermarks} has just folded and the confirm-drop has just run) and
* once at demote time. For each ghost: the fence must be satisfied
* (`roomWatermarks[doc] ≥ finalFlushSeq`; 0 is trivially satisfied) AND no SENT room-domain
* pending may remain (§7.5 — such entries resolve only through the daemon-carried
* outcome/ledger folds; an entry that never reached the room is undecidable, so the ghost
* HOLDS and the stuck event fires exactly once, naming the mids). Both satisfied ⇒
* {@link dropGhost}. */
private evaluateGhosts;
/** Drop one cleared ghost — the 302 §4.2 SWAP-BACK: under the fence the daemon tables are
* value-equal-or-ahead of the room's final state, so (1) every view swapped onto the room's
* namespaced tables re-registers on its ORIGINAL (daemon-table) AST — visually a no-op, the
* Store folds the re-hello as an in-place reset; (2) the namespaced tables unregister (no
* reader is left after the swap); (3) ONE daemon reconcile re-invokes the pending set so any
* entry whose writes had staged onto the now-gone room tables re-stages onto the daemon tables
* (its domain policy stopped naming the dead room when the client dropped it). The whole drop
* runs under one commit boundary so the swap and the re-staged predictions notify as ONE step.
* After this, a FUTURE upgrade of the same doc registers again from scratch. */
private dropGhost;
/** Unregister room `sourceKey`'s namespaced engine tables and drop the {@link roomTables}
* record. Callers must have no view registered on them (the engine refuses otherwise —
* loud by design). No-op for an unknown sourceKey. */
unregisterRoomTables(sourceKey: string): void;
/** Retain one minted SYSTEM subscription (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §4, Slice
* I-iii): a wire sub with NO store view and NO user-visible table. Registered through the same
* {@link RemoteSub} bookkeeping as any remote retain — so qid→channel ownership, the overflow
* re-subscribe, and refcounted release all work unchanged — but with an EMPTY `localQids` set
* (no hydration/resultType coupling) and a {@link systemQids} record telling the release path
* which system table this qid's frames carry (`spec.table`) and which scope/doc it was minted
* for (the fold's row filter). Its frames then buffer on the channel's gate exactly like
* {@link LMID_QID}'s and fold at RELEASE time in {@link foldSystemFrames} — riding the SAME
* buffered cv path as the data they co-committed with (fence coherence: an out-of-band
* shortcut would break I-ii's co-commit ordering guarantee).
*
* `channel` defaults to `"daemon"` — the system tables live in the DAEMON store (that is the
* point: outcome/ledger/watermark rows must be readable with no room socket alive, §7.1
* "load-bearing for §7.5"). Idempotence per (table, scope/doc) is the CALLER's job (client.ts
* keys its retains on exactly that); a duplicate retain of the SAME remote identity refcounts
* like any sub. */
retainSystemQuery(retainQid: QueryId, remote: RemoteQuery, spec: SystemStreamSpec, channel?: string): void;
/** Release a {@link retainSystemQuery} retain. Refcounted like any sub; the LAST release
* unregisters from the owning channel, sweeps its buffered frames, and drops the
* {@link systemQids} record. The folded lifecycle STATE (`roomWatermarks`/`scopeSessions`/
* processed outcomes) deliberately survives — the fence is monotone truth about the store, not
* about the subscription (a re-retained fence must not forget a cleared watermark). */
releaseSystemQuery(retainQid: QueryId): void;
/** Raw CRUD has no optimistic story (§9 replaces it with named mutators). Register a
* mutator — even a trivial one — and `invoke` it. */
mutate(_mutations: Mutation[]): Promise<void>;
/** Direct-commit a batch of LOCAL-only writes (`201-LOCAL-ONLY-TABLES-DESIGN.md` §6 / M2):
* straight to the local engine, OUTSIDE the optimistic pending stack — a local table is
* untracked, so it never rebases, reverts, or waits on a confirmation. Rejects a synced/tracked
* table (the local engine's `writeLocal` is the chokepoint). Reconcile is synchronous and
* non-reentrant (A5), so a local write can never interleave with an open server cycle.
*
* Fires the {@link onLocalWrite} observer AFTER the engine commit but BEFORE subscriber
* delivery — i.e. for exactly the batches that passed the M2 guard and committed (the
* persistence layer's write-through tap, `207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §5.1). A
* subscriber throwing during delivery re-raises out of this call, but only after the tap has
* seen the batch: a committed write can never be invisible to the persistence plane. */
writeLocal(mutations: Mutation[]): void;
/** The write-through tap for the local-persistence layer (207 §5.1): `observer` sees every
* {@link writeLocal} batch post-commit. One observer (the layer); a later registration
* replaces it. The observer must not throw — a persistence failure degrades durability, never
* the write path (P9); the layer catches internally. */
onLocalWrite(observer: (mutations: Mutation[]) => void): void;
/** Apply a REPLICATED local batch (a restore snapshot / a leader `commit` — 207 §5.1): delegates
* to the engine's `writeLocal`, so the M2 locality guard still fires (P8 — a corrupt record
* naming a synced table dies loudly here), but does NOT invoke the {@link onLocalWrite}
* observer — the echo guard is structural, so the persistence layer can never re-enter itself.
* `onCommitted` fires post-commit pre-delivery (same anchor as {@link writeLocal}'s tap): the
* layer updates its mirror there, so a subscriber throw can never desync mirror from engine. */
applyLocalReplica(mutations: Mutation[], onCommitted?: () => void): void;
onEvent(handler: (qid: QueryId, ev: ChangeEvent) => void): void;
onCommitBoundary(handler: (phase: "begin" | "end") => void): void;
/** Bracket a multi-step optimistic apply as ONE notification commit (cross-view-atomic
* notification — {@link Backend.onCommitBoundary}). `invoke`/`invokeFolded` apply the prediction
* and then reconcile the `__agg` head in TWO `this.local` commits, but they are two halves of one
* logical mutation: a relationship-`count` view and the data view it counts must update together.
* The Store's `commitDepth` is a counter, so each inner commit's own `begin`/`end` nests under this
* outer pair and the Store flushes every affected view (data AND count) once, together, at the
* outer `end` — a subscriber re-reading a sibling view then sees post-commit data, never a torn
* half. Balanced on throw (the prediction mutator may reject) via the `finally`, so a thrown
* prediction never wedges the Store in deferred mode. */
private inOneCommit;
/** Run one client mutator against the staged `tx`, accepting BOTH forms (§ shared mutators):
* a plain sync function runs as-is; a shared GENERATOR is driven synchronously — every yielded
* write applies to the wasm txn now, every `tx.row` read is resolved against the same staged
* state (read-your-writes), the SAME body the API server drives asynchronously. `ctx.user` is
* the acting principal (re-read per invoke, stable across a rebase re-invoke). */
private runMutator;
/** Deal the next wire mid from `domain`'s ledger (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
* §7.1) and advance that counter. A domain absent from the map starts at 1. Per-domain, so a
* client writing through room + daemon concurrently keeps two gapless, non-aliasing sequences.
* The client-global `seq` is stamped in the same breath — the ONE cross-domain total order
* (confirmation order is per-domain; replay order is client-global). Bundled here so no call
* site can deal a mid without its seq. ONE caller discards the seq deliberately: the H-v deopt
* flip ({@link handleMutationOutcome}) keeps the entry's ORIGINAL seq — its replay position —
* and takes only the fresh mid (the dealSeq bump is harmless: seq consumers order, never
* count). */
private dealMid;
/** The declared confirming stream for one invocation: the `domainPolicy`'s verdict, `"daemon"`
* when it abstains. Resolved BEFORE the prediction runs — the domain picks the staging map
* (a room domain stages its owned tables onto the room's namespaced twins). */
private resolveDomain;
/** The staging table map for a `domain`-routed prediction ({@link trackingTx}'s `stage`):
* wire table → the room's namespaced engine table for the tables the room owns; identity for
* everything else (including the whole map for the daemon domain). */
private stagingMap;
/** The PLAIN (daemon-homed) engine AST for `ast` — aggregate relationships rewritten to their
* synthetic `__agg_*` reads, no room renames. The ONE form every non-swapped engine
* registration uses ({@link registerQuery}, {@link dropGhost}'s swap-back) and the base the
* swap-in renames ({@link processSwapIns}). */
private plainEngineAst;
/** Mutator names the cross-authority warn below already fired for (once per name). */
private readonly warnedCrossAuthority;
/** 302 §5.1 dev-time guard: a room-DECLARED mutator wrote tables the room does not own. Those
* writes staged onto the PLAIN daemon tables (the staging map covers only owned tables), but
* the entry confirms on the ROOM stream — and only the room's OWNED tables flush back to the
* daemon, so nothing upstream ever echoes them: once the room confirm retires the entry, the
* next release's whole-store rewind reverts them for good. The first-party room shell refuses
* such a mutation (the §3.3 deopt/reject backstop re-routes it to the daemon), so this warns
* for the shapes where that backstop may be absent (a BYO relay) — loud, once, soft (§5.1:
* misdeclarations never throw). */
private warnCrossAuthorityWrites;
/** Run the named client mutator optimistically: the prediction applies to the live
* engine now (affected views update synchronously), `(mid, name, args)` joins the
* pending stack, and the envelope ships upstream. Returns the assigned `mid`. */
invoke(name: string, args: unknown): number;
/** {@link invoke} with an optional PINNED confirming domain (H-v): the deopt handshake's
* already-retired arm re-invokes the frame's echoed `(name, args)` as a FRESH invocation pinned
* to `"daemon"` — an honest re-prediction on the current base, never derived (`pin` bypasses
* {@link resolveDomain} entirely, so the router never runs and no Q6 counter moves). Every
* other step is `invoke` verbatim: prediction now, capture, drainOverlapping, mid dealt from
* the pinned domain's ledger, envelope on its channel. */
private invokeWith;
/** Run a FOLDED invoke (FOLDED-MUTATIONS-DESIGN §8): apply the prediction to the live engine now
* (like `invoke`), but collapse a run of same-key invokes into ONE pending entry whose `args`
* are overwritten in place, debounce the server write, and ship only the last value. The `mid`
* is assigned at flush, not here (§4.1) — so the return is a {@link FoldHandle}, not a mid. */
invokeFolded(name: string, opts: FoldOptions, args: unknown): FoldHandle;
/** Flush-on-enqueue (§4.2): for each outstanding fold whose touched tables overlap `tables`,
* assign its mid NOW and ship it — in creation (insertion) order, so the wire stays gapless. A
* `deferAcrossWrites` fold opts out (it keeps deferring, accepting the read-dependent snap). The
* incoming write's own fold key (if any) is skipped — it is being folded into, not flushed. */
private drainOverlapping;
/** Flush one fold (§8): deal its `mid` from `nextMid` (SEND order — never reserved, so gapless
* by construction), stamp the entry, ship the envelope with the LATEST args, resolve the handle.
* The entry stays on `pendingMutations` (now with a real mid) until the lmid release confirms it. */
private flushFold;
/** The transport a `domain`-confirmed mutation ships on (§7.5 sent-pins-domain: only the
* domain's own authority can confirm it, so its channel is the only correct transport). A
* domain with NO connected gate ships on the daemon channel — the gate-less configurations
* (`__testRelease`-driven tests) and today's entire live path resolve `"daemon"` anyway. */
private channelFor;
/** The gate a channel-keyed retain registers through (G-iii registration-time routing). The
* channel MUST already be connected (`connectSource`; the daemon is constructor-attached) —
* loud by design: a typo'd or not-yet-connected sourceKey must throw at retain time, never
* silently register on the daemon and split the query's frames across channels. */
private requireGate;
/** The channel that owns `sourceQid` — {@link RemoteSub.channel}, the ONE source of truth for
* qid routing (G-iii). `undefined` when no sub owns the qid (a harness-delivered raw feed, or
* a just-released sub): such frames buffer on whatever gate they arrive at. */
private channelOf;
/** Record `(domain, mid)` as processed; `false` if it already was (a duplicate frame —
* ignore it). FIFO-capped per domain ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}). */
private markOutcomeProcessed;
/** One `mutationOutcome` frame from `domain`'s channel (H-v — the §3.3 handshake's client
* half). The frame arrives OUT-OF-BAND (see {@link attachGate}); the state machine:
*
* 1. `mid` never issued on `domain` ⇒ ignore (a confused/foreign frame must not invent work).
* 2. `(domain, mid)` already processed ⇒ ignore — idempotence under duplicate frames (the
* original + a re-send's re-answer; a deopt for a mid whose entry ALREADY FLIPPED also
* lands here harmlessly on its second frame).
* 3. `kind:"rejected"` ⇒ FINAL. Surface the reason through {@link rejectedHandler} (room-plane
* parity with the HTTP queue's callback) and STOP — the drop + snap-back is the EXISTING
* failed-mutation machinery: the room burnt the mid, its lmid release retires the entry
* per-domain and the reconcile rewinds the prediction, exactly the daemon path's
* processed-as-no-op rejection. No new drop path.
* 4. `kind:"deopt"`, entry found (pending `(domain, mid)`) ⇒ FLIP IN PLACE: `domain` becomes
* `"daemon"`, a fresh daemon mid is dealt and the envelope ships NOW on the daemon channel
* ("deal-and-send-now" — the conforming §3.3 re-enqueue: there is no flush machinery for
* non-fold entries, so the design's "mid: null until the daemon flush" is satisfied
* momentarily inside this call). THE ENTRY'S `seq` IS KEPT — settled (§5.3, commit
* 68141096): `seq` is the client-global REPLAY order; re-sequencing would move the entry's
* overlay position and change read-dependent SIBLINGS' replay base. Everything else stays
* (writes/reads/touched/touchedSources/writeSources — union-never-shrink), the prediction
* stays applied (the entry never leaves `pendingMutations`, so no rewind fires), and the
* router does NOT re-run nor does `drainOverlapping` (§3.3 re-enqueues, never re-derives;
* any open overlapping fold was invoked later and flushes later with a larger mid).
* 5. `kind:"deopt"`, entry NOT found ⇒ the burnt-mid confirm won the race, or the frame is a
* replay re-answer for an entry a previous session retired (the replay gotcha): re-invoke
* the frame's echoed `name`/`args` as a FRESH invocation PINNED to `"daemon"` — an honest
* re-prediction on the current base, never a derived route ({@link invokeWith}). A frame
* without `name` (not self-contained) has nothing to re-invoke and is dropped; a re-invoke
* that THROWS (the base moved from under it) is surfaced through {@link rejectedHandler} —
* the mutation is dead with no stream left to confirm it.
*
* A `"deopt"` bump joins the Q6 routing counters either way (`routing.reasons.deopt`) —
* derived-and-deopted routes are visible beside derived successes. */
private handleMutationOutcome;
/** §7.5 rule 3 (H-v): re-send `domain`'s unconfirmed pending envelopes with their ORIGINAL
* mids, in mid order, on the domain's own channel. Folds with `mid === null` are excluded —
* nothing was ever sent for them (the flush deals their mid). Envelopes are reconstructed from
* the pending entries exactly as `invoke` shipped them (`clientID`/`mid`/`name`/`args` —
* entries carry everything the wire needs). Idempotent under the domain's ledger: an APPLIED
* mid dedups silently and its lmid coverage retires the entry; a NON-APPLIED mid is re-answered
* from the shell's recorded-outcome map into {@link handleMutationOutcome}. Confirmed entries
* are already gone from `pendingMutations`, so no filter against the watermark is needed. */
private resendPending;
/** Drain every outstanding fold immediately (FOLDED-MUTATIONS-DESIGN §3): the explicit
* `app.flushFolds()` and the `beforeunload`/`close` hook. Creation (insertion) order. */
flushFolds(): void;
/** A `trackingTx` op collector that records only the ops over a tracked aggregate's child
* table (the others can't move any count). Applied to the overlay by the caller AFTER the
* mutator succeeds, so a throwing mutator (whose staged write is discarded) leaves no delta. */
private opCollector;
/** Push the optimistic per-group delta onto the `__agg` head rows (§4):
* `target = server_base ⊕ delta`. A head-only write to the (tracked) synthetic table, so it
* joins the optimistic layer and is rewound/rebuilt by the reconcile cycle like any
* prediction. `server_base` is read from `NormalizedSync` (the authoritative base) — NOT
* from head, which already carries the optimistic layer (a torn read). Works standalone (an
* ordinary delivery) and inside an open cycle (the write buffers into it). */
private reconcileAggHead;
/** The SERVER CHANNEL's state for a query (§7): `unknown` while not hydrated, else `complete`.
* A pending local mutation no longer moves it — see {@link pending}. */
resultType(qid: QueryId): ResultType;
onResultType(handler: (qid: QueryId, rt: ResultType) => void): void;
__attachDevtoolsServerDeltas(observer: BackendDevObserver): () => void;
/** Whether any pending mutation (folded or not) touches this query's tables — "is a prediction
* pending here?" (FOLDED-MUTATIONS-DESIGN §7.2). Orthogonal to {@link resultType}; this is the
* same `queryTables ∩ pending.touched` computation that used to be smuggled into `unknown`. */
pending(qid: QueryId): boolean;
/** Reactive pending axis (§7.2): fires when a query's pending-ness flips (invoke ↔ confirm), so a
* "saving…" affordance clears on its own when `lmid` catches up. */
onPending(handler: (qid: QueryId, pending: boolean) => void): void;
/** The coarse, table-level pending indicator set (§7.2): every table some pending mutation touched. */
pendingTables(): Set<string>;
/** A read-only snapshot of the optimistic loop for a devtools pane (DEBUG-TOOLS-BROWSER-DESIGN
* §4.1). Built fresh per call from state the backend already holds — no new instrumentation, no
* mutation. Only ever called by `@rindle/devtools` (imported in dev). */
__inspect(): OptimisticInspect;
/** Test-only per-domain ledger snapshot (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1/§8.5).
* Kept separate from {@link __inspect} so the devtools `OptimisticInspect` mirror stays byte-for-
* byte identical (daemon-scalar-only). Exposes the per-domain `nextMid`/`watermark` maps plus each
* pending entry's confirming domain — the axes the §8.5 ledger-isolation assertion checks. */
__inspectDomains(): {
nextMid: Record<string, number>;
watermark: Record<string, number>;
/** Per connected CHANNEL (§5.1): its release watermark + buffered-frame depth — the axis the
* gate-isolation assertions read (one source's laggy cvMin must never move the other's). */
gates: Record<string, {
appliedCv: number;
bufferedFrames: number;
}>;
/** Per connected/registered room: its wire-table → engine-table map (302 §2) and which local
* view qids are currently swapped onto it (302 §4). */
roomTables: Record<string, Record<string, string>>;
swappedViews: Record<number, string>;
/** The §4 lifecycle plane's folded state (Slice I-iii introspection): the per-doc §4.2 fence
* value (`roomWatermarks`, I-v's ghost-drop input), the per-scope §4.1 occupancy map
* (`scopeSessions`: scope → client_id → expires_at, I-iv's doorbell input), and the live
* I-v ghosts (demoted room sources still awaiting their swap-back fence). */
lifecycle: {
roomWatermarks: Record<string, number>;
scopeSessions: Record<string, Record<string, number>>;
ghosts: Record<string, {
doc: string;
finalFlushSeq: number;
}>;
};
pending: {
mid: number | null;
seq: number | null;
name: string;
domain: string;
}[];
};
/** Recompute the pending axis for every query and fire `onPending` on transitions only. Called
* from the two points that move the pending set: invoke/invokeFolded (add) and the confirm-drop
* (remove) — exactly where `:359`/`:468` used to flip ResultType (§7.3). */
private refreshPending;
private onFrame;
private emitServerDelta;
private localQidsForSource;
/** One gate's release (§5.1 release gate): compute the coherent delta from THIS gate's cv-buffer,
* then apply it against the gate's source/domain. Split into {@link computeRelease} (buffer →
* delta, lmid → watermark) and {@link applyRelease} (per-source confirm-drop + reconcile) —
* N independent gates all feed the ONE apply half; {@link __testRelease} drives it directly. */
private onGateProgress;
/** Compute one coherent release from ONE gate's cv-buffer (§5.1) — gate-scoped: its buffer, its
* cvMin timeline. Take every buffered frame at `cv ≤ cvMin`, in (cv, arrival) order, and fold
* it: the lmid system-query frame advances `watermark[gate.key]` (via {@link foldLmidOps} — the
* daemon stream folds "daemon", a room stream folds its own domain); data frames fold through
* this SOURCE's cross-query refcount into ONE net base delta — the §1.3 `D`. Returns that delta
* plus the set of local views this release JUST hydrated (so their reconcile batch phases as a
* `snapshot`). Mutates the gate's buffer/`appliedCv`, hydration, and the gate's domain
* watermark; the pending set and the reconcile are {@link applyRelease}'s job. */
private computeRelease;
/** Apply one released delta against `sourceKey`'s domain (§7.2 per-domain confirm-drop + the §1.3
* reconcile cycle). `watermarkUpdate`, when given, advances `watermark[sourceKey]` first — the
* hook a per-source lmid confirm rides on (the daemon path folds its watermark in
* {@link computeRelease} and passes `undefined`). Then: drop every pending entry its OWN domain's
* watermark now covers (a room confirm can never retire a daemon entry, and vice-versa — the §7.1
* ledger-collision fix), and run the reconcile cycle against `sourceKey` when the base delta or the
* pending set changed. `newlyHydrated` stamps the initial-hydration batch as a catch-up. */
private applyRelease;
/** Test-only per-source release seam (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.2/§8.5): drive
* {@link applyRelease} for `sourceKey` directly — an explicit `watermarkUpdate` (a simulated lmid
* confirm for that domain) and `deltas` (a coherent base delta), with no real gate. Lets a harness
* exercise a room-domain confirm before the real second lmid stream / per-source gate is wired
* (E-iii-b/c). The `__`-prefix marks it a test hook, alongside {@link __inspect}. */
__testRelease(sourceKey: string, deltas: Mutation[], watermarkUpdate?: number): void;
/** Swap every view of each just-hydrated ROOM sub onto the room's namespaced tables (302 §4.1):
* re-register the local engine query with the AST's room-owned table references renamed
* ({@link remapAstTables}); the Store folds the re-hello as an in-place reset, so the caller's
* view reference survives and subscribers see ONE transition. Runs at the applyRelease tail —
* the reconcile has already folded the sub's snapshot into the room tables, so the swapped
* view hydrates straight to the room state (swapping earlier would flash it empty). The
* ORIGINAL ast stays in {@link asts}; the swap-back ({@link dropGhost}) re-registers it.
*
* This is the accepted-flash boundary (302 §4.1/§7.1): the room's copy may be behind the
* daemon rows the view showed a moment ago — accepted by decision, revisit on a real
* two-region deploy. */
private processSwapIns;
/** Fold `domain`'s lmid system query's released ops (lmid-as-data): the one row's
* `last_mutation_id` cell is this client's confirmed high-water mid in that domain — it advances
* `watermark[domain]` and, on a fresh session ahead of our issued mids, `nextMid[domain]`. The
* daemon stream folds `"daemon"`; a room stream folds its own `"room:doc:X"`; the daemon-carried
* §7.1 ledger rows fold through the same {@link foldConfirm} core (Slice I-iii). */
private foldLmidOps;
/** THE one confirm fold (§7.1/§7.2): advance `watermark[domain]` to `lmid` (monotone max) and,
* on a fresh session ahead of our issued mids, adopt `nextMid[domain]`. Shared verbatim by the
* per-channel lmid system query ({@link foldLmidOps}) and the daemon-carried room-ledger rows
* ({@link foldSystemFrames} — one core so the two paths cannot drift). */
private foldConfirm;
/** Fold one release's SYSTEM frames in a FIXED category order — the order is STRUCTURAL (one
* function, categories in sequence), because it is the client half of THE NAMED INVARIANT
* (§3.3's shipped note; documented above {@link handleMutationOutcome}): **never retire a
* room-domain entry off a daemon-carried lmid without outcome resolution.**
*
* 1. **outcome rows** (`_rindle_room_mutation_outcomes`) — each row for OUR clientID is
* synthesized into a {@link MutationOutcomeFrame} and routed through
* {@link handleMutationOutcome}, the SAME H-v state machine the room socket's frames use
* (one verdict path: frames and rows cannot drift). A deopt flips its pending entry to
* the daemon IN PLACE (keep-seq, deal-and-send-now); a rejection surfaces + stays for the
* ordinary burnt-mid retire; a duplicate (frame already seen, or the row re-delivered) is
* absorbed by the processed set — which doubles as the resolved-verdict memory across
* releases (per-domain FIFO, {@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}, mirroring the
* shell's recorded-outcome cap).
* 2. **room-ledger rows** (`_rindle_room_client_mutations`) — the FIRST daemon-carried
* room-lmid path: OUR row's `last_mutation_id` folds into `watermark[room:<doc>]` via
* {@link foldConfirm}. Because step 1 ALREADY resolved every non-applied verdict this
* release carries (and earlier releases' verdicts were resolved at their own release),
* the confirm-drop that follows in {@link applyRelease} retires only entries whose
* outcome is resolution-by-absence — which I-ii's co-commit atomicity defines as APPLIED
* (a room flush co-commits the ledger row and every non-applied mid's outcome row in ONE
* daemon transaction, so a covering lmid without a row IS the applied verdict).
* Processing this category before step 1 is the violation, in two proven directions
* (each run break→fail→revert against `test/system_streams.test.ts`): (a) the ledger's
* fresh-session `nextMid` ADOPTION must not run before historical outcome rows are
* judged — adopted-first, a previous session's retained deopt row passes the
* "never-issued" guard and spuriously re-invokes a mutation that session already handled
* (a double-apply); (b) the RETIRE must not precede resolution — it does not BECAUSE the
* confirm-drop runs in {@link applyRelease}, strictly after this whole function. That
* deferral is load-bearing: an "optimization" retiring inline with the watermark fold
* retires a deopted entry as a silent success (the exact lost-write H-v exists to
* prevent) and mis-attributes a rejected row's reason.
* 3. **watermark rows** (`_rindle_room_watermark`) — the §4.2 fence value, max-folded per
* doc ({@link roomWatermarks}); I-v's ghost-drop consumer, no reaction here.
* 4. **scope-session rows** (`_rindle_scope_sessions`) — the §4.1 occupancy map
* ({@link scopeSessions}); I-iv's doorbell consumer, no reaction here.
*
* Ordinary data ops fold AFTER all of these (the caller's main loop) — outcome/ledger state
* must be in place before {@link applyRelease}'s confirm-drop + reconcile consume the release.
* Every row is filtered against the retain's {@link SystemStreamSpec} scope/doc AND (for the
* client-keyed tables) our own `clientID` — defense in depth: the server predicate may have
* been minted doc-only (no `clientId` at lease time), so other clients' rows are expected and
* must be ignored, and a row for a doc this retain was not minted for is never folded.
*
* Returns the scopes category 4 touched (snapshot or ops) — the I-iv doorbell events' input;
* `null` when none (every non-lifecycle release). The events themselves fire from
* `onGateProgress` AFTER the release applies, never from inside the fold. */
private foldSystemFrames;
/** The I-iv occupancy count — THE one rule (§4.1/D7): unexpired (`expires_at >` the fold
* clock's now) sessions under `scope` from OTHER clientIDs. Shared by the doorbell events
* ({@link onGateProgress}) and the client's registration-time check (a doorbell that folded
* BEFORE a candidate registered must still be able to trigger it) so the two can never
* disagree. Own-clientID rows never count — a solo client cannot ring its own bell — and
* expiry is judged on the injectable {@link FoldClock} (deterministic in a virtual-clock
* harness, the folded-oracle discipline). */
otherScopeSessions(scope: string): number;
/** Register the I-iv doorbell event sink — see {@link ScopeSessionsEvent}. One handler (a later
* registration replaces it, the {@link onLocalWrite} convention); client.ts is the consumer. */
onScopeSessions(handler: (event: ScopeSessionsEvent) => void): void;
/** One §1.3 reconcile cycle: rewind the optimistic layer and fold the coherent SERVER
* delta into BOTH head AND the `sync` baseline (`serverBatchBegin`), re-invoke every
* still-pending mutator to re-stage the optimistic layer (the rewind un-applied it), then
* deliver the coalesced result (`serverBatchEnd`). This is the engine's only sync-moving
* boundary — `onProgress` releases and `unregisterQuery`'s GC both go through here so head
* and sync never diverge (the §1.2 invariant; CRIT#2). */
private runReconcileCycle;
/** ONE channel's authority restarted (a new boot id): it lost all materialization + `cv` state
* and its `cv` sequence reset, so previously-released `cv`s no longer bound the new stream. The
* source has already re-subscribed every query (reconnect → resync); drop THIS gate's buffer
* and `cv` watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
* (`onFrame`/`computeRelease` gate on `appliedCv`). The OTHER gates are untouched — an
* authority restart is per-channel (§5.1). Pending optimistic mutations stay put — they
* re-apply on the next reconcile, and the channel's lmid system query's fresh snapshot restores
* its domain's confirmation watermark. */
private resetGate;
/** The §8.5 escape: ONE gate's buffer outgrew its cap (a pinned `cvMin` under churn on that
* channel). Drop everything it buffered and re-register every query on that source — the fresh
* snapshots arrive as ordinary frames and the next release re-hydrates via the footprint diff
* (the §5.3 path); still-pending optimism re-applies in that cycle. The other gates' buffers
* and subscriptions are untouched. */
private overflow;
private setResultType;
/** Recompute a query's server-channel state from hydration alone (§7): a pending mutation no
* longer affects it. Used when a remote sub attaches to or hydrates a local view. */
private recomputeResultType;
/** A remote sub's first snapshot landed: mark it (and every local view it feeds) hydrated, then
* lift those views out of `unknown` (loading). A ROOM sub's hydration additionally queues the
* 302 §4.1 swap-in — performed at the applyRelease TAIL ({@link processSwapIns}), once the
* reconcile has folded this snapshot into the room tables. Idempotent — a re-hydrate snapshot
* re-marks harmlessly; a source qid with no sub (the lmid system query) is a no-op. */
private markSubHydrated;
/** `channel` (G-iii registration-time routing): the gate the sub registers on — the qid's
* ownership is fixed HERE, at retain time (no lazy claim; `onFrame` only asserts it). Default
* `"daemon"`, so every channel-less caller is byte-identical to before. */
private retainRemote;
private releaseRemote;
private addServerDependencyTables;
}OptimisticBackendOptions
InterfaceDeclaration · Source: packages/optimistic/src/backend.ts:425 · Supporting declarations
export interface OptimisticBackendOptions {
/** Stable per-client identity for the upstream envelopes (§8.1). */
clientID: string;
/** Supplies a shared mutator's local `ctx.user` on every run, including reconciliation.
* Keep the principal stable for this client's lifetime; recreate the client on account changes.
* The server obtains its own authenticated principal. Defaults to the empty string. Plain
* client-only mutators ignore this option. */
user?: () => string;
/** Buffered-frame ceiling before the §8.5 escape (drop + re-hydrate). */
bufferCap?: number;
/** Virtual-clock seam for the fold debounce timers (FOLDED-MUTATIONS-DESIGN §9). Defaults to
* real `setTimeout`/`clearTimeout`/`Date.now`; the fold oracle injects a deterministic clock. */
clock?: FoldClock;
/** The DECLARED confirming stream per mutation (302 §5: declared, not derived — there is no
* routing proof). A policy returning a string pins that domain verbatim: the mutation stages
* onto that room's namespaced tables and ships on its channel. Returning `undefined` (or
* configuring no policy) means `"daemon"`. The client layer builds this from the app's declared
* realtime mutators + the currently attached rooms; a misdeclaration fails SOFT (302 §5.1) —
* the write lands on the other authority's tables and the view simply stops feeling instant
* until the echo relays it. */
domainPolicy?: (name: string, args: unknown) => string | undefined;
/** A FINAL (authz/validation) mutation rejection's reason surface — the room plane's twin of the
* HTTP mutate route's `onRejected` (H-v; the H-iv-b `mutationOutcome {kind:"rejected"}` frame).
* The prediction's snap-back is NOT this callback's job: the room burns the mid and its lmid
* release drops the entry exactly as a daemon-path rejection does (processed-as-no-op) — this
* is where the REASON reaches the app, same contract as the queue's callback. Also invoked when
* a DEOPT's fresh re-invocation (the already-retired arm) throws — that mutation is dead on the
* current base with no stream left to confirm it, the closest thing to a rejection there is. */
onRejected?: (envelope: MutationEnvelope, reason: string) => void;
}OptimisticInspect
InterfaceDeclaration · Source: packages/optimistic/src/backend.ts:497 · Supporting declarations
A read-only snapshot of the optimistic loop ({@link OptimisticBackend.__inspect}).
export interface OptimisticInspect {
/** The pending stack in array order (assigned mids ascending, un-flushed folds interleaved by
* creation — the re-invoke order is derived from this in `runReconcileCycle`). */
pending: PendingInspect[];
/** High-water confirmed mid: an entry with `mid <= confirmedLmid` has been confirmed/dropped. */
confirmedLmid: number;
/** The next mid to be dealt (so `nextMid - 1` is the highest issued). */
nextMid: number;
/** The applied coherent-release watermark (`cvMin`, §8.6). */
appliedCv: number;
/** Frames still buffered awaiting their release point (§8.5) — a backpressure gauge. */
bufferedFrames: number;
/** Every table some pending mutation currently touches (the coarse pending indicator set, §7.2). */
pendingTables: string[];
}OptimisticSource
InterfaceDeclaration · Source: packages/client/src/types.ts:303 · Supporting declarations
The server side of the OPTIMISTIC path (OPTIMISTIC-WRITES-DESIGN.md §8): the
{@link NormalizedSource} stream with cv-tagged data frames, plus the connection-level
{@link ProgressFrame} channel and the named-mutator upstream. The client buffers data
frames by cv and applies all cv ≤ cvMin as one coherent release (§8.5).
export interface OptimisticSource {
registerQuery(queryId: QueryId, remote: RemoteQuery): void;
unregisterQuery(queryId: QueryId): void;
/** Ship one named-mutator invocation upstream (§8.1). Confirmation rides the progress frames. */
pushMutation(envelope: MutationEnvelope): Promise<void>;
onNormalized(handler: (queryId: QueryId, event: NormalizedEvent) => void): void;
onProgress(handler: (frame: ProgressFrame) => void): void;
/** Optional: the backend hands its OWN typed per-table schemas so the source validates each
* server `hello` against them and rejects a schema skew (CRIT#4); see {@link NormalizedSource}. */
expectClientSchema?(tables: NormalizedTableSchema[]): void;
/** Optional: fired when the server restarts (a transport that can detect it, e.g. via a daemon
* boot id). The backend resets its `cv` watermark so the server's reset `cv` sequence is
* accepted rather than dropped as stale. In-process sources never restart and omit it. */
onRestart?(handler: () => void): void;
/** Optional (Slice H-v): the channel's {@link MutationOutcomeFrame} stream — the room deopt
* handshake's client half. **OUT-OF-BAND BY DESIGN**: the frame carries no `cv` and the source
* MUST dispatch it immediately on arrival, never behind the cv buffer — a deopt has to migrate
* its pending entry BEFORE the buffered lmid release that would otherwise retire it as a
* success (and the §7.3 hold-back trigger, keyed on the entry's confirming domain, would then
* park its staged writes the wrong way). Sources whose authority never deopts (the in-process
* native source, a plain daemon) omit it. */
onMutationOutcome?(handler: (frame: MutationOutcomeFrame) => void): void;
/** Optional (Slice H-v, the §7.5 rule-3 crash-window closer): fired when the transport
* RE-establishes its session (reconnect → re-`init`), BEFORE any post-reconnect frame is
* processed — the ordering is load-bearing: the re-subscribed lmid stream's fresh snapshot may
* cover a mid whose `mutationOutcome` frame died with the old socket, and once that release
* retires the entry as an apparent success there is nothing left to re-send. The backend
* re-sends this domain's unconfirmed pending envelopes with their ORIGINAL mids, in mid order;
* the source may DEFER their delivery until the session is re-authorized (a room's
* `pushMutation` requires the lease-token subscribe's subject). Idempotent under the domain's
* own ledger — a processed mid dedups silently (silence + lmid coverage ⇒ applied), a
* non-applied mid is re-answered from the authority's recorded-outcome map (resolving even an
* already-retired entry through the handshake's not-found arm). Distinct from
* {@link onRestart} (a NEW server incarnation): a same-incarnation socket drop re-syncs
* without restarting, and envelopes sent into the dead socket are exactly what this recovers.
* In-process sources never drop a session and omit it. */
onResync?(handler: () => void): void;
}PendingInspect
InterfaceDeclaration · Source: packages/optimistic/src/backend.ts:475 · Supporting declarations
One pending mutation, as the timeline sees it (DEBUG-TOOLS-BROWSER-DESIGN §4.1).
export interface PendingInspect {
/** Stable identity across snapshots: `m:<mid>` once a mid is assigned, else `f:<foldKey>` for an
* un-flushed folded entry (the mid is dealt at flush, FOLDED-MUTATIONS-DESIGN §4.1). */
key: string;
/** The wire mutation id, or `null` for an un-flushed fold. */
mid: number | null;
name: string;
args: unknown;
/** Tables this mutator touched at its last (re)invocation — the pending-axis basis (§7.2). */
tables: string[];
/** The pk-granular write-set captured at this entry's LAST invocation (RINDLE-REALTIME-QUERY-
* ENABLEMENT-DESIGN.md §3.2 #1), flattened from the {@link WriteSet} map for inspection — one
* entry per `(table, pk)` currently held. Pure capture; no routing consumer yet. */
writes: WriteRecord[];
/** The read-log captured at this entry's LAST *recorded* invocation (§3.2 #2). Empty for a
* folded entry — the read TRAP arms there, not recording (see {@link PendingMutation.reads}). */
reads: ReadLog;
/** Present iff this entry is a folded (debounced) write. */
fold?: FoldInspect;
}PersistChannel
InterfaceDeclaration · Source: packages/optimistic/src/local-persist.ts:102 · Supporting declarations
export interface PersistChannel {
post(msg: unknown): void;
onMessage(handler: (msg: unknown) => void): void;
close(): void;
}PersistDb
InterfaceDeclaration · Source: packages/optimistic/src/local-persist.ts:89 · Supporting declarations
The narrow storage surface the layer needs (§3.1): two fixed stores, rows + meta. Each
method is one atomic IDB transaction; a resolved promise means the txn COMPLETED — the P1
("persist-then-broadcast") anchor.
export interface PersistDb {
getMeta(): Promise<PersistMeta | undefined>;
putMeta(meta: PersistMeta): Promise<void>;
getAllRows(): Promise<StoredRow[]>;
/** Apply one commit batch atomically: `row === null` deletes, else puts. */
putBatch(batch: RowState[]): Promise<void>;
/** The P7 gate: clear `rows` and write `meta` in ONE transaction (never a half state). */
reset(meta: PersistMeta): Promise<void>;
/** Delete specific records (the leader's stale-table sweep, §3.1). */
deleteRows(keys: Array<{
table: string;
pkKey: string;
}>): Promise<void>;
close(): void;
}PersistEnv
InterfaceDeclaration · Source: packages/optimistic/src/local-persist.ts:108 · Supporting declarations
export interface PersistEnv {
/** Open (creating if needed) the database. Resolves `null` when storage is unavailable —
* the layer degrades to broadcast-only (§3.2). */
openDatabase(name: string): Promise<PersistDb | null>;
deleteDatabase(name: string): Promise<void>;
/** Open the tab-coherence channel; `null` when BroadcastChannel is unavailable (single-tab). */
createChannel(name: string): PersistChannel | null;
/** Queue for the exclusive leadership lock (§4.1): `onAcquired`'s promise HOLDS the lock until
* it resolves; `signal` aborts a still-queued request. When Web Locks are unavailable, grant
* immediately ONLY if the runtime is provably single-context (no channel) — see
* {@link leaderElectionUnavailable}. */
requestLock(name: string, signal: AbortSignal, onAcquired: () => Promise<void>): void;
/** The runtime has tabs (a channel) but NO exclusive lock (Firefox <96, Safari 15.1–15.3,
* Node): every context would self-promote into concurrent leaders over one database — P2
* violated, permanent divergence. When set, the layer runs INERT: local tables still work,
* session-scoped (the 201 baseline), with no persistence and no cross-tab replication. */
leaderElectionUnavailable?: boolean;
/** `navigator.storage.persist()` (§3.2), best-effort. */
requestPersistentStorage?(): void;
}PersistLocalOptions
InterfaceDeclaration · Source: packages/optimistic/src/local-persist.ts:31 · Supporting declarations
export interface PersistLocalOptions {
/** The storage identity (§3.2): one IDB database per (origin, user). NOT the mutator principal —
* this is fixed for the client's lifetime; an anonymous mode passes its own sentinel (`"anon"`). */
user: string;
/** Call `navigator.storage.persist()` to resist eviction (§3.2). Default false — it can prompt. */
requestPersistentStorage?: boolean;
/** Reports storage-operation failures without rejecting local writes. Default `console.error`.
* Missing browser APIs can warn and disable a feature instead; this hook does not receive
* every channel failure. Persistence and cross-tab delivery remain best effort. */
onError?: (e: Error) => void;
/** Test seam: a fake IDB/locks/channel environment. Defaults to the browser globals. */
env?: PersistEnv;
}PersistMeta
InterfaceDeclaration · Source: packages/optimistic/src/local-persist.ts:129 · Supporting declarations
export interface PersistMeta {
schemaHash: string;
epoch: number;
}ProgressFrame
InterfaceDeclaration · Source: packages/client/src/types.ts:275 · Supporting declarations
The connection-level progress frame (§8.6): advances the coherent-apply release point
(cvMin). A pure release signal — mutation confirmation does NOT ride it: lmid is a
row in {@link CLIENT_MUTATIONS_TABLE}, delivered through the client's own per-client
system query ({@link LMID_QUERY_NAME}) like any data, so it is released by the same
cvMin as the commit's effects (transactionally coherent by construction).
export interface ProgressFrame {
cvMin: number;
}QueryArg
TypeAliasDeclaration · Source: packages/client/src/mutation-ops.ts:84 · Supporting declarations
What {@link IsoTx.query} accepts: a query handle whose .ast() lowers to the wire {@link Ast} —
exactly what the typed query builder produces (newQueryBuilder(schema).<table>…, the tier-agnostic
server-scope builder both tiers can construct because it performs no I/O). Structural so the seam
need not carry the builder's heavy generics.
export type QueryArg = {
ast(): Ast;
};QueryResultRow
TypeAliasDeclaration · Source: packages/client/src/mutation-ops.ts:78 · Supporting declarations
A row returned by a {@link QueryEffect}: column name → cell, plus each materialized relationship
name → its nested row(s) — an array (a plural relationship) or a single row / null (a .one()
relationship). Recursive. Presented identically on both tiers (a view.data row of the same
query).
export type QueryResultRow = {
[key: string]: WireValue | QueryResultRow | QueryResultRow[];
};ReadLog
InterfaceDeclaration · Source: packages/optimistic/src/backend.ts:238 · Supporting declarations
The read-log captured over ONE mutator invocation when recording is armed (RINDLE-REALTIME-
QUERY-ENABLEMENT-DESIGN.md §3.2 #2): every point read (reads — the public tx.get/tx.row
and, since H-ii, the keyed writers' internal pre-existence probes) plus every resolved query AST
(queries, from tx.query). A SIBLING of the folded read TRAP (FoldReadError below) — the
trap arms on the folded path and throws before any read completes (recording never runs there);
recording arms on the ordinary (non-folded) prediction run and never throws. Pure capture for
devtools/inspection (the §3 routing derivation it once fed was removed by
302-ROOM-STORE-SEPARATION-DESIGN.md §5 — mutators DECLARE their domain now).
export interface ReadLog {
reads: ReadRecord[];
queries: Ast[];
}ReadOutcome
TypeAliasDeclaration · Source: packages/optimistic/src/backend.ts:219 · Supporting declarations
Whether a recorded point read (tx.get/tx.row) found a row.
export type ReadOutcome = "present" | "absent";ReadRecord
InterfaceDeclaration · Source: packages/optimistic/src/backend.ts:224 · Supporting declarations
One recorded point read, pk-granular (§3.2 #2). Recording-mode only — see {@link ReadLog}.
Since H-ii this covers BOTH the public reads (tx.get/tx.row) and the keyed writers'
internal pre-existence probes (§3.2 #3 — see the rawGet note in {@link trackingTx}).
export interface ReadRecord {
table: string;
pk: WireValue[];
outcome: ReadOutcome;
}RealtimeAnomaly
InterfaceDeclaration · Source: packages/optimistic/src/client.ts:170 · Supporting declarations
A loud realtime lease anomaly (always ALSO console.error'd).
export interface RealtimeAnomaly {
kind: RealtimeAnomalyKind;
name: string;
args: unknown;
message: string;
}RealtimeAnomalyKind
TypeAliasDeclaration · Source: packages/optimistic/src/client.ts:150 · Supporting declarations
export type RealtimeAnomalyKind =
/** A re-lease (renewal / reconnect re-resolution) came back WITHOUT a realtime block AND
* without a §4.2 fence — the query is no longer room-served but the server gave nothing to
* downgrade behind (a legacy/pre-I-v server, or `lifecycle.drainRoom` unconfigured). Surfaced
* loudly; a reply WITH a `realtimeFence` takes the graceful I-v dance instead. */
"downgrade"
/** The I-v ghost is STUCK (§7.5): its watermark fence cleared but sent room-domain mids never
* resolved (sent-but-undelivered when the socket died — undecidable in general). The ghost
* holds — no timeout-retire is invented — and the mids are named once, actionably. */
| "downgrade-stuck"
/** A lease named a DIFFERENT `sourceKey` than the query's live room sub — surfaced loudly, no
* re-attach. Deliberately NOT composed from demote+upgrade (deferred to §7.6's rare-case
* follow-up): a sourceKey-change reply carries a realtime block for the NEW room but NO
* fence for the OLD one, and without `finalFlushSeq` the old slice cannot be ghosted soundly. */
| "source-key-changed"
/** The lease POST failed or the room attach threw. The initial-materialize case fails OPEN to
* the daemon path (indistinguishable from an unlabeled query's recovery). */
| "lease-failed";RealtimeClientOptions
InterfaceDeclaration · Source: packages/optimistic/src/client.ts:179 · Supporting declarations
Rindle Realtime client knobs (Slice G-v). All optional — an app with no labeled queries never touches any of this.
export interface RealtimeClientOptions {
/** The DECLARED room mutators (302 §5: declared, not derived). A mutator named here routes to
* the attached room — it stages onto the room's own tables and ships on the room socket —
* whenever exactly ONE room is attached; solo (no room) it takes the ordinary daemon path,
* and with several rooms attached it routes daemon too (explicit multi-room binding is a
* later slice). Every mutator NOT named here is a daemon mutator. A misdeclaration fails
* SOFT (302 §5.1): the write lands on the other authority's tables, so the view just stops
* feeling instant until the echo relays it — never a divergence. An explicit top-level
* `domainPolicy` overrides this entirely. */
mutators?: readonly string[];
/** Build the ROOM ws transport for a lease's `realtime.wsEndpoint`. Default
* `(endpoint) => new WsTransport(endpoint)`. Injectable for tests / custom ws impls. */
transport?: (endpoint: string) => Transport;
/** Loud anomaly surface — see {@link RealtimeAnomaly}. Every anomaly is also `console.error`'d. */
onAnomaly?: (anomaly: RealtimeAnomaly) => void;
/** How long before a room lease's `exp` the proactive token renewal fires (default 30s). The
* renewal is a FRESH lease through the app query route (renewal-as-reauthorization), and the
* live room sub proactively re-subscribes with the fresh token so the shell's TTL backstop
* never fires on a healthy session. */
renewMarginMs?: number;
}RealtimeInspect
InterfaceDeclaration · Source: packages/optimistic/src/client.ts:203 · Supporting declarations
Read-only realtime bookkeeping snapshot ({@link RindleClient.__realtimeInspect}) — test/devtools
introspection, mirroring the backend's __inspect convention.
export interface RealtimeInspect {
rooms: Record<string, {
wsEndpoint: string;
/** The room's OWNED tables (302 §2): wire table → its namespaced engine table — read back
* from the BACKEND's registry (`backend.roomTablesFor`, the one source of truth; the
* client keeps no shadow copy). */
promoted: Record<string, string>;
/** Live room-retained queries on this room, by remote key. */
queries: Record<string, {
name: string;
sourceQid: QueryId;
exp: number;
refCount: number;
}>;
}>;
}RealtimeLeaseBlock
InterfaceDeclaration · Source: packages/optimistic/src/client.ts:82 · Supporting declarations
The room-serve block on a query lease (mirror of the api-server's QueryLeaseRealtime).
export interface RealtimeLeaseBlock {
/** The store's gate/domain key for this room source (`connectSource`) — `"room:<profile>/<key>"`. */
sourceKey: string;
/** Where the ROOM ws opens — the lease's DEDICATED field. Never confuse it with the TOP-LEVEL
* `wsEndpoint` (the read-router's whole-DAEMON-session migration signal). */
wsEndpoint: string;
/** The room shell's self-authorizing signed lease (seals the APPROVED query AST) — presented as
* the room subscribe's `leaseToken`. */
roomToken: string;
/** Token expiry (ms epoch) — the renewal clock (renewal = a fresh lease through the app route). */
exp: number;
doc: string;
tables: RealtimeLeaseTableSpec[];
}RealtimeLeaseTableSpec
InterfaceDeclaration · Source: packages/optimistic/src/client.ts:73 · Supporting declarations
One footprint table's spec on the lease wire (mirror of the api-server's RoomTableSpec).
footprintWhere (H-iii lease-wire flip) is the EXACT footprint-membership predicate from the
ONE unified compiler (compileRoomScopeSpecs — the same output the boot wire ships the room
gate): present only for an exact footprint ROOT (lossless row-local extraction; the vacuous-true
empty AND for an unconstrained one), ABSENT for child/correlated tables. It feeds the §3
router's pk-membership read proof (OptimisticBackend's routing table) — never authorization.
export interface RealtimeLeaseTableSpec {
table: string;
footprintWhere?: Condition;
writable: {
kind: "none";
} | {
kind: "predicate";
where?: Condition;
joinKeyCols: string[];
};
}resetStableClientID
FunctionDeclaration · Source: packages/optimistic/src/client-id.ts:52 · Supporting declarations
Clear the persisted client identity so the next page load starts a fresh mid/lmid stream. Intended for dev recovery after out-of-band server state loss, not for normal operation.
export declare function resetStableClientID(): void;ResultType
TypeAliasDeclaration · Source: packages/client/src/types.ts:155 · Supporting declarations
A query's SERVER-CHANNEL state, surfaced on its {@link ArrayView } (FOLDED-MUTATIONS-DESIGN §7 — formerly conflated with pending-ness, OPTIMISTIC-WRITES-DESIGN.md §6):
unknown— not hydrated: the server has not produced a first result for this query yet;complete— the server has answered. STAYScompletewhile a local mutation is pending (the prediction is the client's best current answer); reversion on rejection is an event (onRejected), not a downgrade of completeness;error— RESERVED for a future server-side, query-level error signal (seedesigns/101-QUERY-ERRORS-DESIGN.md); no longer produced by a pending mutation. "Is a prediction pending here?" is now a separate reactive axis (the backend'spending(qid)/onPending), not folded into this type. A backend with no server lifecycle (the in-process engine) leaves every viewcomplete.
export type ResultType = "unknown" | "complete" | "error";RindleClient
InterfaceDeclaration · Source: packages/optimistic/src/client.ts:315 · Supporting declarations
export interface RindleClient<S extends ColsMap, R extends ClientRegistry> {
store: Store<S>;
backend: OptimisticBackend<S>;
/** Retain a named query for navigation/prefetch. By default waits for server authority; pass
* `{ until: "present" }` to continue as soon as the local view has a result. */
ensure<Q extends AnyQuery>(query: Q, options?: EnsureQueryOptions): Promise<void>;
/** Call `mutate.foo(args)` for a normal optimistic write, or `mutate.foo.folded(opts, args)` for a
* debounced, last-value-wins folded write (FOLDED-MUTATIONS-DESIGN §3). */
mutate: {
[K in keyof R]: MutateFn<Parameters<R[K]>[1]>;
};
/** Assign IDs and enqueue every outstanding fold immediately. Also called on
* `beforeunload`/`pagehide` as a best-effort flush; neither path confirms delivery. */
flushFolds(): void;
clientID: string;
/** Release retained prefetch queries, transports, and lifecycle listeners; close local persistence.
* Flushes folded arguments into the queue first; does not await server confirmation. */
close(): void;
/** Read-only realtime bookkeeping snapshot (rooms, promoted tables + their client-held
* `joinKeyCols`, live room queries) — the `__inspect`-convention test/devtools hook. */
__realtimeInspect(): RealtimeInspect;
}RindleClientOptions
InterfaceDeclaration · Source: packages/optimistic/src/client.ts:237 · Supporting declarations
export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry> {
schema: Schema<S>;
/** The PREDICTED mutators (the API server holds the authoritative twins by name). */
mutators: R;
/** The acting principal for a shared (generator) mutator's `ctx.user` — the local identity the
* optimistic prediction writes under (the server injects its OWN authenticated user for the
* authoritative run). Re-read on each run, including replay. Keep this identity stable for
* the client's lifetime and recreate the client on account changes. */
user?: () => string;
/** The app API server: named queries resolve to leases here, mutations push here. */
api: {
url: string;
routes?: {
query?: string;
mutate?: string;
};
/** Extra headers per request (auth). A function is re-evaluated per call. */
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
fetch?: typeof fetch;
};
/** Optional subscription transport override. Omit it in the normal unified setup: the first
* query lease carries `wsEndpoint` + a fresh affinity ticket and opens the transport lazily.
* - `{ wsUrl }` — a static endpoint (single daemon), opened eagerly; in a routed deploy this is
* the SSR-injected bootstrap endpoint (READ-ROUTER-DESIGN.md §2.4). A routed lease naming a
* different follower migrates the connection there.
* - `{ wsUrl }` omitted (or this whole option omitted) — pure-lazy: the first lease's
* `wsEndpoint` opens the connection (a
* routed SPA with no SSR bootstrap).
* - `{ transport }` — a pre-built transport (tests / in-process); fixed, no migration.
*
* With a fixed `wsUrl`, set `affinity: true` to opt into FOLLOWER-AFFINITY mode (design §2).
* With no daemon option, a lease that returns an affinity ticket enables the same mode
* automatically before opening its socket. The ticket is persisted per-tab and forwarded on
* later leases so both legs pin the same nearby follower. Ignored for `{ transport }`. */
daemon?: {
wsUrl?: string;
affinity?: boolean;
} | {
transport: Transport;
};
/** Stable client identity. Default: a per-origin base (localStorage) plus per-tab and per-instance
* suffixes, so each tab — and each client instance within a tab — gets its own mid sequence yet a
* reload keeps it; falls back to a fresh random id when web storage is unavailable. Pass a value
* to override. */
clientID?: string;
/** A policy rejection's reason (the prediction's snap-back rides the lmid release). Fires for
* BOTH planes since H-v: the HTTP mutate route's per-envelope rejections AND a room's
* `mutationOutcome {kind:"rejected"}` frames — one surface, whichever authority said no. */
onRejected?: (envelope: MutationEnvelope, reason: string) => void;
/** A failed mutate FLUSH — the transport/authority leg, not a policy verdict: the batch is
* retried with backoff and nothing has been confirmed yet, so the pending mutations stay
* predicted and the queue is head-of-line blocked until it succeeds. Fires once per attempt.
* This is the twin of {@link onRejected}: `onRejected` is "the authority said no" (final,
* lmid already advanced), this is "the authority never answered" (retrying).
*
* LOUD by contract — every attempt reaching this hook is ALSO `console.error`'d (backed off
* to attempts 1, 2, 4, 8, … so a long outage doesn't flood the console), because an
* indefinitely retried flush is indistinguishable from a hung app if it stays silent. */
onMutationError?: (err: unknown, attempt: number) => void;
/** Persist `local: true` tables across reloads and keep them live-coherent across tabs
* (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md`). `user` is the storage identity (§3.2) — one IDB
* database per (origin, user); pass a sentinel like `"anon"` for a signed-out mode. When set,
* `createRindleClient` awaits the initial restore attempt before returning. Storage failures
* can leave tables empty while local writes remain usable. Close the old client before calling
* `deleteLocalPersistence(user)` at logout; deletion is never implicit.
* Per-table opt-out: declare `table(name, { local: "session" })` for local state that must stay
* ephemeral and per-tab (e.g. selection) even with persistence on (§5.4). */
persistLocal?: PersistLocalOptions;
queue?: {
maxBatch?: number;
retryDelayMs?: (attempt: number) => number;
};
/** Explicitly selects a mutation's confirming stream. Its domain supplies the mutation ID,
* transport, and confirmation watermark. Returning `undefined` selects `"daemon"`.
* If this policy is omitted, `realtime.mutators` supplies the declared room routing policy;
* without either declaration, mutations route to the daemon. Routes are not inferred from
* reads or writes. A room deopt can re-enqueue the mutation on the daemon stream. */
domainPolicy?: (name: string, args: unknown) => string | undefined;
/** Rindle Realtime client knobs (G-v resolve-then-register) — see {@link RealtimeClientOptions}. */
realtime?: RealtimeClientOptions;
/** Development-only recovery knobs. Keep off in production: a mutation gap means state loss
* or two writers sharing a clientID, and should be investigated. */
dev?: {
/** On a mutation-gap response, clear the persisted clientID and hard reload the page. This
* recovers from dev DB wipes while making the reset visible to the developer. */
resetOnMutationGap?: boolean;
};
}ROOM_CLIENT_MUTATIONS_TABLE
VariableDeclaration · Source: packages/optimistic/src/system-streams.ts:33 · Supporting declarations
§7.1 domain-scoped room ledger: (doc, client_id, last_mutation_id), PK (doc, client_id) —
the FIRST daemon-carried room-lmid path (the named invariant's enforcement point).
export declare const ROOM_CLIENT_MUTATIONS_TABLE = "_rindle_room_client_mutations";ROOM_MUTATION_OUTCOMES_TABLE
VariableDeclaration · Source: packages/optimistic/src/system-streams.ts:37 · Supporting declarations
§3.3 durable outcome rows: (doc, client_id, mid, kind, reason, name, args), PK
(doc, client_id, mid) — the H-iv-b mutationOutcome frame's durable twin (Slice I-ii
co-commits one per NON-applied mid; an absent row under a covering lmid means applied).
export declare const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";ROOM_WATERMARK_TABLE
VariableDeclaration · Source: packages/optimistic/src/system-streams.ts:30 · Supporting declarations
§4.2 downgrade fence: (doc, flush_seq), PK doc — monotone, co-committed per room flush.
export declare const ROOM_WATERMARK_TABLE = "_rindle_room_watermark";roomDomainKey
FunctionDeclaration · Source: packages/optimistic/src/system-streams.ts:102 · Supporting declarations
The domain/gate key a room doc's daemon-carried confirms fold into — the SAME key the lease
wire mints for the room source (api-server: sourceKey = "room:" + doc), so a daemon-carried
lmid and a live room socket's lmid stream land on ONE watermark entry.
export declare function roomDomainKey(doc: string): string;RowState
InterfaceDeclaration · Source: packages/optimistic/src/local-persist.ts:142 · Supporting declarations
The replication unit everywhere (§4.3): idempotent full-row state. row: null = tombstone
(live protocol only — removes DELETE the IDB record; there are no persisted tombstones).
export interface RowState {
table: string;
pkKey: string;
row: WireValue[] | null;
}SCOPE_SESSIONS_TABLE
VariableDeclaration · Source: packages/optimistic/src/system-streams.ts:28 · Supporting declarations
§4.1 occupancy: (scope, client_id, expires_at), PK (scope, client_id). The row delta IS
the upgrade doorbell (Slice I-iv reacts; I-iii only folds the map).
export declare const SCOPE_SESSIONS_TABLE = "_rindle_scope_sessions";ScopeSessionsEvent
InterfaceDeclaration · Source: packages/optimistic/src/backend.ts:388 · Supporting declarations
One I-iv doorbell event ({@link OptimisticBackend.onScopeSessions}, §4.1): a release folded
scope-session rows for scope, and others is the count of OTHER clients' unexpired sessions
there — {@link OptimisticBackend.otherScopeSessions} evaluated at fold time (the same one rule,
on the injectable {@link FoldClock}, so a virtual-clock harness gets deterministic verdicts).
The consumer (client.ts) triggers its one debounced re-lease on the 0→≥1 transition; expired
and own-clientID rows never count, so a solo client's own row can never ring its own bell.
export interface ScopeSessionsEvent {
scope: string;
others: number;
}stableClientID
FunctionDeclaration · Source: packages/optimistic/src/client-id.ts:109 · Supporting declarations
The connection's stable clientID. A per-origin base (localStorage) keeps one logical identity across reloads; a per-tab suffix (sessionStorage) gives each tab its OWN mid/lmid stream; a per-load instance suffix keeps two clients constructed in ONE tab from sharing that stream. Falls back to a fresh random id when web storage is unavailable.
Note: an explicit "duplicate tab" copies sessionStorage, so the clone briefly shares its source's suffix until reloaded — the one residual case of the shared-clientID collision.
export declare function stableClientID(): string;StoredRow
InterfaceDeclaration · Source: packages/optimistic/src/local-persist.ts:134 · Supporting declarations
export interface StoredRow {
table: string;
pkKey: string;
row: WireValue[];
}SystemStreamSpec
InterfaceDeclaration · Source: packages/optimistic/src/system-streams.ts:85 · Supporting declarations
What a system retain declares about itself (OptimisticBackend.retainSystemQuery): which
system table its frames carry, and the scope/doc its minted predicate was scoped to. The fold
filters rows against this spec (AND against the client's own clientID) as DEFENSE IN DEPTH —
the server predicate is the tight path, but a server that couldn't scope (no clientId on the
lease request) legitimately delivers other clients' rows, and a confused server must never
invent verdicts for us.
export interface SystemStreamSpec {
table: SystemStreamTable;
/** The §4.1 occupancy scope the doorbell was minted for (`_rindle_scope_sessions` only). */
scope?: string;
/** The room doc the fence entry was minted for (the three room tables). */
doc?: string;
}SystemStreamTable
TypeAliasDeclaration · Source: packages/optimistic/src/system-streams.ts:73 · Supporting declarations
The tables a system retain may serve — the fold category discriminant.
export type SystemStreamTable = typeof SCOPE_SESSIONS_TABLE | typeof ROOM_WATERMARK_TABLE | typeof ROOM_CLIENT_MUTATIONS_TABLE | typeof ROOM_MUTATION_OUTCOMES_TABLE;WriteRecord
InterfaceDeclaration · Source: packages/optimistic/src/backend.ts:198 · Supporting declarations
One captured write, pk-granular (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §3.2 #1: "the
writers already receive (table, row)... this is pure capture, no semantic change"). row is
the post-write image (positional wire cells, schema column order); undefined for a remove —
no row survives it, and row === undefined stays the remove marker.
oldRow is the PRE-IMAGE, captured for a remove AND an edit (H-ii): the full-width row
read via tx.get immediately BEFORE the write staged — read-your-writes, so a write to the
same pk earlier in the SAME invocation shows through — falling back to the caller's asserted
old row when the pk is not txn-visible (a raw remove/edit of an absent row; incl. the
pk-MOVING raw edit, whose record is keyed by the NEW pk yet whose pre-image is the caller's
OLD row). A captured remove/edit thus always carries a full-width pre-image — with ONE
exception: a record that collapses to a (re-)insert has none, see the matrix.
Coalescing within one invocation is last-write-wins per pk on row (the final image matches
the engine head's own semantics for that pk) with oldRow pinned to the TXN-ENTRY BASE — the
H-ii matrix:
- edit-after-add / edit-after-remove: the record collapses to a (re-)insert — post-image
only, NO
oldRow(the pk did not pre-exist this invocation's base; presence hold-back usesrow). - edit-after-edit: keeps the FIRST pre-image (the txn-entry base — the chain nets to ONE edit from the base to the final image).
- remove-after-edit / remove-after-remove: keeps the ORIGINAL pre-image (the first write's captured base), NOT the edited transient — the net effect is a remove of the row the external world last knew.
- remove-after-add: keeps the txn-visible pre-image (the transient added row — the pk had no base, and this is the only truthful full-width row there is; G-iii pinned it).
- add-after-remove (a re-insert): drops
oldRow(presence hold-back usesrow). Across rebase re-invocations the write-set is union-never-shrink ({@link mergeWriteSet}): a re-run that no-ops keeps the prior record — and its pre-image — intact.
export interface WriteRecord {
table: string;
pk: WireValue[];
row: WireValue[] | undefined;
oldRow?: WireValue[];
}WriteSet
TypeAliasDeclaration · Source: packages/optimistic/src/backend.ts:216 · Supporting declarations
The pk-granular write-set captured over ONE mutator invocation: table → pk-key (a stable-JSON encoding of the pk cells, {@link stableJson}) → that pk's write, LAST-WRITE-WINS within the invocation — an add-then-edit or edit-then-edit of the SAME pk collapses to its final image, matching the engine head's own semantics for that pk. Chosen (over a flat array) because the later routing proof needs "is pk P in the writable scope" / "did we already see this pk in this invocation" as cheap lookups, and rebase re-invocation needs to MERGE a fresh write-set into an accumulated one ({@link mergeWriteSet}) — both are Map operations, not scans.
touched (the pre-existing table-granular Set<string> the pending axis reads, §7.2) is
exactly new Set(writeSet.keys()) — derived from this, never separately populated, so the two
can never drift.
export type WriteSet = Map<string, Map<string, WriteRecord>>;YieldEffect
TypeAliasDeclaration · Source: packages/client/src/mutation-ops.ts:95 · Supporting declarations
Everything a generator mutator may yield: a write {@link MutationOp}, a point {@link ReadEffect},
a full-query {@link QueryEffect}, or a {@link BatchEffect} fan-out.
export type YieldEffect = MutationOp | ReadEffect | BatchEffect | QueryEffect;