@rindle/normalized 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.
- package/LICENSE +201 -0
- package/README.md +23 -0
- package/dist/agg-table.d.ts +19 -0
- package/dist/agg-table.d.ts.map +1 -0
- package/dist/agg-table.js +211 -0
- package/dist/agg-table.js.map +1 -0
- package/dist/backend.d.ts +53 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +259 -0
- package/dist/backend.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/sync.d.ts +127 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +366 -0
- package/dist/sync.js.map +1 -0
- package/package.json +41 -0
- package/src/agg-table.ts +216 -0
- package/src/backend.ts +285 -0
- package/src/index.ts +16 -0
- package/src/sync.ts +396 -0
package/src/backend.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// NormalizedBackend — the composition that turns a NORMALIZED server stream into the flat
|
|
2
|
+
// `Backend` seam the existing `Store` already drives (NORMALIZED-CHANGES-DESIGN.md §5/§7).
|
|
3
|
+
//
|
|
4
|
+
// A normalized client runs its OWN local engine and queries over its base tables. This
|
|
5
|
+
// backend wires that up by composing three pieces behind one `Backend`:
|
|
6
|
+
// - a local `@rindle/wasm` engine (the base tables + local IVM that materializes results),
|
|
7
|
+
// - `NormalizedSync` (cross-query refcount → net base-table mutations), and
|
|
8
|
+
// - a `NormalizedSource` (the server's per-query normalized stream).
|
|
9
|
+
//
|
|
10
|
+
// The flow per query: `registerQuery` registers it BOTH on the local engine (→ an empty
|
|
11
|
+
// `FlatArrayView`) and on the server (→ its normalized footprint stream). Each normalized
|
|
12
|
+
// batch folds through `NormalizedSync` into net base mutations, which are applied to the
|
|
13
|
+
// local engine — whose own flat change stream then updates every affected view. Because the
|
|
14
|
+
// local engine fans a base write to ALL local queries, a row synced for one query updates
|
|
15
|
+
// any other query that reads it (the "local query resolution" the design is built for).
|
|
16
|
+
//
|
|
17
|
+
// Since this implements `Backend`, `new Store(schema, new NormalizedBackend(...))` reuses the
|
|
18
|
+
// whole Store/ArrayView machinery unchanged — the normalized path adds composition, not a
|
|
19
|
+
// second materialization layer.
|
|
20
|
+
|
|
21
|
+
import { normalizedTableSchemas, Store, tableSpec } from "@rindle/client";
|
|
22
|
+
import type {
|
|
23
|
+
Ast,
|
|
24
|
+
Backend,
|
|
25
|
+
ChangeEvent,
|
|
26
|
+
ColsMap,
|
|
27
|
+
Mutation,
|
|
28
|
+
NormalizedEvent,
|
|
29
|
+
NormalizedSource,
|
|
30
|
+
NormalizedTableSchema,
|
|
31
|
+
QueryId,
|
|
32
|
+
RemoteQuery,
|
|
33
|
+
Schema,
|
|
34
|
+
} from "@rindle/client";
|
|
35
|
+
import { WasmBackend } from "@rindle/wasm";
|
|
36
|
+
|
|
37
|
+
import { aggTableSchemas, rewriteAggregates } from "./agg-table.ts";
|
|
38
|
+
import { NormalizedSync, type ColCounts, type PkCols } from "./sync.ts";
|
|
39
|
+
|
|
40
|
+
// The normalized seam + event/table-schema types live in `@rindle/client` (sibling of `Backend`),
|
|
41
|
+
// so a ws (`@rindle/remote`) source and the in-process native source emit the same shape and
|
|
42
|
+
// `@rindle/normalized` needs no dependency on `@rindle/remote`. Re-exported for callers.
|
|
43
|
+
export type { NormalizedEvent, NormalizedSource, NormalizedTableSchema } from "@rindle/client";
|
|
44
|
+
|
|
45
|
+
/** Each table's primary-key column indices, from the typed schema. */
|
|
46
|
+
function pkColsFromSchema<S extends ColsMap>(schema: Schema<S>): PkCols {
|
|
47
|
+
const out: PkCols = {};
|
|
48
|
+
for (const name of Object.keys(schema.tables)) out[name] = tableSpec(schema.tables[name]).primaryKey;
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Each table's FULL column count (the union-row width), from the typed schema. */
|
|
53
|
+
function colCountsFromSchema<S extends ColsMap>(schema: Schema<S>): ColCounts {
|
|
54
|
+
const out: ColCounts = {};
|
|
55
|
+
for (const name of Object.keys(schema.tables)) out[name] = tableSpec(schema.tables[name]).columns.length;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Each table's column name → base ColId, from the typed schema (for mapping a projected
|
|
60
|
+
* hello's columns back to base positions, PROJECTION-SUPPORT-DESIGN.md §5.2). */
|
|
61
|
+
function colIndexFromSchema<S extends ColsMap>(schema: Schema<S>): Record<string, Map<string, number>> {
|
|
62
|
+
const out: Record<string, Map<string, number>> = {};
|
|
63
|
+
for (const name of Object.keys(schema.tables)) {
|
|
64
|
+
const cols = tableSpec(schema.tables[name]).columns;
|
|
65
|
+
out[name] = new Map(cols.map((c, i) => [c, i]));
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class NormalizedBackend<S extends ColsMap> implements Backend {
|
|
71
|
+
private readonly local: WasmBackend<S>;
|
|
72
|
+
private readonly sync: NormalizedSync;
|
|
73
|
+
private readonly source: NormalizedSource;
|
|
74
|
+
private handler: (qid: QueryId, ev: ChangeEvent) => void = () => {};
|
|
75
|
+
private readonly remoteSubs = new Map<string, RemoteSub>();
|
|
76
|
+
private readonly localToRemote = new Map<QueryId, string>();
|
|
77
|
+
/** The client's OWN typed per-table schemas (for CRIT#4 validation), fixed at construction. */
|
|
78
|
+
private readonly clientTables: NormalizedTableSchema[];
|
|
79
|
+
/** Synthetic aggregate tables (`__agg_*`) registered so far, by name (§3.3). Per aggregate
|
|
80
|
+
* DEFINITION (not per query), so two queries over the same count share one table. */
|
|
81
|
+
private readonly synthetic = new Map<string, NormalizedTableSchema>();
|
|
82
|
+
/** table → (column name → base ColId), for mapping a projected hello to base positions. */
|
|
83
|
+
private readonly colIndex: Record<string, Map<string, number>>;
|
|
84
|
+
/** table → full column count, to detect whether a hello's table is projected. */
|
|
85
|
+
private readonly colCounts: ColCounts;
|
|
86
|
+
/** Synthetic table name → how many registered queries reference it. Materialized on `0→1`,
|
|
87
|
+
* reclaimed (engine source + refcount layer) on `1→0` — so aggregate state is not permanent (§4). */
|
|
88
|
+
private readonly syntheticRefs = new Map<string, number>();
|
|
89
|
+
/** queryId → the synthetic tables it referenced at registration, to decrement on teardown. */
|
|
90
|
+
private readonly queryAggTables = new Map<QueryId, string[]>();
|
|
91
|
+
|
|
92
|
+
constructor(schema: Schema<S>, source: NormalizedSource) {
|
|
93
|
+
this.local = new WasmBackend(schema);
|
|
94
|
+
// The local engine's flat stream IS this backend's stream upward (→ the Store's views).
|
|
95
|
+
this.local.onEvent((qid, ev) => this.handler(qid, ev));
|
|
96
|
+
this.colIndex = colIndexFromSchema(schema);
|
|
97
|
+
this.colCounts = colCountsFromSchema(schema);
|
|
98
|
+
this.sync = new NormalizedSync(pkColsFromSchema(schema), this.colCounts);
|
|
99
|
+
this.source = source;
|
|
100
|
+
// Hand the source our OWN typed schema so it validates each server hello against it and
|
|
101
|
+
// rejects a column-order / PK skew instead of silently transposing positional cells (CRIT#4).
|
|
102
|
+
this.clientTables = normalizedTableSchemas(schema);
|
|
103
|
+
this.source.expectClientSchema?.(this.clientTables);
|
|
104
|
+
this.source.onNormalized((qid, ev) => this.onNormalized(qid, ev));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
registerQuery(qid: QueryId, ast: Ast, remote?: RemoteQuery): void {
|
|
108
|
+
// A relationship aggregate (`count(child)`) is DISPLAYED from a server-authoritative
|
|
109
|
+
// synthetic base table, not recomputed locally (AGGREGATE-SYNC-DESIGN.md §3.3): register
|
|
110
|
+
// that table (engine + refcount layer + hello validation), then drive the local engine
|
|
111
|
+
// off a rewritten AST whose `count` relationships read it with a plain projected join.
|
|
112
|
+
this.ensureSyntheticTables(qid, ast);
|
|
113
|
+
// Local first: builds the (empty) view synchronously. Then the server stream hydrates it.
|
|
114
|
+
this.local.registerQuery(qid, rewriteAggregates(ast));
|
|
115
|
+
if (remote) this.retainRemote(qid, remote);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Register every synthetic aggregate table `ast` needs that we haven't seen yet: on the
|
|
119
|
+
* local engine (so it can join to it), on `NormalizedSync` (so its rows refcount/GC by
|
|
120
|
+
* group key), and into the source's expected-schema set (so the server's `hello` — which
|
|
121
|
+
* advertises the same table — passes CRIT#4 validation, the client deriving the schema
|
|
122
|
+
* identically). Idempotent across queries that share an aggregate definition. */
|
|
123
|
+
private ensureSyntheticTables(qid: QueryId, ast: Ast): void {
|
|
124
|
+
if (this.queryAggTables.has(qid)) return; // idempotent per qid
|
|
125
|
+
let added = false;
|
|
126
|
+
const names: string[] = [];
|
|
127
|
+
for (const t of aggTableSchemas(ast)) {
|
|
128
|
+
names.push(t.name);
|
|
129
|
+
const prev = this.syntheticRefs.get(t.name) ?? 0;
|
|
130
|
+
this.syntheticRefs.set(t.name, prev + 1);
|
|
131
|
+
if (prev > 0) continue; // another query already materialized it — just refcount
|
|
132
|
+
this.synthetic.set(t.name, t);
|
|
133
|
+
this.local.registerTable(t.name, { columns: t.columns, primaryKey: t.primaryKey });
|
|
134
|
+
this.sync.registerTable(t.name, t.primaryKey);
|
|
135
|
+
added = true;
|
|
136
|
+
}
|
|
137
|
+
if (names.length) this.queryAggTables.set(qid, names);
|
|
138
|
+
if (added) this.source.expectClientSchema?.([...this.clientTables, ...this.synthetic.values()]);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Decrement each synthetic table query `qid` referenced; remove the ones that reach 0 (no
|
|
142
|
+
* reader left) from the engine + refcount layer — aggregate state reclaimed, not permanent
|
|
143
|
+
* (§4). Runs AFTER `local.unregisterQuery(qid)` so the source has no live connection when
|
|
144
|
+
* `unregisterTable` frees it. */
|
|
145
|
+
private releaseSyntheticTables(qid: QueryId): void {
|
|
146
|
+
const names = this.queryAggTables.get(qid);
|
|
147
|
+
if (!names) return;
|
|
148
|
+
this.queryAggTables.delete(qid);
|
|
149
|
+
let removed = false;
|
|
150
|
+
for (const name of names) {
|
|
151
|
+
const next = (this.syntheticRefs.get(name) ?? 1) - 1;
|
|
152
|
+
if (next > 0) {
|
|
153
|
+
this.syntheticRefs.set(name, next);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
this.syntheticRefs.delete(name);
|
|
157
|
+
this.local.unregisterTable(name);
|
|
158
|
+
this.sync.unregisterTable(name);
|
|
159
|
+
this.synthetic.delete(name);
|
|
160
|
+
removed = true;
|
|
161
|
+
}
|
|
162
|
+
if (removed) this.source.expectClientSchema?.([...this.clientTables, ...this.synthetic.values()]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
unregisterQuery(qid: QueryId): void {
|
|
166
|
+
const remoteQid = this.releaseRemote(qid);
|
|
167
|
+
if (remoteQid !== undefined) {
|
|
168
|
+
// Drop this remote footprint's refcounts; rows referenced by no other named query are
|
|
169
|
+
// GC'd from the local base tables (→ other views shrink if they shared them).
|
|
170
|
+
const muts = this.sync.dropQuery(remoteQid);
|
|
171
|
+
if (muts.length) void this.local.mutate(muts);
|
|
172
|
+
}
|
|
173
|
+
this.local.unregisterQuery(qid);
|
|
174
|
+
// Pipeline gone (no live conn) + `__agg` rows GC'd above → free any now-unread synthetic table.
|
|
175
|
+
this.releaseSyntheticTables(qid);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
retainRemoteQuery(qid: QueryId, remote: RemoteQuery): void {
|
|
179
|
+
this.retainRemote(qid, remote);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
releaseRemoteQuery(qid: QueryId): void {
|
|
183
|
+
const remoteQid = this.releaseRemote(qid);
|
|
184
|
+
if (remoteQid === undefined) return;
|
|
185
|
+
const muts = this.sync.dropQuery(remoteQid);
|
|
186
|
+
if (muts.length) void this.local.mutate(muts);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Writes are authoritative-only here: send to the server, the stream reconciles locally.
|
|
190
|
+
* (Optimistic local apply + rebase is Slice 6.) */
|
|
191
|
+
mutate(mutations: Mutation[]): Promise<void> {
|
|
192
|
+
return this.source.mutate(mutations);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
onEvent(handler: (qid: QueryId, ev: ChangeEvent) => void): void {
|
|
196
|
+
this.handler = handler;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private onNormalized(qid: QueryId, ev: NormalizedEvent): void {
|
|
200
|
+
// The slim hello carries table schemas + fingerprint; envelope validation (epoch/seq/gap)
|
|
201
|
+
// is the source's job (the ws Subscriber, §5.3) — it precedes every (re)hydrate snapshot.
|
|
202
|
+
if (ev.type === "hello") {
|
|
203
|
+
// Learn this query's per-table column map (PROJECTION-SUPPORT-DESIGN.md §5.2): map each
|
|
204
|
+
// advertised column to its base ColId BY NAME. The hello may carry FEWER columns than the
|
|
205
|
+
// client's schema (a projection) or MORE (an EXPANDED server table mid an
|
|
206
|
+
// `expand-then-contract` migration) — a column the client lacks maps to `-1`, a DROP
|
|
207
|
+
// sentinel the sync layer discards. Register the map so the sync layer scatters the rows
|
|
208
|
+
// into the shared full-width union; a table whose map is an in-order, drop-free full width
|
|
209
|
+
// IS '*' (a verbatim full row) and is left unregistered. Idempotent across re-hydrate epochs.
|
|
210
|
+
for (const t of ev.tables) {
|
|
211
|
+
const full = this.colCounts[t.name];
|
|
212
|
+
if (full === undefined) continue; // unknown/synthetic table → '*' (full presence)
|
|
213
|
+
const index = this.colIndex[t.name];
|
|
214
|
+
const cols = t.columns.map((name) => index?.get(name) ?? -1);
|
|
215
|
+
// Register a non-trivial map; otherwise revert to '*' — and CLEAR any stale map a prior
|
|
216
|
+
// epoch left (a server that expanded then contracted back), so the now-exact rows don't
|
|
217
|
+
// scatter through a `-1`-bearing layout (silent cell corruption).
|
|
218
|
+
if (cols.length !== full || cols.some((c, i) => c !== i)) this.sync.registerProjection(qid, t.name, cols);
|
|
219
|
+
else this.sync.unregisterProjection(qid, t.name);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// A `snapshot` is the seq-0 baseline — initial OR a re-hydrate under a new epoch (§5.3). Both
|
|
224
|
+
// go through `rehydrate` (a footprint DIFF): for the first one the footprint is empty so it
|
|
225
|
+
// degenerates to all-adds; for a re-hydrate it removes rows that left during the gap. A
|
|
226
|
+
// `batch` is an incremental delta → `applyBatch`.
|
|
227
|
+
const muts = ev.type === "snapshot" ? this.sync.rehydrate(qid, ev.ops) : this.sync.applyBatch(qid, ev.ops);
|
|
228
|
+
if (muts.length) void this.local.mutate(muts); // → local engine → flat stream → views
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private retainRemote(localQid: QueryId, remote: RemoteQuery): void {
|
|
232
|
+
const key = remoteKey(remote);
|
|
233
|
+
let sub = this.remoteSubs.get(key);
|
|
234
|
+
if (!sub) {
|
|
235
|
+
sub = { sourceQid: localQid, remote, refCount: 0 };
|
|
236
|
+
this.remoteSubs.set(key, sub);
|
|
237
|
+
this.source.registerQuery(sub.sourceQid, remote);
|
|
238
|
+
}
|
|
239
|
+
sub.refCount++;
|
|
240
|
+
this.localToRemote.set(localQid, key);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private releaseRemote(localQid: QueryId): QueryId | undefined {
|
|
244
|
+
const key = this.localToRemote.get(localQid);
|
|
245
|
+
if (!key) return undefined;
|
|
246
|
+
this.localToRemote.delete(localQid);
|
|
247
|
+
const sub = this.remoteSubs.get(key);
|
|
248
|
+
if (!sub) return undefined;
|
|
249
|
+
sub.refCount--;
|
|
250
|
+
if (sub.refCount > 0) return undefined;
|
|
251
|
+
this.source.unregisterQuery(sub.sourceQid);
|
|
252
|
+
this.remoteSubs.delete(key);
|
|
253
|
+
return sub.sourceQid;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface RemoteSub {
|
|
258
|
+
sourceQid: QueryId;
|
|
259
|
+
remote: RemoteQuery;
|
|
260
|
+
refCount: number;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function remoteKey(remote: RemoteQuery): string {
|
|
264
|
+
return stableJson([remote.name, remote.args]);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function stableJson(value: unknown): string {
|
|
268
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
269
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
270
|
+
const obj = value as Record<string, unknown>;
|
|
271
|
+
return `{${Object.keys(obj)
|
|
272
|
+
.sort()
|
|
273
|
+
.map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`)
|
|
274
|
+
.join(",")}}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A local-first {@link Store} whose base tables are fed by a server's normalized stream.
|
|
278
|
+
* The returned Store is the ordinary `@rindle/client` Store — `store.query…materialize()` and
|
|
279
|
+
* `store.write(…)` work as always; only the backend composition differs. */
|
|
280
|
+
export function createNormalizedStore<S extends ColsMap>(
|
|
281
|
+
schema: Schema<S>,
|
|
282
|
+
source: NormalizedSource,
|
|
283
|
+
): Store<S> {
|
|
284
|
+
return new Store(schema, new NormalizedBackend(schema, source));
|
|
285
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @rindle/normalized — the normalized local-first client glue (NORMALIZED-CHANGES-DESIGN.md §5/§7).
|
|
2
|
+
//
|
|
3
|
+
// - `NormalizedSync` (Slice 4): the cross-query refcount + GC layer + the `NormalizedOp` wire type.
|
|
4
|
+
// - `NormalizedBackend` / `createNormalizedStore` (Slice 5): compose a NORMALIZED server stream
|
|
5
|
+
// (a `NormalizedSource`) with a local `@rindle/wasm` engine, so the ordinary `Store`/`ArrayView`
|
|
6
|
+
// materializes results from base tables fed by the server.
|
|
7
|
+
|
|
8
|
+
export { NormalizedSync } from "./sync.ts";
|
|
9
|
+
export type { ColCounts, NormalizedOp, PkCols } from "./sync.ts";
|
|
10
|
+
|
|
11
|
+
// Synthetic aggregate tables (§3.3): the byte-exact twin of Rust `agg_table_name` /
|
|
12
|
+
// `agg_table_schemas`, plus the local-engine AST rewrite. Exported for tests + advanced glue.
|
|
13
|
+
export { aggTableName, aggTableSchemas, rewriteAggregates } from "./agg-table.ts";
|
|
14
|
+
|
|
15
|
+
export { NormalizedBackend, createNormalizedStore } from "./backend.ts";
|
|
16
|
+
export type { NormalizedEvent, NormalizedSource, NormalizedTableSchema } from "./backend.ts";
|