@ultimat3/realtime 20.2.1 → 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.
Files changed (89) hide show
  1. package/CLAUDE.md +186 -122
  2. package/README.md +121 -126
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +7 -0
  8. package/src/channel-authz.ts +33 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +144 -0
  11. package/src/channel-describe.ts +33 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +116 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +79 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +289 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +138 -195
  26. package/src/cursor.ts +2 -2
  27. package/src/errors.ts +34 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +113 -0
  30. package/src/idb-types.ts +41 -0
  31. package/src/index.ts +80 -74
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +10 -3
  35. package/src/live-fanout.ts +30 -4
  36. package/src/live-record-type.ts +19 -0
  37. package/src/live-rows.ts +70 -67
  38. package/src/local-store-idb.ts +250 -0
  39. package/src/offline-queue.ts +9 -18
  40. package/src/outbox-slot.ts +31 -0
  41. package/src/page-errors.ts +124 -0
  42. package/src/page-outbox.ts +242 -0
  43. package/src/page-socket.ts +108 -0
  44. package/src/page-store.ts +138 -0
  45. package/src/pg-replication.ts +9 -2
  46. package/src/pgoutput.ts +37 -2
  47. package/src/presence.ts +17 -9
  48. package/src/query-window.ts +3 -0
  49. package/src/reactivity.ts +70 -0
  50. package/src/realtime-error.ts +1 -1
  51. package/src/record-await.ts +102 -0
  52. package/src/record-key.ts +34 -0
  53. package/src/record-names.ts +45 -0
  54. package/src/record-persister.ts +156 -0
  55. package/src/record-store.ts +364 -0
  56. package/src/record-synced.ts +100 -0
  57. package/src/record-tx.ts +145 -0
  58. package/src/replicator.ts +7 -1
  59. package/src/server.ts +2 -8
  60. package/src/socket-engine.ts +332 -0
  61. package/src/socket-host.ts +126 -0
  62. package/src/socket-port.ts +55 -0
  63. package/src/socket-routes.ts +170 -0
  64. package/src/socket.ts +51 -12
  65. package/src/sync-auth.ts +2 -2
  66. package/src/sync-frames.ts +41 -114
  67. package/src/sync-meta.ts +42 -0
  68. package/src/sync-node-contract.ts +100 -0
  69. package/src/sync-node.ts +24 -107
  70. package/src/sync-protocol.ts +63 -212
  71. package/src/sync-worker.ts +12 -0
  72. package/src/thundering-herd.ts +19 -1
  73. package/src/type-pins.ts +30 -61
  74. package/src/use-channel.ts +88 -0
  75. package/src/use-connection.ts +59 -0
  76. package/src/use-mutation.ts +214 -0
  77. package/src/use-query.ts +255 -0
  78. package/src/use-record.ts +121 -0
  79. package/src/wire-channel.ts +116 -0
  80. package/src/wire-read.ts +86 -0
  81. package/src/wire-version.ts +44 -0
  82. package/src/client-mutations.ts +0 -114
  83. package/src/client-topics.ts +0 -54
  84. package/src/hooks.ts +0 -277
  85. package/src/identity-map.ts +0 -141
  86. package/src/local-store.ts +0 -241
  87. package/src/query-hook.ts +0 -56
  88. package/src/rebase.ts +0 -263
  89. package/src/server-render-client.ts +0 -96
@@ -0,0 +1,116 @@
1
+ // Decoding the channel frames (plan 101, slices 09–10): `records` writes the page store, `events`
2
+ // never does, `replay-gap` is the node's verdict that a socket lost a `records` frame. Held to the
3
+ // same ceilings as every other kind — a `records` frame is rows a socket could otherwise size.
4
+
5
+ import { isWriteDigest, type Row, WRITE_DIGEST_LENGTH } from '@ultimat3/core/page';
6
+ import type {
7
+ ChannelAdopt,
8
+ ChannelEventsFrame,
9
+ ChannelRecordsFrame,
10
+ ChannelRemove,
11
+ ChannelSince,
12
+ ChannelSubscribeTarget,
13
+ ReplayGapFrame,
14
+ } from './channel-wire';
15
+ import { isJsonObject, type JsonObject } from './json';
16
+ import { fail, list, num, object, str } from './wire-read';
17
+ import { FRAME_LIMITS, PROTOCOL_VERSION } from './wire-version';
18
+
19
+ export function records(parsed: JsonObject): ChannelRecordsFrame {
20
+ const base = {
21
+ type: 'records',
22
+ v: PROTOCOL_VERSION,
23
+ channel: str(parsed, 'channel'),
24
+ seq: num(parsed, 'seq'),
25
+ epoch: str(parsed, 'epoch'),
26
+ } as const;
27
+ const adopt = parsed['adopt'] === undefined ? undefined : adoptOf(parsed['adopt']);
28
+ const remove = parsed['remove'] === undefined ? undefined : removeOf(parsed['remove']);
29
+ const write = parsed['write'];
30
+ if (write !== undefined && !isWriteDigest(write)) {
31
+ throw fail(`records.write must be a ${WRITE_DIGEST_LENGTH}-character lowercase hex digest`);
32
+ }
33
+ return {
34
+ ...base,
35
+ ...(adopt === undefined ? {} : { adopt }),
36
+ ...(remove === undefined ? {} : { remove }),
37
+ ...(write === undefined ? {} : { write }),
38
+ };
39
+ }
40
+
41
+ export function events(parsed: JsonObject): ChannelEventsFrame {
42
+ return {
43
+ type: 'events',
44
+ v: PROTOCOL_VERSION,
45
+ channel: str(parsed, 'channel'),
46
+ event: object(parsed['event']),
47
+ };
48
+ }
49
+
50
+ export function replayGap(parsed: JsonObject): ReplayGapFrame {
51
+ return {
52
+ type: 'replay-gap',
53
+ v: PROTOCOL_VERSION,
54
+ channel: str(parsed, 'channel'),
55
+ epoch: str(parsed, 'epoch'),
56
+ };
57
+ }
58
+
59
+ /** `subscribe.target` for a declared channel: a name, string params (capped), an optional resume. */
60
+ export function channelTarget(value: JsonObject): ChannelSubscribeTarget {
61
+ const raw = object(value['params'] ?? {});
62
+ const entries = Object.entries(raw);
63
+ if (entries.length > FRAME_LIMITS.channelParams) {
64
+ throw fail(
65
+ `channel params carry ${entries.length}, over the limit of ${FRAME_LIMITS.channelParams}`,
66
+ );
67
+ }
68
+ const params: Record<string, string> = {};
69
+ for (const [key, param] of entries) {
70
+ if (typeof param !== 'string') throw fail(`channel param "${key}" must be a string`);
71
+ params[key] = param;
72
+ }
73
+ const base = { kind: 'channel', channel: str(value, 'channel'), params } as const;
74
+ return value['since'] === undefined || value['since'] === null
75
+ ? base
76
+ : { ...base, since: sinceOf(value['since']) };
77
+ }
78
+
79
+ function sinceOf(value: unknown): ChannelSince {
80
+ if (!isJsonObject(value)) throw fail('channel since must be an object');
81
+ const seq = num(value, 'seq');
82
+ if (!Number.isInteger(seq) || seq < 0) throw fail('channel since.seq must be a whole number');
83
+ return { epoch: str(value, 'epoch'), seq };
84
+ }
85
+
86
+ /** type → key → row. Every row an object, and the whole frame under the snapshot row ceiling. */
87
+ function adoptOf(value: unknown): ChannelAdopt {
88
+ if (!isJsonObject(value)) throw fail('records.adopt must be an object');
89
+ let total = 0;
90
+ const out: Record<string, Readonly<Record<string, Row>>> = {};
91
+ for (const [type, keyed] of Object.entries(value)) {
92
+ if (!isJsonObject(keyed)) throw fail(`records.adopt.${type} must be an object`);
93
+ const rows: Record<string, Row> = {};
94
+ for (const [key, row] of Object.entries(keyed)) {
95
+ total += 1;
96
+ if (total > FRAME_LIMITS.rows) {
97
+ throw fail(`records.adopt carries more than ${FRAME_LIMITS.rows} rows`);
98
+ }
99
+ rows[key] = object(row);
100
+ }
101
+ out[type] = rows;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ function removeOf(value: unknown): ChannelRemove {
107
+ if (!isJsonObject(value)) throw fail('records.remove must be an object');
108
+ const out: Record<string, readonly string[]> = {};
109
+ for (const type of Object.keys(value)) {
110
+ out[type] = list(value, type, FRAME_LIMITS.rows, `records.remove.${type}`).map((key) => {
111
+ if (typeof key !== 'string') throw fail(`records.remove.${type} must hold strings`);
112
+ return key;
113
+ });
114
+ }
115
+ return out;
116
+ }
@@ -0,0 +1,86 @@
1
+ // The decoder's primitives: every field a frame carries is read through one of these, and every
2
+ // refusal is `X_PROTOCOL_VERSION` with the field named. Shared by `sync-protocol.ts` and
3
+ // `wire-channel.ts`, so the channel frames are held to the same ceilings as every other kind.
4
+
5
+ import { isJsonObject, type JsonObject, type JsonValue } from './json';
6
+ import { ProtocolVersionError } from './page-errors';
7
+ import { FRAME_LIMITS, PROTOCOL_VERSION } from './wire-version';
8
+
9
+ export function fail(detail: string): ProtocolVersionError {
10
+ return new ProtocolVersionError({ got: detail, expected: PROTOCOL_VERSION, detail });
11
+ }
12
+
13
+ export function str(obj: JsonObject, key: string): string {
14
+ const value = obj[key];
15
+ if (typeof value !== 'string') throw fail(`field "${key}" must be a string`);
16
+ return value;
17
+ }
18
+
19
+ export function nullableStr(obj: JsonObject, key: string): string | null {
20
+ const value = obj[key];
21
+ if (value === null || value === undefined) return null;
22
+ if (typeof value !== 'string') throw fail(`field "${key}" must be a string or null`);
23
+ return value;
24
+ }
25
+
26
+ export function num(obj: JsonObject, key: string): number {
27
+ const value = obj[key];
28
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
29
+ throw fail(`field "${key}" must be a finite number`);
30
+ }
31
+ return value;
32
+ }
33
+
34
+ export function pick<T extends string>(obj: JsonObject, key: string, allowed: readonly T[]): T {
35
+ const value = str(obj, key);
36
+ const found = allowed.find((candidate) => candidate === value);
37
+ if (found === undefined) throw fail(`field "${key}" must be one of ${allowed.join('|')}`);
38
+ return found;
39
+ }
40
+
41
+ /**
42
+ * An array field, with the ceiling the caller had to choose. `max` is required rather than
43
+ * defaulted: a new list field on a new frame is a new thing an authenticated socket can make
44
+ * arbitrarily large, and a default would let one ship without anyone deciding its size.
45
+ */
46
+ export function list(obj: JsonObject, key: string, max: number, label = key): JsonValue[] {
47
+ const value = obj[key];
48
+ if (value === undefined || value === null) return [];
49
+ if (!Array.isArray(value)) throw fail(`field "${label}" must be an array`);
50
+ if (value.length > max) {
51
+ throw fail(`field "${label}" carries ${value.length} entries, over the limit of ${max}`);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ /**
57
+ * A client-supplied value, walked ITERATIVELY to its limits. Iteratively because the thing being
58
+ * refused is a stack overflow: `queryHash` -> `canonicalJson` recurses over exactly this value, so a
59
+ * depth check that recursed would be the same crash one frame earlier.
60
+ */
61
+ export function bounded(value: JsonValue, label: string): JsonValue {
62
+ const stack: { node: JsonValue; depth: number }[] = [{ node: value, depth: 1 }];
63
+ let seen = 0;
64
+ while (stack.length > 0) {
65
+ // `pop` cannot answer undefined here — the loop guard is the length — and the check is what
66
+ // makes that readable to the compiler without a cast.
67
+ const next = stack.pop();
68
+ if (next === undefined) break;
69
+ seen += 1;
70
+ if (seen > FRAME_LIMITS.inputNodes) {
71
+ throw fail(`field "${label}" holds more than ${FRAME_LIMITS.inputNodes} values`);
72
+ }
73
+ if (next.depth > FRAME_LIMITS.inputDepth) {
74
+ throw fail(`field "${label}" is nested deeper than ${FRAME_LIMITS.inputDepth}`);
75
+ }
76
+ if (next.node === null || typeof next.node !== 'object') continue;
77
+ const children = Array.isArray(next.node) ? next.node : Object.values(next.node);
78
+ for (const child of children) stack.push({ node: child, depth: next.depth + 1 });
79
+ }
80
+ return value;
81
+ }
82
+
83
+ export function object(value: unknown): JsonObject {
84
+ if (!isJsonObject(value)) throw fail('expected a JSON object');
85
+ return value;
86
+ }
@@ -0,0 +1,44 @@
1
+ // The wire's version and its hard ceilings — a leaf, so every reader (`sync-protocol.ts`,
2
+ // `wire-read.ts`, `wire-channel.ts`) shares one number and one table with no import cycle.
3
+
4
+ import { CURSOR_ID_LIMIT } from './cursor';
5
+
6
+ /**
7
+ * **3 since 21.0.0** (plan 101), when the socket stopped carrying writes: the `mutate` and
8
+ * `rebase` kinds are deleted, so a client one major behind sends a frame this node refuses with
9
+ * `X_PROTOCOL_VERSION` — its instruction is the rebuild that client needs. Writes are HTTP
10
+ * (`useMutation`). Slice 09's `records` frame reuses this bump; it does not take another.
11
+ *
12
+ * 2 since 2026-08-24, when `cursor.digest` and `cursor.count` were deleted. The version guards
13
+ * incompatibility, never novelty — an additive optional field (`snapshot.entity`) and a removed
14
+ * field read through `list()` (`hello.resume`) both stayed at 1, because `decode` is a whitelist
15
+ * and `list()` answers `[]` for an absent field. `cursor()` is the other kind of reader: it reads
16
+ * through `str`/`num`, which THROW on an absent field, so a cursor without those two is a frame a
17
+ * node or a client one deploy behind cannot read — in BOTH directions, since a cursor rides the
18
+ * client's `subscribe` and the node's `snapshot`. That is exactly what this number refuses, with
19
+ * one instruction instead of a per-frame "field \"digest\" must be a string".
20
+ */
21
+ export const PROTOCOL_VERSION = 3;
22
+
23
+ /**
24
+ * What one frame may contain. Hard ceilings a caller cannot widen — the shape
25
+ * `packages/mcp/src/query-limits.ts` uses — because every one of them is read off a socket the
26
+ * node has already paid for: an unbounded `cursor.ids` was consumed raw into a `Set`, and an
27
+ * `input` of arbitrary depth reached `canonicalJson`, which recurses.
28
+ *
29
+ * Every number clears what this node itself produces, or the decoder refuses its own frames on
30
+ * the next reconnect: `cursorIds` is `CURSOR_ID_LIMIT`, `patches` clears
31
+ * `defaultReconnectBudget.maxPatches`.
32
+ */
33
+ export const FRAME_LIMITS = Object.freeze({
34
+ cursorIds: CURSOR_ID_LIMIT,
35
+ patches: 4_096,
36
+ rows: 10_000,
37
+ members: 4_096,
38
+ /** Nesting one `input` may reach. 32 is far past any query's real argument shape. */
39
+ inputDepth: 32,
40
+ /** Values one `input` may hold in total, so a flat-but-enormous object is refused too. */
41
+ inputNodes: 10_000,
42
+ /** Params one channel subscribe may name. A channel declares a handful; a client picks none. */
43
+ channelParams: 16,
44
+ });
@@ -1,114 +0,0 @@
1
- // The outbound mutation path: the optimistic twin, the durable queue entry, and the sender the
2
- // drain hands each frame to. One file because those are the three places a single intent is
3
- // recorded, and an intent that reaches two of them is the divergence tier 3 exists to prevent.
4
-
5
- import { uuid } from '@ultimat3/core';
6
- import type { ClientSocket, MutatorRef } from './client-contract';
7
- import { TransportUnavailableError } from './errors';
8
- import type { JsonValue } from './json';
9
- import type { LocalStore, TableMap } from './local-store';
10
- import { type MutationSender, mutateFrame, type OfflineQueue } from './offline-queue';
11
- import type { RebaseLog } from './rebase';
12
- import { encode, type Frame } from './sync-protocol';
13
-
14
- /**
15
- * Queued bytes past which the drain stops rather than adds. The same number the node uses at its
16
- * end of the socket (`DEFAULT_MAX_BUFFERED_BYTES`), and deliberately NOT imported from it: this is
17
- * browser code, and `socket.ts` is the node's socket registry, its metrics and its close codes —
18
- * one import pulls the whole server half into the tab's bundle to read an integer. The node's two
19
- * spellings were merged because they configure one buffer on one side; these are two sides.
20
- */
21
- export const MAX_BUFFERED_BYTES = 1024 * 1024;
22
-
23
- /** Everything the mutation path touches. Narrow on purpose, exactly like `ClientFrameTarget`. */
24
- export interface MutationDeps<T extends TableMap = TableMap> {
25
- readonly store: LocalStore<T> | undefined;
26
- readonly queue: OfflineQueue | undefined;
27
- readonly log: RebaseLog<T> | undefined;
28
- readonly now: () => number;
29
- /** Read per send, never captured: the socket a drain started on may already be gone. */
30
- socket(): ClientSocket | null;
31
- send(frame: Frame): void;
32
- }
33
-
34
- /**
35
- * Record one intent everywhere it has to be recorded: the local store (so the UI moves now), the
36
- * rebase log (so it can be taken back) and the durable queue (so it survives the tab). Nothing is
37
- * sent here — `drain` is the only thing that puts a mutation on a socket, and with no queue at all
38
- * (tier 2) the frame goes straight out because there is nothing to drain it from later.
39
- */
40
- export async function recordMutation<T extends TableMap>(
41
- deps: MutationDeps<T>,
42
- mutator: MutatorRef<T>,
43
- input: JsonValue,
44
- key?: string,
45
- ): Promise<void> {
46
- const idempotencyKey = key ?? `${mutator.name}:${uuid()}`;
47
- const { store, queue } = deps;
48
- const local = mutator.local;
49
- const existing = queue?.find(idempotencyKey);
50
- const queued = await queue?.enqueue({
51
- key: idempotencyKey,
52
- name: mutator.name,
53
- input,
54
- at: deps.now(),
55
- });
56
- // Identity, not a second copy of the queue's collapse rule: `enqueue` hands back the SAME entry
57
- // when it collapses and a new one when it does not. A repeated key is ONE intent whose twin is
58
- // already applied — applying it again double-counts the write (a like becomes two) and replaces
59
- // the log entry a rollback would have undone to the pre-mutation row.
60
- const collapsed = existing !== undefined && queued === existing;
61
- if (store && local && !collapsed) {
62
- store.apply(idempotencyKey, (tx) => local(tx, input));
63
- deps.log?.record({
64
- key: idempotencyKey,
65
- seq: queued?.seq ?? 0,
66
- entity: mutator.entity ?? mutator.name,
67
- strategy: mutator.conflict ?? 'server-wins',
68
- apply: (tx) => local(tx, input),
69
- });
70
- }
71
- if (queue) return;
72
- deps.send(
73
- mutateFrame({
74
- key: idempotencyKey,
75
- seq: 0,
76
- name: mutator.name,
77
- input,
78
- enqueuedAt: deps.now(),
79
- attempts: 0,
80
- status: 'pending',
81
- error: null,
82
- }),
83
- );
84
- }
85
-
86
- /**
87
- * The queue's sender. Throwing is how a sender declines: the queue keeps that mutation pending,
88
- * stops the pass rather than reordering the ones behind it, and the next drain resumes there.
89
- *
90
- * Backpressure is a decline and not a failure — the frames already queued in the tab are ones the
91
- * socket has not managed to write, so adding to them is how a client sends a burst it will never
92
- * see acknowledged. A socket that does not report `bufferedAmount` is treated as never backed up.
93
- */
94
- export function mutationSender<T extends TableMap>(deps: MutationDeps<T>): MutationSender {
95
- return async (mutation) => {
96
- const socket = deps.socket();
97
- if (!socket) {
98
- throw new TransportUnavailableError({
99
- transport: 'websocket',
100
- reason: 'the socket went away before this mutation reached it',
101
- fix: 'it stays queued: await useMutationQueue().drain() once useConnection().online',
102
- });
103
- }
104
- const buffered = socket.bufferedAmount ?? 0;
105
- if (buffered > MAX_BUFFERED_BYTES) {
106
- throw new TransportUnavailableError({
107
- transport: 'websocket',
108
- reason: `${buffered} bytes are already queued on this socket, over the ${MAX_BUFFERED_BYTES} ceiling`,
109
- fix: 'it stays queued: await useMutationQueue().drain() once the socket has caught up',
110
- });
111
- }
112
- socket.send(encode(mutateFrame(mutation)));
113
- };
114
- }
@@ -1,54 +0,0 @@
1
- // The client's channel book: which handlers hold which topic, and the one frame that announces a
2
- // membership. Split out of `client.ts` because the announcement has two callers that must never
3
- // disagree — `subscribe()` and the reconnect replay — and one of them was missing.
4
-
5
- import type { Topic } from './channel';
6
- import type { JsonObject } from './json';
7
- import { PROTOCOL_VERSION, type SubscribeFrame } from './sync-protocol';
8
-
9
- export type TopicHandler = (message: JsonObject) => void;
10
-
11
- /**
12
- * The membership frame. `sid` is the topic itself: a channel subscription is identified by what it
13
- * is subscribed to, so re-sending it after a reconnect re-establishes the same membership rather
14
- * than a second one — and on the node, sending it again IS the presence heartbeat.
15
- */
16
- export function topicSubscribeFrame(name: string, op: 'add' | 'drop'): SubscribeFrame {
17
- return {
18
- type: 'subscribe',
19
- v: PROTOCOL_VERSION,
20
- op,
21
- sid: name,
22
- target: { kind: 'topic', topic: name },
23
- };
24
- }
25
-
26
- /** Topic -> the handlers holding it. One entry per topic, however many components subscribed. */
27
- export class TopicBook {
28
- readonly #topics = new Map<string, Set<TopicHandler>>();
29
-
30
- add(name: Topic, handler: TopicHandler): void {
31
- const handlers = this.#topics.get(name) ?? new Set<TopicHandler>();
32
- handlers.add(handler);
33
- this.#topics.set(name, handlers);
34
- }
35
-
36
- /** True when that was the last holder, so the caller is the one that sends the drop frame. */
37
- remove(name: Topic, handler: TopicHandler): boolean {
38
- const handlers = this.#topics.get(name);
39
- if (!handlers) return false;
40
- handlers.delete(handler);
41
- if (handlers.size > 0) return false;
42
- this.#topics.delete(name);
43
- return true;
44
- }
45
-
46
- handlers(name: string): ReadonlySet<TopicHandler> | undefined {
47
- return this.#topics.get(name);
48
- }
49
-
50
- /** Every membership this client still holds — what a reconnect has to re-announce. */
51
- names(): readonly string[] {
52
- return [...this.#topics.keys()];
53
- }
54
- }