@ultimat3/realtime 29.4.0 → 30.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 +13 -16
- package/README.md +45 -5
- package/package.json +15 -5
- package/src/client-contract.ts +12 -0
- package/src/client.ts +18 -8
- package/src/device-agent.ts +174 -0
- package/src/device-bus.ts +258 -0
- package/src/device-declarer.ts +32 -0
- package/src/device-errors.ts +89 -0
- package/src/device-host.ts +288 -0
- package/src/device-socket.ts +386 -0
- package/src/device-upgrade.ts +218 -0
- package/src/device-wire.ts +155 -0
- package/src/error-titles.ts +8 -0
- package/src/index.ts +8 -1
- package/src/live-contract.ts +15 -1
- package/src/live-definition.ts +15 -3
- package/src/live-fanout.ts +40 -0
- package/src/live-query.ts +42 -2
- package/src/live-spend.ts +2 -0
- package/src/offline-queue.ts +5 -0
- package/src/outbox-slot.ts +7 -1
- package/src/page-errors.ts +15 -0
- package/src/page-outbox.ts +16 -9
- package/src/pinned-wire.ts +64 -0
- package/src/query-poll.ts +19 -0
- package/src/query-window.ts +39 -7
- package/src/server.ts +43 -0
- package/src/socket.ts +1 -1
- package/src/sync-frames.ts +8 -0
- package/src/sync-node-bounds.ts +20 -0
- package/src/sync-node.ts +10 -2
- package/src/sync-origin.ts +5 -3
- package/src/sync-protocol.ts +18 -3
- package/src/use-mutation.ts +22 -5
- package/src/use-query.ts +82 -23
package/src/query-window.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// read once for N subscribers, and how one that is known to be wrong is replaced. The authz
|
|
3
3
|
// decision is never here — `live-query.ts` owns that, once per subscriber, over what this returns.
|
|
4
4
|
|
|
5
|
+
import type { LiveReadPhase } from '@ultimat3/query';
|
|
5
6
|
import { WindowReadTimeoutError } from './errors';
|
|
6
7
|
import type { JsonValue, Row } from './json';
|
|
7
8
|
import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
|
|
@@ -64,10 +65,16 @@ export interface QueryEntry {
|
|
|
64
65
|
* can answer: a definition with no lsn provider returns `''` from every snapshot.
|
|
65
66
|
*/
|
|
66
67
|
generation: number;
|
|
67
|
-
/** The
|
|
68
|
+
/** The newest read settled into `rows`, or discarded as behind them. `0` before the first. */
|
|
68
69
|
applied: number;
|
|
69
70
|
readonly readDeadlineMs: number;
|
|
70
71
|
readonly schedule: Scheduler;
|
|
72
|
+
/**
|
|
73
|
+
* The newest subscriber's `traceparent`, raw (`noteTraceparent`). A subscribe's read is that
|
|
74
|
+
* trace's child; a re-run a change forced LINKS to it. One per window, because the read is: N
|
|
75
|
+
* subscribers share one read, and it can parent on one trace only.
|
|
76
|
+
*/
|
|
77
|
+
traceparent: string | null;
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
/**
|
|
@@ -119,9 +126,24 @@ export function queryEntry(
|
|
|
119
126
|
applied: 0,
|
|
120
127
|
readDeadlineMs: usableDeadline(options?.readDeadlineMs),
|
|
121
128
|
schedule: options?.schedule ?? timeoutScheduler,
|
|
129
|
+
traceparent: null,
|
|
122
130
|
};
|
|
123
131
|
}
|
|
124
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Remember the subscriber's `traceparent` for the window's next read. Bounded and never parsed
|
|
135
|
+
* here: a value is either a string a W3C header could be or it is dropped, and `traceLiveRead`
|
|
136
|
+
* ignores one that does not parse — telemetry never fails a subscribe.
|
|
137
|
+
*/
|
|
138
|
+
export function noteTraceparent(entry: QueryEntry, traceparent: string | null | undefined): void {
|
|
139
|
+
if (typeof traceparent === 'string' && traceparent.length <= TRACEPARENT_MAX) {
|
|
140
|
+
entry.traceparent = traceparent;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `00-<32>-<16>-<2>` is 55 characters; anything far past it is not one. */
|
|
145
|
+
const TRACEPARENT_MAX = 128;
|
|
146
|
+
|
|
125
147
|
/**
|
|
126
148
|
* The window this subscriber is served from, read once per entry. A subscriber arriving while
|
|
127
149
|
* another's read is in flight joins that read rather than issuing its own — N cold subscribers on
|
|
@@ -137,7 +159,7 @@ export async function fillWindow(entry: QueryEntry): Promise<FilledWindow> {
|
|
|
137
159
|
// Read before `startRead` clears it: a second caller arriving during the read joins it and is
|
|
138
160
|
// not the one that forced it, which is what keeps one forced read from becoming N.
|
|
139
161
|
const forced = entry.stale;
|
|
140
|
-
const pending = forced || entry.reading === null ? startRead(entry) : entry.reading;
|
|
162
|
+
const pending = forced || entry.reading === null ? startRead(entry, 'snapshot') : entry.reading;
|
|
141
163
|
const result = await pending.result;
|
|
142
164
|
const again = await entry.lock.run(async () => {
|
|
143
165
|
// Two rules, and neither can stand in for the other. Against another READ it is identity —
|
|
@@ -156,6 +178,10 @@ export async function fillWindow(entry: QueryEntry): Promise<FilledWindow> {
|
|
|
156
178
|
applyRead(entry, pending, result);
|
|
157
179
|
// A forced read outranks a first one: what it replaced was known to be wrong.
|
|
158
180
|
landed = forced || landed === 'forced' ? 'forced' : first ? 'first' : landed;
|
|
181
|
+
} else if (isNewestRead(entry, pending)) {
|
|
182
|
+
// Discarded: the window is already past this answer. Settled all the same, or `applied`
|
|
183
|
+
// reads as a read still out and no repair ever trusts this window again.
|
|
184
|
+
entry.applied = pending.generation;
|
|
159
185
|
}
|
|
160
186
|
// A change reached this window while its first read was in flight and could not be folded,
|
|
161
187
|
// so what just landed may predate it: read once more before serving anyone a partial window.
|
|
@@ -189,7 +215,8 @@ const COLD_REREADS = 3;
|
|
|
189
215
|
* that repairs a stale window mid-fanout is spelled here rather than deadlocking on the other.
|
|
190
216
|
*/
|
|
191
217
|
export async function refillWindowInLane(entry: QueryEntry): Promise<void> {
|
|
192
|
-
|
|
218
|
+
// A change asked for this read, not a subscriber: it links to the window's trace (`rerun`).
|
|
219
|
+
const pending = startRead(entry, 'rerun');
|
|
193
220
|
const result = await pending.result;
|
|
194
221
|
// Same identity rule as `fillWindow`: a read issued before this one may still be in flight, and
|
|
195
222
|
// whichever was issued LAST is the one the window keeps.
|
|
@@ -219,13 +246,13 @@ function usableDeadline(declared: number | undefined): number {
|
|
|
219
246
|
* otherwise would be a second, false promise — so the abandoned read runs to completion with
|
|
220
247
|
* nobody listening, and the next caller issues its own instead of joining a corpse.
|
|
221
248
|
*/
|
|
222
|
-
function startRead(entry: QueryEntry): PendingRead {
|
|
249
|
+
function startRead(entry: QueryEntry, phase: LiveReadPhase): PendingRead {
|
|
223
250
|
// Cleared here rather than when the read lands: the read about to be issued is the one that
|
|
224
251
|
// answers the staleness, so a second caller must join it instead of forcing another.
|
|
225
252
|
entry.stale = false;
|
|
226
253
|
entry.generation += 1;
|
|
227
254
|
|
|
228
|
-
const read = readSnapshot(entry);
|
|
255
|
+
const read = readSnapshot(entry, phase);
|
|
229
256
|
let expire: () => void = noop;
|
|
230
257
|
const deadline = new Promise<never>((_resolve, reject) => {
|
|
231
258
|
// Assigned synchronously — a Promise executor runs before the constructor returns — so `done`
|
|
@@ -272,9 +299,14 @@ function startRead(entry: QueryEntry): PendingRead {
|
|
|
272
299
|
* to prevent is now permanent and silent. `async` so a definition that throws synchronously takes
|
|
273
300
|
* the same path as one that rejects.
|
|
274
301
|
*/
|
|
275
|
-
async function readSnapshot(entry: QueryEntry): Promise<SnapshotResult> {
|
|
302
|
+
async function readSnapshot(entry: QueryEntry, phase: LiveReadPhase): Promise<SnapshotResult> {
|
|
276
303
|
try {
|
|
277
|
-
return await entry.definition.snapshot({
|
|
304
|
+
return await entry.definition.snapshot({
|
|
305
|
+
input: entry.input,
|
|
306
|
+
tenant: entry.tenant,
|
|
307
|
+
traceparent: entry.traceparent,
|
|
308
|
+
phase,
|
|
309
|
+
});
|
|
278
310
|
} catch (error) {
|
|
279
311
|
entry.stale = true;
|
|
280
312
|
throw error;
|
package/src/server.ts
CHANGED
|
@@ -50,6 +50,49 @@ export {
|
|
|
50
50
|
resetChannelTransport,
|
|
51
51
|
setChannelTransport,
|
|
52
52
|
} from './channel-publish';
|
|
53
|
+
export {
|
|
54
|
+
type AgentDial,
|
|
55
|
+
type AgentSocket,
|
|
56
|
+
type DeviceAgent,
|
|
57
|
+
type DeviceAgentOptions,
|
|
58
|
+
deviceAgent,
|
|
59
|
+
} from './device-agent';
|
|
60
|
+
export {
|
|
61
|
+
DeviceFrameTooLargeError,
|
|
62
|
+
DeviceOfflineError,
|
|
63
|
+
DeviceRequestTimeoutError,
|
|
64
|
+
} from './device-errors';
|
|
65
|
+
export {
|
|
66
|
+
DEVICE_FRAME_QUEUE_MAX,
|
|
67
|
+
DEVICE_REQUESTS_OUTSTANDING_MAX,
|
|
68
|
+
type DeviceFrameHandler,
|
|
69
|
+
type DeviceInfo,
|
|
70
|
+
type DeviceWs,
|
|
71
|
+
} from './device-host';
|
|
72
|
+
export {
|
|
73
|
+
beginDeviceSocketGeneration,
|
|
74
|
+
DEFAULT_DEVICE_HEARTBEAT_MS,
|
|
75
|
+
DEFAULT_DEVICE_REQUEST_DEADLINE_MS,
|
|
76
|
+
DEFAULT_DEVICE_SOCKETS_PER_ACTOR,
|
|
77
|
+
DEVICE_AUTH_DEADLINE_MS,
|
|
78
|
+
DEVICE_MAX_PENDING_UPGRADES,
|
|
79
|
+
type DeviceAttachment,
|
|
80
|
+
type DeviceAttachOptions,
|
|
81
|
+
type DeviceRequestOptions,
|
|
82
|
+
type DeviceSocket,
|
|
83
|
+
type DeviceSocketOptions,
|
|
84
|
+
declaredDeviceSockets,
|
|
85
|
+
deviceSocket,
|
|
86
|
+
forgetDeviceSockets,
|
|
87
|
+
} from './device-socket';
|
|
88
|
+
export type {
|
|
89
|
+
DeviceGrant,
|
|
90
|
+
DeviceTokenResolver,
|
|
91
|
+
DeviceUpgradeFacts,
|
|
92
|
+
DeviceUpgradeTarget,
|
|
93
|
+
DeviceWsData,
|
|
94
|
+
} from './device-upgrade';
|
|
95
|
+
export { DEVICE_FRAME_MAX_BYTES } from './device-wire';
|
|
53
96
|
export { RealtimeTopologyError } from './errors';
|
|
54
97
|
export {
|
|
55
98
|
InProcessTransport,
|
package/src/socket.ts
CHANGED
|
@@ -199,7 +199,7 @@ export class SyncSocket {
|
|
|
199
199
|
* The `hello` frame is the documented place a client names its build, and until 2026-09-07 the
|
|
200
200
|
* node never read it: `clientBuildId` came from the dial's `?build=` alone and defaulted to this
|
|
201
201
|
* node's OWN id when the query was absent — so a client that said `hello` from any build at all
|
|
202
|
-
* was deemed current forever, and `update-available` never came. Measured on
|
|
202
|
+
* was deemed current forever, and `update-available` never came. Measured on one adopter: a page
|
|
203
203
|
* sending `buildId: "dev"` in every hello to a node on `46db23f57d6ef969`. The last word wins,
|
|
204
204
|
* and the hello is the later one; `?build=` still works for a dial that carries it.
|
|
205
205
|
*/
|
package/src/sync-frames.ts
CHANGED
|
@@ -76,6 +76,13 @@ export function frameRouter(options: FrameRouterOptions): FrameRouter {
|
|
|
76
76
|
? await presence.roster(name)
|
|
77
77
|
: await presence.join(name, { id: socket.id, actorId: socket.actorId });
|
|
78
78
|
if (presence) options.busAnswered?.();
|
|
79
|
+
// Asked after the awaits, because the race IS the awaits: `teardown` ran during the beat, the
|
|
80
|
+
// beat answered false, and the `join` above wrote a member no close will ever remove — a ghost
|
|
81
|
+
// on every other client's roster until the TTL. Taken back here, by this write's own author.
|
|
82
|
+
if (presence && socket.closed) {
|
|
83
|
+
await presence.leave(name, socket.id);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
79
86
|
// Read, though nothing here can repair it: the next beat re-sends the set, and the log is the
|
|
80
87
|
// only trace a dropped one leaves anywhere.
|
|
81
88
|
if (!socket.send(presenceFrame(name, 'sync', roster.members, roster.total))) {
|
|
@@ -184,6 +191,7 @@ export function frameRouter(options: FrameRouterOptions): FrameRouter {
|
|
|
184
191
|
input: frame.target.input,
|
|
185
192
|
sid: frame.sid,
|
|
186
193
|
cursor: frame.target.cursor,
|
|
194
|
+
...(frame.traceparent === undefined ? {} : { traceparent: frame.traceparent }),
|
|
187
195
|
});
|
|
188
196
|
// `subscribe` has already seated the subscription and cleared its desync mark, so a reply
|
|
189
197
|
// the socket refuses leaves the server believing a client that holds no rows is in sync:
|
package/src/sync-node-bounds.ts
CHANGED
|
@@ -142,3 +142,23 @@ export function clientHeartbeatMs(
|
|
|
142
142
|
const idleBound = Math.max(1_000, Math.floor(idleTimeoutMs / 4));
|
|
143
143
|
return Math.min(presenceBeatMs ?? DEFAULT_HEARTBEAT_MS, idleBound);
|
|
144
144
|
}
|
|
145
|
+
|
|
146
|
+
const utf8 = new TextEncoder();
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Closes the socket 1009 — Bun's own code for a payload past `maxPayloadLength` — when an
|
|
150
|
+
* inbound frame is over `maxFrameBytes`, and says so. The node refuses it itself: on a listener a device
|
|
151
|
+
* socket shares, Bun's limit is the widest mount's, not this node's.
|
|
152
|
+
*/
|
|
153
|
+
export function refusedOverBytes(
|
|
154
|
+
ws: { close(code: number, reason: string): void },
|
|
155
|
+
message: string | Uint8Array,
|
|
156
|
+
limit: number,
|
|
157
|
+
): boolean {
|
|
158
|
+
const over =
|
|
159
|
+
typeof message === 'string'
|
|
160
|
+
? message.length * 3 > limit && utf8.encode(message).length > limit
|
|
161
|
+
: message.byteLength > limit;
|
|
162
|
+
if (over) ws.close(1009, 'frame over maxFrameBytes');
|
|
163
|
+
return over;
|
|
164
|
+
}
|
package/src/sync-node.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { ackRefOf, frameRouter } from './sync-frames';
|
|
|
32
32
|
import {
|
|
33
33
|
clientHeartbeatMs,
|
|
34
34
|
drainGraceMs,
|
|
35
|
+
refusedOverBytes,
|
|
35
36
|
socketCeilings,
|
|
36
37
|
syncNodeBounds,
|
|
37
38
|
} from './sync-node-bounds';
|
|
@@ -267,6 +268,7 @@ export function syncNode(options: SyncNodeOptions): SyncNode {
|
|
|
267
268
|
// 120s budget under it decided nothing.
|
|
268
269
|
idling = setInterval(() => {
|
|
269
270
|
for (const socket of sockets.idle()) evict(socket, CLOSE.idle, 'idle timeout');
|
|
271
|
+
detach(options.registry.repairMarked(), 'sync.repair_sweep');
|
|
270
272
|
}, idleSweepPeriodMs(sockets.idleTimeoutMs));
|
|
271
273
|
idling.unref();
|
|
272
274
|
if (options.authenticate) {
|
|
@@ -398,6 +400,8 @@ export function syncNode(options: SyncNodeOptions): SyncNode {
|
|
|
398
400
|
message(ws: SyncWs, message: string | Uint8Array): void {
|
|
399
401
|
const socket = sockets.get(ws.data.socketId);
|
|
400
402
|
if (!socket) return;
|
|
403
|
+
// Bun's `maxPayloadLength` is the widest mount's on a shared listener: the bound is ours.
|
|
404
|
+
if (refusedOverBytes(ws, message, bounds.maxFrameBytes)) return;
|
|
401
405
|
void (async () => {
|
|
402
406
|
// Decoded into a binding the failure path can read: an ack has to name the thing that
|
|
403
407
|
// failed — the mutation key the client's queue holds, the sid its subscription holds —
|
|
@@ -428,9 +432,13 @@ export function syncNode(options: SyncNodeOptions): SyncNode {
|
|
|
428
432
|
|
|
429
433
|
drain(ws: SyncWs): void {
|
|
430
434
|
// A `records` frame backpressure refused left this socket gapped on that channel; the
|
|
431
|
-
// repair is the node's verdict, sent the moment the socket can take a frame again.
|
|
435
|
+
// repair is the node's verdict, sent the moment the socket can take a frame again. A live
|
|
436
|
+
// query's refused frame is the same debt, and its only other reader is the next change to
|
|
437
|
+
// that query, which may never come.
|
|
432
438
|
const socket = sockets.get(ws.data.socketId);
|
|
433
|
-
if (socket)
|
|
439
|
+
if (!socket) return;
|
|
440
|
+
sockets.gapRepairs.repairAll(socket);
|
|
441
|
+
if (socket.desynced.size > 0) detach(options.registry.repair(socket), 'sync.drain_repair');
|
|
434
442
|
},
|
|
435
443
|
|
|
436
444
|
close(ws: SyncWs): void {
|
package/src/sync-origin.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { type OriginVerdict, proveSameOrigin } from '@ultimat3/core';
|
|
|
13
13
|
* declaration IS the app's origin, so it is the whole list: the origin the node sees is derived
|
|
14
14
|
* from the `Host` header, which names whatever host the request was aimed at — a sibling
|
|
15
15
|
* subdomain pointed at this node is "its own origin" by that reading, and a `Domain=`-wide
|
|
16
|
-
* session cookie rides its socket
|
|
16
|
+
* session cookie rides its socket. `sec-fetch-site` is withheld from the rule here for the same
|
|
17
|
+
* reason: a browser says `same-origin` of the host it dialled, and that host is the question;
|
|
17
18
|
* - **the node's own ORIGIN, only when nothing is declared** — host and port exactly as the
|
|
18
19
|
* request reached it (`x dev`, a combined-role container). A host name alone is not this app:
|
|
19
20
|
* cookies are not isolated by port. `admitReached` keeps it beside a declared list (`x dev`).
|
|
@@ -28,10 +29,11 @@ export function upgradeOrigin(
|
|
|
28
29
|
if (origin === null) return { ok: true };
|
|
29
30
|
// Normalised once (`https://x/` and `https://x` are one origin), then matched as whole strings.
|
|
30
31
|
const admitted = allowedOrigins.map((allowed) => URL.parse(allowed)?.origin);
|
|
32
|
+
const listIsWhole = admitted.length > 0 && !admitReached;
|
|
31
33
|
return proveSameOrigin({
|
|
32
|
-
selfOrigins:
|
|
34
|
+
selfOrigins: listIsWhole ? [] : selfOrigins(url),
|
|
33
35
|
origin,
|
|
34
|
-
secFetchSite: request.headers.get('sec-fetch-site'),
|
|
36
|
+
secFetchSite: listIsWhole ? null : request.headers.get('sec-fetch-site'),
|
|
35
37
|
listed: (asked) => admitted.includes(asked),
|
|
36
38
|
listName: 'APP_URL or syncNode({ allowedOrigins })',
|
|
37
39
|
});
|
package/src/sync-protocol.ts
CHANGED
|
@@ -101,6 +101,13 @@ export interface SubscribeFrame {
|
|
|
101
101
|
readonly op: 'add' | 'drop';
|
|
102
102
|
readonly sid: string;
|
|
103
103
|
readonly target: SubscribeTarget;
|
|
104
|
+
/**
|
|
105
|
+
* The page's W3C `traceparent`, so the snapshot this subscribe causes joins the page's trace.
|
|
106
|
+
* Optional and **additive**, no `PROTOCOL_VERSION` bump: an old node never reads it, an old
|
|
107
|
+
* client never sends it. A value that is not a short string is DROPPED, never refused — a
|
|
108
|
+
* garbage header costs the subscriber its trace, not its subscription.
|
|
109
|
+
*/
|
|
110
|
+
readonly traceparent?: string;
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
export interface SnapshotFrame {
|
|
@@ -201,6 +208,9 @@ export const FRAME_KINDS: readonly FrameKind[] = [
|
|
|
201
208
|
'replay-gap',
|
|
202
209
|
];
|
|
203
210
|
|
|
211
|
+
/** `00-<32>-<16>-<2>` is 55 characters; past this bound a value is dropped unread. */
|
|
212
|
+
const TRACEPARENT_MAX = 128;
|
|
213
|
+
|
|
204
214
|
export function encode(frame: Frame): string {
|
|
205
215
|
return JSON.stringify(frame);
|
|
206
216
|
}
|
|
@@ -233,14 +243,19 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
233
243
|
? hello
|
|
234
244
|
: { ...hello, heartbeatMs: num(parsed, 'heartbeatMs') };
|
|
235
245
|
}
|
|
236
|
-
case 'subscribe':
|
|
237
|
-
|
|
246
|
+
case 'subscribe': {
|
|
247
|
+
const frame = {
|
|
238
248
|
type: 'subscribe',
|
|
239
249
|
v: PROTOCOL_VERSION,
|
|
240
250
|
op: pick(parsed, 'op', ['add', 'drop'] as const),
|
|
241
251
|
sid: str(parsed, 'sid'),
|
|
242
252
|
target: target(parsed['target']),
|
|
243
|
-
};
|
|
253
|
+
} as const;
|
|
254
|
+
const trace = parsed['traceparent'];
|
|
255
|
+
return typeof trace === 'string' && trace.length <= TRACEPARENT_MAX
|
|
256
|
+
? { ...frame, traceparent: trace }
|
|
257
|
+
: frame;
|
|
258
|
+
}
|
|
244
259
|
case 'snapshot': {
|
|
245
260
|
const base = {
|
|
246
261
|
type: 'snapshot',
|
package/src/use-mutation.ts
CHANGED
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
// before the overlay goes — no flicker — and a refusal takes the overlay back. No socket writes.
|
|
5
5
|
|
|
6
6
|
import type { ConflictPolicy } from '@ultimat3/core/page';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
isSuperseded,
|
|
9
|
+
isUltimateError,
|
|
10
|
+
pageClient,
|
|
11
|
+
renderedActionPin,
|
|
12
|
+
uuidV7,
|
|
13
|
+
} from '@ultimat3/core/page';
|
|
8
14
|
import type { JsonValue } from './json';
|
|
9
15
|
import { knownOffline, type OutboxHandle, peekOutbox } from './outbox-slot';
|
|
10
16
|
import { OfflineQueueAbandonedError, ServerRenderLiveError } from './page-errors';
|
|
11
17
|
import { installedPage, type PageWrites, pageRealtime } from './page-store';
|
|
18
|
+
import { mutationWire } from './pinned-wire';
|
|
12
19
|
import { isServerRender, signalFor } from './reactivity';
|
|
13
20
|
import { carriedBy } from './record-store';
|
|
14
21
|
|
|
@@ -121,6 +128,11 @@ export function useMutation(mutator: MutatorLike): Mutate {
|
|
|
121
128
|
const call = async (input: JsonValue): Promise<unknown> => {
|
|
122
129
|
if (writes === undefined) throw new ServerRenderLiveError({ operation: 'useMutation()' });
|
|
123
130
|
const page = installedPage('useMutation');
|
|
131
|
+
// The pin the server stamped into this document, resolved ONCE: the outbox keeps it with the
|
|
132
|
+
// write, so a replay goes where the write was aimed. Before anything is painted, counted or
|
|
133
|
+
// queued: an input that cannot fill the pin is no write.
|
|
134
|
+
const http = renderedActionPin(mutator.name);
|
|
135
|
+
const wire = mutationWire(mutator.name, input, http);
|
|
124
136
|
const issued = pageClient().scope.epoch;
|
|
125
137
|
const key = `${mutator.name}:${uuidV7()}`;
|
|
126
138
|
const local = mutator.local;
|
|
@@ -152,7 +164,12 @@ export function useMutation(mutator: MutatorLike): Mutate {
|
|
|
152
164
|
if (pageClient().scope.epoch !== issued) {
|
|
153
165
|
throw new OfflineQueueAbandonedError({ name: mutator.name });
|
|
154
166
|
}
|
|
155
|
-
await outbox.enqueue({
|
|
167
|
+
await outbox.enqueue({
|
|
168
|
+
key,
|
|
169
|
+
name: mutator.name,
|
|
170
|
+
input,
|
|
171
|
+
...(http === undefined ? {} : { http }),
|
|
172
|
+
});
|
|
156
173
|
} catch (error) {
|
|
157
174
|
page.store.drop(key);
|
|
158
175
|
// A principal change is nobody's failure to report; a disk that refused the write is.
|
|
@@ -196,9 +213,9 @@ export function useMutation(mutator: MutatorLike): Mutate {
|
|
|
196
213
|
const carried = new Set<string>();
|
|
197
214
|
try {
|
|
198
215
|
output = await page.services.send({
|
|
199
|
-
method:
|
|
200
|
-
url:
|
|
201
|
-
body:
|
|
216
|
+
method: wire.method,
|
|
217
|
+
url: wire.url,
|
|
218
|
+
body: wire.body,
|
|
202
219
|
idempotencyKey: key,
|
|
203
220
|
onEnvelope: (envelope) => carriedBy(envelope, carried),
|
|
204
221
|
});
|
package/src/use-query.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { type AsyncState, isSuperseded, type RecordEnvelope, type Row } from '@u
|
|
|
7
7
|
import type { LiveHandle } from './client-contract';
|
|
8
8
|
import type { JsonValue } from './json';
|
|
9
9
|
import { installedPage, type PageServices } from './page-store';
|
|
10
|
+
import type { QueryPoll } from './query-poll';
|
|
10
11
|
import { isServerRender, signalFor } from './reactivity';
|
|
11
12
|
import { keptAnswer } from './record-answer';
|
|
12
13
|
import { type RecordStore, recordKey } from './record-store';
|
|
@@ -30,8 +31,13 @@ export interface QueryRef {
|
|
|
30
31
|
|
|
31
32
|
/** A callable `AsyncState` over the list, plus the two things a caller does to it. */
|
|
32
33
|
export type QueryAccessor<R extends object = Row> = (() => AsyncState<readonly R[]>) & {
|
|
33
|
-
/**
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Read again from the first page, keeping the current rows on screen (`refreshing`). Settles
|
|
36
|
+
* once the answer is on screen or refused — never rejects, so a pull or a button awaits it. A
|
|
37
|
+
* live read settles at once — its socket is its refresh — unless its subscription was refused:
|
|
38
|
+
* that one is asked again, and settles on the node's next answer.
|
|
39
|
+
*/
|
|
40
|
+
refetch(): Promise<void>;
|
|
35
41
|
/**
|
|
36
42
|
* The next page, appended — from the cursor the server's last page answered with. A no-op on a
|
|
37
43
|
* live read, on a read with no `first`, and once the server said there is no next page.
|
|
@@ -45,6 +51,7 @@ export type QueryAccessor<R extends object = Row> = (() => AsyncState<readonly R
|
|
|
45
51
|
|
|
46
52
|
const PENDING: AsyncState<never> = Object.freeze({ status: 'pending' });
|
|
47
53
|
const nothing = (): void => undefined;
|
|
54
|
+
const settled = (): Promise<void> => Promise.resolve();
|
|
48
55
|
|
|
49
56
|
/**
|
|
50
57
|
* `input` is read ONCE, at call time: there is no reactive runtime here to re-run it, and a
|
|
@@ -53,8 +60,33 @@ const nothing = (): void => undefined;
|
|
|
53
60
|
export interface QueryOptions {
|
|
54
61
|
/** Read in pages of this many rows (`query.page`); `more()` appends the next. Non-live only. */
|
|
55
62
|
readonly first?: number;
|
|
63
|
+
/**
|
|
64
|
+
* `pollEvery(ms)`: re-read (`refetch`) on a timer that a hidden tab and a lost session stop, and
|
|
65
|
+
* `release` ends. The safety read beside an at-most-once channel. A VALUE and not a number, so
|
|
66
|
+
* the timer ships only in an island that polls. Non-live only, by type: a live read has its socket.
|
|
67
|
+
*/
|
|
68
|
+
readonly poll?: QueryPoll;
|
|
56
69
|
}
|
|
57
70
|
|
|
71
|
+
/** A live read's options: no `poll`, because the socket is its refresh. */
|
|
72
|
+
export type LiveQueryOptions = Omit<QueryOptions, 'poll'> & { readonly poll?: never };
|
|
73
|
+
|
|
74
|
+
export function useQuery<R extends object = Row>(
|
|
75
|
+
ref: QueryRef & { readonly live: true },
|
|
76
|
+
input: JsonValue,
|
|
77
|
+
options?: LiveQueryOptions,
|
|
78
|
+
): QueryAccessor<R>;
|
|
79
|
+
export function useQuery<R extends object = Row>(
|
|
80
|
+
ref: QueryRef & { readonly live?: false },
|
|
81
|
+
input: JsonValue,
|
|
82
|
+
options?: QueryOptions,
|
|
83
|
+
): QueryAccessor<R>;
|
|
84
|
+
/** A ref whose liveness the type does not know may be live, so it takes no `poll` either. */
|
|
85
|
+
export function useQuery<R extends object = Row>(
|
|
86
|
+
ref: QueryRef,
|
|
87
|
+
input: JsonValue,
|
|
88
|
+
options?: LiveQueryOptions,
|
|
89
|
+
): QueryAccessor<R>;
|
|
58
90
|
export function useQuery<R extends object = Row>(
|
|
59
91
|
ref: QueryRef,
|
|
60
92
|
input: JsonValue,
|
|
@@ -63,7 +95,7 @@ export function useQuery<R extends object = Row>(
|
|
|
63
95
|
const signal = signalFor('useQuery');
|
|
64
96
|
if (isServerRender()) {
|
|
65
97
|
return Object.assign((): AsyncState<readonly R[]> => PENDING, {
|
|
66
|
-
refetch:
|
|
98
|
+
refetch: settled,
|
|
67
99
|
more: nothing,
|
|
68
100
|
hasMore: () => false,
|
|
69
101
|
release: nothing,
|
|
@@ -85,10 +117,6 @@ function liveAccessor<R extends object>(
|
|
|
85
117
|
): QueryAccessor<R> {
|
|
86
118
|
// Seen once a snapshot landed — read or not — so a drop after it keeps the rows on screen.
|
|
87
119
|
let seen = handle.state() === 'live';
|
|
88
|
-
const off = handle.onChange(() => {
|
|
89
|
-
if (handle.state() === 'live') seen = true;
|
|
90
|
-
bump();
|
|
91
|
-
});
|
|
92
120
|
/** The last answer handed out: the same rows back keep its `data` array (`keptAnswer`). */
|
|
93
121
|
let answer: AsyncState<readonly R[]> | null = null;
|
|
94
122
|
const read = (): AsyncState<readonly R[]> => {
|
|
@@ -101,12 +129,32 @@ function liveAccessor<R extends object>(
|
|
|
101
129
|
answer = keptAnswer(answer, state === 'live' ? 'ready' : 'refreshing', handle.rows());
|
|
102
130
|
return answer;
|
|
103
131
|
};
|
|
132
|
+
/** Settled by the next move after a retry — the snapshot, or a second refusal. */
|
|
133
|
+
let waiting: (() => void)[] = [];
|
|
134
|
+
const settle = (): void => {
|
|
135
|
+
const done = waiting;
|
|
136
|
+
waiting = [];
|
|
137
|
+
for (const resolve of done) resolve();
|
|
138
|
+
};
|
|
139
|
+
const off = handle.onChange(() => {
|
|
140
|
+
if (handle.state() === 'live') seen = true;
|
|
141
|
+
bump();
|
|
142
|
+
if (handle.state() !== 'loading') settle();
|
|
143
|
+
});
|
|
144
|
+
// The socket is a live read's refresh — except for a refused subscription on an open socket,
|
|
145
|
+
// which stays refused until the socket reconnects: that one is asked again, here. A second pull
|
|
146
|
+
// while the retry is out waits on the same answer.
|
|
147
|
+
const refetch = (): Promise<void> =>
|
|
148
|
+
waiting.length > 0 || (handle.state() === 'failed' && handle.retry())
|
|
149
|
+
? new Promise((resolve) => waiting.push(resolve))
|
|
150
|
+
: settled();
|
|
104
151
|
const release = (): void => {
|
|
105
152
|
off();
|
|
153
|
+
settle();
|
|
106
154
|
handle.unsubscribe();
|
|
107
155
|
};
|
|
108
156
|
return Object.assign(read, {
|
|
109
|
-
refetch
|
|
157
|
+
refetch,
|
|
110
158
|
more: nothing,
|
|
111
159
|
hasMore: () => false,
|
|
112
160
|
release,
|
|
@@ -159,7 +207,8 @@ function readAccessor<R extends object>(
|
|
|
159
207
|
|
|
160
208
|
/**
|
|
161
209
|
* One answer: its rows, and — when the server sent the records envelope — the KEYS of this
|
|
162
|
-
* read's records in answer order
|
|
210
|
+
* read's records in answer order: `order[type]` where the server sent one, because an object
|
|
211
|
+
* enumerates integer-like keys ascending; `records[type]`'s own key order where it did not.
|
|
163
212
|
* The browser never derives a key: an answer with no records envelope holds its rows itself.
|
|
164
213
|
*/
|
|
165
214
|
const fetch = (
|
|
@@ -167,8 +216,9 @@ function readAccessor<R extends object>(
|
|
|
167
216
|
): Promise<{ rows: readonly Row[]; keys: string[] | null; next?: string | null }> => {
|
|
168
217
|
let keysOf: string[] | null = null;
|
|
169
218
|
const onEnvelope = (envelope: RecordEnvelope): void => {
|
|
170
|
-
|
|
171
|
-
|
|
219
|
+
if (type === undefined) return;
|
|
220
|
+
const records = envelope.records?.[type];
|
|
221
|
+
keysOf = records === undefined ? null : [...(envelope.order?.[type] ?? Object.keys(records))];
|
|
172
222
|
};
|
|
173
223
|
const answered = (rows: readonly Row[]) => ({ rows, keys: keysOf });
|
|
174
224
|
if (options.first === undefined) {
|
|
@@ -185,11 +235,12 @@ function readAccessor<R extends object>(
|
|
|
185
235
|
}));
|
|
186
236
|
};
|
|
187
237
|
|
|
188
|
-
|
|
238
|
+
/** Settles when this read's answer is applied or refused — never rejects. */
|
|
239
|
+
const load = (append = false): Promise<void> => {
|
|
189
240
|
const mine = ++generation;
|
|
190
241
|
if (state.status === 'ready') state = { status: 'refreshing', data: rows() };
|
|
191
242
|
bump();
|
|
192
|
-
fetch(append).then(
|
|
243
|
+
return fetch(append).then(
|
|
193
244
|
(answer) => {
|
|
194
245
|
if (released || mine !== generation) return;
|
|
195
246
|
if (answer.next !== undefined) after = answer.next;
|
|
@@ -217,17 +268,15 @@ function readAccessor<R extends object>(
|
|
|
217
268
|
if (released || mine !== generation) return;
|
|
218
269
|
// The page changed principal under this read: its answer was the previous one's, and the
|
|
219
270
|
// store it would have landed in is already empty. Read again, as the new principal.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
271
|
+
// Returned, so a poll waiting on this read waits on the re-read too.
|
|
272
|
+
if (isSuperseded(error)) return load();
|
|
224
273
|
state = { status: 'failed', error };
|
|
225
274
|
bump();
|
|
226
275
|
},
|
|
227
276
|
);
|
|
228
277
|
};
|
|
229
278
|
|
|
230
|
-
load();
|
|
279
|
+
void load();
|
|
231
280
|
/**
|
|
232
281
|
* The last answer handed out: a re-read that answered the same rows (a poll, a refetch, a return
|
|
233
282
|
* to the tab) hands back its `data` array, so nothing derived from it runs again.
|
|
@@ -240,20 +289,30 @@ function readAccessor<R extends object>(
|
|
|
240
289
|
answer = keptAnswer(answer, state.status, rows() as readonly R[]);
|
|
241
290
|
return answer;
|
|
242
291
|
};
|
|
292
|
+
const refetch = (): Promise<void> => {
|
|
293
|
+
after = null;
|
|
294
|
+
return load();
|
|
295
|
+
};
|
|
296
|
+
// The poll awaits each read, so a slow answer is never overlapped by the next one.
|
|
297
|
+
// A read that ended `failed` rejects for the poll alone, so `maxMs` backs it off; `load` and
|
|
298
|
+
// `refetch` keep resolving for everyone else.
|
|
299
|
+
const stopPoll = options.poll?.start(() =>
|
|
300
|
+
refetch().then(() => {
|
|
301
|
+
if (state.status === 'failed') throw state.error;
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
243
304
|
const release = (): void => {
|
|
244
305
|
if (released) return;
|
|
245
306
|
released = true;
|
|
307
|
+
stopPoll?.();
|
|
246
308
|
off();
|
|
247
309
|
hold([]);
|
|
248
310
|
keys = [];
|
|
249
311
|
};
|
|
250
312
|
return Object.assign(read, {
|
|
251
|
-
refetch
|
|
252
|
-
after = null;
|
|
253
|
-
load();
|
|
254
|
-
},
|
|
313
|
+
refetch,
|
|
255
314
|
more: () => {
|
|
256
|
-
if (after !== null && state.status === 'ready') load(true);
|
|
315
|
+
if (after !== null && state.status === 'ready') void load(true);
|
|
257
316
|
},
|
|
258
317
|
hasMore: () => {
|
|
259
318
|
version();
|