@ultimat3/realtime 20.2.0 → 21.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 +186 -122
- package/README.md +121 -126
- 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 +7 -0
- package/src/channel-authz.ts +33 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +144 -0
- package/src/channel-describe.ts +33 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +116 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +79 -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 +289 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +138 -195
- package/src/cursor.ts +2 -2
- package/src/errors.ts +34 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +113 -0
- package/src/idb-types.ts +41 -0
- package/src/index.ts +80 -74
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +10 -3
- package/src/live-fanout.ts +30 -4
- package/src/live-record-type.ts +19 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +250 -0
- package/src/offline-queue.ts +9 -18
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +242 -0
- package/src/page-socket.ts +108 -0
- package/src/page-store.ts +138 -0
- package/src/pg-replication.ts +9 -2
- package/src/pgoutput.ts +37 -2
- package/src/presence.ts +17 -9
- package/src/query-window.ts +3 -0
- 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 +7 -1
- package/src/server.ts +2 -8
- package/src/socket-engine.ts +332 -0
- package/src/socket-host.ts +126 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +51 -12
- 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 +24 -107
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +19 -1
- 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 +214 -0
- package/src/use-query.ts +255 -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/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
|
-
}
|