@ultimat3/realtime 20.2.1 → 22.0.0
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/CLAUDE.md +300 -952
- package/README.md +192 -131
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +14 -1
- package/src/channel-authz.ts +52 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +155 -0
- package/src/channel-describe.ts +35 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +134 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +87 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +359 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +150 -195
- package/src/cursor.ts +7 -2
- package/src/errors.ts +55 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +133 -0
- package/src/idb-types.ts +48 -0
- package/src/index.ts +80 -75
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +15 -4
- package/src/live-fanout.ts +81 -6
- package/src/live-query.ts +11 -0
- package/src/live-record-type.ts +19 -0
- package/src/live-replicator.ts +160 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +324 -0
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +85 -39
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +312 -0
- package/src/page-socket.ts +139 -0
- package/src/page-store.ts +138 -0
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-preflight.ts +24 -2
- package/src/pg-replication.ts +28 -8
- package/src/pg-wire.ts +51 -15
- package/src/pgoutput.ts +37 -2
- package/src/policy-fake.ts +14 -0
- package/src/presence.ts +17 -9
- package/src/query-window.ts +38 -21
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +20 -4
- package/src/server.ts +10 -11
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +344 -0
- package/src/socket-host.ts +225 -0
- package/src/socket-idle.ts +21 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +91 -49
- package/src/subscriber-gate.ts +92 -3
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +26 -114
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +31 -12
- package/src/transport-env.ts +55 -14
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +227 -0
- package/src/use-query.ts +260 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- package/src/server-render-client.ts +0 -96
package/src/local-store.ts
DELETED
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
// Tier 3: the durable local store a `mutator`'s `local(tx, input)` half writes to.
|
|
2
|
-
//
|
|
3
|
-
// `local` must be replayable — no I/O, no Date.now(), no Math.random() — because rebase replays it.
|
|
4
|
-
// That is why every write goes through a journal keyed by the mutation's idempotency key: rollback
|
|
5
|
-
// is "undo this key's journal in reverse", not "re-fetch and hope".
|
|
6
|
-
//
|
|
7
|
-
// A table owns MEMBERSHIP (which ids it holds); the shared `IdentityMap` owns the VALUES, so an
|
|
8
|
-
// optimistic write and the live query rendering that row are one row, not two copies.
|
|
9
|
-
|
|
10
|
-
import { NotImplementedError } from './errors';
|
|
11
|
-
import { IdentityMap } from './identity-map';
|
|
12
|
-
import type { Row } from './json';
|
|
13
|
-
|
|
14
|
-
export interface LocalTable<R extends Row = Row> {
|
|
15
|
-
get(id: string): R | undefined;
|
|
16
|
-
all(): readonly R[];
|
|
17
|
-
insert(row: R): void;
|
|
18
|
-
upsert(row: R): void;
|
|
19
|
-
/** `patch` returns changed fields only, mirroring the canonical mutator example. */
|
|
20
|
-
update(id: string, patch: (row: R) => Partial<R>): void;
|
|
21
|
-
delete(id: string): void;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export type TableMap = Record<string, Row>;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The transaction handle passed to `local`. In a generated app the table map comes from the app's
|
|
28
|
-
* entities, so `tx.posts` is a real property with a real row type — never an index signature.
|
|
29
|
-
*/
|
|
30
|
-
export type LocalTx<T extends TableMap = TableMap> = { readonly [K in keyof T]: LocalTable<T[K]> };
|
|
31
|
-
|
|
32
|
-
interface JournalEntry {
|
|
33
|
-
readonly table: string;
|
|
34
|
-
readonly id: string;
|
|
35
|
-
/** Row state before the write; `undefined` means "did not exist" (so undo = drop membership). */
|
|
36
|
-
readonly before: Row | undefined;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface LocalStore<T extends TableMap = TableMap> {
|
|
40
|
-
/**
|
|
41
|
-
* The one map every row value lives in, shared with the live-query windows on the same client.
|
|
42
|
-
* A `LiveClient` reads it off the store rather than building its own — two identity maps in one
|
|
43
|
-
* client is the bug an identity map exists to prevent, one level up.
|
|
44
|
-
*/
|
|
45
|
-
readonly identity: IdentityMap;
|
|
46
|
-
readonly tx: LocalTx<T>;
|
|
47
|
-
table(name: string): LocalTable;
|
|
48
|
-
/** Runs `fn` while journalling every write under `key`, so it can be rolled back verbatim. */
|
|
49
|
-
apply(key: string, fn: (tx: LocalTx<T>) => void): void;
|
|
50
|
-
/** Undo one key's writes, newest first. Used by rebase before reapplying pending mutations. */
|
|
51
|
-
rollback(key: string): void;
|
|
52
|
-
/** Server confirmed: drop the journal. After this the write is no longer optimistic. */
|
|
53
|
-
commit(key: string): void;
|
|
54
|
-
pendingKeys(): readonly string[];
|
|
55
|
-
snapshot(name: string): readonly Row[];
|
|
56
|
-
reset(tables: Readonly<Record<string, readonly Row[]>>): void;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* The reference implementation. Every rule in the tier-3 contract (journalling, ordered undo, key
|
|
61
|
-
* scoping) is implemented here; OPFS SQLite swaps the storage, not the semantics.
|
|
62
|
-
*/
|
|
63
|
-
export class MemoryLocalStore<T extends TableMap = TableMap> implements LocalStore<T> {
|
|
64
|
-
/** Membership and nothing else: `#members.get('posts')` is which ids this table holds. */
|
|
65
|
-
readonly #members = new Map<string, Set<string>>();
|
|
66
|
-
readonly #journals = new Map<string, JournalEntry[]>();
|
|
67
|
-
#recordingKey: string | null = null;
|
|
68
|
-
|
|
69
|
-
readonly identity: IdentityMap;
|
|
70
|
-
readonly tx: LocalTx<T>;
|
|
71
|
-
|
|
72
|
-
constructor(tables: Readonly<Record<string, readonly Row[]>> = {}, identity = new IdentityMap()) {
|
|
73
|
-
this.identity = identity;
|
|
74
|
-
this.reset(tables);
|
|
75
|
-
const handler: ProxyHandler<Record<string, LocalTable>> = {
|
|
76
|
-
// Symbols (`Symbol.iterator`, `then`) must not resolve to a table, or awaiting a tx would
|
|
77
|
-
// silently create one.
|
|
78
|
-
get: (_target, property) => (typeof property === 'symbol' ? undefined : this.table(property)),
|
|
79
|
-
};
|
|
80
|
-
this.tx = new Proxy({} as Record<string, LocalTable>, handler) as LocalTx<T>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
table(name: string): LocalTable {
|
|
84
|
-
const ids = this.#ids(name);
|
|
85
|
-
const read = (id: string): Row | undefined =>
|
|
86
|
-
ids.has(id) ? this.identity.peek(name, id) : undefined;
|
|
87
|
-
return {
|
|
88
|
-
get: read,
|
|
89
|
-
all: () => {
|
|
90
|
-
const rows: Row[] = [];
|
|
91
|
-
for (const id of ids) {
|
|
92
|
-
const row = this.identity.peek(name, id);
|
|
93
|
-
if (row !== undefined) rows.push(row);
|
|
94
|
-
}
|
|
95
|
-
return rows;
|
|
96
|
-
},
|
|
97
|
-
insert: (row) => {
|
|
98
|
-
this.#journal(name, row.id, read(row.id));
|
|
99
|
-
this.#join(name, row.id, ids);
|
|
100
|
-
this.identity.set(name, row);
|
|
101
|
-
},
|
|
102
|
-
upsert: (row) => {
|
|
103
|
-
this.#journal(name, row.id, read(row.id));
|
|
104
|
-
this.#join(name, row.id, ids);
|
|
105
|
-
this.identity.merge(name, row.id, row);
|
|
106
|
-
},
|
|
107
|
-
update: (id, patch) => {
|
|
108
|
-
const current = read(id);
|
|
109
|
-
if (!current) return;
|
|
110
|
-
this.#journal(name, id, current);
|
|
111
|
-
this.identity.merge(name, id, patch(current));
|
|
112
|
-
},
|
|
113
|
-
delete: (id) => {
|
|
114
|
-
const current = read(id);
|
|
115
|
-
if (!current) return;
|
|
116
|
-
this.#journal(name, id, current);
|
|
117
|
-
this.#leave(name, id, ids);
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
apply(key: string, fn: (tx: LocalTx<T>) => void): void {
|
|
123
|
-
const previous = this.#recordingKey;
|
|
124
|
-
this.#recordingKey = key;
|
|
125
|
-
if (!this.#journals.has(key)) this.#journals.set(key, []);
|
|
126
|
-
try {
|
|
127
|
-
// One notification for the whole twin: a mutator touching twenty rows is one render.
|
|
128
|
-
this.identity.batch(() => fn(this.tx));
|
|
129
|
-
} finally {
|
|
130
|
-
this.#recordingKey = previous;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
rollback(key: string): void {
|
|
135
|
-
const journal = this.#journals.get(key);
|
|
136
|
-
if (!journal) return;
|
|
137
|
-
this.identity.batch(() => {
|
|
138
|
-
for (let i = journal.length - 1; i >= 0; i -= 1) {
|
|
139
|
-
const entry = journal[i];
|
|
140
|
-
if (!entry) continue;
|
|
141
|
-
const ids = this.#ids(entry.table);
|
|
142
|
-
if (entry.before === undefined) {
|
|
143
|
-
this.#leave(entry.table, entry.id, ids);
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
this.#join(entry.table, entry.id, ids);
|
|
147
|
-
this.identity.set(entry.table, entry.before);
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
this.#journals.delete(key);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
commit(key: string): void {
|
|
154
|
-
this.#journals.delete(key);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
pendingKeys(): readonly string[] {
|
|
158
|
-
return [...this.#journals.keys()];
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
snapshot(name: string): readonly Row[] {
|
|
162
|
-
return this.table(name).all();
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
reset(tables: Readonly<Record<string, readonly Row[]>>): void {
|
|
166
|
-
this.identity.batch(() => {
|
|
167
|
-
for (const [name, ids] of this.#members) {
|
|
168
|
-
for (const id of ids) this.identity.release(name, id);
|
|
169
|
-
}
|
|
170
|
-
this.#members.clear();
|
|
171
|
-
this.#journals.clear();
|
|
172
|
-
for (const [name, rows] of Object.entries(tables)) {
|
|
173
|
-
const ids = this.#ids(name);
|
|
174
|
-
for (const row of rows) {
|
|
175
|
-
this.#join(name, row.id, ids);
|
|
176
|
-
this.identity.set(name, row);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
#ids(name: string): Set<string> {
|
|
183
|
-
const existing = this.#members.get(name);
|
|
184
|
-
if (existing) return existing;
|
|
185
|
-
const created = new Set<string>();
|
|
186
|
-
this.#members.set(name, created);
|
|
187
|
-
return created;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** Membership is what holds a value in the map, so joining and retaining are one step. */
|
|
191
|
-
#join(name: string, id: string, ids: Set<string>): void {
|
|
192
|
-
if (ids.has(id)) return;
|
|
193
|
-
ids.add(id);
|
|
194
|
-
this.identity.retain(name, id);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** Leaving releases: the value survives only while a live window still holds the same row. */
|
|
198
|
-
#leave(name: string, id: string, ids: Set<string>): void {
|
|
199
|
-
if (!ids.delete(id)) return;
|
|
200
|
-
this.identity.release(name, id);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Only the *first* write to a row within one key is journalled — undo must reach the base state. */
|
|
204
|
-
#journal(table: string, id: string, before: Row | undefined): void {
|
|
205
|
-
const key = this.#recordingKey;
|
|
206
|
-
if (key === null) return;
|
|
207
|
-
const journal = this.#journals.get(key);
|
|
208
|
-
if (!journal) return;
|
|
209
|
-
if (journal.some((entry) => entry.table === table && entry.id === id)) return;
|
|
210
|
-
journal.push(before === undefined ? { table, id, before: undefined } : { table, id, before });
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export interface OpfsLocalStoreOptions {
|
|
215
|
-
/** OPFS file name, versioned so a client-side migration can run before first read. */
|
|
216
|
-
readonly file: string;
|
|
217
|
-
readonly schemaVersion: number;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* The production tier-3 store: SQLite over the Origin Private File System, opened in a worker so a
|
|
222
|
-
* long write never blocks the main thread. Browser-only, so it must not be reachable from a server
|
|
223
|
-
* bundle — which is why it is a factory that throws rather than a class you can accidentally new
|
|
224
|
-
* on the server.
|
|
225
|
-
*
|
|
226
|
-
* The refusal below is the whole of what tier 3's durable half ships today, so its two lines have
|
|
227
|
-
* to be true of THIS build. Both were false until 2026-08-23: the fix told the caller to import
|
|
228
|
-
* this factory from a `/browser` subpath, which `package.json`'s `exports` has never declared —
|
|
229
|
-
* two entries ship, `.` and `./server` — so pasting it ended in a module-resolution failure. Its
|
|
230
|
-
* alternative was `persist: false` on the query, which `query()` has never accepted either (a
|
|
231
|
-
* `TS2353` excess property). An instruction that cannot run is axiom 4 failing in the package that
|
|
232
|
-
* documents it, so the fix now names an export that exists on an entry that exists:
|
|
233
|
-
* `MemoryLocalStore`, on `.`, declared beside this factory. `fix-specifier.test.ts` is the
|
|
234
|
-
* mechanical half.
|
|
235
|
-
*/
|
|
236
|
-
export function createOpfsLocalStore(options: OpfsLocalStoreOptions): LocalStore {
|
|
237
|
-
throw new NotImplementedError({
|
|
238
|
-
what: `the OPFS SQLite local store for ${options.file} (schema v${options.schemaVersion}), which is realtime tier 3's durable half,`,
|
|
239
|
-
fix: "replace createOpfsLocalStore(...) with new MemoryLocalStore(), imported from '@ultimat3/realtime' beside it: the same LocalStore contract — journalled writes, ordered rollback, one row value per (entity, id) — held for the tab's lifetime rather than across a reload",
|
|
240
|
-
});
|
|
241
|
-
}
|
package/src/query-hook.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
// The typed projection of a query into the hook a component calls: `liveHookFor(liveFeed)` is
|
|
2
|
-
// `useLiveFeed`, and `useLiveFeed({ orgId })` carries that query's own input and row types. It
|
|
3
|
-
// binds `useLive` rather than re-implementing it — one subscribe path, given the query's name.
|
|
4
|
-
|
|
5
|
-
import { QueryNotSubscribableError } from './errors';
|
|
6
|
-
import { type LiveInput, type LiveRows, useLive } from './hooks';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* What the hook needs from a `@ultimat3/query` `Query`: the name it subscribes under, the declared
|
|
10
|
-
* `live:` flag, and the call signature both types are read off. Named structurally rather than
|
|
11
|
-
* imported, the way `hooks.ts` names a mutator — a hook is browser code, and a value import of
|
|
12
|
-
* `@ultimat3/query` would carry the server's read path into the bundle.
|
|
13
|
-
*
|
|
14
|
-
* `options` is `never` because this side never passes one; a `Query`, whose second parameter is
|
|
15
|
-
* optional and wider, still assigns.
|
|
16
|
-
*/
|
|
17
|
-
export interface LiveQuerySource<TInput, TRow extends object> {
|
|
18
|
-
(input: TInput, options?: never): Promise<readonly TRow[]>;
|
|
19
|
-
readonly name: string;
|
|
20
|
-
readonly isLive: boolean;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* The bound hook. Input is the query's own — a wrong key is a compile error in the component, not
|
|
25
|
-
* a subscription that returns nothing. A thunk is read **once**, at subscribe time, exactly as
|
|
26
|
-
* `useLive`'s is: there is no reactive runtime here to re-run it, so new input means a new
|
|
27
|
-
* subscription.
|
|
28
|
-
*/
|
|
29
|
-
export type LiveQueryHook<TInput, TRow extends object> = (
|
|
30
|
-
input: TInput | (() => TInput),
|
|
31
|
-
) => LiveRows<TRow>;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Bind one live query to one hook: `export const useLiveFeed = liveHookFor(liveFeed)`, then
|
|
35
|
-
* `useLiveFeed({ orgId })` in a component. Nothing is generated and nothing is fetched by hand —
|
|
36
|
-
* the types come off the query declaration and the rows off its subscription.
|
|
37
|
-
*
|
|
38
|
-
* Binding a query that is not `live: true` throws here, at module load, rather than handing back a
|
|
39
|
-
* hook that could only ever return an empty set.
|
|
40
|
-
*/
|
|
41
|
-
export function liveHookFor<TInput, TRow extends object>(
|
|
42
|
-
query: LiveQuerySource<TInput, TRow>,
|
|
43
|
-
): LiveQueryHook<TInput, TRow> {
|
|
44
|
-
if (!query.isLive) throw new QueryNotSubscribableError({ name: query.name });
|
|
45
|
-
// The query object is handed through, never its `name` read now: `registerQueries()` stamps that
|
|
46
|
-
// at boot, and this binding runs at import — earlier. `useLive` reads it per subscription.
|
|
47
|
-
return (input) => {
|
|
48
|
-
// Both assertions are the one wire seam: the input is about to be serialised into a subscribe
|
|
49
|
-
// frame, and the rows come back off that subscription. `unknown` in between rather than a
|
|
50
|
-
// direct cast, exactly as `query.client()` hops through it at the same seam.
|
|
51
|
-
const rows: unknown = useLive(query, input as LiveInput);
|
|
52
|
-
// Erased at the wire seam, the way `query.client()` erases its own: the rows arriving on this
|
|
53
|
-
// subscription are this query's by construction, because the server built them from its `sql`.
|
|
54
|
-
return rows as LiveRows<TRow>;
|
|
55
|
-
};
|
|
56
|
-
}
|
package/src/rebase.ts
DELETED
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
// Tier 3: the reconcile loop. The server rebases; the client rolls back and reapplies.
|
|
2
|
-
//
|
|
3
|
-
// Truth is always the server — a client is never the merge authority. So reconciliation is exactly:
|
|
4
|
-
// undo the optimistic writes from the acknowledged mutation onward (newest first), land server
|
|
5
|
-
// truth, then replay the still-pending mutators in sequence order. Because `local` is a pure
|
|
6
|
-
// function of `(tx, input)`, that replay is deterministic; that purity rule is the whole reason
|
|
7
|
-
// tier 3 is affordable.
|
|
8
|
-
|
|
9
|
-
import { RebaseConflictError } from './errors';
|
|
10
|
-
import type { Row } from './json';
|
|
11
|
-
import type { LocalStore, LocalTx, TableMap } from './local-store';
|
|
12
|
-
import { type ConflictStrategyName, PROTOCOL_VERSION, type RebaseFrame } from './sync-protocol';
|
|
13
|
-
|
|
14
|
-
export interface MergeArgs {
|
|
15
|
-
/** Local row as the user last saw it, before any rollback. */
|
|
16
|
-
readonly local: Row | undefined;
|
|
17
|
-
/** Local row after the optimistic writes were undone — the shared base. */
|
|
18
|
-
readonly base: Row | undefined;
|
|
19
|
-
/** Server truth. `null` means the server deleted the row. */
|
|
20
|
-
readonly server: Row | null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface CustomMerge {
|
|
24
|
-
readonly kind: 'custom';
|
|
25
|
-
merge(args: MergeArgs): Row | null | undefined;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** `conflict: custom(merge)` — exactly the name the mutator contract uses. */
|
|
29
|
-
export function custom(merge: (args: MergeArgs) => Row | null | undefined): CustomMerge {
|
|
30
|
-
return { kind: 'custom', merge };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export type ConflictStrategy = 'server-wins' | 'last-write-wins' | CustomMerge;
|
|
34
|
-
|
|
35
|
-
export function strategyName(strategy: ConflictStrategy): ConflictStrategyName {
|
|
36
|
-
return typeof strategy === 'string' ? strategy : 'custom';
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface RebaseEntry<T extends TableMap = TableMap> {
|
|
40
|
-
/** Idempotency key — the same key the offline queue uses. */
|
|
41
|
-
readonly key: string;
|
|
42
|
-
readonly seq: number;
|
|
43
|
-
readonly entity: string;
|
|
44
|
-
readonly strategy: ConflictStrategy;
|
|
45
|
-
/** The mutator's `local` half, curried with its input. Pure, therefore replayable. */
|
|
46
|
-
apply(tx: LocalTx<T>): void;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Optimistic writes awaiting server truth, ordered by client sequence. */
|
|
50
|
-
export class RebaseLog<T extends TableMap = TableMap> {
|
|
51
|
-
readonly #entries = new Map<string, RebaseEntry<T>>();
|
|
52
|
-
|
|
53
|
-
record(entry: RebaseEntry<T>): void {
|
|
54
|
-
this.#entries.set(entry.key, entry);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
get(key: string): RebaseEntry<T> | undefined {
|
|
58
|
-
return this.#entries.get(key);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
drop(key: string): void {
|
|
62
|
-
this.#entries.delete(key);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
pending(): readonly RebaseEntry<T>[] {
|
|
66
|
-
return [...this.#entries.values()].sort((a, b) => a.seq - b.seq);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
get size(): number {
|
|
70
|
-
return this.#entries.size;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export interface ServerAck {
|
|
75
|
-
readonly key: string;
|
|
76
|
-
readonly entity: string;
|
|
77
|
-
readonly id: string;
|
|
78
|
-
readonly row: Row | null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface ReconcileOptions {
|
|
82
|
-
/** Field compared by `last-write-wins`. Must be a number (epoch ms) written by the server. */
|
|
83
|
-
readonly clockField?: string;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export interface ReconcileResult {
|
|
87
|
-
readonly strategy: ConflictStrategyName;
|
|
88
|
-
readonly rolledBack: readonly string[];
|
|
89
|
-
readonly reapplied: readonly string[];
|
|
90
|
-
readonly winner: 'server' | 'local' | 'merge';
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* One acknowledgement, one rebase. Rolls back the acked mutation and every later optimistic write,
|
|
95
|
-
* lands server truth under the mutator's conflict strategy, then replays the rest in sequence order.
|
|
96
|
-
*/
|
|
97
|
-
export function reconcile<T extends TableMap = TableMap>(
|
|
98
|
-
args: {
|
|
99
|
-
store: LocalStore<T>;
|
|
100
|
-
log: RebaseLog<T>;
|
|
101
|
-
ack: ServerAck;
|
|
102
|
-
},
|
|
103
|
-
options: ReconcileOptions = {},
|
|
104
|
-
): ReconcileResult {
|
|
105
|
-
const { store, log, ack } = args;
|
|
106
|
-
const entry = log.get(ack.key);
|
|
107
|
-
const strategy = entry?.strategy ?? 'server-wins';
|
|
108
|
-
const table = store.table(ack.entity);
|
|
109
|
-
const local = table.get(ack.id);
|
|
110
|
-
|
|
111
|
-
const { affected, rolledBack } = undoFrom(store, log, entry?.seq ?? 0);
|
|
112
|
-
|
|
113
|
-
const base = store.table(ack.entity).get(ack.id);
|
|
114
|
-
const winner = land(store, ack, strategy, { local, base }, options);
|
|
115
|
-
log.drop(ack.key);
|
|
116
|
-
|
|
117
|
-
return {
|
|
118
|
-
strategy: strategyName(strategy),
|
|
119
|
-
rolledBack,
|
|
120
|
-
reapplied: replayExcept(store, affected, ack.key),
|
|
121
|
-
winner,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Everything at or after `from` is optimistic, so it is undone newest-first — and `reconcile` and
|
|
127
|
-
* `rollbackMutation` are one rule with different middles, not two. Spelled twice, the next change
|
|
128
|
-
* to the replay order has to be made twice, and the half that is missed diverges silently.
|
|
129
|
-
*/
|
|
130
|
-
function undoFrom<T extends TableMap>(
|
|
131
|
-
store: LocalStore<T>,
|
|
132
|
-
log: RebaseLog<T>,
|
|
133
|
-
from: number,
|
|
134
|
-
): { affected: readonly RebaseEntry<T>[]; rolledBack: string[] } {
|
|
135
|
-
const affected = log.pending().filter((candidate) => candidate.seq >= from);
|
|
136
|
-
const rolledBack: string[] = [];
|
|
137
|
-
for (const candidate of [...affected].reverse()) {
|
|
138
|
-
store.rollback(candidate.key);
|
|
139
|
-
rolledBack.push(candidate.key);
|
|
140
|
-
}
|
|
141
|
-
return { affected, rolledBack };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* The other half: replay in sequence order, skipping the one the server has now settled. `local` is
|
|
146
|
-
* pure, which is what makes replaying it deterministic and therefore safe to do at all.
|
|
147
|
-
*/
|
|
148
|
-
function replayExcept<T extends TableMap>(
|
|
149
|
-
store: LocalStore<T>,
|
|
150
|
-
affected: readonly RebaseEntry<T>[],
|
|
151
|
-
settled: string,
|
|
152
|
-
): string[] {
|
|
153
|
-
const reapplied: string[] = [];
|
|
154
|
-
for (const candidate of affected) {
|
|
155
|
-
if (candidate.key === settled) continue;
|
|
156
|
-
store.apply(candidate.key, (tx) => candidate.apply(tx));
|
|
157
|
-
reapplied.push(candidate.key);
|
|
158
|
-
}
|
|
159
|
-
return reapplied;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export interface RollbackResult {
|
|
163
|
-
readonly rolledBack: readonly string[];
|
|
164
|
-
readonly reapplied: readonly string[];
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* The other half of `reconcile`: the server **refused** a mutation, so there is no server truth to
|
|
169
|
-
* land — only an optimistic write to take back. Same shape as a reconcile, and for the same reason:
|
|
170
|
-
* the writes made after it may depend on it, so everything from its sequence onward is undone
|
|
171
|
-
* newest-first and then replayed without it. Replay is deterministic because `local` is pure.
|
|
172
|
-
*
|
|
173
|
-
* Idempotent for a key the log does not hold: a denial can arrive twice, and tier 2 records nothing
|
|
174
|
-
* to undo in the first place.
|
|
175
|
-
*/
|
|
176
|
-
export function rollbackMutation<T extends TableMap = TableMap>(args: {
|
|
177
|
-
store: LocalStore<T>;
|
|
178
|
-
log: RebaseLog<T>;
|
|
179
|
-
key: string;
|
|
180
|
-
}): RollbackResult {
|
|
181
|
-
const { store, log, key } = args;
|
|
182
|
-
const entry = log.get(key);
|
|
183
|
-
if (!entry) return { rolledBack: [], reapplied: [] };
|
|
184
|
-
|
|
185
|
-
const { affected, rolledBack } = undoFrom(store, log, entry.seq);
|
|
186
|
-
// The one thing that differs from `reconcile`'s middle: there is no server truth to land. Dropped
|
|
187
|
-
// and never retried — a denial is a decision about this intent, so replaying it on the next
|
|
188
|
-
// reconcile would put the write the server refused back on the screen.
|
|
189
|
-
log.drop(key);
|
|
190
|
-
return { rolledBack, reapplied: replayExcept(store, affected, key) };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function land<T extends TableMap>(
|
|
194
|
-
store: LocalStore<T>,
|
|
195
|
-
ack: ServerAck,
|
|
196
|
-
strategy: ConflictStrategy,
|
|
197
|
-
rows: { local: Row | undefined; base: Row | undefined },
|
|
198
|
-
options: ReconcileOptions,
|
|
199
|
-
): 'server' | 'local' | 'merge' {
|
|
200
|
-
const table = store.table(ack.entity);
|
|
201
|
-
|
|
202
|
-
if (strategy === 'server-wins') {
|
|
203
|
-
write(store, ack.entity, ack.id, ack.row);
|
|
204
|
-
return 'server';
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (strategy === 'last-write-wins') {
|
|
208
|
-
const field = options.clockField ?? 'updatedAt';
|
|
209
|
-
const localAt = numberAt(rows.local, field);
|
|
210
|
-
const serverAt = numberAt(ack.row, field);
|
|
211
|
-
if (localAt !== null && serverAt !== null && localAt > serverAt) {
|
|
212
|
-
// The local write is newer by the server's own clock field: keep it, do not clobber.
|
|
213
|
-
if (rows.local) table.upsert(rows.local);
|
|
214
|
-
return 'local';
|
|
215
|
-
}
|
|
216
|
-
write(store, ack.entity, ack.id, ack.row);
|
|
217
|
-
return 'server';
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const merged = strategy.merge({ local: rows.local, base: rows.base, server: ack.row });
|
|
221
|
-
if (merged === undefined) {
|
|
222
|
-
throw new RebaseConflictError({
|
|
223
|
-
key: ack.key,
|
|
224
|
-
entity: ack.entity,
|
|
225
|
-
reason: 'custom(merge) returned undefined; return a row, or null to accept the delete',
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
write(store, ack.entity, ack.id, merged);
|
|
229
|
-
return 'merge';
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function write<T extends TableMap>(
|
|
233
|
-
store: LocalStore<T>,
|
|
234
|
-
entity: string,
|
|
235
|
-
id: string,
|
|
236
|
-
row: Row | null,
|
|
237
|
-
): void {
|
|
238
|
-
const table = store.table(entity);
|
|
239
|
-
if (row === null) table.delete(id);
|
|
240
|
-
else table.upsert(row);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function numberAt(row: Row | null | undefined, field: string): number | null {
|
|
244
|
-
if (!row) return null;
|
|
245
|
-
const value = row[field];
|
|
246
|
-
return typeof value === 'number' ? value : null;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* `RebaseFrame`, not `Frame`: this builds exactly one member of the union and declaring the whole
|
|
251
|
-
* union threw that away, so every caller had to re-narrow a frame it had just constructed before it
|
|
252
|
-
* could read `strategy` or `row` back off it.
|
|
253
|
-
*/
|
|
254
|
-
export function rebaseFrame(ack: ServerAck, strategy: ConflictStrategy): RebaseFrame {
|
|
255
|
-
return {
|
|
256
|
-
type: 'rebase',
|
|
257
|
-
v: PROTOCOL_VERSION,
|
|
258
|
-
key: ack.key,
|
|
259
|
-
entity: ack.entity,
|
|
260
|
-
strategy: strategyName(strategy),
|
|
261
|
-
row: ack.row,
|
|
262
|
-
};
|
|
263
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
// What a live client IS on the server: one that serves the first render and opens no socket.
|
|
2
|
-
//
|
|
3
|
-
// The rule it exists for is `@ultimat3/ui`'s, one package over — no runtime and no DOM is a SERVER
|
|
4
|
-
// RENDER, and a server render gets an honest account of itself rather than a throw. A page whose
|
|
5
|
-
// whole body reads a live query could not server-render at all before this: `useConnection()` threw
|
|
6
|
-
// `X_LIVE_CLIENT_MISSING` and the route answered 500 (issue #271).
|
|
7
|
-
//
|
|
8
|
-
// It implements `LiveClientLike` and imports NO connection lifecycle — no `LiveClient`, no
|
|
9
|
-
// heartbeat, no wire protocol. Measured: reaching the class from here costs every island that
|
|
10
|
-
// calls `useLive` 18 kB it can never run.
|
|
11
|
-
|
|
12
|
-
import type { LiveClientLike, LiveHandle, LiveQueryRef, SignalFactory } from './client-contract';
|
|
13
|
-
import { ServerRenderLiveError } from './errors';
|
|
14
|
-
import type { JsonValue, Row } from './json';
|
|
15
|
-
import type { LiveState } from './live-rows';
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* A signal that never changes, because nothing on the server can change it: one render, one pass,
|
|
19
|
-
* no reactive runtime. The setter is kept rather than dropped so a caller that writes through it
|
|
20
|
-
* reads its own write back — a signal that swallowed writes would be a different lie.
|
|
21
|
-
*/
|
|
22
|
-
const inertSignal: SignalFactory = <T>(initial: T): [() => T, (next: T) => void] => {
|
|
23
|
-
let held = initial;
|
|
24
|
-
return [
|
|
25
|
-
(): T => held,
|
|
26
|
-
(next: T): void => {
|
|
27
|
-
held = next;
|
|
28
|
-
},
|
|
29
|
-
];
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/** Frozen, so a handle a page holds cannot be turned into a result set by writing to it. */
|
|
33
|
-
const NO_ROWS: readonly Row[] = Object.freeze([]);
|
|
34
|
-
|
|
35
|
-
/** Nothing was subscribed, so nothing is released — and a teardown never fails a render. */
|
|
36
|
-
const releaseNothing = (): void => undefined;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* The handle a server render gets for a live query: `loading`, never `offline` and never `live`.
|
|
40
|
-
*
|
|
41
|
-
* That is the one honest state — the rows arrive over a socket this render does not have, so the
|
|
42
|
-
* page's own loading fallback is what the document carries until hydration replaces it. `offline`
|
|
43
|
-
* would be read as a SETTLED answer (`state() !== 'loading'` is the gate `examples/dummy`'s feed
|
|
44
|
-
* uses), so an empty result set would render "you have no posts" for a feed that has some.
|
|
45
|
-
*/
|
|
46
|
-
function serverRenderHandle<R extends Row>(): LiveHandle<R> {
|
|
47
|
-
return {
|
|
48
|
-
rows: () => NO_ROWS as readonly R[],
|
|
49
|
-
state: (): LiveState => 'loading',
|
|
50
|
-
cursor: () => null,
|
|
51
|
-
unsubscribe: releaseNothing,
|
|
52
|
-
[Symbol.dispose]: releaseNothing,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Every member that can only mean "talk to the socket" refuses; every member a render READS
|
|
58
|
-
* answers what a server render actually is.
|
|
59
|
-
*
|
|
60
|
-
* `connected: true` is not a lie about the socket — `useConnection().offline` is a banner about
|
|
61
|
-
* THIS visitor's connectivity, and the request being served is the proof it is up. Answering
|
|
62
|
-
* `false` would server-render "you are offline" into every document, for a reader who is not, and
|
|
63
|
-
* then remove it on hydrate.
|
|
64
|
-
*
|
|
65
|
-
* It registers nothing, which is what makes ONE instance per process safe under concurrent
|
|
66
|
-
* renders: a client that kept a registration per `useLive` would grow by one entry per request,
|
|
67
|
-
* forever, and hold a row window with each.
|
|
68
|
-
*/
|
|
69
|
-
function build(): LiveClientLike {
|
|
70
|
-
return {
|
|
71
|
-
signal: inertSignal,
|
|
72
|
-
queue: undefined,
|
|
73
|
-
connected: true,
|
|
74
|
-
reconnectAt: () => null,
|
|
75
|
-
appUpdateAvailable: () => null,
|
|
76
|
-
useLive: <R extends Row>(_query: LiveQueryRef, _input: JsonValue): LiveHandle<R> =>
|
|
77
|
-
serverRenderHandle<R>(),
|
|
78
|
-
mutate: (): Promise<void> => {
|
|
79
|
-
throw new ServerRenderLiveError({ operation: 'mutate()' });
|
|
80
|
-
},
|
|
81
|
-
drain: (): Promise<void> => {
|
|
82
|
-
throw new ServerRenderLiveError({ operation: 'drain()' });
|
|
83
|
-
},
|
|
84
|
-
// A listener is accepted and never called: nothing on the server can change a queue that does
|
|
85
|
-
// not exist. Refusing here would break `setLiveClient`, which registers one unconditionally.
|
|
86
|
-
onQueueChange: () => releaseNothing,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
let held: LiveClientLike | null = null;
|
|
91
|
-
|
|
92
|
-
/** ONE per process, built on first use. It holds nothing per request — see `build` above. */
|
|
93
|
-
export function serverRenderLiveClient(): LiveClientLike {
|
|
94
|
-
held ??= build();
|
|
95
|
-
return held;
|
|
96
|
-
}
|