@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/sync-protocol.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// The wire
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
// The wire, READ-ONLY for the client: channel and live-query subscriptions go up, snapshots,
|
|
2
|
+
// patches, presence and refusals come down. A client write is HTTP (`useMutation`), never a frame —
|
|
3
|
+
// so there is one write path, with the action's authz, idempotency store and contract behind it.
|
|
4
|
+
|
|
5
|
+
import { renderThrowable, stringField } from '@ultimat3/core/page';
|
|
6
|
+
import type {
|
|
7
|
+
ChannelEventsFrame,
|
|
8
|
+
ChannelRecordsFrame,
|
|
9
|
+
ChannelSubscribeTarget,
|
|
10
|
+
ReplayGapFrame,
|
|
11
|
+
} from './channel-wire';
|
|
12
|
+
import type { LiveCursor } from './cursor';
|
|
12
13
|
import {
|
|
13
14
|
isJsonObject,
|
|
14
15
|
isRow,
|
|
@@ -17,41 +18,19 @@ import {
|
|
|
17
18
|
type Row,
|
|
18
19
|
type RowPatch,
|
|
19
20
|
} from './json';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* What one frame may contain. Hard ceilings a caller cannot widen — the shape
|
|
35
|
-
* `packages/mcp/src/query-limits.ts` uses — because every one of them is read off a socket the
|
|
36
|
-
* node has already paid for: an unbounded `cursor.ids` was consumed raw into a `Set`, and an
|
|
37
|
-
* `input` of arbitrary depth reached `canonicalJson`, which recurses.
|
|
38
|
-
*
|
|
39
|
-
* Every number clears what this node itself produces, or the decoder refuses its own frames on
|
|
40
|
-
* the next reconnect: `cursorIds` is `CURSOR_ID_LIMIT`, `patches` clears
|
|
41
|
-
* `defaultReconnectBudget.maxPatches`.
|
|
42
|
-
*/
|
|
43
|
-
export const FRAME_LIMITS = Object.freeze({
|
|
44
|
-
cursorIds: CURSOR_ID_LIMIT,
|
|
45
|
-
patches: 4_096,
|
|
46
|
-
rows: 10_000,
|
|
47
|
-
members: 4_096,
|
|
48
|
-
/** Nesting one `input` may reach. 32 is far past any query's real argument shape. */
|
|
49
|
-
inputDepth: 32,
|
|
50
|
-
/** Values one `input` may hold in total, so a flat-but-enormous object is refused too. */
|
|
51
|
-
inputNodes: 10_000,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
export type ConflictStrategyName = 'server-wins' | 'last-write-wins' | 'custom';
|
|
21
|
+
import { ProtocolVersionError } from './page-errors';
|
|
22
|
+
import { channelTarget, events, records, replayGap } from './wire-channel';
|
|
23
|
+
import { bounded, fail, list, nullableStr, num, object, pick, str } from './wire-read';
|
|
24
|
+
import { FRAME_LIMITS, PROTOCOL_VERSION } from './wire-version';
|
|
25
|
+
|
|
26
|
+
export type {
|
|
27
|
+
ChannelEventsFrame,
|
|
28
|
+
ChannelRecordsFrame,
|
|
29
|
+
ChannelSubscribeTarget,
|
|
30
|
+
ReplayGapFrame,
|
|
31
|
+
} from './channel-wire';
|
|
32
|
+
/** The version and the ceilings live below this file so the readers can share them without a cycle. */
|
|
33
|
+
export { FRAME_LIMITS, PROTOCOL_VERSION } from './wire-version';
|
|
55
34
|
|
|
56
35
|
export interface WireError {
|
|
57
36
|
readonly code: string;
|
|
@@ -69,7 +48,8 @@ export interface PresenceMember {
|
|
|
69
48
|
}
|
|
70
49
|
|
|
71
50
|
export type SubscribeTarget =
|
|
72
|
-
|
|
51
|
+
/** A declared `channel()`: its NAME and params, never a topic string the client spelled. */
|
|
52
|
+
| ChannelSubscribeTarget
|
|
73
53
|
| {
|
|
74
54
|
/**
|
|
75
55
|
* Client -> server, `qid` carries the *query name*; the server derives the real qid from
|
|
@@ -124,6 +104,12 @@ export interface SnapshotFrame {
|
|
|
124
104
|
* skews are safe in both directions, which is why it carries no `PROTOCOL_VERSION` bump.
|
|
125
105
|
*/
|
|
126
106
|
readonly entity?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Each row's RECORD key, parallel to `rows`, sent only when some key is not its row's `id` (a
|
|
109
|
+
* composite primary key). The server renders it with the entity's projection; the browser never
|
|
110
|
+
* derives a key.
|
|
111
|
+
*/
|
|
112
|
+
readonly keys?: readonly string[];
|
|
127
113
|
}
|
|
128
114
|
|
|
129
115
|
export interface PatchFrame {
|
|
@@ -134,51 +120,18 @@ export interface PatchFrame {
|
|
|
134
120
|
readonly lsn: string;
|
|
135
121
|
}
|
|
136
122
|
|
|
137
|
-
export interface MutateFrame {
|
|
138
|
-
readonly type: 'mutate';
|
|
139
|
-
readonly v: number;
|
|
140
|
-
/** Idempotency key. The server collapses repeats; the client never renumbers. */
|
|
141
|
-
readonly key: string;
|
|
142
|
-
readonly seq: number;
|
|
143
|
-
readonly name: string;
|
|
144
|
-
readonly input: JsonValue;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
123
|
export interface AckFrame {
|
|
148
124
|
readonly type: 'ack';
|
|
149
125
|
readonly v: number;
|
|
150
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* What a refusal refers to: the sid of a subscription the node refused, or the socket id for a
|
|
128
|
+
* frame it could not read at all. The socket carries no writes, so an ack is never a receipt.
|
|
129
|
+
*/
|
|
151
130
|
readonly ref: string;
|
|
152
131
|
readonly lsn: string | null;
|
|
153
132
|
readonly error: WireError | null;
|
|
154
133
|
}
|
|
155
134
|
|
|
156
|
-
export interface RebaseFrame {
|
|
157
|
-
readonly type: 'rebase';
|
|
158
|
-
readonly v: number;
|
|
159
|
-
readonly key: string;
|
|
160
|
-
readonly entity: string;
|
|
161
|
-
readonly strategy: ConflictStrategyName;
|
|
162
|
-
/** Server truth for the row the mutation touched; `null` when the server deleted it. */
|
|
163
|
-
readonly row: Row | null;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export interface PresenceFrame {
|
|
167
|
-
readonly type: 'presence';
|
|
168
|
-
readonly v: number;
|
|
169
|
-
readonly topic: string;
|
|
170
|
-
readonly op: 'join' | 'leave' | 'update' | 'sync';
|
|
171
|
-
readonly members: readonly PresenceMember[];
|
|
172
|
-
/**
|
|
173
|
-
* Members in the whole set behind a `sync` frame, which is capped: a 5,000-avatar row is not a UI
|
|
174
|
-
* anyone renders, and the count is what lets a client say "and 4,744 others" without holding
|
|
175
|
-
* them. Optional and **additive**, exactly like `snapshot.entity`: an old node omits it and a new
|
|
176
|
-
* one reads its absence as "this frame is the whole set", so neither skew is unreadable and
|
|
177
|
-
* `PROTOCOL_VERSION` does not move. Never set on a `join`/`leave`/`update` — those are deltas.
|
|
178
|
-
*/
|
|
179
|
-
readonly total?: number;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
135
|
export interface ReconnectFrame {
|
|
183
136
|
readonly type: 'reconnect';
|
|
184
137
|
readonly v: number;
|
|
@@ -198,12 +151,12 @@ export type Frame =
|
|
|
198
151
|
| SubscribeFrame
|
|
199
152
|
| SnapshotFrame
|
|
200
153
|
| PatchFrame
|
|
201
|
-
| MutateFrame
|
|
202
154
|
| AckFrame
|
|
203
|
-
| RebaseFrame
|
|
204
|
-
| PresenceFrame
|
|
205
155
|
| ReconnectFrame
|
|
206
|
-
| UpdateAvailableFrame
|
|
156
|
+
| UpdateAvailableFrame
|
|
157
|
+
| ChannelRecordsFrame
|
|
158
|
+
| ChannelEventsFrame
|
|
159
|
+
| ReplayGapFrame;
|
|
207
160
|
|
|
208
161
|
export type FrameKind = Frame['type'];
|
|
209
162
|
|
|
@@ -212,12 +165,12 @@ export const FRAME_KINDS: readonly FrameKind[] = [
|
|
|
212
165
|
'subscribe',
|
|
213
166
|
'snapshot',
|
|
214
167
|
'patch',
|
|
215
|
-
'mutate',
|
|
216
168
|
'ack',
|
|
217
|
-
'rebase',
|
|
218
|
-
'presence',
|
|
219
169
|
'reconnect',
|
|
220
170
|
'update-available',
|
|
171
|
+
'records',
|
|
172
|
+
'events',
|
|
173
|
+
'replay-gap',
|
|
221
174
|
];
|
|
222
175
|
|
|
223
176
|
export function encode(frame: Frame): string {
|
|
@@ -265,7 +218,14 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
265
218
|
cursor: cursor(parsed['cursor']),
|
|
266
219
|
} as const;
|
|
267
220
|
const entity = nullableStr(parsed, 'entity');
|
|
268
|
-
|
|
221
|
+
const scoped = entity === null ? base : { ...base, entity };
|
|
222
|
+
if (parsed['keys'] === undefined) return scoped;
|
|
223
|
+
const keys = list(parsed, 'keys', FRAME_LIMITS.rows).map((key) => {
|
|
224
|
+
if (typeof key !== 'string') throw fail('snapshot.keys must hold strings');
|
|
225
|
+
return key;
|
|
226
|
+
});
|
|
227
|
+
if (keys.length !== base.rows.length) throw fail('snapshot.keys must pair with rows');
|
|
228
|
+
return { ...scoped, keys };
|
|
269
229
|
}
|
|
270
230
|
case 'patch':
|
|
271
231
|
return {
|
|
@@ -275,15 +235,6 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
275
235
|
patches: list(parsed, 'patches', FRAME_LIMITS.patches).map(patch),
|
|
276
236
|
lsn: str(parsed, 'lsn'),
|
|
277
237
|
};
|
|
278
|
-
case 'mutate':
|
|
279
|
-
return {
|
|
280
|
-
type: 'mutate',
|
|
281
|
-
v: PROTOCOL_VERSION,
|
|
282
|
-
key: str(parsed, 'key'),
|
|
283
|
-
seq: num(parsed, 'seq'),
|
|
284
|
-
name: str(parsed, 'name'),
|
|
285
|
-
input: bounded(parsed['input'] ?? null, 'input'),
|
|
286
|
-
};
|
|
287
238
|
case 'ack':
|
|
288
239
|
return {
|
|
289
240
|
type: 'ack',
|
|
@@ -292,25 +243,6 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
292
243
|
lsn: nullableStr(parsed, 'lsn'),
|
|
293
244
|
error: wireError(parsed['error']),
|
|
294
245
|
};
|
|
295
|
-
case 'rebase':
|
|
296
|
-
return {
|
|
297
|
-
type: 'rebase',
|
|
298
|
-
v: PROTOCOL_VERSION,
|
|
299
|
-
key: str(parsed, 'key'),
|
|
300
|
-
entity: str(parsed, 'entity'),
|
|
301
|
-
strategy: pick(parsed, 'strategy', ['server-wins', 'last-write-wins', 'custom'] as const),
|
|
302
|
-
row: parsed['row'] === null ? null : row(parsed['row']),
|
|
303
|
-
};
|
|
304
|
-
case 'presence': {
|
|
305
|
-
const base = {
|
|
306
|
-
type: 'presence',
|
|
307
|
-
v: PROTOCOL_VERSION,
|
|
308
|
-
topic: str(parsed, 'topic'),
|
|
309
|
-
op: pick(parsed, 'op', ['join', 'leave', 'update', 'sync'] as const),
|
|
310
|
-
members: list(parsed, 'members', FRAME_LIMITS.members).map(member),
|
|
311
|
-
} as const;
|
|
312
|
-
return parsed['total'] === undefined ? base : { ...base, total: num(parsed, 'total') };
|
|
313
|
-
}
|
|
314
246
|
case 'reconnect':
|
|
315
247
|
return {
|
|
316
248
|
type: 'reconnect',
|
|
@@ -320,6 +252,12 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
320
252
|
};
|
|
321
253
|
case 'update-available':
|
|
322
254
|
return { type: 'update-available', v: PROTOCOL_VERSION, buildId: str(parsed, 'buildId') };
|
|
255
|
+
case 'records':
|
|
256
|
+
return records(parsed);
|
|
257
|
+
case 'events':
|
|
258
|
+
return events(parsed);
|
|
259
|
+
case 'replay-gap':
|
|
260
|
+
return replayGap(parsed);
|
|
323
261
|
default:
|
|
324
262
|
throw fail(`unknown frame type ${JSON.stringify(kind)}`);
|
|
325
263
|
}
|
|
@@ -340,80 +278,6 @@ export function toWireError(error: unknown): WireError {
|
|
|
340
278
|
return docs === undefined ? { code, cause, fix } : { code, cause, fix, docs };
|
|
341
279
|
}
|
|
342
280
|
|
|
343
|
-
function fail(detail: string): ProtocolVersionError {
|
|
344
|
-
return new ProtocolVersionError({ got: detail, expected: PROTOCOL_VERSION, detail });
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function str(obj: JsonObject, key: string): string {
|
|
348
|
-
const value = obj[key];
|
|
349
|
-
if (typeof value !== 'string') throw fail(`field "${key}" must be a string`);
|
|
350
|
-
return value;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function nullableStr(obj: JsonObject, key: string): string | null {
|
|
354
|
-
const value = obj[key];
|
|
355
|
-
if (value === null || value === undefined) return null;
|
|
356
|
-
if (typeof value !== 'string') throw fail(`field "${key}" must be a string or null`);
|
|
357
|
-
return value;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function num(obj: JsonObject, key: string): number {
|
|
361
|
-
const value = obj[key];
|
|
362
|
-
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
363
|
-
throw fail(`field "${key}" must be a finite number`);
|
|
364
|
-
}
|
|
365
|
-
return value;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function pick<T extends string>(obj: JsonObject, key: string, allowed: readonly T[]): T {
|
|
369
|
-
const value = str(obj, key);
|
|
370
|
-
const found = allowed.find((candidate) => candidate === value);
|
|
371
|
-
if (found === undefined) throw fail(`field "${key}" must be one of ${allowed.join('|')}`);
|
|
372
|
-
return found;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* An array field, with the ceiling the caller had to choose. `max` is required rather than
|
|
377
|
-
* defaulted: a new list field on a new frame is a new thing an authenticated socket can make
|
|
378
|
-
* arbitrarily large, and a default would let one ship without anyone deciding its size.
|
|
379
|
-
*/
|
|
380
|
-
function list(obj: JsonObject, key: string, max: number, label = key): JsonValue[] {
|
|
381
|
-
const value = obj[key];
|
|
382
|
-
if (value === undefined || value === null) return [];
|
|
383
|
-
if (!Array.isArray(value)) throw fail(`field "${label}" must be an array`);
|
|
384
|
-
if (value.length > max) {
|
|
385
|
-
throw fail(`field "${label}" carries ${value.length} entries, over the limit of ${max}`);
|
|
386
|
-
}
|
|
387
|
-
return value;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* A client-supplied value, walked ITERATIVELY to its limits. Iteratively because the thing being
|
|
392
|
-
* refused is a stack overflow: `queryHash` -> `canonicalJson` recurses over exactly this value, so a
|
|
393
|
-
* depth check that recursed would be the same crash one frame earlier.
|
|
394
|
-
*/
|
|
395
|
-
function bounded(value: JsonValue, label: string): JsonValue {
|
|
396
|
-
const stack: { node: JsonValue; depth: number }[] = [{ node: value, depth: 1 }];
|
|
397
|
-
let seen = 0;
|
|
398
|
-
while (stack.length > 0) {
|
|
399
|
-
// `pop` cannot answer undefined here — the loop guard is the length — and the check is what
|
|
400
|
-
// makes that readable to the compiler without a cast.
|
|
401
|
-
const next = stack.pop();
|
|
402
|
-
if (next === undefined) break;
|
|
403
|
-
seen += 1;
|
|
404
|
-
if (seen > FRAME_LIMITS.inputNodes) {
|
|
405
|
-
throw fail(`field "${label}" holds more than ${FRAME_LIMITS.inputNodes} values`);
|
|
406
|
-
}
|
|
407
|
-
if (next.depth > FRAME_LIMITS.inputDepth) {
|
|
408
|
-
throw fail(`field "${label}" is nested deeper than ${FRAME_LIMITS.inputDepth}`);
|
|
409
|
-
}
|
|
410
|
-
if (next.node === null || typeof next.node !== 'object') continue;
|
|
411
|
-
const children = Array.isArray(next.node) ? next.node : Object.values(next.node);
|
|
412
|
-
for (const child of children) stack.push({ node: child, depth: next.depth + 1 });
|
|
413
|
-
}
|
|
414
|
-
return value;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
281
|
function row(value: unknown): Row {
|
|
418
282
|
if (!isRow(value)) throw fail('row must be an object with a string "id"');
|
|
419
283
|
return value;
|
|
@@ -440,23 +304,15 @@ function patch(value: unknown): RowPatch {
|
|
|
440
304
|
row: value['row'] === null || value['row'] === undefined ? null : object(value['row']),
|
|
441
305
|
lsn: str(value, 'lsn'),
|
|
442
306
|
};
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
function member(value: unknown): PresenceMember {
|
|
447
|
-
if (!isJsonObject(value)) throw fail('presence member must be an object');
|
|
448
|
-
return {
|
|
449
|
-
id: str(value, 'id'),
|
|
450
|
-
actorId: nullableStr(value, 'actorId'),
|
|
451
|
-
meta: object(value['meta'] ?? {}),
|
|
452
|
-
updatedAt: num(value, 'updatedAt'),
|
|
453
|
-
};
|
|
307
|
+
const indexed = value['index'] === undefined ? base : { ...base, index: num(value, 'index') };
|
|
308
|
+
const key = nullableStr(value, 'key');
|
|
309
|
+
return key === null ? indexed : { ...indexed, key };
|
|
454
310
|
}
|
|
455
311
|
|
|
456
312
|
function target(value: unknown): SubscribeTarget {
|
|
457
313
|
if (!isJsonObject(value)) throw fail('subscribe.target must be an object');
|
|
458
|
-
const kind = pick(value, 'kind', ['
|
|
459
|
-
if (kind === '
|
|
314
|
+
const kind = pick(value, 'kind', ['query', 'channel'] as const);
|
|
315
|
+
if (kind === 'channel') return channelTarget(value);
|
|
460
316
|
return {
|
|
461
317
|
kind,
|
|
462
318
|
qid: str(value, 'qid'),
|
|
@@ -466,11 +322,6 @@ function target(value: unknown): SubscribeTarget {
|
|
|
466
322
|
};
|
|
467
323
|
}
|
|
468
324
|
|
|
469
|
-
function object(value: unknown): JsonObject {
|
|
470
|
-
if (!isJsonObject(value)) throw fail('expected a JSON object');
|
|
471
|
-
return value;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
325
|
function wireError(value: unknown): WireError | null {
|
|
475
326
|
if (value === null || value === undefined) return null;
|
|
476
327
|
if (!isJsonObject(value)) throw fail('ack.error must be an object or null');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The SharedWorker entry (plan 101, slice 11): every tab of this origin and principal connects a
|
|
2
|
+
// port, and the one engine behind them holds the one socket. `x build` bundles this file as a
|
|
3
|
+
// classic script at `/_x/sync-worker/<hash>.js`. No other code lives here.
|
|
4
|
+
|
|
5
|
+
import { browserSocket, dialUrl } from './browser-socket';
|
|
6
|
+
import { messagePort, SocketEngine } from './socket-engine';
|
|
7
|
+
|
|
8
|
+
const engine = new SocketEngine({ dial: (target) => browserSocket(dialUrl(target)) });
|
|
9
|
+
|
|
10
|
+
(globalThis as { onconnect?: (event: MessageEvent) => void }).onconnect = (event) => {
|
|
11
|
+
for (const port of event.ports) engine.attach(messagePort(port));
|
|
12
|
+
};
|
package/src/thundering-herd.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
type JitterMode,
|
|
14
14
|
type Random,
|
|
15
15
|
systemClock,
|
|
16
|
-
} from '@ultimat3/core';
|
|
16
|
+
} from '@ultimat3/core/page';
|
|
17
17
|
import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
18
18
|
|
|
19
19
|
/** Injected so tests are deterministic and `local` mutators stay replayable. */
|
|
@@ -40,6 +40,24 @@ export const defaultBackoff: BackoffPolicy = {
|
|
|
40
40
|
jitter: 'full',
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
/** The longest a browser waits between two dials, whatever the attempt. */
|
|
44
|
+
export const BROWSER_RECONNECT_MAX_MS = 4_000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A browser's redial: the same curve, capped at seconds rather than `defaultBackoff`'s thirty.
|
|
48
|
+
* Measured after a deploy — six dials in ~3s, then a full-jitter roll under a 30s cap left the
|
|
49
|
+
* node that came back (and the `update-available` it had for the tab) unreached for 27s. `equal`
|
|
50
|
+
* jitter, because at the cap it keeps a floor: a SIGKILLed node's herd redials inside a 2-4s
|
|
51
|
+
* window rather than at once, and the `AcceptBudget` sheds the excess before any query runs. A
|
|
52
|
+
* PLANNED restart never reaches this: the drain's `reconnect` frame assigns each socket its slot.
|
|
53
|
+
*/
|
|
54
|
+
export const browserBackoff: BackoffPolicy = {
|
|
55
|
+
baseMs: 500,
|
|
56
|
+
maxMs: BROWSER_RECONNECT_MAX_MS,
|
|
57
|
+
factor: 2,
|
|
58
|
+
jitter: 'equal',
|
|
59
|
+
};
|
|
60
|
+
|
|
43
61
|
/**
|
|
44
62
|
* Attempt is 0-BASED. Result is always in `[0, maxMs]`.
|
|
45
63
|
*
|
package/src/type-pins.ts
CHANGED
|
@@ -1,83 +1,52 @@
|
|
|
1
|
-
// Compile-time pins for the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// (axiom 3). What it protects is the whole reason `liveHookFor` exists: `useLiveFeed({ orgId })`
|
|
6
|
-
// carrying the query's own input and row types. Lose that and the hook still runs — it just
|
|
7
|
-
// stops catching the typo that makes a subscription match nothing.
|
|
1
|
+
// Compile-time pins for the hook surface. Source, not a `.test.ts`, on purpose: `tsconfig.json`
|
|
2
|
+
// excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a type-level assertion
|
|
3
|
+
// written there can never fail. This module emits nothing anybody imports — a regression is a
|
|
4
|
+
// build error, the only enforcement that counts (axiom 3).
|
|
8
5
|
|
|
9
|
-
import type {
|
|
10
|
-
import type {
|
|
11
|
-
import type {
|
|
12
|
-
import type {
|
|
6
|
+
import type { AsyncState, Row } from '@ultimat3/core';
|
|
7
|
+
import type { LiveHandle, Unsubscribe } from './client-contract';
|
|
8
|
+
import type { QueryAccessor, QueryRef } from './use-query';
|
|
9
|
+
import type { RecordAccessor } from './use-record';
|
|
13
10
|
|
|
14
11
|
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
15
12
|
type Assert<T extends true> = T;
|
|
16
13
|
|
|
17
14
|
type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
18
15
|
|
|
19
|
-
/** The input type a `Query` accepts, read off its call signature rather than its schema. */
|
|
20
|
-
type InputOf<Q> = Q extends (input: infer I, options?: never) => unknown ? I : never;
|
|
21
|
-
|
|
22
|
-
interface FeedInput {
|
|
23
|
-
readonly orgId: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
16
|
interface FeedRow {
|
|
27
17
|
readonly id: string;
|
|
28
18
|
readonly title: string;
|
|
29
19
|
}
|
|
30
20
|
|
|
31
|
-
type FeedHook = LiveQueryHook<FeedInput, FeedRow>;
|
|
32
|
-
|
|
33
|
-
/** The hook takes the query's own input, as a value or as the thunk `useLive` reads once. */
|
|
34
|
-
export type _HookInputIsTheQueryInput = Assert<
|
|
35
|
-
Equals<Parameters<FeedHook>[0], FeedInput | (() => FeedInput)>
|
|
36
|
-
>;
|
|
37
|
-
|
|
38
|
-
/** …and answers in the query's own row type, not the wire's `Row`. */
|
|
39
|
-
export type _HookRowsAreTheQueryRows = Assert<
|
|
40
|
-
Equals<ReturnType<ReturnType<FeedHook>>, readonly FeedRow[]>
|
|
41
|
-
>;
|
|
42
|
-
|
|
43
21
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
22
|
+
* `useQuery` answers core's `AsyncState` over the caller's own row type — the value `@ultimat3/ui`
|
|
23
|
+
* takes as `state`, with no adapter between them (plan 101, decision 10).
|
|
46
24
|
*/
|
|
47
|
-
export type
|
|
48
|
-
|
|
25
|
+
export type _QueryAnswersAsyncState = Assert<
|
|
26
|
+
Equals<ReturnType<QueryAccessor<FeedRow>>, AsyncState<readonly FeedRow[]>>
|
|
49
27
|
>;
|
|
50
28
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
* place a change to `Query` — losing `isLive`, ceasing to be callable — fails, instead of every
|
|
55
|
-
* component call site in every app.
|
|
56
|
-
*/
|
|
57
|
-
export type _DeclaredQueryBindsToTheHook = Assert<
|
|
58
|
-
[Query] extends [LiveQuerySource<InputOf<Query>, Record<string, unknown>>] ? true : false
|
|
29
|
+
/** …and `useRecord` the same vocabulary, with `undefined` for a record the server removed. */
|
|
30
|
+
export type _RecordAnswersAsyncState = Assert<
|
|
31
|
+
Equals<ReturnType<RecordAccessor<FeedRow>>, AsyncState<FeedRow | undefined>>
|
|
59
32
|
>;
|
|
60
33
|
|
|
61
34
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* `[Symbol.dispose]` member while refactoring `unsubscribe`.
|
|
35
|
+
* A query ref carries no server field: a `Query` VALUE in an island drags its whole read path into
|
|
36
|
+
* the bundle, so the ref is a name and two declared facts. A `sql` key is refused.
|
|
65
37
|
*/
|
|
66
|
-
export type
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
/** `channel.subscribe()`'s return must stay both callable and `Disposable`. */
|
|
72
|
-
export type _UnsubscribeIsDisposable = Assert<[Unsubscribe] extends [Disposable] ? true : false>;
|
|
38
|
+
export type _QueryRefHasNoServerHalf = Assert<
|
|
39
|
+
[{ readonly name: string; readonly sql: () => unknown }] extends [Required<QueryRef>]
|
|
40
|
+
? false
|
|
41
|
+
: true
|
|
42
|
+
>;
|
|
73
43
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
* `hooks.ts` needs and `LiveClient` stops providing fails HERE, at the build, rather than at the
|
|
79
|
-
* one app that registered a real client.
|
|
80
|
-
*/
|
|
81
|
-
export type _LiveClientSatisfiesTheHookSeam = Assert<
|
|
82
|
-
[LiveClient] extends [LiveClientLike] ? true : false
|
|
44
|
+
/** Every handle a caller gets back stays `Disposable`, so `using` releases it on scope exit. */
|
|
45
|
+
export type _LiveHandleIsDisposable = Assert<[LiveHandle] extends [Disposable] ? true : false>;
|
|
46
|
+
export type _QueryAccessorIsDisposable = Assert<
|
|
47
|
+
[QueryAccessor] extends [Disposable] ? true : false
|
|
83
48
|
>;
|
|
49
|
+
export type _RecordAccessorIsDisposable = Assert<
|
|
50
|
+
[RecordAccessor<Row>] extends [Disposable] ? true : false
|
|
51
|
+
>;
|
|
52
|
+
export type _UnsubscribeIsDisposable = Assert<[Unsubscribe] extends [Disposable] ? true : false>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// A declared channel, held by a component. Every holder of one topic on the page shares ONE
|
|
2
|
+
// membership on the page socket; its `records` land in the store (read them with `useRecord` /
|
|
3
|
+
// `useQuery`), and only `events` and presence reach the handlers given here.
|
|
4
|
+
|
|
5
|
+
import type { ChannelHandlers, ChannelRef, ChannelState } from './client-channels';
|
|
6
|
+
import { pageSocket } from './page-socket';
|
|
7
|
+
import { isServerRender, signalFor } from './reactivity';
|
|
8
|
+
import type { PresenceMember } from './sync-protocol';
|
|
9
|
+
|
|
10
|
+
/** The membership's state, callable, plus its release (Solid: `onCleanup`). */
|
|
11
|
+
export type ChannelAccessor = (() => ChannelState) & {
|
|
12
|
+
release(): void;
|
|
13
|
+
[Symbol.dispose](): void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const nothing = (): void => undefined;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Takes the `channel()` DECLARATION (any `ChannelRef` — a declaration is one), never a topic
|
|
20
|
+
* string: the declaration is the only thing that may spell a topic (`bun run channel-literals`
|
|
21
|
+
* holds it), so both halves spell it one way.
|
|
22
|
+
*/
|
|
23
|
+
export function useChannel<K extends string>(
|
|
24
|
+
declared: ChannelRef<K>,
|
|
25
|
+
params: Readonly<Record<K, string>>,
|
|
26
|
+
handlers?: ChannelHandlers,
|
|
27
|
+
): ChannelAccessor {
|
|
28
|
+
const signal = signalFor('useChannel');
|
|
29
|
+
if (isServerRender()) {
|
|
30
|
+
return Object.assign((): ChannelState => 'joining', {
|
|
31
|
+
release: nothing,
|
|
32
|
+
[Symbol.dispose]: nothing,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const membership = pageSocket('useChannel').holdChannel(declared, params, handlers);
|
|
36
|
+
const [version, setVersion] = signal(0);
|
|
37
|
+
const off = membership.onChange(() => setVersion(version() + 1));
|
|
38
|
+
const read = (): ChannelState => {
|
|
39
|
+
version();
|
|
40
|
+
return membership.state();
|
|
41
|
+
};
|
|
42
|
+
const release = (): void => {
|
|
43
|
+
off();
|
|
44
|
+
membership.release();
|
|
45
|
+
};
|
|
46
|
+
return Object.assign(read, { release, [Symbol.dispose]: release });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Who is in a channel's room, as the node's presence set says — one entry per member id. */
|
|
50
|
+
export type PresenceAccessor = (() => readonly PresenceMember[]) & {
|
|
51
|
+
release(): void;
|
|
52
|
+
[Symbol.dispose](): void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The roster of a channel declared with `events: true`: a `sync` replaces it, `join` / `update`
|
|
57
|
+
* upsert a member, `leave` removes one. It is a membership of the channel like any other — the
|
|
58
|
+
* same one `useChannel` holds, shared on the page — so holding both costs one subscribe.
|
|
59
|
+
*/
|
|
60
|
+
export function usePresence<K extends string>(
|
|
61
|
+
declared: ChannelRef<K>,
|
|
62
|
+
params: Readonly<Record<K, string>>,
|
|
63
|
+
): PresenceAccessor {
|
|
64
|
+
const signal = signalFor('usePresence');
|
|
65
|
+
if (isServerRender()) {
|
|
66
|
+
return Object.assign((): readonly PresenceMember[] => [], {
|
|
67
|
+
release: nothing,
|
|
68
|
+
[Symbol.dispose]: nothing,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const [version, setVersion] = signal(0);
|
|
72
|
+
let members = new Map<string, PresenceMember>();
|
|
73
|
+
const membership = pageSocket('usePresence').holdChannel(declared, params, {
|
|
74
|
+
onPresence: (event) => {
|
|
75
|
+
if (event.presence === 'sync') members = new Map();
|
|
76
|
+
for (const member of event.members) {
|
|
77
|
+
if (event.presence === 'leave') members.delete(member.id);
|
|
78
|
+
else members.set(member.id, member);
|
|
79
|
+
}
|
|
80
|
+
setVersion(version() + 1);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const read = (): readonly PresenceMember[] => {
|
|
84
|
+
version();
|
|
85
|
+
return [...members.values()];
|
|
86
|
+
};
|
|
87
|
+
return Object.assign(read, { release: membership.release, [Symbol.dispose]: membership.release });
|
|
88
|
+
}
|