Rindle

API index and search · Build metadata

@rindle/normalized

0.0.0 · Public export map; development manifest version (0.0.0).

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.

Entry point source

aggTableName

FunctionDeclaration · Source: packages/normalized/src/agg-table.ts:71 · Supporting declarations

The synthetic base-table name for a relationship count aggregate — the byte-exact twin of Rust agg_table_name (normalize.rs §3.1). A content hash of the aggregate definition (child table, kind, group key = correlation child fields, child where); the parent correlation field is excluded. Same definition ⇒ same name (cross-query sharing); a different filter ⇒ a different name (no (table, pk) collision).

export declare function aggTableName(csq: CorrelatedSubquery): string;

aggTableSchemas

FunctionDeclaration · Source: packages/normalized/src/agg-table.ts:218 · Supporting declarations

Every synthetic aggregate table ast surfaces (recursively, a nested aggregate under a materialized relationship included), as flat table schemas — the twin of Rust agg_table_schemas. Columns [childField…, "count"]; PK = the leading group columns.

export declare function aggTableSchemas(ast: Ast, isLocal?: (table: string) => boolean): NormalizedTableSchema[];

ColCounts

TypeAliasDeclaration · Source: packages/normalized/src/sync.ts:47 · Supporting declarations

Per-table FULL column count — the width of a base/union row. Needed to scatter a projected (narrower) wire row into the shared positional layout (PROJECTION-SUPPORT-DESIGN.md §5.3).

export type ColCounts = Record<string, number>;

createNormalizedStore

FunctionDeclaration · Source: packages/normalized/src/backend.ts:439 · Supporting declarations

A local-first {@link Store} whose base tables are fed by a server's normalized stream. The returned Store is the ordinary @rindle/client Store — store.query…materialize() and store.write(…) work as always; only the backend composition differs.

export declare function createNormalizedStore<S extends ColsMap>(schema: Schema<S>, source: NormalizedSource): Store<S>;

NormalizedBackend

ClassDeclaration · Source: packages/normalized/src/backend.ts:72 · Supporting declarations

export declare class NormalizedBackend<S extends ColsMap> implements Backend {
    private readonly local;
    private readonly sync;
    private readonly source;
    private handler;
    /** 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). A normalized batch becomes net base mutations
     *  applied to the local engine in one commit, which fans to every view reading those rows — this
     *  carries that commit's boundary up so all those views notify together. */
    private boundaryHandler;
    private readonly devObservers;
    private readonly remoteSubs;
    private readonly localToRemote;
    private readonly remoteRetainToLocal;
    private readonly sourceToRemote;
    private readonly resultTypes;
    private readonly hydrated;
    /** Local qids that are JUST hydrating on the in-flight `onNormalized` snapshot — set only for the
     *  duration of its `local.mutate`, so the local-event forwarder stamps their fold `catchUp`: the
     *  whole first result set arrives as a `batch` (we hydrate the view by mutating the embedded engine,
     *  which speaks in batches — there is no second `snapshot`), and `catchUp` is the flag that tells the
     *  Store this batch IS hydration (retire the SSR seed, phase it as a snapshot for narration). The
     *  optimistic backend does the identical thing across its reconcile cycle. `null` outside a hydrate. */
    private catchUpQids;
    private resultTypeHandler;
    /** The client's OWN typed per-table schemas (for CRIT#4 validation), fixed at construction. */
    private readonly clientTables;
    /** Synthetic aggregate tables (`__agg_*`) registered so far, by name (§3.3). Per aggregate
     *  DEFINITION (not per query), so two queries over the same count share one table. */
    private readonly synthetic;
    /** table → (column name → base ColId), for mapping a projected hello to base positions. */
    private readonly colIndex;
    /** table → full column count, to detect whether a hello's table is projected. */
    private readonly colCounts;
    /** Synthetic table name → how many registered queries reference it. Materialized on `0→1`,
     *  reclaimed (engine source + refcount layer) on `1→0` — so aggregate state is not permanent (§4). */
    private readonly syntheticRefs;
    /** queryId → the synthetic tables it referenced at registration, to decrement on teardown. */
    private readonly queryAggTables;
    constructor(schema: Schema<S>, source: NormalizedSource);
    registerQuery(qid: QueryId, ast: Ast, remote?: RemoteQuery): void;
    /** Register every synthetic aggregate table `ast` needs that we haven't seen yet: on the
     *  local engine (so it can join to it), 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, the client deriving the schema
     *  identically). Idempotent across queries that share an aggregate definition. */
    private ensureSyntheticTables;
    /** Decrement each synthetic table query `qid` referenced; remove the ones that reach 0 (no
     *  reader left) from the engine + refcount layer — aggregate state reclaimed, not permanent
     *  (§4). Runs AFTER `local.unregisterQuery(qid)` so the source has no live connection when
     *  `unregisterTable` frees it. */
    private releaseSyntheticTables;
    unregisterQuery(qid: QueryId): void;
    retainRemoteQuery(qid: QueryId, remote: RemoteQuery, localQueryId?: QueryId, ast?: Ast): void;
    releaseRemoteQuery(qid: QueryId): void;
    /** Writes are authoritative-only here: send to the server, the stream reconciles locally.
     *  (Optimistic local apply + rebase is Slice 6.) */
    mutate(mutations: Mutation[]): Promise<void>;
    onEvent(handler: (qid: QueryId, ev: ChangeEvent) => void): void;
    onCommitBoundary(handler: (phase: "begin" | "end") => void): void;
    onResultType(handler: (qid: QueryId, rt: ResultType) => void): void;
    __attachDevtoolsServerDeltas(observer: BackendDevObserver): () => void;
    private onNormalized;
    private emitServerDelta;
    private localQidsForSource;
    private retainRemote;
    private releaseRemote;
    private markSubHydrated;
    private hasRemoteDependency;
    private setResultType;
    private recomputeResultType;
}

NormalizedEvent

TypeAliasDeclaration · Source: packages/client/src/types.ts:123 · Supporting declarations

A per-query NORMALIZED stream event — the path-free twin of {@link ChangeEvent}. The hello carries the flat per-table schemas (no nested view schema, §3); snapshot/batch carry table-tagged {@link NormalizedOp}s. The NormalizedSync layer folds these into the local engine's base tables.

cv (the global commit version the frame's data reflects — OPTIMISTIC-WRITES-DESIGN.md §8.3/§8.6) is present on sources that speak the optimistic protocol; the plain normalized path may omit it.

export type NormalizedEvent = {
    type: "hello";
    tables: NormalizedTableSchema[];
    comparatorVersion: number;
    normalizedFp: string;
} | {
    type: "snapshot";
    ops: NormalizedOp[];
    cv?: number;
} | {
    type: "batch";
    ops: NormalizedOp[];
    cv?: number;
};

NormalizedOp

TypeAliasDeclaration · Source: packages/client/src/types.ts:100 · Supporting declarations

A table-tagged, path-free row delta — the normalized wire payload (the path-free twin of {@link FlatChange}; NORMALIZED-CHANGES-DESIGN.md §3). Rows are positional (bare cells; a json column is its raw JSON string). op is the discriminant. Lives here (not in @rindle/normalized) so both the protocol (@rindle/remote) and the sync layer share one type.

export type NormalizedOp = {
    table: string;
    op: "add";
    row: WireValue[];
} | {
    table: string;
    op: "remove";
    row: WireValue[];
} | {
    table: string;
    op: "edit";
    old: WireValue[];
    new: WireValue[];
};

NormalizedSource

InterfaceDeclaration · Source: packages/client/src/types.ts:221 · Supporting declarations

The server side of the normalized local-first path (NORMALIZED-CHANGES-DESIGN.md §5/§7): registers queries and pushes each one's normalized footprint stream ({@link NormalizedEvent}s). Both the in-process native (@rindle/replica) source and the ws (@rindle/remote RemoteNormalizedSource) implement it; @rindle/normalized's NormalizedBackend consumes one, never knowing which — a sibling seam of {@link Backend} for the normalized composition.

export interface NormalizedSource {
    registerQuery(queryId: QueryId, remote: RemoteQuery): void;
    unregisterQuery(queryId: QueryId): void;
    /** Send base-table writes to the server; the authoritative stream flows back. */
    mutate(mutations: Mutation[]): Promise<void>;
    onNormalized(handler: (queryId: QueryId, event: NormalizedEvent) => void): void;
    /** Optional: the backend hands its OWN typed per-table schemas so the source can validate
     *  each server `hello` against them (column order / PK by name) and reject a schema skew
     *  rather than silently transposing positional cells (CRIT#4 / §3 "drift ⇒ re-subscribe").
     *  Sources that can't skew (the in-process native source) may omit it. */
    expectClientSchema?(tables: NormalizedTableSchema[]): void;
}

NormalizedSync

ClassDeclaration · Source: packages/normalized/src/sync.ts:78 · Supporting declarations

export declare class NormalizedSync {
    private readonly pkCols;
    /** Per-table full width, for scattering projected rows. Empty ⇒ projection unsupported
     *  (every query must be `'*'`), which is the pre-projection behavior. */
    private readonly colCounts;
    /** `(table, pk)` key → the one shared base entry. */
    private readonly base;
    /** queryId → the set of `(table, pk)` keys that query currently footprints. */
    private readonly qfoot;
    /** queryId → (table → the base ColIds it contributes for that table, in the order its wire
     *  rows are positional against — its `required_cols` for the table, §4.1). A query spans
     *  multiple base tables (root + related/EXISTS children), each projected independently; a
     *  table absent from the inner map ⇒ that query syncs it `'*'` (all columns, full width). */
    private readonly qcols;
    constructor(pkCols: PkCols, colCounts?: ColCounts);
    /**
     * Register a query's projection for one base table (PROJECTION-SUPPORT-DESIGN.md §4.1): the
     * base ColIds it contributes for `table`, in the order that table's wire rows are positional
     * against. Omit a table (or never call) for a `'*'` table — it then contributes every column
     * and its rows are full width. Must be called before the query's first batch.
     *
     * A `cols[i] < 0` entry is a DROP sentinel: wire column `i` is an EXPANDED server column the
     * client doesn't have (the server side of an `expand-then-contract` migration), so its cell is
     * discarded rather than scattered. The query still contributes only its real (`>= 0`) ColIds.
     */
    registerProjection(queryId: QueryId, table: string, cols: number[]): void;
    /**
     * Drop a query's projection for one table, reverting it to `'*'` (full presence, rows scattered
     * verbatim). The inverse of {@link registerProjection}; a no-op if none was registered. Needed
     * because `qcols` persists across re-hydrate epochs: if a live subscription's hello narrows from
     * an EXPANDED layout (a `-1`-bearing map) back to an exact full-width one, the stale map must be
     * cleared or it would mis-scatter the now-exact rows. A no-op for an unprojected (`'*'`) table.
     */
    unregisterProjection(queryId: QueryId, table: string): void;
    /**
     * Register a table's primary-key columns after construction — for a **synthetic aggregate
     * table** (`AGGREGATE-SYNC-DESIGN.md` §3.3): a relationship `count` is synced as a
     * server-authoritative `__agg_*` base table that is not in the client's typed schema, so
     * the backend registers it here (and on the local engine) as queries that use it arrive.
     * Idempotent — re-registering the same table (a second query over the same aggregate)
     * is a no-op. Once registered, its rows refcount/GC exactly like any base table.
     */
    registerTable(table: string, primaryKey: number[]): void;
    /**
     * The inverse of {@link registerTable} for a synthetic aggregate table whose last
     * referencing query is gone (`AGGREGATE-SYNC-DESIGN.md` §4): drop its primary-key
     * registration so the table is unknown again. A balanced stream has already GC'd its rows
     * at the `1→0` transition (via {@link dropQuery}); defensively this also sweeps any residual
     * base rows + per-query footprint entries for the table, so a later re-registration of the
     * same name starts clean. A no-op for an unregistered table.
     */
    unregisterTable(table: string): void;
    /**
     * Apply one query's normalized batch (its hydrate snapshot or one transaction's ops) and
     * return the NET base-table mutations to commit to the wasm `Db` in a single transaction.
     * Cross-query refcount + per-query dedup + column union (§4, §5):
     * - `add`: counted into this query's footprint once; on the base `0→1` transition the row
     *   enters at this query's projection; on a `1→N` transition the query's columns are merged
     *   into the shared union (widen) and an `edit` is forwarded if the union changed.
     * - `remove`: on the base `N→0` transition the row leaves; on `N→M>0` the union is recomputed
     *   from the remaining queries and an `edit` narrows the shared row.
     * - `edit`: column-merge this query's cells into the shared union; forward once (idempotent
     *   across queries — same source row, same values).
     */
    applyBatch(queryId: QueryId, ops: NormalizedOp[]): Mutation[];
    /**
     * Re-hydrate one query under a new epoch (§5.3): the server re-sent `queryId`'s whole
     * footprint as seq-0 `add`s. Diff the new footprint against the query's current one —
     * rows only in the old set leave (refcount out, GC/narrow), rows only in the new set enter
     * (refcount in / widen), and an intersecting row whose value changed during the gap is an
     * edit. Only `queryId`'s references move; other queries' counts are untouched. Returns the
     * net mutations to commit.
     */
    rehydrate(queryId: QueryId, snapshot: NormalizedOp[]): Mutation[];
    /**
     * Drop a query (§5.1): decrement its footprint's refcounts, GC each row at the last
     * reference (or narrow the union if others remain), and forget the query. `O(footprint)`,
     * no per-row scan. Returns the net mutations. (The caller also tells the server to
     * deregister the stream.)
     */
    dropQuery(queryId: QueryId): Mutation[];
    /** The number of distinct base rows currently synced into the local store. */
    baseSize(): number;
    /** How many queries currently reference `(table, row)` (0 if absent). The lookup keys by PK,
     *  so a projected `row` need only carry its PK columns at the base positions. */
    refCount(table: string, row: UnionCell[]): number;
    /**
     * The synced (server-authoritative) row currently held for `(table, pkCells)`, or
     * `undefined` if no query references it. `pkCells` are the primary-key cells in
     * `primaryKey` order (NOT a full row) — the same key the cross-query refcount uses.
     *
     * The optimistic aggregate overlay (`AGGREGATE-SYNC-DESIGN.md` §4) reads the server's
     * `__agg` count cell through this so it can compute `displayed = server_base ⊕ delta`
     * from the authoritative base rather than re-deriving it from the local engine's head
     * (which already carries the optimistic layer — a torn read).
     */
    baseRow(table: string, pkCells: WireValue[]): (WireValue | undefined)[] | undefined;
    private footOf;
    /** Scatter a query's (possibly narrower) wire row for `table` into the shared full-width
     *  positional layout. No registered projection for `(queryId, table)` ⇒ a `'*'` table whose
     *  row is already full width (returned as-is). Otherwise allocate a full-width row of
     *  `ABSENT` and place `row[i]` at `cols[i]`. */
    private scatter;
    /** Column-merge `incoming`'s present cells over `e.row` (a widen / pure-value change) and
     *  forward a single `edit` if the union changed. Both rows are full width. */
    private mergeInto;
    private add;
    private remove;
    private removeKey;
    private edit;
    /** The columns present on a `table` row footprinted by `queries`: the union of their
     *  projections for that table. `null` ⇒ all columns present (some referencing query syncs
     *  the table `'*'`), so the row is full. */
    private presentCols;
    private key;
}

NormalizedTableSchema

InterfaceDeclaration · Source: packages/client/src/types.ts:107 · Supporting declarations

One base table's flat schema on the normalized hello (§3): column names (in order) + primary-key column indices. Wire rows are positional against columns.

export interface NormalizedTableSchema {
    name: string;
    columns: string[];
    primaryKey: number[];
}

PkCols

TypeAliasDeclaration · Source: packages/normalized/src/sync.ts:43 · Supporting declarations

Per-table primary-key column indices (into a positional row) — how (table, pk) is keyed.

export type PkCols = Record<string, number[]>;

rewriteAggregates

FunctionDeclaration · Source: packages/normalized/src/agg-table.ts:259 · Supporting declarations

Rewrite an AST for the LOCAL engine: each relationship count becomes a precomputed, source-backed singular relationship over its synthetic table — read the server's count with a plain join + the same scalar projection (aggregatePrecomputed), never a reduce (which would recount the already-aggregated rows). Non-aggregate relationships recurse (a nested aggregate is rewritten too); the parent's frame is otherwise untouched, so the view-schema slot order is preserved. Returns a new AST; the input is not mutated.

The where tree is rewritten the SAME way (PARENT-AGGREGATE-FILTER-DESIGN.md §3): a having_count parent gate lowers to an EXISTS whose subquery CLONES the display count_as and adds a post-aggregation HAVING. That is a relationship count like any other, so §3's premise — the client never recomputes a count, it lacks the child rows — applies to it too; left un-rewritten the gate would reduce over child rows the server (rightly) does not sync and every parent would fail it. agg_table_name hashes neither alias nor having, so the gate resolves to the SAME __agg_* table the display count_as already registers and syncs: the rewrite costs no extra table, no extra rows, and reads a count the server already sent. Its counterpart is the server pruning the gate's witnesses from the footprint (Rust table_tree) — that prune is only sound BECAUSE of this rewrite, so ship this side first.

export declare function rewriteAggregates(ast: Ast, isLocal?: (table: string) => boolean): Ast;