@ultimat3/realtime 24.0.0 → 25.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 +61 -63
- package/README.md +131 -46
- package/package.json +9 -6
- package/src/advisory-lock.ts +10 -5
- package/src/boot.ts +14 -9
- package/src/changefeed-env.ts +8 -8
- package/src/changefeed.ts +42 -18
- package/src/channel-authz.ts +7 -7
- package/src/channel-describe.ts +1 -1
- package/src/channel-logs.ts +16 -25
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +3 -3
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel.ts +8 -7
- package/src/client-channels.ts +2 -0
- package/src/client-frames.ts +9 -2
- package/src/client.ts +29 -2
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +47 -90
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +24 -6
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +12 -2
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +81 -24
- package/src/live-query-options.ts +6 -0
- package/src/live-query.ts +66 -144
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +26 -4
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +127 -15
- package/src/offline-queue.ts +5 -0
- package/src/page-errors.ts +8 -9
- package/src/page-outbox.ts +5 -5
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +9 -4
- package/src/pg-connection.ts +7 -1
- package/src/pg-replication.ts +6 -6
- package/src/pg-socket.ts +20 -3
- package/src/pg-values.ts +29 -14
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +2 -2
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +1 -1
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +3 -3
- package/src/replicator-envelope.ts +61 -11
- package/src/replicator-row.ts +1 -1
- package/src/replicator.ts +12 -23
- package/src/server.ts +36 -18
- package/src/socket-defaults.ts +20 -0
- package/src/socket.ts +14 -19
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +1 -1
- package/src/sync-node-bounds.ts +1 -1
- package/src/sync-node-contract.ts +16 -2
- package/src/sync-node.ts +115 -109
- package/src/sync-origin.ts +1 -1
- package/src/sync-protocol.ts +73 -13
- package/src/sync-upgrade.ts +39 -0
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +9 -16
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +5 -5
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/errors.ts
CHANGED
|
@@ -1,43 +1,19 @@
|
|
|
1
1
|
// Realtime's X_* codes. Every throw in this package goes through one of these classes so
|
|
2
2
|
// the same string renders in the terminal, the browser overlay, and `--json`.
|
|
3
3
|
|
|
4
|
-
import { isFixShellSafe,
|
|
4
|
+
import { isFixShellSafe, renderFixShellArg } from '@ultimat3/core';
|
|
5
|
+
// The owned code list and its titles are `error-titles.ts` — the one module `sideEffects` lists,
|
|
6
|
+
// imported bare here and from the barrel — so this module is side-effect-free and a chunk keeps
|
|
7
|
+
// only the classes it constructs. Re-exported, so every name is still importable from `./errors`.
|
|
8
|
+
import './error-titles';
|
|
9
|
+
import { REALTIME_OWNED_ERROR_CODES } from './error-titles';
|
|
5
10
|
import { RealtimeError } from './realtime-error';
|
|
6
11
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
'X_FRAME_RATE_LIMIT',
|
|
13
|
-
'X_PROTOCOL_VERSION',
|
|
14
|
-
'X_CURSOR_STALE',
|
|
15
|
-
'X_REBASE_CONFLICT',
|
|
16
|
-
'X_TRANSPORT_UNAVAILABLE',
|
|
17
|
-
'X_TRANSPORT_PROTOCOL',
|
|
18
|
-
'X_REPLICATION_PROTOCOL',
|
|
19
|
-
'X_REPLICATION_FAILED',
|
|
20
|
-
'X_REPLICATOR_SLOT_HELD',
|
|
21
|
-
// Retired in 21.0.0 and never thrown since — kept registered, because a shipped code is
|
|
22
|
-
// forever: a log line from an older build still resolves through `x errors explain`.
|
|
23
|
-
'X_LIVE_CLIENT_MISSING',
|
|
24
|
-
'X_QUERY_NOT_SUBSCRIBABLE',
|
|
25
|
-
'X_REALTIME_UNINSTALLED',
|
|
26
|
-
'X_SYNC_UNCONFIGURED',
|
|
27
|
-
'X_RECORD_REJECTED',
|
|
28
|
-
'X_CHANNEL_DECLARATION_INVALID',
|
|
29
|
-
'X_LOCAL_STORE_UNAVAILABLE',
|
|
30
|
-
'X_LIVE_SERVER_RENDER',
|
|
31
|
-
'X_LIVE_ROW_UNIDENTIFIED',
|
|
32
|
-
'X_LIVE_QUERY_UNKNOWN',
|
|
33
|
-
'X_LIVE_REPLICA_IDENTITY',
|
|
34
|
-
'X_SOCKET_UNAUTHENTICATED',
|
|
35
|
-
'X_SOCKET_AUTH_UNAVAILABLE',
|
|
36
|
-
'X_REALTIME_TOPOLOGY',
|
|
37
|
-
'X_REPLICATION_TLS',
|
|
38
|
-
'X_SOCKET_ORIGIN_REFUSED',
|
|
39
|
-
'X_OFFLINE_QUEUE_ABANDONED',
|
|
40
|
-
] as const;
|
|
12
|
+
export {
|
|
13
|
+
REALTIME_ERROR_TITLES,
|
|
14
|
+
REALTIME_OWNED_ERROR_CODES,
|
|
15
|
+
type RealtimeOwnedErrorCode,
|
|
16
|
+
} from './error-titles';
|
|
41
17
|
|
|
42
18
|
/**
|
|
43
19
|
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s, and `X_FORBIDDEN` — thrown by the surface denials this
|
|
@@ -131,51 +107,8 @@ export const REALTIME_ERROR_CODES = [
|
|
|
131
107
|
...REALTIME_BORROWED_ERROR_CODES,
|
|
132
108
|
] as const;
|
|
133
109
|
|
|
134
|
-
export type RealtimeOwnedErrorCode = (typeof REALTIME_OWNED_ERROR_CODES)[number];
|
|
135
110
|
export type RealtimeErrorCode = (typeof REALTIME_ERROR_CODES)[number];
|
|
136
111
|
|
|
137
|
-
export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, string>> = {
|
|
138
|
-
X_TOPIC_FORBIDDEN: 'the actor may not subscribe to this topic',
|
|
139
|
-
X_SUBSCRIPTION_LIMIT: 'socket, tenant or node hit its subscription cap',
|
|
140
|
-
X_SUBSCRIPTION_ID_TAKEN: 'a subscribe frame reused a sid this socket already holds',
|
|
141
|
-
X_FRAME_RATE_LIMIT: 'one socket sent frames faster than this node will route them',
|
|
142
|
-
X_PROTOCOL_VERSION: 'client and sync node disagree on the wire protocol',
|
|
143
|
-
X_CURSOR_STALE: 'the resume LSN is outside the change buffer',
|
|
144
|
-
X_REBASE_CONFLICT: 'a local mutation could not be rebased',
|
|
145
|
-
X_TRANSPORT_UNAVAILABLE: 'the fanout bus is unreachable',
|
|
146
|
-
X_TRANSPORT_PROTOCOL: 'the bus does not speak the protocol this build speaks',
|
|
147
|
-
X_REPLICATION_PROTOCOL: 'the WAL stream cannot be decoded',
|
|
148
|
-
X_REPLICATION_FAILED: 'the replication connection was refused',
|
|
149
|
-
X_REPLICATOR_SLOT_HELD: 'another replicator already owns this database',
|
|
150
|
-
X_LIVE_CLIENT_MISSING:
|
|
151
|
-
'retired in 21.0.0 (no LiveClient to register; see X_REALTIME_UNINSTALLED): a realtime hook ran in a browser with no LiveClient registered',
|
|
152
|
-
X_QUERY_NOT_SUBSCRIBABLE:
|
|
153
|
-
'retired in 21.0.0 (liveHookFor was deleted; useQuery reads any query): a hook was bound to a query that is not declared live',
|
|
154
|
-
X_REALTIME_UNINSTALLED: 'a realtime hook ran in an island bundle that never installed realtime',
|
|
155
|
-
X_SYNC_UNCONFIGURED: 'a live hook needed the page socket and no sync target was configured',
|
|
156
|
-
X_RECORD_REJECTED: 'a row reached the record store in a shape it cannot hold',
|
|
157
|
-
X_CHANNEL_DECLARATION_INVALID: 'a channel() declaration cannot route its rows',
|
|
158
|
-
X_LOCAL_STORE_UNAVAILABLE: "the page's durable store could not open",
|
|
159
|
-
X_LIVE_SERVER_RENDER: 'a browser-only live operation ran during a server render',
|
|
160
|
-
X_LIVE_ROW_UNIDENTIFIED: 'a live query returned a row with no id',
|
|
161
|
-
X_LIVE_QUERY_UNKNOWN: 'no live query is registered under the name a subscribe frame asked for',
|
|
162
|
-
X_LIVE_REPLICA_IDENTITY: 'a replicated table has no replica identity',
|
|
163
|
-
X_SOCKET_UNAUTHENTICATED: 'the sync upgrade carried no credential this app accepts',
|
|
164
|
-
X_SOCKET_AUTH_UNAVAILABLE: 'the sync node could not decide who a connecting socket is',
|
|
165
|
-
X_REALTIME_TOPOLOGY: 'a sync node boots on a real database with no reachable change feed',
|
|
166
|
-
X_REPLICATION_TLS: 'the replication connection failed TLS',
|
|
167
|
-
X_SOCKET_ORIGIN_REFUSED: 'the websocket upgrade came from another origin',
|
|
168
|
-
X_OFFLINE_QUEUE_ABANDONED: 'a write was queued after its page changed principal',
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
// One unconditional call, so a second package claiming one of realtime's codes throws
|
|
172
|
-
// X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
|
|
173
|
-
registerErrorCodes(
|
|
174
|
-
Object.fromEntries(
|
|
175
|
-
Object.entries(REALTIME_ERROR_TITLES).map(([code, title]) => [code, { title }]),
|
|
176
|
-
),
|
|
177
|
-
);
|
|
178
|
-
|
|
179
112
|
export {
|
|
180
113
|
CursorStaleError,
|
|
181
114
|
LocalStoreUnavailableError,
|
|
@@ -189,9 +122,9 @@ export {
|
|
|
189
122
|
} from './page-errors';
|
|
190
123
|
// Re-exported, never re-declared: `RealtimeError` lives in `realtime-error.ts`, the four
|
|
191
124
|
// replication errors in `replication-errors.ts` and the browser-reachable refusals in
|
|
192
|
-
// `page-errors.ts`, so this file is the
|
|
193
|
-
// importable from `./errors`, which is what the `pg-*` modules and the barrel
|
|
194
|
-
// browser module imports `./page-errors` so it does not load
|
|
125
|
+
// `page-errors.ts`, and the code table in `error-titles.ts`, so this file is the server's refusals.
|
|
126
|
+
// Every name is still importable from `./errors`, which is what the `pg-*` modules and the barrel
|
|
127
|
+
// already do; a browser module imports `./page-errors` so it does not load these classes.
|
|
195
128
|
export { RealtimeError } from './realtime-error';
|
|
196
129
|
export {
|
|
197
130
|
ReplicaIdentityError,
|
|
@@ -214,17 +147,19 @@ export class TopicForbiddenError extends RealtimeError {
|
|
|
214
147
|
}
|
|
215
148
|
|
|
216
149
|
/**
|
|
217
|
-
* Load shedding, not a crash: a socket, a tenant or this node asked for more than its cap.
|
|
150
|
+
* Load shedding, not a crash: a socket, a tenant, an actor or this node asked for more than its cap.
|
|
218
151
|
*
|
|
219
152
|
* `knob` is the option that raises it, and it is passed rather than derived because the `node`
|
|
220
153
|
* scope has more than one — a live-query entry ceiling and a channel-topic ceiling are two
|
|
221
|
-
* different numbers on two different objects.
|
|
222
|
-
* `app.config.ts` field
|
|
223
|
-
*
|
|
154
|
+
* different numbers on two different objects. Every scope but `actor` names a CONSTRUCTOR option:
|
|
155
|
+
* those have no `app.config.ts` field, and a fix naming a field that does not exist is an
|
|
156
|
+
* instruction that cannot be followed. The actor cap is the one that is app config —
|
|
157
|
+
* `realtime.maxSubscriptionsPerActor` — so its fix names that key and that file first, then the
|
|
158
|
+
* registry option a host that builds its own `LiveQueryRegistry` sets instead.
|
|
224
159
|
*/
|
|
225
160
|
export class SubscriptionLimitError extends RealtimeError {
|
|
226
161
|
constructor(args: {
|
|
227
|
-
scope: 'socket' | 'tenant' | 'node';
|
|
162
|
+
scope: 'socket' | 'tenant' | 'actor' | 'node';
|
|
228
163
|
id: string;
|
|
229
164
|
limit: number;
|
|
230
165
|
knob?: string;
|
|
@@ -233,7 +168,10 @@ export class SubscriptionLimitError extends RealtimeError {
|
|
|
233
168
|
super({
|
|
234
169
|
code: 'X_SUBSCRIPTION_LIMIT',
|
|
235
170
|
cause: `${args.scope} ${args.id} reached the subscription cap of ${args.limit}`,
|
|
236
|
-
fix:
|
|
171
|
+
fix:
|
|
172
|
+
args.scope === 'actor'
|
|
173
|
+
? `defineConfig({ realtime: { maxSubscriptionsPerActor: ${args.limit * 2} } }) # in app.config.ts — realtime.maxSubscriptionsPerActor; or maxPerActor on new LiveQueryRegistry({…}) where a host builds its own; or unsubscribe unused live queries`
|
|
174
|
+
: `raise ${knob} where this sync node is constructed, or unsubscribe unused live queries`,
|
|
237
175
|
});
|
|
238
176
|
}
|
|
239
177
|
}
|
|
@@ -251,7 +189,7 @@ export class FrameRateLimitError extends RealtimeError {
|
|
|
251
189
|
super({
|
|
252
190
|
code: 'X_FRAME_RATE_LIMIT',
|
|
253
191
|
cause: `socket ${args.socketId} exceeded ${args.perSecond} frames per second`,
|
|
254
|
-
fix: 'batch subscribes into one frame per subscription and retry after the delay, or raise maxFramesPerSecond where
|
|
192
|
+
fix: 'batch subscribes into one frame per subscription and retry after the delay, or raise maxFramesPerSecond where syncNode() is called',
|
|
255
193
|
});
|
|
256
194
|
}
|
|
257
195
|
}
|
|
@@ -388,7 +326,26 @@ export class SocketUnauthenticatedError extends RealtimeError {
|
|
|
388
326
|
super({
|
|
389
327
|
code: 'X_SOCKET_UNAUTHENTICATED',
|
|
390
328
|
cause: `the websocket upgrade was refused: ${args.reason}`,
|
|
391
|
-
fix: 'send the credential
|
|
329
|
+
fix: 'send the credential syncNode({ authenticate }) reads on the upgrade request, or return an anonymous Actor from it to admit this socket',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* One principal — an actor, or an anonymous caller's network — already holds this node's per-
|
|
336
|
+
* principal share of sockets. Answered on the upgrade with 429, before a socket exists. The
|
|
337
|
+
* principal is the node's own key (`actor:<id>` / `address:<network>`), never request text.
|
|
338
|
+
*/
|
|
339
|
+
export class SocketLimitError extends RealtimeError {
|
|
340
|
+
constructor(args: { principal: string; limit: number }) {
|
|
341
|
+
// An anonymous network's cap is 8 x `maxSocketsPerActor` (`principal-sockets.ts`), so the
|
|
342
|
+
// value to set is derived from the actor cap either way, never the limit this socket hit.
|
|
343
|
+
const network = args.principal.startsWith('address:');
|
|
344
|
+
const perActor = network ? Math.ceil(args.limit / 8) : args.limit;
|
|
345
|
+
super({
|
|
346
|
+
code: 'X_SOCKET_LIMIT',
|
|
347
|
+
cause: `${args.principal} already holds ${args.limit} sockets on this sync node — the per-principal cap${network ? ' (an anonymous network gets 8 x realtime.maxSocketsPerActor)' : ''}`,
|
|
348
|
+
fix: `defineConfig({ realtime: { maxSocketsPerActor: ${perActor * 2} } }) # in app.config.ts — or maxSocketsPerActor on syncNode({…}) where a host builds its own; or close other tabs of this app`,
|
|
392
349
|
});
|
|
393
350
|
}
|
|
394
351
|
}
|
|
@@ -414,7 +371,7 @@ export class SocketOriginRefusedError extends RealtimeError {
|
|
|
414
371
|
super({
|
|
415
372
|
code: 'X_SOCKET_ORIGIN_REFUSED',
|
|
416
373
|
cause: `the websocket upgrade was refused: ${args.reason} — asked from ${asked ?? 'an origin that cannot be rendered'}; this node admits ${list}`,
|
|
417
|
-
fix: `export APP_URL=${asked ?? 'https://www.example.com'} # on the sync role, and only when that is the origin your pages are served on (or
|
|
374
|
+
fix: `export APP_URL=${asked ?? 'https://www.example.com'} # on the sync role, and only when that is the origin your pages are served on (or syncNode({ allowedOrigins }))`,
|
|
418
375
|
});
|
|
419
376
|
}
|
|
420
377
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// What a realtime island's bootstrap awaits before its `mount` may run (#506, owner option 3): the
|
|
2
|
+
// page boot's disk restore AND the outbox it opened, so the island's first render already carries
|
|
3
|
+
// the overlays a reload rebuilds from the queue — never the server's stale count. Capped, because
|
|
4
|
+
// an IndexedDB that hangs must cost the hold, never the island.
|
|
5
|
+
|
|
6
|
+
import { peekOutbox } from './outbox-slot';
|
|
7
|
+
import { pageRealtime } from './page-store';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The longest a realtime island waits on the restore before it mounts anyway. A warm IndexedDB
|
|
11
|
+
* opens in tens of milliseconds; a second is a disk that is not answering, and past it the island
|
|
12
|
+
* paints what it has. Below `@ultimat3/render`'s `ISLAND_HOLD_MS`, which reveals the server's
|
|
13
|
+
* markup regardless — so this cap, not that one, is the one a slow disk meets.
|
|
14
|
+
*/
|
|
15
|
+
export const FIRST_PAINT_HOLD_MS = 1_000;
|
|
16
|
+
|
|
17
|
+
/** `settled` — the restore finished (or was refused; there is nothing more to wait for). */
|
|
18
|
+
export type FirstPaintHold = 'settled' | 'capped';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolves once the page's records are back AND its outbox is open — what `useMutation` rebuilds a
|
|
22
|
+
* queued write's overlay from, synchronously from then on — or at the cap. Never rejects: a hold
|
|
23
|
+
* that threw would take the island's module graph, and with it the island, down with the disk.
|
|
24
|
+
* A page with no boot resolves at once (`booted` does, and no outbox is seated). No options: the
|
|
25
|
+
* cap is the constant, and an option would ship its finite-number screen (~550 B) in every
|
|
26
|
+
* realtime island to serve tests that can stub the timer instead.
|
|
27
|
+
*/
|
|
28
|
+
export async function holdFirstPaint(): Promise<FirstPaintHold> {
|
|
29
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
30
|
+
const capped = new Promise<FirstPaintHold>((resolve) => {
|
|
31
|
+
timer = setTimeout(() => resolve('capped'), FIRST_PAINT_HOLD_MS);
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
return await Promise.race([restored(), capped]);
|
|
35
|
+
} finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function restored(): Promise<FirstPaintHold> {
|
|
41
|
+
try {
|
|
42
|
+
await pageRealtime().booted;
|
|
43
|
+
// Read after the boot, never before: the boot seats the outbox as its last step.
|
|
44
|
+
await peekOutbox()?.ready;
|
|
45
|
+
} catch {
|
|
46
|
+
// The boot already reported what the disk refused (`boot.ts`); the hold only stops waiting.
|
|
47
|
+
}
|
|
48
|
+
return 'settled';
|
|
49
|
+
}
|
package/src/idb-types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The slice of IndexedDB `local-store-idb.ts` touches, as structural types: the browser's
|
|
3
|
-
* `indexedDB` satisfies them and so does `idb-fake.ts`, so the store is tested against the
|
|
4
|
-
* surface it runs on without a DOM shim.
|
|
3
|
+
* `indexedDB` satisfies them and so does `idb-fake-fixture.ts`, so the store is tested against the
|
|
4
|
+
* same surface it runs on without a DOM shim.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export interface IdbRequestLike<T> {
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// Nothing here reaches `nats`, a Postgres socket or the sync node; those are `./server`, and
|
|
4
4
|
// `packages/cli/src/realtime-browser-barrel.test.ts` is the build error that keeps them apart.
|
|
5
5
|
|
|
6
|
+
// Imported bare, and the one module `sideEffects` lists: a page rebuilds a sync node's refusal by
|
|
7
|
+
// code (`refusalError`) and `page-errors.ts` constructs codes from it without importing it, so
|
|
8
|
+
// every chunk reaching this barrel registers the titles. `SIDE_EFFECTS_ANCHORS` carries the
|
|
9
|
+
// argument and `bun run side-effects` enforces it.
|
|
10
|
+
import './error-titles';
|
|
11
|
+
|
|
6
12
|
// ---- the client's one stateless piece, reusable against an app's own store ----------------------
|
|
7
13
|
export { applyPatches, orderAfterPatches } from './apply-patches';
|
|
8
14
|
// ---- channels: the declaration is the only way to spell a topic, and the frames it rides --------
|
|
@@ -84,10 +90,15 @@ export {
|
|
|
84
90
|
TransportUnavailableError,
|
|
85
91
|
WindowReadTimeoutError,
|
|
86
92
|
} from './errors';
|
|
93
|
+
// ---- the page: one record store, one socket, installed by the island bootstrap ------------------
|
|
94
|
+
export {
|
|
95
|
+
FIRST_PAINT_HOLD_MS,
|
|
96
|
+
type FirstPaintHold,
|
|
97
|
+
holdFirstPaint,
|
|
98
|
+
} from './first-paint-hold';
|
|
87
99
|
// ---- shared value domain ---------------------------------------------------------------------
|
|
88
100
|
export {
|
|
89
101
|
changedColumns,
|
|
90
|
-
isJsonObject,
|
|
91
102
|
isRow,
|
|
92
103
|
type JsonObject,
|
|
93
104
|
type JsonValue,
|
|
@@ -99,7 +110,8 @@ export { type LiveState, type Registration, RowWindows, unnamedType } from './li
|
|
|
99
110
|
// ---- offline: the durable store, the persisted records, the one outbox (plan 101 slice 12) ------
|
|
100
111
|
export {
|
|
101
112
|
type LocalStore,
|
|
102
|
-
MemoryLocalStore,
|
|
113
|
+
type MemoryLocalStore,
|
|
114
|
+
memoryLocalStore,
|
|
103
115
|
openLocalStore,
|
|
104
116
|
pageLocalStore,
|
|
105
117
|
scopeKey,
|
|
@@ -107,22 +119,22 @@ export {
|
|
|
107
119
|
// ---- the offline outbox (wired over HTTP in plan 101 slice 12) ----------------------------------
|
|
108
120
|
export {
|
|
109
121
|
type DrainReport,
|
|
110
|
-
MemoryQueueStore,
|
|
122
|
+
type MemoryQueueStore,
|
|
111
123
|
type MutationSender,
|
|
112
124
|
type MutationStatus,
|
|
125
|
+
memoryQueueStore,
|
|
113
126
|
OfflineQueue,
|
|
114
127
|
type QueuedMutation,
|
|
115
128
|
type QueueState,
|
|
116
129
|
type QueueStore,
|
|
117
130
|
} from './offline-queue';
|
|
118
131
|
export {
|
|
119
|
-
createOutbox,
|
|
120
132
|
listenForDrain,
|
|
133
|
+
localOutbox,
|
|
121
134
|
type OutboxEntry,
|
|
122
135
|
type PageOutbox,
|
|
123
136
|
pageOutbox,
|
|
124
137
|
} from './page-outbox';
|
|
125
|
-
// ---- the page: one record store, one socket, installed by the island bootstrap ------------------
|
|
126
138
|
export { hasPageSocket, type SyncTarget } from './page-store';
|
|
127
139
|
export { installRealtime, type RealtimeInstall } from './reactivity';
|
|
128
140
|
export { persistedTypes, type RecordPersister, recordPersister } from './record-persister';
|
|
@@ -182,4 +194,10 @@ export {
|
|
|
182
194
|
useMutationQueue,
|
|
183
195
|
} from './use-mutation';
|
|
184
196
|
export { type QueryAccessor, type QueryOptions, type QueryRef, useQuery } from './use-query';
|
|
185
|
-
export {
|
|
197
|
+
export {
|
|
198
|
+
type RecordAccessor,
|
|
199
|
+
type RecordSelection,
|
|
200
|
+
type RecordsAccessor,
|
|
201
|
+
useRecord,
|
|
202
|
+
useRecords,
|
|
203
|
+
} from './use-record';
|
package/src/json.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// for having no reader. A 32-bit hash nothing calls is a hash the next caller reaches for as a
|
|
9
9
|
// sharing key, which is the one thing it must never be.
|
|
10
10
|
|
|
11
|
+
import { isJsonObject } from '@ultimat3/core/page';
|
|
12
|
+
|
|
11
13
|
export type JsonValue =
|
|
12
14
|
| string
|
|
13
15
|
| number
|
|
@@ -39,14 +41,25 @@ export interface RowPatch {
|
|
|
39
41
|
* projection on the server. Absent means the key is the `id`.
|
|
40
42
|
*/
|
|
41
43
|
readonly key?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The write that made this patch's change (`ChangeEvent.write`), held only on the RETAINED
|
|
46
|
+
* patch so a delta resume can name it. Server-side only: a frame names its writes once, in
|
|
47
|
+
* `PatchFrame.writes`, so `live-resume.ts` strips it before sending and `decode` never reads it.
|
|
48
|
+
*/
|
|
49
|
+
readonly write?: string;
|
|
42
50
|
}
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Core's `isJsonObject`, read as the wire's value domain. Sound ONLY for a value that came out of
|
|
54
|
+
* `JSON.parse` (a frame, a stored row): every member of one is a `JsonValue` by construction, which
|
|
55
|
+
* is the one thing core's shape check cannot know and so does not claim. One implementation, core's.
|
|
56
|
+
*/
|
|
57
|
+
export function isParsedJsonObject(value: unknown): value is JsonObject {
|
|
58
|
+
return isJsonObject(value);
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
export function isRow(value: unknown): value is Row {
|
|
49
|
-
return
|
|
62
|
+
return isParsedJsonObject(value) && typeof value['id'] === 'string';
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
/** Shallow diff used to keep update patches minimal — the pipeline never ships unchanged columns. */
|
package/src/live-contract.ts
CHANGED
|
@@ -34,6 +34,13 @@ export interface LiveQueryDefinition<R extends Row = Row> {
|
|
|
34
34
|
snapshot(args: { input: JsonValue; tenant: string | null }): Promise<SnapshotResult<R>>;
|
|
35
35
|
/** Subscribe-time gate. Throws to deny — the same `policy` used by HTTP, jobs, and MCP. */
|
|
36
36
|
authorize?(args: { actor: Actor | null; input: JsonValue }): void | Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* The read's declared `rateLimit:`, spent once per SUBSCRIBE, after `authorize` allowed it —
|
|
39
|
+
* the same bucket HTTP, a paged read and the MCP tool spend. Per subscriber because the shared
|
|
40
|
+
* window builds with no subject and spends nothing; never on a re-authorization, because the
|
|
41
|
+
* subscription it re-decides was already paid for. Throws `X_RATE_LIMITED` to refuse.
|
|
42
|
+
*/
|
|
43
|
+
spend?(caller: LiveSpender): Promise<void>;
|
|
37
44
|
/** Row-level gate, evaluated per subscriber. The only row filter in the pipeline. */
|
|
38
45
|
visible(args: { actor: Actor | null; row: R; input: JsonValue }): boolean | Promise<boolean>;
|
|
39
46
|
/** Built once per `qid`, since a qid pins both the query and its input. */
|
|
@@ -59,6 +66,13 @@ export interface LiveQueryDefinition<R extends Row = Row> {
|
|
|
59
66
|
prepare?(input: JsonValue): Promise<void>;
|
|
60
67
|
}
|
|
61
68
|
|
|
69
|
+
/** Who a live subscribe is charged to: the socket's actor, else its address, on the node's clock. */
|
|
70
|
+
export interface LiveSpender {
|
|
71
|
+
readonly actor: Actor | null;
|
|
72
|
+
readonly clientAddress: string | null;
|
|
73
|
+
readonly nowMs: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
62
76
|
export interface LiveSubscription {
|
|
63
77
|
readonly sid: string;
|
|
64
78
|
readonly qid: string;
|
package/src/live-definition.ts
CHANGED
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
// every time. Collapsing the second onto the first is privilege escalation with a cache hit rate.
|
|
10
10
|
|
|
11
11
|
import type { Ctx } from '@ultimat3/core';
|
|
12
|
-
import {
|
|
13
|
-
|
|
12
|
+
import {
|
|
13
|
+
anonymousActor,
|
|
14
|
+
finiteOption,
|
|
15
|
+
runWithContext,
|
|
16
|
+
serviceActor,
|
|
17
|
+
withChildContext,
|
|
18
|
+
} from '@ultimat3/core';
|
|
19
|
+
import { type AnyQuery, queryHash, queryName, spendQueryLimit } from '@ultimat3/query';
|
|
14
20
|
import { LiveRowUnidentifiedError } from './errors';
|
|
15
21
|
import { isRow, type JsonValue, type Row } from './json';
|
|
16
22
|
import type { LiveQueryDefinition, SnapshotResult } from './live-contract';
|
|
@@ -182,6 +188,10 @@ export function liveQueryDefinition(
|
|
|
182
188
|
// memoised anywhere: `authorize` runs on every subscribe, `visible` on every row of every
|
|
183
189
|
// delivery, and there is no key here an actor could share with another actor.
|
|
184
190
|
authorize: authorizeWithPolicy(target.policy, { query: name, ctx: options.ctx }),
|
|
191
|
+
// The read's own `rateLimit:` bucket, per subscriber — the one HTTP and MCP spend. A socket
|
|
192
|
+
// with no actor is the anonymous reader, keyed by its address as an unauthenticated HTTP read is.
|
|
193
|
+
spend: ({ actor, clientAddress, nowMs }) =>
|
|
194
|
+
spendQueryLimit(target, { actor: actor ?? anonymousActor(), clientAddress, nowMs }),
|
|
185
195
|
visible: visibleWithPolicy(target.policy, { query: name, ctx: options.ctx }),
|
|
186
196
|
};
|
|
187
197
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// One change, fanned out over every live window on the node. Split from `live-query.ts` at its
|
|
2
|
+
// size ceiling: the registry owns who holds which window, this owns the ordering of one delivery.
|
|
3
|
+
|
|
4
|
+
import type { ChangeEvent } from './changefeed';
|
|
5
|
+
import { type FanoutDeps, fanoutChange } from './live-fanout';
|
|
6
|
+
import type { QueryEntry } from './query-window';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fan one change out. Matched once per query id, authorized once per subscriber. Returns the
|
|
10
|
+
* number of frames sent — the metric the reconnect benchmark watches.
|
|
11
|
+
*
|
|
12
|
+
* Each entry's turn is taken in that entry's lane. Nothing upstream orders this: `sync` fires
|
|
13
|
+
* `void registry.deliver(change)` straight off the bus subscription, so two changes arriving back
|
|
14
|
+
* to back would otherwise interleave inside one query id — lsn 2 delivered before lsn 1, the
|
|
15
|
+
* subscriber's cursor rewound to 1, and every gate deciding against whichever window won.
|
|
16
|
+
*
|
|
17
|
+
* Every lane is *entered* before any of them is awaited, and nothing inside a fanout takes a
|
|
18
|
+
* second lane, so holding all of them at once cannot be a cycle. That is what makes the ordering
|
|
19
|
+
* claim true: two deliveries queue onto each query id in call order, serialized per query id and
|
|
20
|
+
* never per node — awaiting one entry before entering the next made one slow policy pass the
|
|
21
|
+
* whole node's pace, and let a lane that threw skip every entry behind it with nobody desynced.
|
|
22
|
+
*/
|
|
23
|
+
export async function deliverChange(
|
|
24
|
+
entries: Iterable<QueryEntry>,
|
|
25
|
+
fanout: FanoutDeps,
|
|
26
|
+
change: ChangeEvent,
|
|
27
|
+
onStale: (count: number) => void,
|
|
28
|
+
): Promise<number> {
|
|
29
|
+
const lanes = [...entries].map(async (entry) => {
|
|
30
|
+
try {
|
|
31
|
+
const result = await entry.lock.run(() => fanoutChange(fanout, entry, change));
|
|
32
|
+
onStale(result.stale);
|
|
33
|
+
return result.sent;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// The window advanced under a fanout that did not finish, so every subscriber of this one
|
|
36
|
+
// query id now holds a cursor below the change and no later flush would correct them:
|
|
37
|
+
// desynced here, re-snapshotted on the next one. Silent divergence is the whole reason
|
|
38
|
+
// `markDesynced` exists, and skipping this is how a failure became one.
|
|
39
|
+
for (const subscription of entry.subscribers.values()) {
|
|
40
|
+
subscription.socket.markDesynced(subscription.sid);
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
// `allSettled`, so one lane's rejection neither cancels the others nor goes unhandled. The
|
|
46
|
+
// first failure still reaches the caller — `sync` logs it — but it costs one query id.
|
|
47
|
+
let sent = 0;
|
|
48
|
+
let failure: { readonly error: unknown } | null = null;
|
|
49
|
+
for (const lane of await Promise.allSettled(lanes)) {
|
|
50
|
+
if (lane.status === 'fulfilled') sent += lane.value;
|
|
51
|
+
else failure ??= { error: lane.reason };
|
|
52
|
+
}
|
|
53
|
+
if (failure !== null) throw failure.error;
|
|
54
|
+
return sent;
|
|
55
|
+
}
|