@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/use-query.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// The ONE read hook. Live-ness is the query's, not the hook's: a live ref subscribes over the
|
|
2
|
+
// page socket, any other is one HTTP read through `@ultimat3/query`'s client (and so through
|
|
3
|
+
// core's one transport). Either way a list is an ORDER of keys and the rows are the page store's,
|
|
4
|
+
// so a record updated anywhere re-renders every list showing it without refetching the list.
|
|
5
|
+
|
|
6
|
+
import { type AsyncState, isSuperseded, type RecordEnvelope, type Row } from '@ultimat3/core/page';
|
|
7
|
+
import { queryClientMethodFor } from '@ultimat3/query/client';
|
|
8
|
+
import type { LiveHandle } from './client-contract';
|
|
9
|
+
import type { JsonValue } from './json';
|
|
10
|
+
import { pageSocket } from './page-socket';
|
|
11
|
+
import { pageStore } from './page-store';
|
|
12
|
+
import { isServerRender, signalFor } from './reactivity';
|
|
13
|
+
import { type RecordStore, recordKey } from './record-store';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What a browser may name a query by: its registered name and two facts the server declaration
|
|
17
|
+
* holds. Never the query VALUE — importing one drags its whole read path into the island
|
|
18
|
+
* (measured 698,801 B for the dummy feed). The type has no server field, which is the rule.
|
|
19
|
+
*/
|
|
20
|
+
export interface QueryRef {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
/** `live: true` on the declaration: rows arrive and move over the page socket. */
|
|
23
|
+
readonly live?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The record type (entity name) a NON-live read's rows are. Named, the list is the keys of the
|
|
26
|
+
* answer's `records[type]` — store records any write moves; omitted, or answered with no records
|
|
27
|
+
* envelope, the list holds its rows itself and only a refetch moves them. A live read needs none.
|
|
28
|
+
*/
|
|
29
|
+
readonly entity?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A callable `AsyncState` over the list, plus the two things a caller does to it. */
|
|
33
|
+
export type QueryAccessor<R extends object = Row> = (() => AsyncState<readonly R[]>) & {
|
|
34
|
+
/** Read again from the first page, keeping the current rows on screen (`refreshing`). */
|
|
35
|
+
refetch(): void;
|
|
36
|
+
/**
|
|
37
|
+
* The next page, appended — from the cursor the server's last page answered with. A no-op on a
|
|
38
|
+
* live read, on a read with no `first`, and once the server said there is no next page.
|
|
39
|
+
*/
|
|
40
|
+
more(): void;
|
|
41
|
+
/** Whether the server's last page said another follows. Reactive, like the accessor itself. */
|
|
42
|
+
hasMore(): boolean;
|
|
43
|
+
release(): void;
|
|
44
|
+
[Symbol.dispose](): void;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const PENDING: AsyncState<never> = Object.freeze({ status: 'pending' });
|
|
48
|
+
const nothing = (): void => undefined;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `input` is read ONCE, at call time: there is no reactive runtime here to re-run it, and a
|
|
52
|
+
* silently stale subscription is worse than a new call. A changed input is a new `useQuery`.
|
|
53
|
+
*/
|
|
54
|
+
export interface QueryOptions {
|
|
55
|
+
/** Read in pages of this many rows (`query.page`); `more()` appends the next. Non-live only. */
|
|
56
|
+
readonly first?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useQuery<R extends object = Row>(
|
|
60
|
+
ref: QueryRef,
|
|
61
|
+
input: JsonValue,
|
|
62
|
+
options: QueryOptions = {},
|
|
63
|
+
): QueryAccessor<R> {
|
|
64
|
+
const signal = signalFor('useQuery');
|
|
65
|
+
if (isServerRender()) {
|
|
66
|
+
return Object.assign((): AsyncState<readonly R[]> => PENDING, {
|
|
67
|
+
refetch: nothing,
|
|
68
|
+
more: nothing,
|
|
69
|
+
hasMore: () => false,
|
|
70
|
+
release: nothing,
|
|
71
|
+
[Symbol.dispose]: nothing,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const [version, setVersion] = signal(0);
|
|
75
|
+
const bump = (): void => setVersion(version() + 1);
|
|
76
|
+
return ref.live === true
|
|
77
|
+
? liveAccessor<R>(pageSocket('useQuery').subscribeLive<R>(ref, input), version, bump)
|
|
78
|
+
: readAccessor<R>(pageStore(), ref, input, options, version, bump);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function liveAccessor<R extends object>(
|
|
82
|
+
handle: LiveHandle<R>,
|
|
83
|
+
version: () => number,
|
|
84
|
+
bump: () => void,
|
|
85
|
+
): QueryAccessor<R> {
|
|
86
|
+
// Seen once a snapshot landed — read or not — so a drop after it keeps the rows on screen.
|
|
87
|
+
let seen = handle.state() === 'live';
|
|
88
|
+
const off = handle.onChange(() => {
|
|
89
|
+
if (handle.state() === 'live') seen = true;
|
|
90
|
+
bump();
|
|
91
|
+
});
|
|
92
|
+
const read = (): AsyncState<readonly R[]> => {
|
|
93
|
+
version();
|
|
94
|
+
const state = handle.state();
|
|
95
|
+
if (state === 'failed') return { status: 'failed', error: handle.error() };
|
|
96
|
+
if (state === 'live') {
|
|
97
|
+
seen = true;
|
|
98
|
+
return { status: 'ready', data: handle.rows() };
|
|
99
|
+
}
|
|
100
|
+
// Loading, stale or offline: what was shown stays shown, marked busy — never torn down.
|
|
101
|
+
return seen ? { status: 'refreshing', data: handle.rows() } : PENDING;
|
|
102
|
+
};
|
|
103
|
+
const release = (): void => {
|
|
104
|
+
off();
|
|
105
|
+
handle.unsubscribe();
|
|
106
|
+
};
|
|
107
|
+
return Object.assign(read, {
|
|
108
|
+
refetch: nothing,
|
|
109
|
+
more: nothing,
|
|
110
|
+
hasMore: () => false,
|
|
111
|
+
release,
|
|
112
|
+
[Symbol.dispose]: release,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readAccessor<R extends object>(
|
|
117
|
+
store: RecordStore,
|
|
118
|
+
ref: QueryRef,
|
|
119
|
+
input: JsonValue,
|
|
120
|
+
options: QueryOptions,
|
|
121
|
+
version: () => number,
|
|
122
|
+
bump: () => void,
|
|
123
|
+
): QueryAccessor<R> {
|
|
124
|
+
const type = ref.entity;
|
|
125
|
+
const method = queryClientMethodFor(ref.name, { baseUrl: '' });
|
|
126
|
+
let state: AsyncState<readonly Row[]> = PENDING;
|
|
127
|
+
/** Record keys in answer order when the rows are records; the rows themselves when not. */
|
|
128
|
+
let keys: readonly string[] = [];
|
|
129
|
+
let own: readonly Row[] = [];
|
|
130
|
+
let released = false;
|
|
131
|
+
let generation = 0;
|
|
132
|
+
/** The cursor the server's last page answered with; `null` = no next page (or not paged). */
|
|
133
|
+
let after: string | null = null;
|
|
134
|
+
const off =
|
|
135
|
+
type === undefined
|
|
136
|
+
? nothing
|
|
137
|
+
: store.subscribe((changed) => {
|
|
138
|
+
if (keys.some((key) => changed.has(recordKey(type, key)))) bump();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const rows = (): readonly Row[] => {
|
|
142
|
+
if (type === undefined || keys.length === 0) return own;
|
|
143
|
+
const out: Row[] = [];
|
|
144
|
+
for (const key of keys) {
|
|
145
|
+
const row = store.peek(type, key);
|
|
146
|
+
if (row !== undefined) out.push(row);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const hold = (next: readonly string[]): void => {
|
|
152
|
+
if (type === undefined) return;
|
|
153
|
+
store.batch(() => {
|
|
154
|
+
for (const key of next) store.retain(type, key);
|
|
155
|
+
for (const key of keys) store.release(type, key);
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One answer: its rows, and — when the server sent the records envelope — the KEYS of this
|
|
161
|
+
* read's records in answer order (`records[type]`, which `rowsOf` fills first-seen = data order).
|
|
162
|
+
* The browser never derives a key: an answer with no records envelope holds its rows itself.
|
|
163
|
+
*/
|
|
164
|
+
const fetch = (
|
|
165
|
+
append: boolean,
|
|
166
|
+
): Promise<{ rows: readonly Row[]; keys: string[] | null; next?: string | null }> => {
|
|
167
|
+
let keysOf: string[] | null = null;
|
|
168
|
+
const onEnvelope = (envelope: RecordEnvelope): void => {
|
|
169
|
+
const records = type === undefined ? undefined : envelope.records?.[type];
|
|
170
|
+
keysOf = records === undefined ? null : Object.keys(records);
|
|
171
|
+
};
|
|
172
|
+
const answered = (rows: readonly Row[]) => ({ rows, keys: keysOf });
|
|
173
|
+
if (options.first === undefined) {
|
|
174
|
+
return (method(input, { onEnvelope }) as Promise<readonly Row[]>).then(answered);
|
|
175
|
+
}
|
|
176
|
+
const controls =
|
|
177
|
+
append && after !== null ? { first: options.first, after } : { first: options.first };
|
|
178
|
+
// The page's cursor travels WITH its rows and is taken only where the rows are: a `more()`
|
|
179
|
+
// a refetch superseded wrote its cursor here, before the generation check discarded its rows.
|
|
180
|
+
return method.page(input, controls, { onEnvelope }).then((page) => ({
|
|
181
|
+
...answered(page.rows as readonly Row[]),
|
|
182
|
+
next: page.hasNextPage ? page.endCursor : null,
|
|
183
|
+
}));
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const load = (append = false): void => {
|
|
187
|
+
const mine = ++generation;
|
|
188
|
+
if (state.status === 'ready') state = { status: 'refreshing', data: rows() };
|
|
189
|
+
bump();
|
|
190
|
+
fetch(append).then(
|
|
191
|
+
(answer) => {
|
|
192
|
+
if (released || mine !== generation) return;
|
|
193
|
+
if (answer.next !== undefined) after = answer.next;
|
|
194
|
+
if (type === undefined || answer.keys === null) {
|
|
195
|
+
// Not records — no type named, or no envelope: the list holds its own rows.
|
|
196
|
+
own = append ? [...own, ...answer.rows] : answer.rows;
|
|
197
|
+
if (!append) {
|
|
198
|
+
hold([]);
|
|
199
|
+
keys = [];
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
// The transport adopted the envelope's records before this ran; the window is their keys.
|
|
203
|
+
const answered = answer.keys;
|
|
204
|
+
const next = append
|
|
205
|
+
? [...keys, ...answered.filter((key) => !keys.includes(key))]
|
|
206
|
+
: answered;
|
|
207
|
+
hold(next);
|
|
208
|
+
keys = next;
|
|
209
|
+
if (!append) own = [];
|
|
210
|
+
}
|
|
211
|
+
state = { status: 'ready', data: [] };
|
|
212
|
+
bump();
|
|
213
|
+
},
|
|
214
|
+
(error: unknown) => {
|
|
215
|
+
if (released || mine !== generation) return;
|
|
216
|
+
// The page changed principal under this read: its answer was the previous one's, and the
|
|
217
|
+
// store it would have landed in is already empty. Read again, as the new principal.
|
|
218
|
+
if (isSuperseded(error)) {
|
|
219
|
+
load();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
state = { status: 'failed', error };
|
|
223
|
+
bump();
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
load();
|
|
229
|
+
const read = (): AsyncState<readonly R[]> => {
|
|
230
|
+
version();
|
|
231
|
+
if (state.status === 'pending' || state.status === 'failed') return state;
|
|
232
|
+
// Rows are typed by the caller's query; the store holds them as JSON rows.
|
|
233
|
+
const data = rows() as readonly R[];
|
|
234
|
+
return state.status === 'refreshing'
|
|
235
|
+
? { status: 'refreshing', data }
|
|
236
|
+
: { status: 'ready', data };
|
|
237
|
+
};
|
|
238
|
+
const release = (): void => {
|
|
239
|
+
if (released) return;
|
|
240
|
+
released = true;
|
|
241
|
+
off();
|
|
242
|
+
hold([]);
|
|
243
|
+
keys = [];
|
|
244
|
+
};
|
|
245
|
+
return Object.assign(read, {
|
|
246
|
+
refetch: () => {
|
|
247
|
+
after = null;
|
|
248
|
+
load();
|
|
249
|
+
},
|
|
250
|
+
more: () => {
|
|
251
|
+
if (after !== null && state.status === 'ready') load(true);
|
|
252
|
+
},
|
|
253
|
+
hasMore: () => {
|
|
254
|
+
version();
|
|
255
|
+
return after !== null;
|
|
256
|
+
},
|
|
257
|
+
release,
|
|
258
|
+
[Symbol.dispose]: release,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// One record, by type and key, out of the page's store — the same object every island showing it
|
|
2
|
+
// holds, moved once by whichever write lands first (an HTTP answer, a socket frame, an optimistic
|
|
3
|
+
// twin). Imports no socket: an island that only reads records ships none of the lifecycle.
|
|
4
|
+
|
|
5
|
+
import type { AsyncState, Row } from '@ultimat3/core/page';
|
|
6
|
+
import { pageStore } from './page-store';
|
|
7
|
+
import { isServerRender, signalFor } from './reactivity';
|
|
8
|
+
import { recordKey } from './record-store';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A callable `AsyncState`: `pending` until the record first arrives, `ready` from then on —
|
|
12
|
+
* `ready` with `undefined` once the server removed it, so a deleted record is a settled answer
|
|
13
|
+
* and never a skeleton forever. `release()` lets go; the caller owns it (Solid: `onCleanup`).
|
|
14
|
+
*/
|
|
15
|
+
export type RecordAccessor<R extends object = Row> = (() => AsyncState<R | undefined>) & {
|
|
16
|
+
release(): void;
|
|
17
|
+
[Symbol.dispose](): void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const PENDING: AsyncState<never> = Object.freeze({ status: 'pending' });
|
|
21
|
+
|
|
22
|
+
/** Nothing was held, so nothing is released — and a teardown never fails a render. */
|
|
23
|
+
const releaseNothing = (): void => undefined;
|
|
24
|
+
|
|
25
|
+
export function useRecord<R extends object = Row>(type: string, key: string): RecordAccessor<R> {
|
|
26
|
+
const signal = signalFor('useRecord');
|
|
27
|
+
if (isServerRender()) {
|
|
28
|
+
// The record arrives in a browser this render does not have: the page's own loading branch.
|
|
29
|
+
return Object.assign((): AsyncState<R | undefined> => PENDING, {
|
|
30
|
+
release: releaseNothing,
|
|
31
|
+
[Symbol.dispose]: releaseNothing,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const store = pageStore();
|
|
35
|
+
const [version, setVersion] = signal(0);
|
|
36
|
+
const target = recordKey(type, key);
|
|
37
|
+
store.retain(type, key);
|
|
38
|
+
// Seen once it has ever been there — read or not — so a removal after it is a settled answer.
|
|
39
|
+
let seen = store.peek(type, key) !== undefined;
|
|
40
|
+
const unsubscribe = store.subscribe((changed) => {
|
|
41
|
+
if (!changed.has(target)) return;
|
|
42
|
+
if (store.peek(type, key) !== undefined) seen = true;
|
|
43
|
+
setVersion(version() + 1);
|
|
44
|
+
});
|
|
45
|
+
const read = (): AsyncState<R | undefined> => {
|
|
46
|
+
version();
|
|
47
|
+
// The store holds JSON rows; the caller names the entity row type it reads them as.
|
|
48
|
+
const row = store.peek(type, key) as R | undefined;
|
|
49
|
+
if (row !== undefined) seen = true;
|
|
50
|
+
return row === undefined && !seen ? PENDING : { status: 'ready', data: row };
|
|
51
|
+
};
|
|
52
|
+
let held = true;
|
|
53
|
+
const release = (): void => {
|
|
54
|
+
if (!held) return;
|
|
55
|
+
held = false;
|
|
56
|
+
unsubscribe();
|
|
57
|
+
store.release(type, key);
|
|
58
|
+
};
|
|
59
|
+
return Object.assign(read, { release, [Symbol.dispose]: release });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A callable `AsyncState` over several records, in the order their keys were given. */
|
|
63
|
+
export type RecordsAccessor<R extends object = Row> = (() => AsyncState<readonly R[]>) & {
|
|
64
|
+
release(): void;
|
|
65
|
+
[Symbol.dispose](): void;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Several records of one type, by key — the same store objects `useRecord` hands out. `pending`
|
|
70
|
+
* until the first of them arrives; then `ready` with those present, in key order: a record the
|
|
71
|
+
* server removed simply drops out, and a list is never shown as its skeleton again.
|
|
72
|
+
*/
|
|
73
|
+
export function useRecords<R extends object = Row>(
|
|
74
|
+
type: string,
|
|
75
|
+
keys: readonly string[],
|
|
76
|
+
): RecordsAccessor<R> {
|
|
77
|
+
const signal = signalFor('useRecords');
|
|
78
|
+
if (isServerRender()) {
|
|
79
|
+
return Object.assign((): AsyncState<readonly R[]> => PENDING, {
|
|
80
|
+
release: releaseNothing,
|
|
81
|
+
[Symbol.dispose]: releaseNothing,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const store = pageStore();
|
|
85
|
+
const [version, setVersion] = signal(0);
|
|
86
|
+
const targets = new Set(keys.map((key) => recordKey(type, key)));
|
|
87
|
+
const present = (): R[] => {
|
|
88
|
+
const out: R[] = [];
|
|
89
|
+
// The store holds JSON rows; the caller names the entity row type it reads them as.
|
|
90
|
+
for (const key of keys) {
|
|
91
|
+
const row = store.peek(type, key) as R | undefined;
|
|
92
|
+
if (row !== undefined) out.push(row);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
store.batch(() => {
|
|
97
|
+
for (const key of keys) store.retain(type, key);
|
|
98
|
+
});
|
|
99
|
+
let seen = present().length > 0;
|
|
100
|
+
const unsubscribe = store.subscribe((changed) => {
|
|
101
|
+
if (![...changed].some((key) => targets.has(key))) return;
|
|
102
|
+
if (present().length > 0) seen = true;
|
|
103
|
+
setVersion(version() + 1);
|
|
104
|
+
});
|
|
105
|
+
const read = (): AsyncState<readonly R[]> => {
|
|
106
|
+
version();
|
|
107
|
+
const rows = present();
|
|
108
|
+
if (rows.length > 0) seen = true;
|
|
109
|
+
return !seen ? PENDING : { status: 'ready', data: rows };
|
|
110
|
+
};
|
|
111
|
+
let held = true;
|
|
112
|
+
const release = (): void => {
|
|
113
|
+
if (!held) return;
|
|
114
|
+
held = false;
|
|
115
|
+
unsubscribe();
|
|
116
|
+
store.batch(() => {
|
|
117
|
+
for (const key of keys) store.release(type, key);
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
return Object.assign(read, { release, [Symbol.dispose]: release });
|
|
121
|
+
}
|
|
@@ -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
|
+
}
|
package/src/wire-read.ts
ADDED
|
@@ -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
|
+
});
|