@ultimat3/realtime 29.3.2 → 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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The device socket's wire, both hops: the frames a device and its node exchange, and the envelopes
|
|
2
|
+
// nodes exchange over the bus to address one device from anywhere. JSON text only, and a size
|
|
3
|
+
// limit the framework owns — the one number no declaration can widen.
|
|
4
|
+
|
|
5
|
+
import { isJsonObject } from '@ultimat3/core';
|
|
6
|
+
import type { JsonValue } from './json';
|
|
7
|
+
import type { ReconnectFrame } from './sync-protocol';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The largest frame either side may send, in UTF-8 bytes: 256 KiB. Bun's `maxPayloadLength` on the
|
|
11
|
+
* mount enforces it before a byte is buffered, and `decodeDeviceFrame` again for a host that did
|
|
12
|
+
* not apply it. Work that is bigger is a key into storage, not a frame.
|
|
13
|
+
*/
|
|
14
|
+
export const DEVICE_FRAME_MAX_BYTES = 256 * 1024;
|
|
15
|
+
|
|
16
|
+
/** What a device sends: a beat, a frame for `onFrame`, or the answer to a `request` by its id. */
|
|
17
|
+
export type DeviceInbound =
|
|
18
|
+
| { readonly type: 'beat' }
|
|
19
|
+
| { readonly type: 'frame'; readonly frame: JsonValue }
|
|
20
|
+
| { readonly type: 'response'; readonly id: string; readonly frame: JsonValue };
|
|
21
|
+
|
|
22
|
+
/** What a node sends a device. `reconnect` is the sync protocol's own frame, unchanged. */
|
|
23
|
+
export type DeviceOutbound =
|
|
24
|
+
| { readonly type: 'hello'; readonly heartbeatMs: number; readonly maxFrameBytes: number }
|
|
25
|
+
| { readonly type: 'request'; readonly id: string; readonly frame: JsonValue }
|
|
26
|
+
| { readonly type: 'frame'; readonly frame: JsonValue }
|
|
27
|
+
| { readonly type: 'error'; readonly code: string }
|
|
28
|
+
| ReconnectFrame;
|
|
29
|
+
|
|
30
|
+
const encoder = new TextEncoder();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The frame's UTF-8 length when it is past the limit, else `null`. A UTF-16 unit is 1-3 UTF-8
|
|
34
|
+
* bytes, so a string short enough cannot be over it and is never encoded just to be measured.
|
|
35
|
+
*/
|
|
36
|
+
export function bytesOverLimit(text: string): number | null {
|
|
37
|
+
if (text.length * 3 <= DEVICE_FRAME_MAX_BYTES) return null;
|
|
38
|
+
const bytes = encoder.encode(text).length;
|
|
39
|
+
return bytes > DEVICE_FRAME_MAX_BYTES ? bytes : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `JSON.parse` that answers `undefined` instead of throwing — the input is a device's bytes. */
|
|
43
|
+
export function parseJson(text: string): unknown {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(text);
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A request/response id: what this node minted, so short and token-safe, never a device's prose. */
|
|
52
|
+
const isId = (value: unknown): value is string =>
|
|
53
|
+
typeof value === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(value);
|
|
54
|
+
|
|
55
|
+
/** A device frame, or `undefined` for anything this protocol does not speak. */
|
|
56
|
+
export function decodeDeviceFrame(text: string): DeviceInbound | undefined {
|
|
57
|
+
const raw = parseJson(text);
|
|
58
|
+
if (!isJsonObject(raw)) return undefined;
|
|
59
|
+
if (raw['type'] === 'beat') return { type: 'beat' };
|
|
60
|
+
if (!Object.hasOwn(raw, 'frame')) return undefined;
|
|
61
|
+
const frame = raw['frame'] as JsonValue;
|
|
62
|
+
if (raw['type'] === 'frame') return { type: 'frame', frame };
|
|
63
|
+
if (raw['type'] === 'response' && isId(raw['id']))
|
|
64
|
+
return { type: 'response', id: raw['id'], frame };
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A node's frame to a device, or `undefined` — the device agent's half of the same whitelist. */
|
|
69
|
+
export function decodeNodeFrame(text: string): DeviceOutbound | undefined {
|
|
70
|
+
const raw = parseJson(text);
|
|
71
|
+
if (!isJsonObject(raw)) return undefined;
|
|
72
|
+
switch (raw['type']) {
|
|
73
|
+
case 'hello': {
|
|
74
|
+
const { heartbeatMs, maxFrameBytes } = raw;
|
|
75
|
+
if (typeof heartbeatMs !== 'number' || typeof maxFrameBytes !== 'number') return undefined;
|
|
76
|
+
return { type: 'hello', heartbeatMs, maxFrameBytes };
|
|
77
|
+
}
|
|
78
|
+
case 'request':
|
|
79
|
+
return isId(raw['id']) && Object.hasOwn(raw, 'frame')
|
|
80
|
+
? { type: 'request', id: raw['id'], frame: raw['frame'] as JsonValue }
|
|
81
|
+
: undefined;
|
|
82
|
+
case 'frame':
|
|
83
|
+
return Object.hasOwn(raw, 'frame')
|
|
84
|
+
? { type: 'frame', frame: raw['frame'] as JsonValue }
|
|
85
|
+
: undefined;
|
|
86
|
+
case 'error':
|
|
87
|
+
return typeof raw['code'] === 'string' ? { type: 'error', code: raw['code'] } : undefined;
|
|
88
|
+
case 'reconnect': {
|
|
89
|
+
const { afterMs, reason, v } = raw;
|
|
90
|
+
if (typeof afterMs !== 'number' || typeof v !== 'number') return undefined;
|
|
91
|
+
if (reason !== 'drain' && reason !== 'overload' && reason !== 'rebalance') return undefined;
|
|
92
|
+
return { type: 'reconnect', v, afterMs, reason };
|
|
93
|
+
}
|
|
94
|
+
default:
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A request on its way to the node that holds the device. */
|
|
100
|
+
export interface BusRequest {
|
|
101
|
+
readonly kind: 'request';
|
|
102
|
+
readonly id: string;
|
|
103
|
+
/** The requesting node: its reply subject is derived from this, never taken as a subject. */
|
|
104
|
+
readonly from: string;
|
|
105
|
+
readonly tenant: string;
|
|
106
|
+
readonly deviceId: string;
|
|
107
|
+
/** How long the requester waits — the holder forgets the request after it, answered or not. */
|
|
108
|
+
readonly deadlineMs: number;
|
|
109
|
+
readonly frame: JsonValue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The holder's answer: the device's frame, or "it is not here". */
|
|
113
|
+
export type BusReply =
|
|
114
|
+
| { readonly kind: 'response'; readonly id: string; readonly frame: JsonValue }
|
|
115
|
+
| { readonly kind: 'offline'; readonly id: string; readonly reason: string };
|
|
116
|
+
|
|
117
|
+
/** A newer socket for one device opened somewhere: every other holder closes its own. */
|
|
118
|
+
export interface BusClaim {
|
|
119
|
+
readonly kind: 'claim';
|
|
120
|
+
readonly tenant: string;
|
|
121
|
+
readonly deviceId: string;
|
|
122
|
+
readonly node: string;
|
|
123
|
+
readonly socketId: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type BusMessage = BusRequest | BusReply | BusClaim;
|
|
127
|
+
|
|
128
|
+
const isText = (value: unknown): value is string => typeof value === 'string';
|
|
129
|
+
|
|
130
|
+
/** A bus payload, rebuilt field by field — another node's bytes are still bytes. */
|
|
131
|
+
export function decodeBusMessage(text: string): BusMessage | undefined {
|
|
132
|
+
const raw = parseJson(text);
|
|
133
|
+
if (!isJsonObject(raw)) return undefined;
|
|
134
|
+
const { kind, id, tenant, deviceId } = raw;
|
|
135
|
+
if (kind === 'request') {
|
|
136
|
+
const { from, deadlineMs } = raw;
|
|
137
|
+
if (!isId(id) || !isId(from) || !isText(tenant) || !isText(deviceId)) return undefined;
|
|
138
|
+
if (typeof deadlineMs !== 'number' || !(deadlineMs > 0) || !Object.hasOwn(raw, 'frame')) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
return { kind, id, from, tenant, deviceId, deadlineMs, frame: raw['frame'] as JsonValue };
|
|
142
|
+
}
|
|
143
|
+
if (kind === 'response' && isId(id) && Object.hasOwn(raw, 'frame')) {
|
|
144
|
+
return { kind, id, frame: raw['frame'] as JsonValue };
|
|
145
|
+
}
|
|
146
|
+
if (kind === 'offline' && isId(id) && isText(raw['reason'])) {
|
|
147
|
+
return { kind, id, reason: raw['reason'] };
|
|
148
|
+
}
|
|
149
|
+
if (kind === 'claim') {
|
|
150
|
+
const { node, socketId } = raw;
|
|
151
|
+
if (!isText(tenant) || !isText(deviceId) || !isId(node) || !isText(socketId)) return undefined;
|
|
152
|
+
return { kind, tenant, deviceId, node, socketId };
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
package/src/error-titles.ts
CHANGED
|
@@ -45,6 +45,10 @@ export const REALTIME_OWNED_ERROR_CODES = [
|
|
|
45
45
|
'X_OFFLINE_QUEUE_ABANDONED',
|
|
46
46
|
'X_SOCKET_LIMIT',
|
|
47
47
|
'X_REALTIME_PRODUCER_CONFLICT',
|
|
48
|
+
'X_MUTATION_PATH_UNFILLED',
|
|
49
|
+
'X_DEVICE_OFFLINE',
|
|
50
|
+
'X_DEVICE_REQUEST_TIMEOUT',
|
|
51
|
+
'X_DEVICE_FRAME_TOO_LARGE',
|
|
48
52
|
] as const;
|
|
49
53
|
|
|
50
54
|
export type RealtimeOwnedErrorCode = (typeof REALTIME_OWNED_ERROR_CODES)[number];
|
|
@@ -83,6 +87,10 @@ export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, stri
|
|
|
83
87
|
X_OFFLINE_QUEUE_ABANDONED: 'a write was queued after its page changed principal',
|
|
84
88
|
X_SOCKET_LIMIT: 'one principal holds too many sockets on this node',
|
|
85
89
|
X_REALTIME_PRODUCER_CONFLICT: 'two kinds of producer carry one table on the change bus',
|
|
90
|
+
X_MUTATION_PATH_UNFILLED: "a pinned action's :param had no value in the write's input",
|
|
91
|
+
X_DEVICE_OFFLINE: 'no socket is open for the device a request addressed',
|
|
92
|
+
X_DEVICE_REQUEST_TIMEOUT: 'a device did not answer a request before its deadline',
|
|
93
|
+
X_DEVICE_FRAME_TOO_LARGE: 'a device socket frame is over the framework frame limit',
|
|
86
94
|
};
|
|
87
95
|
|
|
88
96
|
// One unconditional call, so a second package claiming one of realtime's codes throws
|
package/src/index.ts
CHANGED
|
@@ -136,6 +136,7 @@ export {
|
|
|
136
136
|
pageOutbox,
|
|
137
137
|
} from './page-outbox';
|
|
138
138
|
export { hasPageSocket, type SyncTarget } from './page-store';
|
|
139
|
+
export { pollEvery, type QueryPoll } from './query-poll';
|
|
139
140
|
export { installRealtime, type RealtimeInstall } from './reactivity';
|
|
140
141
|
export { persistedTypes, type RecordPersister, recordPersister } from './record-persister';
|
|
141
142
|
export {
|
|
@@ -195,7 +196,13 @@ export {
|
|
|
195
196
|
useMutationQueue,
|
|
196
197
|
} from './use-mutation';
|
|
197
198
|
export { type OutboxView, useOutbox } from './use-outbox';
|
|
198
|
-
export {
|
|
199
|
+
export {
|
|
200
|
+
type LiveQueryOptions,
|
|
201
|
+
type QueryAccessor,
|
|
202
|
+
type QueryOptions,
|
|
203
|
+
type QueryRef,
|
|
204
|
+
useQuery,
|
|
205
|
+
} from './use-query';
|
|
199
206
|
export {
|
|
200
207
|
type RecordAccessor,
|
|
201
208
|
type RecordSelection,
|
package/src/live-contract.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// decision and every window lookup keyed differently.
|
|
11
11
|
|
|
12
12
|
import type { Actor } from '@ultimat3/core';
|
|
13
|
+
import type { LiveReadPhase } from '@ultimat3/query';
|
|
13
14
|
import type { LiveCursor } from './cursor';
|
|
14
15
|
import type { JsonValue, Row } from './json';
|
|
15
16
|
import type { IncrementalMatcher } from './matcher-bridge';
|
|
@@ -20,6 +21,19 @@ export interface SnapshotResult<R extends Row = Row> {
|
|
|
20
21
|
readonly lsn: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* One shared read's arguments. `traceparent` is the value a subscriber put on its `subscribe`
|
|
26
|
+
* frame — raw, so an invalid one is ignored where it is parsed (`@ultimat3/query`'s
|
|
27
|
+
* `traceLiveRead`), never refused. `phase` says why the read runs: a subscribe asked for it, or a
|
|
28
|
+
* change made the window re-read. Both optional: a definition that traces nothing ignores them.
|
|
29
|
+
*/
|
|
30
|
+
export interface SnapshotArgs {
|
|
31
|
+
readonly input: JsonValue;
|
|
32
|
+
readonly tenant: string | null;
|
|
33
|
+
readonly traceparent?: string | null | undefined;
|
|
34
|
+
readonly phase?: LiveReadPhase | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
23
37
|
export interface LiveQueryDefinition<R extends Row = Row> {
|
|
24
38
|
readonly name: string;
|
|
25
39
|
/** Dependency set for the pre-filter. `x verify` rejects a `live: true` query without one. */
|
|
@@ -31,7 +45,7 @@ export interface LiveQueryDefinition<R extends Row = Row> {
|
|
|
31
45
|
* FOR `tenant`: the org of the subscribers this window serves, `null` for subscribers that carry
|
|
32
46
|
* none. A window is keyed by it (`live-tenant.ts`), so one read is never two orgs' rows.
|
|
33
47
|
*/
|
|
34
|
-
snapshot(args:
|
|
48
|
+
snapshot(args: SnapshotArgs): Promise<SnapshotResult<R>>;
|
|
35
49
|
/** Subscribe-time gate. Throws to deny — the same `policy` used by HTTP, jobs, and MCP. */
|
|
36
50
|
authorize?(args: { actor: Actor | null; input: JsonValue }): void | Promise<void>;
|
|
37
51
|
/**
|
package/src/live-definition.ts
CHANGED
|
@@ -16,7 +16,13 @@ import {
|
|
|
16
16
|
serviceActor,
|
|
17
17
|
withChildContext,
|
|
18
18
|
} from '@ultimat3/core';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
type AnyQuery,
|
|
21
|
+
queryHash,
|
|
22
|
+
queryName,
|
|
23
|
+
spendQueryLimit,
|
|
24
|
+
traceLiveRead,
|
|
25
|
+
} from '@ultimat3/query';
|
|
20
26
|
import { LiveRowUnidentifiedError } from './errors';
|
|
21
27
|
import { isRow, type JsonValue, type Row } from './json';
|
|
22
28
|
import type { LiveQueryDefinition, SnapshotResult } from './live-contract';
|
|
@@ -172,7 +178,7 @@ export function liveQueryDefinition(
|
|
|
172
178
|
prepare: async (input) => {
|
|
173
179
|
await resolve(input);
|
|
174
180
|
},
|
|
175
|
-
snapshot: async ({ input, tenant }): Promise<SnapshotResult> => {
|
|
181
|
+
snapshot: async ({ input, tenant, traceparent, phase }): Promise<SnapshotResult> => {
|
|
176
182
|
const window = await resolve(input);
|
|
177
183
|
// The position is taken BEFORE the rows: the rows are then at least that new, so the claim
|
|
178
184
|
// is true. Taken after, a commit landing mid-read was claimed and missing — and every change
|
|
@@ -181,7 +187,13 @@ export function liveQueryDefinition(
|
|
|
181
187
|
// a made-up one: a position the node never held becomes a ring floor every foreign cursor
|
|
182
188
|
// is "inside", and the client that resumed there kept rows this node never patched.
|
|
183
189
|
const lsn = options.lsn?.() ?? '';
|
|
184
|
-
|
|
190
|
+
// In a span joined to the subscriber's trace: the snapshot a subscribe asked for is its
|
|
191
|
+
// child, a re-run a change forced links to it (`traceLiveRead`). Inside the tenant's child
|
|
192
|
+
// context, so the span is the read's and nothing else's.
|
|
193
|
+
const rows = await readFor(options.ctx, tenant, () =>
|
|
194
|
+
traceLiveRead(name, phase ?? 'snapshot', traceparent, () => window.read()),
|
|
195
|
+
);
|
|
196
|
+
return { rows, lsn };
|
|
185
197
|
},
|
|
186
198
|
matcher: (input) => windows.get(queryHash(name, input))?.matcher ?? UNRESOLVED,
|
|
187
199
|
// Read off the same resolved window as the matcher, so the scope the client keys rows under and
|
package/src/live-fanout.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { LiveSubscription } from './live-contract';
|
|
|
11
11
|
import { applyToWindow, bridgeChange, type Projection, projectionOf } from './matcher-bridge';
|
|
12
12
|
import { type QueryEntry, refillWindowInLane } from './query-window';
|
|
13
13
|
import { type Subscriber, type SubscriberGate, windowIndex } from './subscriber-gate';
|
|
14
|
+
import { subscriptionKey } from './subscription-book';
|
|
14
15
|
import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
15
16
|
import { FRAME_LIMITS } from './wire-version';
|
|
16
17
|
|
|
@@ -259,6 +260,45 @@ async function resnapshot(
|
|
|
259
260
|
return true;
|
|
260
261
|
}
|
|
261
262
|
|
|
263
|
+
/**
|
|
264
|
+
* The repair when no change is coming: the socket drained, and a query nobody writes to has no next
|
|
265
|
+
* fanout to read the mark. Run in the entry's lane, and the mark asked inside it, so two drains
|
|
266
|
+
* landing together send one snapshot. Named nothing: this window was not read for any write.
|
|
267
|
+
*
|
|
268
|
+
* A window nothing trusts stays marked, for the reason `fanoutChange` checks a guess before the
|
|
269
|
+
* mark: a repair clears it, so one served out of these rows is recorded as healed against rows known
|
|
270
|
+
* to be wrong. `generation` covers the read in flight: `startRead` has already cleared `stale`.
|
|
271
|
+
*/
|
|
272
|
+
export async function repairSubscriber(
|
|
273
|
+
deps: FanoutDeps,
|
|
274
|
+
entry: QueryEntry,
|
|
275
|
+
subscription: LiveSubscription,
|
|
276
|
+
): Promise<boolean> {
|
|
277
|
+
if (entry.stale || entry.applied !== entry.generation) return false;
|
|
278
|
+
if (!subscription.socket.desynced.has(subscription.sid)) return false;
|
|
279
|
+
const held = entry.subscribers.get(subscriptionKey(subscription.socket.id, subscription.sid));
|
|
280
|
+
if (held !== subscription) return false;
|
|
281
|
+
return await resnapshot(deps, entry, subscription, []);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Every mark on one window answered, in its lane: a read just landed, or the node's sweep came round
|
|
286
|
+
* (a mark's other readers are a change or a drain, and a quiet query may never see either). A stale
|
|
287
|
+
* window is re-read first, as a fanout would, once a pass however many subscribers it owes. A read
|
|
288
|
+
* still out is left alone: its landing runs this. Frames that left.
|
|
289
|
+
*/
|
|
290
|
+
export async function repairWindow(deps: FanoutDeps, entry: QueryEntry): Promise<number> {
|
|
291
|
+
const subscribers = [...entry.subscribers.values()];
|
|
292
|
+
if (!subscribers.some((held) => held.socket.desynced.has(held.sid))) return 0;
|
|
293
|
+
if (entry.stale) {
|
|
294
|
+
await refillWindowInLane(entry);
|
|
295
|
+
deps.source.floorAt?.(entry.qid, entry.lsn, { exclusive: true });
|
|
296
|
+
for (const held of subscribers) held.socket.markDesynced(held.sid);
|
|
297
|
+
}
|
|
298
|
+
if (entry.applied !== entry.generation) return 0;
|
|
299
|
+
return await resnapshotAll(deps, entry);
|
|
300
|
+
}
|
|
301
|
+
|
|
262
302
|
/** A write this truth may hold, and the RECORD key of the row it made — what it is shown by. */
|
|
263
303
|
export interface NamedWrite {
|
|
264
304
|
readonly write: string;
|
package/src/live-query.ts
CHANGED
|
@@ -14,14 +14,14 @@ import { LiveQueryUnknownError, SubscriptionLimitError } from './errors';
|
|
|
14
14
|
import type { JsonValue } from './json';
|
|
15
15
|
import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
|
|
16
16
|
import { deliverChange } from './live-deliver';
|
|
17
|
-
import { type FanoutDeps, snapshotFrame } from './live-fanout';
|
|
17
|
+
import { type FanoutDeps, repairSubscriber, repairWindow, snapshotFrame } from './live-fanout';
|
|
18
18
|
import { floorAfterRead } from './live-floor';
|
|
19
19
|
import { DEFAULT_MAX_ENTRIES, type LiveQueryRegistryOptions } from './live-query-options';
|
|
20
20
|
import { reauthorizeSocket } from './live-reauth';
|
|
21
21
|
import { resumeOnto } from './live-resume';
|
|
22
22
|
import { type Charge, type SubscribeArgs, spendOnce } from './live-spend';
|
|
23
23
|
import { liveTenantOf, windowId } from './live-tenant';
|
|
24
|
-
import { fillWindow, type QueryEntry, queryEntry } from './query-window';
|
|
24
|
+
import { fillWindow, noteTraceparent, type QueryEntry, queryEntry } from './query-window';
|
|
25
25
|
import type { SyncSocket } from './socket';
|
|
26
26
|
import { type Subscriber, SubscriberGate } from './subscriber-gate';
|
|
27
27
|
import { SubscriptionBook, subscriptionKey } from './subscription-book';
|
|
@@ -171,6 +171,7 @@ export class LiveQueryRegistry {
|
|
|
171
171
|
name: string;
|
|
172
172
|
input: JsonValue;
|
|
173
173
|
cursor?: LiveCursor | null;
|
|
174
|
+
traceparent?: string | undefined;
|
|
174
175
|
},
|
|
175
176
|
charge: Charge,
|
|
176
177
|
): Promise<{ subscription: LiveSubscription; frame: Frame }> {
|
|
@@ -184,6 +185,7 @@ export class LiveQueryRegistry {
|
|
|
184
185
|
await definition.prepare?.(args.input);
|
|
185
186
|
|
|
186
187
|
const entry = this.#entryFor(qid, definition, args.input, tenant);
|
|
188
|
+
noteTraceparent(entry, args.traceparent);
|
|
187
189
|
try {
|
|
188
190
|
// Charged where a read happens, never from the cursor's say-so (`live-spend.ts`).
|
|
189
191
|
const pay = () => spendOnce(definition, args.socket, charge, this.#clock);
|
|
@@ -336,6 +338,39 @@ export class LiveQueryRegistry {
|
|
|
336
338
|
});
|
|
337
339
|
}
|
|
338
340
|
|
|
341
|
+
/**
|
|
342
|
+
* This socket's marked subscriptions, repaired now it can take a frame: Bun's `drain`. Frames that
|
|
343
|
+
* left. Every lane is entered before any is awaited, as `deliverChange` does, so one slow policy
|
|
344
|
+
* pass holds up its own query id and nothing else.
|
|
345
|
+
*/
|
|
346
|
+
async repair(socket: SyncSocket): Promise<number> {
|
|
347
|
+
const repairs = [...socket.desynced].map(async (sid) => {
|
|
348
|
+
const subscription = this.#book.get(socket.id, sid);
|
|
349
|
+
const entry = subscription && this.#entries.get(subscription.qid);
|
|
350
|
+
if (!subscription || !entry) return false;
|
|
351
|
+
// Its rows are about to be replaced, so wait: this drain is the only one coming. Bounded by
|
|
352
|
+
// the read's own deadline, and a read that failed staled the window, which refuses below.
|
|
353
|
+
await entry.reading?.result.catch(() => undefined);
|
|
354
|
+
return await entry.lock.run(() => repairSubscriber(this.#fanout, entry, subscription));
|
|
355
|
+
});
|
|
356
|
+
return (await Promise.all(repairs)).filter(Boolean).length;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The node's sweep: every window owing a mark, each in its lane, one read a window at most. */
|
|
360
|
+
async repairMarked(): Promise<number> {
|
|
361
|
+
const lanes = [...this.#entries.values()].map((entry) =>
|
|
362
|
+
entry.lock.run(() => repairWindow(this.#fanout, entry)),
|
|
363
|
+
);
|
|
364
|
+
let sent = 0;
|
|
365
|
+
let failure: { readonly error: unknown } | null = null;
|
|
366
|
+
for (const lane of await Promise.allSettled(lanes)) {
|
|
367
|
+
if (lane.status === 'fulfilled') sent += lane.value;
|
|
368
|
+
else failure ??= { error: lane.reason };
|
|
369
|
+
}
|
|
370
|
+
if (failure !== null) throw failure.error;
|
|
371
|
+
return sent;
|
|
372
|
+
}
|
|
373
|
+
|
|
339
374
|
#attachUnlessGone(
|
|
340
375
|
entry: QueryEntry,
|
|
341
376
|
socket: SyncSocket,
|
|
@@ -416,6 +451,11 @@ export class LiveQueryRegistry {
|
|
|
416
451
|
lastLsn: this.#lastLsn,
|
|
417
452
|
origin: this.#origin,
|
|
418
453
|
});
|
|
454
|
+
// A mark refused while this read was out has no other reader on a quiet query. Still stale: the
|
|
455
|
+
// sweep's, because re-reading here would charge this subscribe for a read it did not ask for.
|
|
456
|
+
if (window.landed !== null && !entry.stale) {
|
|
457
|
+
await entry.lock.run(() => repairWindow(this.#fanout, entry));
|
|
458
|
+
}
|
|
419
459
|
return { rows: await this.#gate.filterRows(entry, who, window.rows), lsn };
|
|
420
460
|
}
|
|
421
461
|
}
|
package/src/live-spend.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface SubscribeArgs {
|
|
|
14
14
|
readonly input: JsonValue;
|
|
15
15
|
readonly sid?: string;
|
|
16
16
|
readonly cursor?: LiveCursor | null;
|
|
17
|
+
/** The subscribe frame's `traceparent`, raw: the window's read joins that trace (`query-window`). */
|
|
18
|
+
readonly traceparent?: string | undefined;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/**
|
package/src/offline-queue.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// to a socket and nothing more, so a drained mutation is `inflight` — not `acked` — until an
|
|
10
10
|
// `ack`/`fail` frame settles it, or a lost connection returns it to the queue.
|
|
11
11
|
|
|
12
|
+
import type { ActionPin } from '@ultimat3/core/page';
|
|
12
13
|
import { renderThrowable, stringField } from '@ultimat3/core/page';
|
|
13
14
|
import type { JsonValue } from './json';
|
|
14
15
|
import { OfflineQueueAbandonedError } from './page-errors';
|
|
@@ -23,6 +24,8 @@ export interface QueuedMutation {
|
|
|
23
24
|
readonly seq: number;
|
|
24
25
|
readonly name: string;
|
|
25
26
|
readonly input: JsonValue;
|
|
27
|
+
/** The action's `http` pin, when the write was made against one (`pinned-wire.ts`). */
|
|
28
|
+
readonly http?: ActionPin | undefined;
|
|
26
29
|
readonly enqueuedAt: number;
|
|
27
30
|
attempts: number;
|
|
28
31
|
status: MutationStatus;
|
|
@@ -215,6 +218,7 @@ export class OfflineQueue {
|
|
|
215
218
|
key: string;
|
|
216
219
|
name: string;
|
|
217
220
|
input: JsonValue;
|
|
221
|
+
http?: ActionPin | undefined;
|
|
218
222
|
at?: number;
|
|
219
223
|
}): Promise<QueuedMutation> {
|
|
220
224
|
// Read BEFORE the key is looked up, never between the lookup and the push: with the await in
|
|
@@ -244,6 +248,7 @@ export class OfflineQueue {
|
|
|
244
248
|
seq: this.#nextSeq,
|
|
245
249
|
name: args.name,
|
|
246
250
|
input: args.input,
|
|
251
|
+
...(args.http === undefined ? {} : { http: args.http }),
|
|
247
252
|
enqueuedAt: args.at ?? 0,
|
|
248
253
|
attempts: 0,
|
|
249
254
|
status: 'pending',
|
package/src/outbox-slot.ts
CHANGED
|
@@ -2,14 +2,20 @@
|
|
|
2
2
|
// (`boot.ts`) is the one module that constructs it — IndexedDB, the queue, the drain listeners —
|
|
3
3
|
// so a writing island ships this reader and none of that (~7.5 kB it would otherwise carry).
|
|
4
4
|
|
|
5
|
+
import type { ActionPin } from '@ultimat3/core/page';
|
|
5
6
|
import type { JsonValue } from './json';
|
|
6
7
|
|
|
7
8
|
export interface OutboxEntry {
|
|
8
9
|
/** The idempotency key the write was first attempted under — the SAME key on every replay. */
|
|
9
10
|
readonly key: string;
|
|
10
|
-
/** The mutator's action name; the replay POSTs to `actionPath(name)
|
|
11
|
+
/** The mutator's action name; the replay POSTs to `actionPath(name)` unless `http` pins it. */
|
|
11
12
|
readonly name: string;
|
|
12
13
|
readonly input: JsonValue;
|
|
14
|
+
/**
|
|
15
|
+
* The action's pin, as the document stamped it when the write was made — persisted, so a reload
|
|
16
|
+
* replays it there.
|
|
17
|
+
*/
|
|
18
|
+
readonly http?: ActionPin | undefined;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
/** What a hook asks of the outbox the boot opened: queue a write, list them, replay them. */
|
package/src/page-errors.ts
CHANGED
|
@@ -138,3 +138,18 @@ export class CursorStaleError extends RealtimeError {
|
|
|
138
138
|
});
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A write to an action pinned at a `:param` template (`http: { path: '/orders/:id/refund' }`) whose
|
|
144
|
+
* input could not fill it: no URL exists to send it to, so it is refused before anything is sent
|
|
145
|
+
* or queued. `name` and `path` are the mutator's declared strings; the input is not rendered.
|
|
146
|
+
*/
|
|
147
|
+
export class MutationPathUnfilledError extends RealtimeError {
|
|
148
|
+
constructor(args: { name: string; path: string; param: string; reason: string }) {
|
|
149
|
+
super({
|
|
150
|
+
code: 'X_MUTATION_PATH_UNFILLED',
|
|
151
|
+
cause: `the write "${args.name}" is pinned at ${args.path}, and its :${args.param} ${args.reason} — it was not sent; the input must carry the URL's :${args.param} as a non-empty string`,
|
|
152
|
+
fix: `mutate({ ${args.param}: '<value>', ... })`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/page-outbox.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ClientScope, UltimateError } from '@ultimat3/core/page';
|
|
8
8
|
import {
|
|
9
|
-
actionPath,
|
|
10
9
|
classifyThrown,
|
|
11
10
|
clientTransport,
|
|
12
11
|
OUTBOX_DRAIN_MESSAGE,
|
|
@@ -28,6 +27,7 @@ import {
|
|
|
28
27
|
import { type OutboxTabs, type OutboxTabsWindow, outboxTabs } from './outbox-tabs';
|
|
29
28
|
import { LocalStoreUnavailableError, OfflineQueueAbandonedError } from './page-errors';
|
|
30
29
|
import { peekPageRealtime } from './page-store';
|
|
30
|
+
import { mutationWire } from './pinned-wire';
|
|
31
31
|
import { carriedBy } from './record-store';
|
|
32
32
|
|
|
33
33
|
export type { OutboxEntry } from './outbox-slot';
|
|
@@ -55,7 +55,7 @@ export interface OutboxOptions {
|
|
|
55
55
|
readonly local: LocalStore | Promise<LocalStore>;
|
|
56
56
|
readonly principal?: (() => ClientScope['principal']) | undefined;
|
|
57
57
|
/**
|
|
58
|
-
* One write, over HTTP. Default: `clientTransport`
|
|
58
|
+
* One write, over HTTP. Default: `clientTransport` to the entry's pin or `actionPath(entry.name)` (`pinned-wire.ts`), adding every
|
|
59
59
|
* record the answer carried (`type:key`) to `carried` — what its overlay settles against.
|
|
60
60
|
*/
|
|
61
61
|
readonly send?: ((entry: OutboxEntry, carried: Set<string>) => Promise<unknown>) | undefined;
|
|
@@ -175,7 +175,8 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
|
|
|
175
175
|
async (mutation: QueuedMutation): Promise<void> => {
|
|
176
176
|
const carried = new Set<string>();
|
|
177
177
|
try {
|
|
178
|
-
|
|
178
|
+
const { key, name, input, http } = mutation;
|
|
179
|
+
await send({ key, name, input, ...(http === undefined ? {} : { http }) }, carried);
|
|
179
180
|
} catch (error) {
|
|
180
181
|
const kind = classifyThrown(error);
|
|
181
182
|
// The network, or a server asking to be asked again: stays queued, and the pass stops so
|
|
@@ -297,7 +298,12 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
|
|
|
297
298
|
return held?.queue.pending().length ?? 0;
|
|
298
299
|
},
|
|
299
300
|
pending: () =>
|
|
300
|
-
(held?.queue.pending() ?? []).map(({ key, name, input }) => ({
|
|
301
|
+
(held?.queue.pending() ?? []).map(({ key, name, input, http }) => ({
|
|
302
|
+
key,
|
|
303
|
+
name,
|
|
304
|
+
input,
|
|
305
|
+
...(http === undefined ? {} : { http }),
|
|
306
|
+
})),
|
|
301
307
|
refresh: async () => {
|
|
302
308
|
await current();
|
|
303
309
|
await refreshHeld();
|
|
@@ -369,11 +375,12 @@ function exclusive<T>(name: string, work: () => Promise<T>): Promise<T> {
|
|
|
369
375
|
|
|
370
376
|
const LOCAL_LOCKS: unique symbol = Symbol.for('ultimate.outbox-locks');
|
|
371
377
|
type LockHost = { [LOCAL_LOCKS]?: Map<string, Promise<unknown>> };
|
|
372
|
-
function sendOverHttp(entry: OutboxEntry, carried: Set<string>): Promise<unknown> {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
378
|
+
async function sendOverHttp(entry: OutboxEntry, carried: Set<string>): Promise<unknown> {
|
|
379
|
+
const wire = mutationWire(entry.name, entry.input, entry.http);
|
|
380
|
+
return await clientTransport({
|
|
381
|
+
method: wire.method,
|
|
382
|
+
url: wire.url,
|
|
383
|
+
body: wire.body,
|
|
377
384
|
idempotencyKey: entry.key,
|
|
378
385
|
onEnvelope: (envelope) => carriedBy(envelope, carried),
|
|
379
386
|
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Where a page's write is sent: the action's pin — resolved off the document the server stamped
|
|
2
|
+
// (`renderedActionPin`) — with `:param`s filled and dropped from the body by core's `fillWire`, the
|
|
3
|
+
// request `action.client()` makes; else `POST actionPath(name)` with the whole input, as always.
|
|
4
|
+
|
|
5
|
+
import type { ActionPin, PinnedActionMethod } from '@ultimat3/core/page';
|
|
6
|
+
import { actionPath, fillWire } from '@ultimat3/core/page';
|
|
7
|
+
import type { JsonValue } from './json';
|
|
8
|
+
import { MutationPathUnfilledError } from './page-errors';
|
|
9
|
+
|
|
10
|
+
export interface MutationWire {
|
|
11
|
+
readonly method: PinnedActionMethod;
|
|
12
|
+
readonly url: string;
|
|
13
|
+
readonly body: JsonValue;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const METHODS: readonly unknown[] = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One leading `/` and no second, then printable ASCII only (`!`–`~`, the backslash cut out): no
|
|
20
|
+
* scheme can open it, and no whitespace, control or `\` survives — a URL parser drops a tab or
|
|
21
|
+
* newline (`/\t/host` reads as `//host`) and reads `\` as `/`. A stamped pin is plain ASCII.
|
|
22
|
+
*/
|
|
23
|
+
const SAME_ORIGIN_PATH = /^\/(?!\/)[!-[\]-~]*$/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The pin, when it is one this build would have stamped. Checked HERE, the one door every write
|
|
27
|
+
* reaches the wire through — fresh off the document or replayed off disk — because the outbox's
|
|
28
|
+
* entries come back from IndexedDB, which anything on the origin can rewrite: one check at the
|
|
29
|
+
* wire covers every store a pin can rest in, where a check at each load would be one per store.
|
|
30
|
+
*/
|
|
31
|
+
function trustedPin(pin: unknown): ActionPin | undefined {
|
|
32
|
+
if (typeof pin !== 'object' || pin === null) return undefined;
|
|
33
|
+
const path: unknown = Reflect.get(pin, 'path');
|
|
34
|
+
const method: unknown = Reflect.get(pin, 'method');
|
|
35
|
+
if (typeof path !== 'string' || !SAME_ORIGIN_PATH.test(path) || !METHODS.includes(method)) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return pin as ActionPin;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Refuses (`X_MUTATION_PATH_UNFILLED`) an input that leaves a `:param` of the pin unfilled. A pin
|
|
43
|
+
* that is not a same-origin path and a known verb is dropped for `POST actionPath(name)`: the
|
|
44
|
+
* server answers that URL or refuses it, and nothing leaves the origin.
|
|
45
|
+
*/
|
|
46
|
+
export function mutationWire(
|
|
47
|
+
name: string,
|
|
48
|
+
input: JsonValue,
|
|
49
|
+
stored: ActionPin | undefined,
|
|
50
|
+
): MutationWire {
|
|
51
|
+
const pin = trustedPin(stored);
|
|
52
|
+
if (pin === undefined) return { method: 'POST', url: actionPath(name), body: input };
|
|
53
|
+
const filled = fillWire(pin.path, input);
|
|
54
|
+
if (!filled.ok) {
|
|
55
|
+
throw new MutationPathUnfilledError({
|
|
56
|
+
name,
|
|
57
|
+
path: pin.path,
|
|
58
|
+
param: filled.missing,
|
|
59
|
+
reason: filled.reason,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// The input's own keys, minus the URL's: still JSON.
|
|
63
|
+
return { method: pin.method, url: filled.url, body: filled.rest as JsonValue };
|
|
64
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// `useQuery`'s timed re-read, as a VALUE the caller imports: `{ poll: pollEvery(ms) }`. Its own
|
|
2
|
+
// module so the timer (core's `visibleInterval`) is in an island's bundle only when the island
|
|
3
|
+
// polls — `useQuery` itself never references it, and a live-only island stays its old size.
|
|
4
|
+
|
|
5
|
+
import { type VisibleIntervalOptions, visibleInterval } from '@ultimat3/core/page';
|
|
6
|
+
|
|
7
|
+
/** What `useQuery` starts with its `refetch`; the disposer is called by `release()`. */
|
|
8
|
+
export interface QueryPoll {
|
|
9
|
+
start(run: () => Promise<void>): () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Re-read every `ms` through `visibleInterval`: no read while the tab is hidden, one make-up read
|
|
14
|
+
* when it shows, none after a lost session. `maxMs` backs the period off toward it. A non-finite
|
|
15
|
+
* value is `visibleInterval`'s refusal (`finiteOption`), raised when the read starts.
|
|
16
|
+
*/
|
|
17
|
+
export function pollEvery(ms: number, options: VisibleIntervalOptions = {}): QueryPoll {
|
|
18
|
+
return { start: (run) => visibleInterval(run, ms, options) };
|
|
19
|
+
}
|