@rindle/client 0.1.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +65 -0
  3. package/dist/ast.d.ts +83 -0
  4. package/dist/ast.d.ts.map +1 -0
  5. package/dist/ast.js +5 -0
  6. package/dist/ast.js.map +1 -0
  7. package/dist/compare.d.ts +17 -0
  8. package/dist/compare.d.ts.map +1 -0
  9. package/dist/compare.js +81 -0
  10. package/dist/compare.js.map +1 -0
  11. package/dist/index.d.ts +12 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +13 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/key.d.ts +2 -0
  16. package/dist/key.d.ts.map +1 -0
  17. package/dist/key.js +26 -0
  18. package/dist/key.js.map +1 -0
  19. package/dist/operators.d.ts +39 -0
  20. package/dist/operators.d.ts.map +1 -0
  21. package/dist/operators.js +45 -0
  22. package/dist/operators.js.map +1 -0
  23. package/dist/query.d.ts +280 -0
  24. package/dist/query.d.ts.map +1 -0
  25. package/dist/query.js +348 -0
  26. package/dist/query.js.map +1 -0
  27. package/dist/schema.d.ts +111 -0
  28. package/dist/schema.d.ts.map +1 -0
  29. package/dist/schema.js +92 -0
  30. package/dist/schema.js.map +1 -0
  31. package/dist/ssr.d.ts +73 -0
  32. package/dist/ssr.d.ts.map +1 -0
  33. package/dist/ssr.js +66 -0
  34. package/dist/ssr.js.map +1 -0
  35. package/dist/store.d.ts +90 -0
  36. package/dist/store.d.ts.map +1 -0
  37. package/dist/store.js +225 -0
  38. package/dist/store.js.map +1 -0
  39. package/dist/types.d.ts +250 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +20 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist/view.d.ts +88 -0
  44. package/dist/view.d.ts.map +1 -0
  45. package/dist/view.js +294 -0
  46. package/dist/view.js.map +1 -0
  47. package/package.json +36 -0
  48. package/src/ast.ts +94 -0
  49. package/src/compare.ts +85 -0
  50. package/src/index.ts +57 -0
  51. package/src/key.ts +23 -0
  52. package/src/operators.ts +68 -0
  53. package/src/query.ts +734 -0
  54. package/src/schema.ts +181 -0
  55. package/src/ssr.ts +115 -0
  56. package/src/store.ts +279 -0
  57. package/src/types.ts +234 -0
  58. package/src/view.ts +348 -0
package/src/types.ts ADDED
@@ -0,0 +1,234 @@
1
+ // Wire + protocol types for the flat-change client core (WASM-CLIENT-DESIGN.md §2, §6).
2
+ //
3
+ // Cells cross the boundary BARE (the host has a typed schema, so per-column types are
4
+ // known here, not per-cell). JSON columns cross as their raw JSON string.
5
+
6
+ /** A bare wire cell. (JSON columns arrive as their raw JSON string.) */
7
+ export type WireValue = number | string | boolean | null;
8
+
9
+ /** Declared column type (from the typed schema) — drives the comparator + JSON parsing. */
10
+ export type ColType = "string" | "number" | "boolean" | "json";
11
+
12
+ /** A scalar-projection annotation on a relationship slot (REDUCE-DESIGN.md §9): a
13
+ * relationship aggregate (`issue { commentCount: count(comments) }`) lives as a singular
14
+ * one-row relationship whose child is the aggregate row; this tells the receiver to unwrap
15
+ * it into a scalar field — `commentCount: 5` instead of `commentCount: [{ count: 5 }]`. */
16
+ export interface WireProjection {
17
+ /** Which CHILD column to surface as the scalar value (the aggregate column). */
18
+ col: number;
19
+ /** The value to emit when the relationship is empty (a childless parent) — the
20
+ * aggregate identity (`0` for count, `null` for sum/avg). */
21
+ identity: WireValue;
22
+ }
23
+
24
+ export interface WireRel {
25
+ name: string;
26
+ slot: number;
27
+ /** `null` ⇒ an out-of-view / gating slot (the in-view gate drops `Child`s addressed here). */
28
+ child: WireSchema | null;
29
+ /** Non-null ⇒ a scalar-projected relationship aggregate the receiver unwraps to a scalar
30
+ * (REDUCE-DESIGN.md §9). Absent/`null` for an ordinary (plural or `.one()`) relationship. */
31
+ project?: WireProjection | null;
32
+ }
33
+
34
+ export interface WireSchema {
35
+ columns: string[];
36
+ primaryKey: number[];
37
+ /** Resolved, PK-completed sort: `[columnIndex, ascending]` pairs (the comparator input). */
38
+ sort: [number, boolean][];
39
+ singular: boolean;
40
+ relationships: WireRel[];
41
+ }
42
+
43
+ export interface WireNode {
44
+ row: WireValue[];
45
+ rels: { rel: number; children: WireNode[] }[];
46
+ }
47
+
48
+ export interface PathSeg {
49
+ rel: number;
50
+ parentRow: WireValue[];
51
+ }
52
+
53
+ export type FlatOp =
54
+ | { tag: "add"; node: WireNode }
55
+ | { tag: "remove"; row: WireValue[] }
56
+ | { tag: "edit"; old: WireValue[]; new: WireValue[] };
57
+
58
+ export interface FlatChange {
59
+ path: PathSeg[];
60
+ op: FlatOp;
61
+ }
62
+
63
+ /**
64
+ * What a {@link Backend} pushes per query: the handshake (`hello`), the (possibly
65
+ * chunked) hydrate `snapshot`, then incremental `batch`es. The core builds an ArrayView
66
+ * on `hello`, hydrates on `snapshot`, folds on `batch` — identically for any backend.
67
+ */
68
+ export type ChangeEvent =
69
+ | { type: "hello"; schema: WireSchema; comparatorVersion: number }
70
+ | { type: "snapshot"; adds: FlatChange[]; last: boolean }
71
+ | { type: "batch"; events: FlatChange[] };
72
+
73
+ export type Mutation =
74
+ | { op: "add"; table: string; row: WireValue[] }
75
+ | { op: "remove"; table: string; row: WireValue[] }
76
+ | { op: "edit"; table: string; old: WireValue[]; new: WireValue[] };
77
+
78
+ /**
79
+ * A table-tagged, path-free row delta — the **normalized** wire payload (the path-free twin
80
+ * of {@link FlatChange}; NORMALIZED-CHANGES-DESIGN.md §3). Rows are positional (bare cells; a
81
+ * json column is its raw JSON string). `op` is the discriminant. Lives here (not in
82
+ * `@rindle/normalized`) so both the protocol (`@rindle/remote`) and the sync layer share one type.
83
+ */
84
+ export type NormalizedOp =
85
+ | { table: string; op: "add"; row: WireValue[] }
86
+ | { table: string; op: "remove"; row: WireValue[] }
87
+ | { table: string; op: "edit"; old: WireValue[]; new: WireValue[] };
88
+
89
+ /** One base table's flat schema on the normalized `hello` (§3): column names (in order) +
90
+ * primary-key column indices. Wire rows are positional against `columns`. */
91
+ export interface NormalizedTableSchema {
92
+ name: string;
93
+ columns: string[];
94
+ primaryKey: number[];
95
+ }
96
+
97
+ /**
98
+ * A per-query NORMALIZED stream event — the path-free twin of {@link ChangeEvent}. The
99
+ * `hello` carries the flat per-table schemas (no nested view schema, §3); `snapshot`/`batch`
100
+ * carry table-tagged {@link NormalizedOp}s. The `NormalizedSync` layer folds these into the
101
+ * local engine's base tables.
102
+ *
103
+ * `cv` (the global commit version the frame's data reflects — OPTIMISTIC-WRITES-DESIGN.md
104
+ * §8.3/§8.6) is present on sources that speak the optimistic protocol; the plain normalized
105
+ * path may omit it.
106
+ */
107
+ export type NormalizedEvent =
108
+ | { type: "hello"; tables: NormalizedTableSchema[]; comparatorVersion: number; normalizedFp: string }
109
+ | { type: "snapshot"; ops: NormalizedOp[]; cv?: number }
110
+ | { type: "batch"; ops: NormalizedOp[]; cv?: number };
111
+
112
+ export type QueryId = number;
113
+
114
+ /** A query's SERVER-CHANNEL state, surfaced on its {@link ArrayView} (FOLDED-MUTATIONS-DESIGN §7 —
115
+ * formerly conflated with pending-ness, OPTIMISTIC-WRITES-DESIGN.md §6):
116
+ * - `unknown` — not hydrated: the server has not produced a first result for this query yet;
117
+ * - `complete` — the server has answered. STAYS `complete` while a local mutation is pending (the
118
+ * prediction is the client's best current answer); reversion on rejection is an
119
+ * event (`onRejected`), not a downgrade of completeness;
120
+ * - `error` — RESERVED for a future server-side, query-level error signal
121
+ * (see `designs/QUERY-ERRORS-DESIGN.md`); no longer produced by a pending mutation.
122
+ * "Is a prediction pending here?" is now a separate reactive axis (the backend's `pending(qid)` /
123
+ * `onPending`), not folded into this type. A backend with no server lifecycle (the in-process
124
+ * engine) leaves every view `complete`. */
125
+ export type ResultType = "unknown" | "complete" | "error";
126
+
127
+ /** The network identity for a named query subscription. The local AST remains local; remote
128
+ * normalized/optimistic sources use only this `(name,args)` pair upstream. */
129
+ export interface RemoteQuery {
130
+ name: string;
131
+ args: unknown;
132
+ }
133
+
134
+ /**
135
+ * The seam the core talks to. Backend-agnostic: the same interface for the in-process
136
+ * WASM backend and a remote (network) backend. The core never knows which. A backend
137
+ * pushes a per-query {@link ChangeEvent} stream via {@link Backend.onEvent}; the remote
138
+ * backend additionally owns the epoch/seq/gap protocol and emits only clean, in-order
139
+ * events (so the ArrayView never sees the wire protocol).
140
+ */
141
+ export interface Backend {
142
+ registerQuery(queryId: QueryId, ast: unknown, remote?: RemoteQuery): void;
143
+ unregisterQuery(queryId: QueryId): void;
144
+ /** Optional split path for local-first backends: retain/release a named remote footprint
145
+ * without creating another local materialized view. `localQueryId`, when provided, names
146
+ * the local AST view this remote footprint feeds. */
147
+ retainRemoteQuery?(queryId: QueryId, remote: RemoteQuery, localQueryId?: QueryId): void;
148
+ releaseRemoteQuery?(queryId: QueryId): void;
149
+ /** Local: applies now (changes flow back on the stream). Remote: sends to the server. */
150
+ mutate(mutations: Mutation[]): Promise<void>;
151
+ onEvent(handler: (queryId: QueryId, event: ChangeEvent) => void): void;
152
+ /** Optional: the backend pushes per-query {@link ResultType} changes here and the core routes
153
+ * each to the matching view (so `view.resultType` tracks it). Backends with no lifecycle (the
154
+ * in-process engine) omit it and every view stays `complete`. */
155
+ onResultType?(handler: (queryId: QueryId, resultType: ResultType) => void): void;
156
+ }
157
+
158
+ /**
159
+ * The server side of the **normalized** local-first path (NORMALIZED-CHANGES-DESIGN.md §5/§7):
160
+ * registers queries and pushes each one's normalized footprint stream ({@link NormalizedEvent}s).
161
+ * Both the in-process native (`@rindle/replica`) source and the ws (`@rindle/remote`
162
+ * `RemoteNormalizedSource`) implement it; `@rindle/normalized`'s `NormalizedBackend` consumes one,
163
+ * never knowing which — a sibling seam of {@link Backend} for the normalized composition.
164
+ */
165
+ export interface NormalizedSource {
166
+ registerQuery(queryId: QueryId, remote: RemoteQuery): void;
167
+ unregisterQuery(queryId: QueryId): void;
168
+ /** Send base-table writes to the server; the authoritative stream flows back. */
169
+ mutate(mutations: Mutation[]): Promise<void>;
170
+ onNormalized(handler: (queryId: QueryId, event: NormalizedEvent) => void): void;
171
+ /** Optional: the backend hands its OWN typed per-table schemas so the source can validate
172
+ * each server `hello` against them (column order / PK by name) and reject a schema skew
173
+ * rather than silently transposing positional cells (CRIT#4 / §3 "drift ⇒ re-subscribe").
174
+ * Sources that can't skew (the in-process native source) may omit it. */
175
+ expectClientSchema?(tables: NormalizedTableSchema[]): void;
176
+ }
177
+
178
+ /** The upstream named-mutator envelope (OPTIMISTIC-WRITES-DESIGN.md §8.1): the wire
179
+ * carries the name + JSON args, never code; `mid` totally orders a client's mutations. */
180
+ export interface MutationEnvelope {
181
+ clientID: string;
182
+ mid: number;
183
+ name: string;
184
+ args: unknown;
185
+ }
186
+
187
+ /** The connection-level progress frame (§8.6): advances the coherent-apply release point
188
+ * (`cvMin`). A pure release signal — mutation confirmation does NOT ride it: `lmid` is a
189
+ * row in {@link CLIENT_MUTATIONS_TABLE}, delivered through the client's own per-client
190
+ * system query ({@link LMID_QUERY_NAME}) like any data, so it is released by the same
191
+ * `cvMin` as the commit's effects (transactionally coherent by construction). */
192
+ export interface ProgressFrame {
193
+ cvMin: number;
194
+ }
195
+
196
+ /** The replicated bookkeeping table carrying each client's high-water mutation id
197
+ * (`lmid`). Engine-hosted and served like any base table; reserved (never part of a
198
+ * user schema). Columns: `[client_id, last_mutation_id]`, PK `client_id`. */
199
+ export const CLIENT_MUTATIONS_TABLE = "_rindle_client_mutations";
200
+
201
+ /** The reserved server-query name every optimistic client subscribes at connect: the
202
+ * one-row system query `CLIENT_MUTATIONS_TABLE WHERE client_id = <me>`. The server
203
+ * derives the identity from the connection (args are ignored). */
204
+ export const LMID_QUERY_NAME = "_rindle/clientLmid";
205
+
206
+ /** The system table's wire schema — appended to the client's expected schemas so the
207
+ * lmid query's `hello` passes CRIT#4 validation. */
208
+ export const CLIENT_MUTATIONS_SCHEMA: NormalizedTableSchema = {
209
+ name: CLIENT_MUTATIONS_TABLE,
210
+ columns: ["client_id", "last_mutation_id"],
211
+ primaryKey: [0],
212
+ };
213
+
214
+ /**
215
+ * The server side of the OPTIMISTIC path (OPTIMISTIC-WRITES-DESIGN.md §8): the
216
+ * {@link NormalizedSource} stream with `cv`-tagged data frames, plus the connection-level
217
+ * {@link ProgressFrame} channel and the named-mutator upstream. The client buffers data
218
+ * frames by `cv` and applies all `cv ≤ cvMin` as one coherent release (§8.5).
219
+ */
220
+ export interface OptimisticSource {
221
+ registerQuery(queryId: QueryId, remote: RemoteQuery): void;
222
+ unregisterQuery(queryId: QueryId): void;
223
+ /** Ship one named-mutator invocation upstream (§8.1). Confirmation rides the progress frames. */
224
+ pushMutation(envelope: MutationEnvelope): Promise<void>;
225
+ onNormalized(handler: (queryId: QueryId, event: NormalizedEvent) => void): void;
226
+ onProgress(handler: (frame: ProgressFrame) => void): void;
227
+ /** Optional: the backend hands its OWN typed per-table schemas so the source validates each
228
+ * server `hello` against them and rejects a schema skew (CRIT#4); see {@link NormalizedSource}. */
229
+ expectClientSchema?(tables: NormalizedTableSchema[]): void;
230
+ /** Optional: fired when the server restarts (a transport that can detect it, e.g. via a daemon
231
+ * boot id). The backend resets its `cv` watermark so the server's reset `cv` sequence is
232
+ * accepted rather than dropped as stale. In-process sources never restart and omit it. */
233
+ onRestart?(handler: () => void): void;
234
+ }
package/src/view.ts ADDED
@@ -0,0 +1,348 @@
1
+ // The JS ArrayView — the materialized, typed result tree a backend's ChangeEvent stream
2
+ // folds into (WASM-CLIENT-DESIGN.md §7, §8). A faithful port of the View's `apply_change`
3
+ // (src/view.rs / src/flat_receiver.rs), the §6 receiver contract of FLAT-CHANGES-DESIGN.md:
4
+ // descend the path with the in-view gate, then Add / Remove / Edit (rc + the edit-move
5
+ // ghost/adjusted-position/merge protocol) located by value-driven binary search.
6
+ //
7
+ // Reads are cheap and reference-stable: each node memoizes its projected output (`out`),
8
+ // invalidated only along the root→mutation spine, so `.data` re-projects only what changed
9
+ // and untouched subtrees keep their object identity (React/Solid memoize on it).
10
+
11
+ import { compareRows } from "./compare.ts";
12
+ import type { ColType, FlatChange, FlatOp, ResultType, WireNode, WireSchema, WireValue } from "./types.ts";
13
+
14
+ /** Per-level column types (parallel to the WireSchema), used to JSON.parse json columns on
15
+ * projection. Built by the Store from the typed schema; absent ⇒ no parsing (bare values). */
16
+ export interface ViewTypes {
17
+ columnTypes: ColType[];
18
+ rels: Record<number, ViewTypes>;
19
+ }
20
+
21
+ /** The public ArrayView contract `materialize()` returns. */
22
+ export interface ArrayView<R> {
23
+ /** The current materialized result (reference-stable where data is unchanged). */
24
+ readonly data: readonly R[];
25
+ /** The query's SERVER-CHANNEL state (`unknown` while loading, `complete` once server-authoritative;
26
+ * the `error` variant is reserved and currently unproduced). A pending optimistic mutation no
27
+ * longer moves this — it is a separate axis (FOLDED-MUTATIONS-DESIGN §7). `complete` for backends
28
+ * with no server lifecycle. Changes notify subscribers. */
29
+ readonly resultType: ResultType;
30
+ /** Subscribe; fires immediately with the current data, then after each applied batch (and after
31
+ * a {@link resultType} change — re-read `resultType` in the listener). */
32
+ subscribe(listener: (data: readonly R[]) => void): () => void;
33
+ /** Tear down + stop receiving updates. */
34
+ destroy(): void;
35
+ }
36
+
37
+ /** What a top-level `.one()` query materializes to: the single row (or `null`), not an array.
38
+ * A thin adapter over a {@link FlatArrayView} — all the folding is shared; only the result
39
+ * boundary unwraps (`data[0] ?? null`). Reference identity of the row is preserved. */
40
+ export interface SingularArrayView<R> {
41
+ /** The single current row, or `null` when the query matches nothing. */
42
+ readonly data: R | null;
43
+ /** The query's lifecycle state — see {@link ArrayView.resultType}. */
44
+ readonly resultType: ResultType;
45
+ /** Subscribe; fires immediately with the current row, then after each applied batch (and after
46
+ * a {@link resultType} change). */
47
+ subscribe(listener: (data: R | null) => void): () => void;
48
+ /** Tear down + stop receiving updates. */
49
+ destroy(): void;
50
+ }
51
+
52
+ /** Wrap a plural {@link FlatArrayView} as a {@link SingularArrayView} for a `.one()` query
53
+ * (the engine caps it to `limit = 1`, so the top list holds at most one node). */
54
+ export class SingularView<R> implements SingularArrayView<R> {
55
+ private readonly inner: ArrayView<R>;
56
+
57
+ constructor(inner: ArrayView<R>) {
58
+ this.inner = inner;
59
+ }
60
+
61
+ get data(): R | null {
62
+ return this.inner.data[0] ?? null;
63
+ }
64
+
65
+ get resultType(): ResultType {
66
+ return this.inner.resultType;
67
+ }
68
+
69
+ subscribe(listener: (data: R | null) => void): () => void {
70
+ return this.inner.subscribe((d) => listener(d[0] ?? null));
71
+ }
72
+
73
+ destroy(): void {
74
+ this.inner.destroy();
75
+ }
76
+ }
77
+
78
+ /** An internal reconstruction node: the row, multi-path refcount, per-slot child lists,
79
+ * and a memoized projected output (`null` ⇒ stale, rebuilt on next read). */
80
+ interface Node {
81
+ row: WireValue[];
82
+ rc: number;
83
+ rels: Node[][];
84
+ out: unknown;
85
+ }
86
+
87
+ function binarySearch(
88
+ list: Node[],
89
+ row: WireValue[],
90
+ sort: [number, boolean][],
91
+ ): { found: boolean; index: number } {
92
+ let lo = 0;
93
+ let hi = list.length;
94
+ while (lo < hi) {
95
+ const mid = (lo + hi) >>> 1;
96
+ const c = compareRows(list[mid].row, row, sort);
97
+ if (c < 0) lo = mid + 1;
98
+ else if (c > 0) hi = mid;
99
+ else return { found: true, index: mid };
100
+ }
101
+ return { found: false, index: lo };
102
+ }
103
+
104
+ /** Build a fresh node (rc = 1) with its inline subtree, mirroring `init_rels_for_new_entry`:
105
+ * one list per declared slot; fill in-view slots from the payload (children inserted in the
106
+ * order shipped — NEVER re-sorted — folding same-sort-key dups to rc += 1). */
107
+ function buildNode(wnode: WireNode, schema: WireSchema): Node {
108
+ const rels: Node[][] = schema.relationships.map(() => []);
109
+ for (const { rel, children } of wnode.rels) {
110
+ const child = schema.relationships[rel]?.child;
111
+ if (!child) continue; // join-only / gating slot — not materialized
112
+ const built: Node[] = [];
113
+ for (const c of children) {
114
+ const at = binarySearch(built, c.row, child.sort);
115
+ if (at.found) built[at.index].rc += 1;
116
+ else built.splice(at.index, 0, buildNode(c, child));
117
+ }
118
+ rels[rel] = built;
119
+ }
120
+ return { row: wnode.row, rc: 1, rels, out: null };
121
+ }
122
+
123
+ function cloneNode(n: Node): Node {
124
+ return { row: n.row.slice(), rc: n.rc, rels: n.rels.map((r) => r.map(cloneNode)), out: null };
125
+ }
126
+
127
+ const EMPTY: readonly never[] = Object.freeze([]);
128
+
129
+ export class FlatArrayView<R = unknown> implements ArrayView<R> {
130
+ // `null` ⇒ PENDING (no schema yet): a remote backend's `hello` arrives async, so the view
131
+ // exists (and reads as `[]`) before its schema lands. Set by `reset` (first hello / re-hydrate).
132
+ private schema: WireSchema | null;
133
+ private types?: ViewTypes;
134
+ // SSR first-paint seed (SSR-DESIGN.md §6): pre-projected rows installed by `seed`, returned by
135
+ // `data` WHILE pending (no live schema yet). Cleared on `reset` — the first live `hello` —
136
+ // so the maintained tree takes over and the live snapshot reconciles. `null` ⇒ no seed.
137
+ private seeded: readonly R[] | null = null;
138
+ private top: Node[] = [];
139
+ private dirty = true;
140
+ private cached: readonly R[] | null = null;
141
+ // `complete` by default: a backend with no server lifecycle (the in-process engine) never pushes
142
+ // a resultType, so its views read as authoritative. The Store overrides this for backends that do
143
+ // (the optimistic backend: `unknown` until hydrated, etc.).
144
+ private rt: ResultType = "complete";
145
+ private readonly listeners = new Set<(data: readonly R[]) => void>();
146
+
147
+ constructor(schema?: WireSchema, types?: ViewTypes) {
148
+ this.schema = schema ?? null;
149
+ this.types = types;
150
+ }
151
+
152
+ get resultType(): ResultType {
153
+ return this.rt;
154
+ }
155
+
156
+ /** Set the query's lifecycle state (the Store routes the backend's per-query signal here).
157
+ * Notifies subscribers on a change so a status-bound listener (React `useQueryStatus`) re-reads,
158
+ * WITHOUT re-projecting data (it is unchanged). */
159
+ setResultType(rt: ResultType): void {
160
+ if (this.rt === rt) return;
161
+ this.rt = rt;
162
+ for (const l of this.listeners) l(this.data);
163
+ }
164
+
165
+ /** (Re)bind to a schema and clear the tree IN PLACE. The first `hello` (pending → ready)
166
+ * and a re-hydrate (gap → new epoch — FLAT-CHANGES-DESIGN.md §2.3) both go through here, so
167
+ * the caller's view reference and its subscribers survive a re-subscribe. Does NOT notify —
168
+ * the snapshot that follows (`applyChanges`) does, avoiding an empty-then-filled flicker. */
169
+ reset(schema: WireSchema, types?: ViewTypes): void {
170
+ this.schema = schema;
171
+ this.types = types;
172
+ this.seeded = null; // live data takes over; the seed was first-paint only
173
+ this.top = [];
174
+ this.cached = null;
175
+ this.dirty = true;
176
+ }
177
+
178
+ /** Install a pre-projected SSR first-paint snapshot (SSR-DESIGN.md §6). The rows are already
179
+ * in result shape (json columns parsed, relationships nested), so a view with no live backend
180
+ * (the server one-shot Store) reads them directly, and a browser view shows them until its
181
+ * first live `hello` (`reset`) swaps in the maintained tree. Does NOT notify — it is set at
182
+ * materialize time, before any subscriber, and the live snapshot that follows notifies. */
183
+ seed(rows: readonly R[]): void {
184
+ this.seeded = rows;
185
+ }
186
+
187
+ /** Apply a batch (the hydrate snapshot or one transaction's events) in order, then
188
+ * notify subscribers once. Order is significant (FLAT-CHANGES-DESIGN.md §5.4). A no-op
189
+ * while pending (changes never precede the `hello` that resets the schema). */
190
+ applyChanges(events: FlatChange[]): void {
191
+ if (this.schema === null) return;
192
+ for (const e of events) this.applyAt(this.top, this.schema, e.path, e.op, 0);
193
+ this.dirty = true;
194
+ this.notify();
195
+ }
196
+
197
+ get data(): readonly R[] {
198
+ if (this.schema === null) return this.seeded ?? (EMPTY as readonly R[]);
199
+ if (!this.dirty && this.cached !== null) return this.cached;
200
+ this.cached = this.top.map((n) => this.project(n, this.schema as WireSchema, this.types)) as R[];
201
+ this.dirty = false;
202
+ return this.cached;
203
+ }
204
+
205
+ subscribe(listener: (data: readonly R[]) => void): () => void {
206
+ this.listeners.add(listener);
207
+ listener(this.data);
208
+ return () => {
209
+ this.listeners.delete(listener);
210
+ };
211
+ }
212
+
213
+ destroy(): void {
214
+ this.listeners.clear();
215
+ this.top = [];
216
+ }
217
+
218
+ // --- apply -------------------------------------------------------------------
219
+
220
+ private applyAt(list: Node[], schema: WireSchema, path: FlatChange["path"], op: FlatOp, depth: number): void {
221
+ if (depth === path.length) {
222
+ this.applyOp(list, schema, op);
223
+ return;
224
+ }
225
+ const seg = path[depth];
226
+ const child = schema.relationships[seg.rel]?.child;
227
+ if (!child) return; // in-view gate: a gating slot drops the whole change
228
+ const { found, index } = binarySearch(list, seg.parentRow, schema.sort);
229
+ if (!found) throw new Error("flat ArrayView: parent not found at path hop (inconsistent stream)");
230
+ const node = list[index];
231
+ node.out = null; // a descendant changed → this node must re-project its rel array
232
+ this.applyAt(node.rels[seg.rel], child, path, op, depth + 1);
233
+ }
234
+
235
+ private applyOp(list: Node[], schema: WireSchema, op: FlatOp): void {
236
+ if (op.tag === "add") this.applyAdd(list, schema, op.node);
237
+ else if (op.tag === "remove") this.applyRemove(list, schema, op.row);
238
+ else this.applyEdit(list, schema, op.old, op.new);
239
+ }
240
+
241
+ private applyAdd(list: Node[], schema: WireSchema, wnode: WireNode): void {
242
+ const at = binarySearch(list, wnode.row, schema.sort);
243
+ if (at.found) list[at.index].rc += 1; // duplicate path to an existing row; ignore subtree
244
+ else list.splice(at.index, 0, buildNode(wnode, schema));
245
+ }
246
+
247
+ private applyRemove(list: Node[], schema: WireSchema, row: WireValue[]): void {
248
+ const at = binarySearch(list, row, schema.sort);
249
+ if (!at.found) throw new Error("flat ArrayView: remove of a non-existent node");
250
+ if (list[at.index].rc === 1) list.splice(at.index, 1);
251
+ else list[at.index].rc -= 1;
252
+ }
253
+
254
+ private applyEdit(list: Node[], schema: WireSchema, oldRow: WireValue[], newRow: WireValue[]): void {
255
+ const sort = schema.sort;
256
+ if (compareRows(oldRow, newRow, sort) === 0) {
257
+ // Sort key unchanged → edit in place (keeps position + children + rc).
258
+ const at = binarySearch(list, oldRow, sort);
259
+ if (!at.found) throw new Error("flat ArrayView: edit of a non-existent node");
260
+ list[at.index].row = newRow;
261
+ list[at.index].out = null;
262
+ return;
263
+ }
264
+
265
+ // Sort key changed → the row may move; rc may be > 1.
266
+ const oldAt = binarySearch(list, oldRow, sort);
267
+ if (!oldAt.found) throw new Error("flat ArrayView: edit old node does not exist");
268
+ const oldPos = oldAt.index;
269
+ const oldRc = list[oldPos].rc;
270
+ const raw = binarySearch(list, newRow, sort);
271
+ const found = raw.found;
272
+ const pos = raw.index;
273
+ const oldEntry = cloneNode(list[oldPos]); // capture (with children) BEFORE mutating
274
+
275
+ // Fast path: rc==1 and the row lands in the same slot after removal → edit in place.
276
+ if (oldRc === 1 && (pos === oldPos || pos - 1 === oldPos)) {
277
+ list[oldPos].row = newRow;
278
+ list[oldPos].out = null;
279
+ return;
280
+ }
281
+
282
+ // General move.
283
+ const newRc = oldRc - 1;
284
+ let adjusted: number;
285
+ if (newRc === 0) {
286
+ list.splice(oldPos, 1);
287
+ adjusted = oldPos < pos ? pos - 1 : pos;
288
+ } else {
289
+ list[oldPos].rc = newRc; // ghost survives for the other path(s); its row is unchanged
290
+ adjusted = pos;
291
+ }
292
+ if (found) {
293
+ // Merge into the existing destination entry (keep its children); bump its rc.
294
+ const existingRc = list[adjusted].rc;
295
+ list[adjusted].row = newRow;
296
+ list[adjusted].rc = existingRc + 1;
297
+ list[adjusted].out = null;
298
+ } else {
299
+ // Move the (edited) old entry — keeping its children — to the new position.
300
+ oldEntry.row = newRow;
301
+ oldEntry.rc = 1;
302
+ list.splice(adjusted, 0, oldEntry);
303
+ }
304
+ }
305
+
306
+ // --- projection (memoized, structurally shared) -----------------------------
307
+
308
+ private project(node: Node, schema: WireSchema, types?: ViewTypes): unknown {
309
+ if (node.out !== null) return node.out;
310
+ const obj: Record<string, unknown> = {};
311
+ for (let i = 0; i < schema.columns.length; i++) {
312
+ const v = node.row[i];
313
+ // A projected query carries an ABSENT cell (`undefined`, PROJECTION-SUPPORT-DESIGN.md
314
+ // §5.3) for a column it did not sync; omit it from the result object so a query never
315
+ // resolves a row from columns it did not select (§6). A present-and-null cell is `null`
316
+ // and is kept. For a `'*'` query no cell is ever `undefined`, so this is inert (§7).
317
+ if (v === undefined) continue;
318
+ // convert-once: a json column's raw string is parsed to an object on store (cached in `out`).
319
+ obj[schema.columns[i]] = types?.columnTypes[i] === "json" && typeof v === "string" ? JSON.parse(v) : v;
320
+ }
321
+ for (const rel of schema.relationships) {
322
+ if (rel.child === null) continue; // gating slot — not part of .data
323
+ const childList = node.rels[rel.slot] ?? [];
324
+ const ct = types?.rels[rel.slot];
325
+ if (rel.project) {
326
+ // A scalar-projected relationship aggregate (REDUCE-DESIGN.md §9): unwrap the one-row
327
+ // child to a bare scalar (its project.col cell), substituting the aggregate identity
328
+ // when empty (a childless parent). A json-typed projected column is parsed like a column.
329
+ const cell = childList.length > 0 ? childList[0].row[rel.project.col] : rel.project.identity;
330
+ const projType = ct?.columnTypes[rel.project.col];
331
+ obj[rel.name] = projType === "json" && typeof cell === "string" ? JSON.parse(cell) : cell;
332
+ continue;
333
+ }
334
+ obj[rel.name] = rel.child.singular
335
+ ? childList.length > 0
336
+ ? this.project(childList[0], rel.child, ct)
337
+ : null
338
+ : childList.map((c) => this.project(c, rel.child as WireSchema, ct));
339
+ }
340
+ node.out = obj;
341
+ return obj;
342
+ }
343
+
344
+ private notify(): void {
345
+ const d = this.data;
346
+ for (const l of this.listeners) l(d);
347
+ }
348
+ }