@ultimat3/realtime 30.8.0 → 31.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 +72 -82
- package/README.md +36 -40
- package/package.json +8 -6
- package/src/adapter.ts +14 -0
- package/src/apply-patches.ts +8 -5
- package/src/boot.ts +15 -2
- package/src/changefeed.ts +11 -4
- package/src/channel-decl.ts +29 -1
- package/src/channel-publish.ts +17 -19
- package/src/channel-ref.ts +22 -2
- package/src/client-channels.ts +11 -5
- package/src/client-contract.ts +2 -2
- package/src/cursor.ts +5 -5
- package/src/device-agent.ts +2 -2
- package/src/device-errors.ts +1 -1
- package/src/device-socket.ts +1 -1
- package/src/error-titles-server.ts +40 -0
- package/src/error-titles.ts +12 -21
- package/src/errors.ts +43 -10
- package/src/idb-types.ts +6 -0
- package/src/index.ts +5 -27
- package/src/json.ts +7 -3
- package/src/live-contract.ts +4 -4
- package/src/live-definition.ts +9 -6
- package/src/live-deliver.ts +40 -2
- package/src/live-entries.ts +73 -0
- package/src/live-fanout.ts +25 -9
- package/src/live-patch-encode.ts +30 -0
- package/src/live-query-options.ts +11 -0
- package/src/live-query.ts +26 -11
- package/src/live-replicator.ts +24 -21
- package/src/local-store-idb.ts +66 -6
- package/src/matcher-bridge.ts +15 -15
- package/src/nats-transport.ts +5 -5
- package/src/offline-queue-store.ts +69 -0
- package/src/offline-queue.ts +91 -91
- package/src/page-errors.ts +15 -0
- package/src/page-outbox.ts +18 -3
- package/src/pg-connection.ts +9 -1
- package/src/pg-preflight.ts +21 -5
- package/src/pg-replication-dial.ts +95 -0
- package/src/pg-replication-liveness.ts +84 -0
- package/src/pg-replication-position.ts +42 -0
- package/src/pg-replication-row.ts +6 -6
- package/src/pg-replication-stats.ts +28 -0
- package/src/pg-replication.ts +106 -125
- package/src/pg-slot-release.ts +40 -0
- package/src/policy-gate.ts +2 -2
- package/src/query-window.ts +4 -4
- package/src/record-publisher.ts +2 -2
- package/src/record-store.ts +7 -5
- package/src/replication-errors.ts +40 -2
- package/src/replication-resync.ts +38 -0
- package/src/replicator-deadline.ts +33 -0
- package/src/replicator-reconnect.ts +85 -0
- package/src/replicator-resync.ts +30 -0
- package/src/replicator-row.ts +5 -5
- package/src/replicator-subject.ts +28 -0
- package/src/replicator.ts +79 -69
- package/src/server.ts +8 -6
- package/src/socket-engine.ts +2 -2
- package/src/subscriber-gate.ts +14 -10
- package/src/sync-bus-handlers.ts +20 -4
- package/src/sync-protocol.ts +3 -3
- package/src/thundering-herd.ts +17 -24
- package/src/transport-env.ts +20 -48
- package/src/window-lock.ts +15 -3
package/src/index.ts
CHANGED
|
@@ -34,7 +34,6 @@ export type {
|
|
|
34
34
|
ChannelRemove,
|
|
35
35
|
ChannelSince,
|
|
36
36
|
ChannelSubscribeTarget,
|
|
37
|
-
ChannelWireFrame,
|
|
38
37
|
ReplayGapFrame,
|
|
39
38
|
} from './channel-wire';
|
|
40
39
|
// ---- the hooks: every read and write a component makes -----------------------------------------
|
|
@@ -42,7 +41,6 @@ export type { ChannelHandlers, ChannelRef, ChannelState } from './client-channel
|
|
|
42
41
|
// ---- the shapes a hook hands back ----------------------------------------------------------------
|
|
43
42
|
export type {
|
|
44
43
|
LiveHandle,
|
|
45
|
-
LiveQueryRef,
|
|
46
44
|
SignalFactory,
|
|
47
45
|
Unsubscribe,
|
|
48
46
|
} from './client-contract';
|
|
@@ -50,9 +48,7 @@ export type {
|
|
|
50
48
|
export {
|
|
51
49
|
advance,
|
|
52
50
|
CURSOR_ID_LIMIT,
|
|
53
|
-
defaultReconnectBudget,
|
|
54
51
|
type LiveCursor,
|
|
55
|
-
makeCursor,
|
|
56
52
|
type ReconnectBudget,
|
|
57
53
|
type ResumeDecision,
|
|
58
54
|
type ResumeDeps,
|
|
@@ -64,31 +60,16 @@ export {
|
|
|
64
60
|
} from './cursor';
|
|
65
61
|
// ---- errors: one vocabulary for both halves, because every code reaches the wire ---------------
|
|
66
62
|
export {
|
|
67
|
-
CursorStaleError,
|
|
68
|
-
FrameRateLimitError,
|
|
69
|
-
LiveQueryUnknownError,
|
|
70
|
-
LiveRowUnidentifiedError,
|
|
71
63
|
OfflineQueueAbandonedError,
|
|
72
|
-
|
|
73
|
-
REALTIME_ERROR_CODES,
|
|
64
|
+
OfflineQueueFullError,
|
|
74
65
|
REALTIME_ERROR_TITLES,
|
|
66
|
+
REALTIME_PAGE_ERROR_TITLES,
|
|
75
67
|
RealtimeError,
|
|
76
68
|
type RealtimeErrorCode,
|
|
77
|
-
RealtimeUninstalledError,
|
|
78
|
-
RebaseConflictError,
|
|
79
|
-
RecordRejectedError,
|
|
80
|
-
ReplicaIdentityError,
|
|
81
|
-
ReplicationFailedError,
|
|
82
|
-
ReplicationProtocolError,
|
|
83
|
-
ReplicationTlsError,
|
|
84
69
|
ReplicatorSlotHeldError,
|
|
85
|
-
ServerRenderLiveError,
|
|
86
70
|
SubscriptionLimitError,
|
|
87
|
-
SyncUnconfiguredError,
|
|
88
71
|
TopicForbiddenError,
|
|
89
|
-
TransportProtocolError,
|
|
90
72
|
TransportUnavailableError,
|
|
91
|
-
WindowReadTimeoutError,
|
|
92
73
|
} from './errors';
|
|
93
74
|
// ---- the page: one record store, one socket, installed by the island bootstrap ------------------
|
|
94
75
|
export {
|
|
@@ -102,11 +83,11 @@ export {
|
|
|
102
83
|
isRow,
|
|
103
84
|
type JsonObject,
|
|
104
85
|
type JsonValue,
|
|
105
|
-
type
|
|
86
|
+
type LiveRow,
|
|
106
87
|
type RowOp,
|
|
107
88
|
type RowPatch,
|
|
108
89
|
} from './json';
|
|
109
|
-
export { type LiveState, type Registration,
|
|
90
|
+
export { type LiveState, type Registration, unnamedType } from './live-rows';
|
|
110
91
|
// ---- offline: the durable store, the persisted records, the one outbox (plan 101 slice 12) ------
|
|
111
92
|
export {
|
|
112
93
|
type LocalStore,
|
|
@@ -124,6 +105,7 @@ export {
|
|
|
124
105
|
type MutationStatus,
|
|
125
106
|
memoryQueueStore,
|
|
126
107
|
OfflineQueue,
|
|
108
|
+
type OfflineQueueOptions,
|
|
127
109
|
type QueuedMutation,
|
|
128
110
|
type QueueState,
|
|
129
111
|
type QueueStore,
|
|
@@ -139,7 +121,6 @@ export { resetPageRealtime } from './page-reset';
|
|
|
139
121
|
export { hasPageSocket, type SyncTarget } from './page-store';
|
|
140
122
|
export { pollEvery, type QueryPoll } from './query-poll';
|
|
141
123
|
export { installRealtime, type RealtimeInstall } from './reactivity';
|
|
142
|
-
export { persistedTypes, type RecordPersister, recordPersister } from './record-persister';
|
|
143
124
|
export {
|
|
144
125
|
DEFAULT_MAX_ROWS_PER_TYPE,
|
|
145
126
|
type RecordKey,
|
|
@@ -172,15 +153,12 @@ export {
|
|
|
172
153
|
} from './sync-protocol';
|
|
173
154
|
// ---- the client's own reconnect: the backoff it computes and the timer it arms -----------------
|
|
174
155
|
export {
|
|
175
|
-
type BackoffPolicy,
|
|
176
156
|
BROWSER_RECONNECT_MAX_MS,
|
|
177
157
|
browserBackoff,
|
|
178
158
|
defaultBackoff,
|
|
179
159
|
type JitterMode,
|
|
180
|
-
type ReconnectReason,
|
|
181
160
|
type Rng,
|
|
182
161
|
type Scheduler,
|
|
183
|
-
timeoutScheduler,
|
|
184
162
|
} from './thundering-herd';
|
|
185
163
|
export {
|
|
186
164
|
type ChannelAccessor,
|
package/src/json.ts
CHANGED
|
@@ -20,8 +20,12 @@ export type JsonValue =
|
|
|
20
20
|
|
|
21
21
|
export type JsonObject = { [key: string]: JsonValue };
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Every row that crosses the wire or lands in the local store is identified by `id`. `LiveRow`, not
|
|
25
|
+
* `Row`: core's `Row` (`conflict-policy.ts`) is any keyed record and entity's `Row` an expression
|
|
26
|
+
* row; three types under one name was an import that type-checked against the wrong one.
|
|
27
|
+
*/
|
|
28
|
+
export type LiveRow = JsonObject & { id: string };
|
|
25
29
|
|
|
26
30
|
export type RowOp = 'insert' | 'update' | 'delete';
|
|
27
31
|
|
|
@@ -58,7 +62,7 @@ export function isParsedJsonObject(value: unknown): value is JsonObject {
|
|
|
58
62
|
return isJsonObject(value);
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
export function isRow(value: unknown): value is
|
|
65
|
+
export function isRow(value: unknown): value is LiveRow {
|
|
62
66
|
return isParsedJsonObject(value) && typeof value['id'] === 'string';
|
|
63
67
|
}
|
|
64
68
|
|
package/src/live-contract.ts
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
import type { Actor } from '@ultimat3/core';
|
|
13
13
|
import type { LiveReadPhase } from '@ultimat3/query';
|
|
14
14
|
import type { LiveCursor } from './cursor';
|
|
15
|
-
import type { JsonValue,
|
|
15
|
+
import type { JsonValue, LiveRow } from './json';
|
|
16
16
|
import type { IncrementalMatcher } from './matcher-bridge';
|
|
17
17
|
import type { SyncSocket } from './socket';
|
|
18
18
|
|
|
19
|
-
export interface SnapshotResult<R extends
|
|
19
|
+
export interface SnapshotResult<R extends LiveRow = LiveRow> {
|
|
20
20
|
readonly rows: readonly R[];
|
|
21
21
|
readonly lsn: string;
|
|
22
22
|
}
|
|
@@ -34,7 +34,7 @@ export interface SnapshotArgs {
|
|
|
34
34
|
readonly phase?: LiveReadPhase | undefined;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export interface LiveQueryDefinition<R extends
|
|
37
|
+
export interface LiveQueryDefinition<R extends LiveRow = LiveRow> {
|
|
38
38
|
readonly name: string;
|
|
39
39
|
/** Dependency set for the pre-filter. `x verify` rejects a `live: true` query without one. */
|
|
40
40
|
readonly entities: readonly string[];
|
|
@@ -70,7 +70,7 @@ export interface LiveQueryDefinition<R extends Row = Row> {
|
|
|
70
70
|
* The record key each row travels under, resolved with `rowEntity`: the entity's own projection.
|
|
71
71
|
* `null` (or absent) is a plain table, whose rows are keyed by `id`.
|
|
72
72
|
*/
|
|
73
|
-
rowKey?(input: JsonValue): ((row:
|
|
73
|
+
rowKey?(input: JsonValue): ((row: LiveRow) => string) | null;
|
|
74
74
|
/**
|
|
75
75
|
* Resolve whatever this input needs before an entry is built. `matcher` is synchronous by
|
|
76
76
|
* design — a change event must not await anything — so a definition that has to compile a
|
package/src/live-definition.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
traceLiveRead,
|
|
25
25
|
} from '@ultimat3/query';
|
|
26
26
|
import { LiveRowUnidentifiedError } from './errors';
|
|
27
|
-
import { isRow, type JsonValue, type
|
|
27
|
+
import { isRow, type JsonValue, type LiveRow } from './json';
|
|
28
28
|
import type { LiveQueryDefinition, SnapshotResult } from './live-contract';
|
|
29
29
|
import { liveRecords } from './live-record-type';
|
|
30
30
|
import { type IncrementalMatcher, matcherFor, type Projection } from './matcher-bridge';
|
|
@@ -64,8 +64,8 @@ interface SharedWindow {
|
|
|
64
64
|
/** The compiled shape's root entity — the client's identity scope for every row of this read. */
|
|
65
65
|
readonly rowEntity: string;
|
|
66
66
|
/** Its projection's record key; `null` for a plain table keyed by `id`. */
|
|
67
|
-
readonly rowKey: ((row:
|
|
68
|
-
read(): Promise<readonly
|
|
67
|
+
readonly rowKey: ((row: LiveRow) => string) | null;
|
|
68
|
+
read(): Promise<readonly LiveRow[]>;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
@@ -74,7 +74,10 @@ interface SharedWindow {
|
|
|
74
74
|
* read it from. Learned once and kept, because the case the window's own rows cannot answer is an
|
|
75
75
|
* EMPTY window: the first row to arrive would otherwise be sent as the whole table row.
|
|
76
76
|
*/
|
|
77
|
-
const learnProjection = (): {
|
|
77
|
+
const learnProjection = (): {
|
|
78
|
+
read: () => Projection;
|
|
79
|
+
teach: (rows: readonly LiveRow[]) => void;
|
|
80
|
+
} => {
|
|
78
81
|
let projection: Projection;
|
|
79
82
|
return {
|
|
80
83
|
read: () => projection,
|
|
@@ -227,8 +230,8 @@ function evictOldest(windows: Map<string, SharedWindow>, max: number): void {
|
|
|
227
230
|
* same row (`pg-replication.ts`'s `toRow`), so a snapshot row and its later patch name one identity.
|
|
228
231
|
* Anything else is refused here rather than delivered as a row nobody can patch.
|
|
229
232
|
*/
|
|
230
|
-
function rowsOf(query: string, rows: readonly object[]): readonly
|
|
231
|
-
const out:
|
|
233
|
+
function rowsOf(query: string, rows: readonly object[]): readonly LiveRow[] {
|
|
234
|
+
const out: LiveRow[] = [];
|
|
232
235
|
for (const row of rows) {
|
|
233
236
|
const id: unknown = (row as { id?: unknown }).id;
|
|
234
237
|
const candidate =
|
package/src/live-deliver.ts
CHANGED
|
@@ -1,10 +1,37 @@
|
|
|
1
1
|
// One change, fanned out over every live window on the node. Split from `live-query.ts` at its
|
|
2
2
|
// size ceiling: the registry owns who holds which window, this owns the ordering of one delivery.
|
|
3
3
|
|
|
4
|
+
import { finiteOption } from '@ultimat3/core';
|
|
4
5
|
import type { ChangeEvent } from './changefeed';
|
|
5
6
|
import { type FanoutDeps, fanoutChange } from './live-fanout';
|
|
7
|
+
import { DEFAULT_MAX_LANE_DEPTH } from './live-query-options';
|
|
6
8
|
import type { QueryEntry } from './query-window';
|
|
7
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The two counters every delivery on a node feeds, and the lane bound it sheds at. Owned by the
|
|
12
|
+
* registry, one per node; read back as `staleChanges` / `changesShed`.
|
|
13
|
+
*/
|
|
14
|
+
export class DeliveryCounts {
|
|
15
|
+
readonly maxLaneDepth: number;
|
|
16
|
+
/** `live.changes_stale`: a change at or below a window's own lsn, refused rather than folded. */
|
|
17
|
+
staleChanges = 0;
|
|
18
|
+
/** `live.changes_shed`: a change a full lane did not queue; its window re-reads instead. */
|
|
19
|
+
changesShed = 0;
|
|
20
|
+
|
|
21
|
+
constructor(maxLaneDepth: number | undefined) {
|
|
22
|
+
// At least two: depth counts the RUNNING task, and a shed is repaired by a task queued behind
|
|
23
|
+
// it finding the mark. A lane of one shed with nobody behind to read it.
|
|
24
|
+
this.maxLaneDepth = Math.max(
|
|
25
|
+
2,
|
|
26
|
+
finiteOption(
|
|
27
|
+
'the live-query registry',
|
|
28
|
+
'maxLaneDepth',
|
|
29
|
+
maxLaneDepth ?? DEFAULT_MAX_LANE_DEPTH,
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
8
35
|
/**
|
|
9
36
|
* Fan one change out. Matched once per query id, authorized once per subscriber. Returns the
|
|
10
37
|
* number of frames sent — the metric the reconnect benchmark watches.
|
|
@@ -24,12 +51,23 @@ export async function deliverChange(
|
|
|
24
51
|
entries: Iterable<QueryEntry>,
|
|
25
52
|
fanout: FanoutDeps,
|
|
26
53
|
change: ChangeEvent,
|
|
27
|
-
|
|
54
|
+
counts: DeliveryCounts,
|
|
28
55
|
): Promise<number> {
|
|
29
56
|
const lanes = [...entries].map(async (entry) => {
|
|
57
|
+
if (entry.lock.depth >= counts.maxLaneDepth) {
|
|
58
|
+
// Shed, never queued: the lane is a closure per change, and one slow policy pass would hold
|
|
59
|
+
// them all. Staled, not desynced, because the lane is still full of changes that will run —
|
|
60
|
+
// the first one in finds the mark and re-reads (`live-fanout.ts`), and that read began after
|
|
61
|
+
// this change was handed to the registry, so it holds it. Over-reading is the cost; losing
|
|
62
|
+
// the change is not one. Outside the lane, as `invalidate()` sets it: a read issued after
|
|
63
|
+
// this answers it, and one issued before cleared the mark already and cannot clear this.
|
|
64
|
+
entry.stale = true;
|
|
65
|
+
counts.changesShed += 1;
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
30
68
|
try {
|
|
31
69
|
const result = await entry.lock.run(() => fanoutChange(fanout, entry, change));
|
|
32
|
-
|
|
70
|
+
counts.staleChanges += result.stale;
|
|
33
71
|
return result.sent;
|
|
34
72
|
} catch (error) {
|
|
35
73
|
// The window advanced under a fanout that did not finish, so every subscriber of this one
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The registry's windows, indexed by the relations each one reads. One owner for both, so an entry
|
|
2
|
+
// added or forgotten can never be in the table and missing from the index (or the reverse): a
|
|
3
|
+
// change then reaches only the windows that read its table, never every window on the node.
|
|
4
|
+
|
|
5
|
+
import type { QueryEntry } from './query-window';
|
|
6
|
+
|
|
7
|
+
export class LiveEntries {
|
|
8
|
+
readonly #byQid = new Map<string, QueryEntry>();
|
|
9
|
+
/** `shape.entities` → the entries reading it. A shape is fixed at birth, so this never moves. */
|
|
10
|
+
readonly #byTable = new Map<string, Set<QueryEntry>>();
|
|
11
|
+
|
|
12
|
+
get size(): number {
|
|
13
|
+
return this.#byQid.size;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get(qid: string): QueryEntry | undefined {
|
|
17
|
+
return this.#byQid.get(qid);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
values(): IterableIterator<QueryEntry> {
|
|
21
|
+
return this.#byQid.values();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
add(entry: QueryEntry): void {
|
|
25
|
+
this.delete(entry.qid);
|
|
26
|
+
this.#byQid.set(entry.qid, entry);
|
|
27
|
+
for (const table of entry.shape.entities) {
|
|
28
|
+
let readers = this.#byTable.get(table);
|
|
29
|
+
if (readers === undefined) {
|
|
30
|
+
readers = new Set();
|
|
31
|
+
this.#byTable.set(table, readers);
|
|
32
|
+
}
|
|
33
|
+
readers.add(entry);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
delete(qid: string): void {
|
|
38
|
+
const entry = this.#byQid.get(qid);
|
|
39
|
+
if (entry === undefined) return;
|
|
40
|
+
this.#byQid.delete(qid);
|
|
41
|
+
for (const table of entry.shape.entities) {
|
|
42
|
+
const readers = this.#byTable.get(table);
|
|
43
|
+
if (readers === undefined) continue;
|
|
44
|
+
readers.delete(entry);
|
|
45
|
+
// An emptied bucket goes too: a client-chosen table name must not outlive its windows.
|
|
46
|
+
if (readers.size === 0) this.#byTable.delete(table);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The windows that read `table` — `invalidate(entity)`'s set, and a change's first. */
|
|
51
|
+
readersOf(table: string): ReadonlySet<QueryEntry> {
|
|
52
|
+
return this.#byTable.get(table) ?? EMPTY;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every window one change is work for: its table's readers, plus every window that owes a
|
|
57
|
+
* read whatever it reads — a `stale` one is repaired by the next change to reach it, and one no
|
|
58
|
+
* read has landed in yet marks itself stale so its in-flight read is followed by a fresh one
|
|
59
|
+
* (`live-fanout.ts`). Those two are a boolean pair per entry, read without a lane; the expensive
|
|
60
|
+
* part — a lane entered, a closure queued, a fanout awaited — is paid by these entries only.
|
|
61
|
+
* Measured: 10k windows on other tables, 34.6 ms → 0.23 ms per change (29 → 4,430/s).
|
|
62
|
+
*/
|
|
63
|
+
targets(table: string): QueryEntry[] {
|
|
64
|
+
const readers = this.readersOf(table);
|
|
65
|
+
const out = [...readers];
|
|
66
|
+
for (const entry of this.#byQid.values()) {
|
|
67
|
+
if ((entry.stale || entry.applied === 0) && !readers.has(entry)) out.push(entry);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const EMPTY: ReadonlySet<QueryEntry> = new Set();
|
package/src/live-fanout.ts
CHANGED
|
@@ -6,13 +6,19 @@
|
|
|
6
6
|
import { type Clock, isWriteDigest } from '@ultimat3/core';
|
|
7
7
|
import type { ChangeEvent } from './changefeed';
|
|
8
8
|
import { advance, type LiveCursor, makeCursor, type ResumeSource } from './cursor';
|
|
9
|
-
import type {
|
|
9
|
+
import type { LiveRow, RowPatch } from './json';
|
|
10
10
|
import type { LiveSubscription } from './live-contract';
|
|
11
|
+
import { encodePatchFrame, samePatches } from './live-patch-encode';
|
|
11
12
|
import { applyToWindow, bridgeChange, type Projection, projectionOf } from './matcher-bridge';
|
|
12
13
|
import { type QueryEntry, refillWindowInLane } from './query-window';
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
type Subscriber,
|
|
16
|
+
type SubscriberGate,
|
|
17
|
+
type WindowIndex,
|
|
18
|
+
windowIndex,
|
|
19
|
+
} from './subscriber-gate';
|
|
14
20
|
import { subscriptionKey } from './subscription-book';
|
|
15
|
-
import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
21
|
+
import { encode, type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
16
22
|
import { FRAME_LIMITS } from './wire-version';
|
|
17
23
|
|
|
18
24
|
export interface FanoutDeps {
|
|
@@ -101,8 +107,11 @@ export async function fanoutChange(
|
|
|
101
107
|
else deps.source.floorAt?.(entry.qid, change.lsn, { exclusive: true });
|
|
102
108
|
|
|
103
109
|
let sent = 0;
|
|
104
|
-
// Indexed once for every subscriber below, never searched per subscriber per patch
|
|
105
|
-
|
|
110
|
+
// Indexed once for every subscriber below, never searched per subscriber per patch — and only
|
|
111
|
+
// once a subscriber reaches its gate: a change every subscriber is desynced for builds none.
|
|
112
|
+
let index: WindowIndex | null = null;
|
|
113
|
+
// The patch list as text, once per change for every subscriber the gate let see all of it.
|
|
114
|
+
let shared: string | null = null;
|
|
106
115
|
for (const subscription of entry.subscribers.values()) {
|
|
107
116
|
if (guessed) {
|
|
108
117
|
// The window lost its tail: guessing is how a sync engine silently diverges. Checked BEFORE
|
|
@@ -126,6 +135,7 @@ export async function fanoutChange(
|
|
|
126
135
|
}
|
|
127
136
|
const who: Subscriber = { sid: subscription.sid, actor: subscription.socket.actor };
|
|
128
137
|
let allowed: readonly RowPatch[];
|
|
138
|
+
index ??= windowIndex(entry.rows);
|
|
129
139
|
try {
|
|
130
140
|
allowed = await deps.gate.filterPatches(
|
|
131
141
|
entry,
|
|
@@ -143,8 +153,14 @@ export async function fanoutChange(
|
|
|
143
153
|
continue;
|
|
144
154
|
}
|
|
145
155
|
if (allowed.length === 0) continue;
|
|
146
|
-
|
|
147
|
-
if (
|
|
156
|
+
let text: string;
|
|
157
|
+
if (samePatches(allowed, result.patches)) {
|
|
158
|
+
shared ??= JSON.stringify(result.patches);
|
|
159
|
+
text = encodePatchFrame(subscription.sid, shared, change.lsn, writes);
|
|
160
|
+
} else {
|
|
161
|
+
text = encode(patchFrame(subscription.sid, allowed, change.lsn, writes));
|
|
162
|
+
}
|
|
163
|
+
if (subscription.socket.sendEncoded(text)) {
|
|
148
164
|
subscription.cursor = advance(subscription.cursor, allowed, change.lsn, change.at);
|
|
149
165
|
sent += 1;
|
|
150
166
|
} else {
|
|
@@ -244,7 +260,7 @@ async function resnapshot(
|
|
|
244
260
|
named: readonly NamedWrite[],
|
|
245
261
|
): Promise<boolean> {
|
|
246
262
|
const who: Subscriber = { sid: subscription.sid, actor: subscription.socket.actor };
|
|
247
|
-
let rows: readonly
|
|
263
|
+
let rows: readonly LiveRow[];
|
|
248
264
|
try {
|
|
249
265
|
rows = await deps.gate.filterRows(entry, who, entry.rows);
|
|
250
266
|
} catch {
|
|
@@ -315,7 +331,7 @@ export interface NamedWrite {
|
|
|
315
331
|
export function snapshotFrame(
|
|
316
332
|
entry: QueryEntry,
|
|
317
333
|
sid: string,
|
|
318
|
-
rows: readonly
|
|
334
|
+
rows: readonly LiveRow[],
|
|
319
335
|
cursor: LiveCursor,
|
|
320
336
|
named: readonly NamedWrite[] = [],
|
|
321
337
|
): Frame {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A patch frame as text, built around a patch list encoded ONCE for every subscriber of a change.
|
|
2
|
+
// The frame differs per subscriber by its `sid` alone when the gate let the whole list through, so
|
|
3
|
+
// stringifying the list per subscriber was the fanout's cost growing with every viewer.
|
|
4
|
+
|
|
5
|
+
import type { RowPatch } from './json';
|
|
6
|
+
import { PROTOCOL_VERSION } from './sync-protocol';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Byte-for-byte `encode(patchFrame(sid, patches, lsn, writes))` with `patches` already encoded:
|
|
10
|
+
* the same keys in the same order, and `JSON.stringify` is compositional with no indentation.
|
|
11
|
+
* Pinned against the unshared spelling by `live-fanout.test.ts`.
|
|
12
|
+
*/
|
|
13
|
+
export function encodePatchFrame(
|
|
14
|
+
sid: string,
|
|
15
|
+
patchesJson: string,
|
|
16
|
+
lsn: string,
|
|
17
|
+
writes: readonly string[],
|
|
18
|
+
): string {
|
|
19
|
+
const head = `{"type":"patch","v":${JSON.stringify(PROTOCOL_VERSION)},"sid":${JSON.stringify(sid)}`;
|
|
20
|
+
const tail = writes.length === 0 ? '' : `,"writes":${JSON.stringify(writes)}`;
|
|
21
|
+
return `${head},"patches":${patchesJson},"lsn":${JSON.stringify(lsn)}${tail}}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The gate let every patch through untouched: the shared encoding is this subscriber's too. */
|
|
25
|
+
export function samePatches(allowed: readonly RowPatch[], shared: readonly RowPatch[]): boolean {
|
|
26
|
+
if (allowed === shared) return true;
|
|
27
|
+
if (allowed.length !== shared.length) return false;
|
|
28
|
+
for (let at = 0; at < allowed.length; at += 1) if (allowed[at] !== shared[at]) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
@@ -34,8 +34,19 @@ export interface LiveQueryRegistryOptions extends SubscriberGateOptions {
|
|
|
34
34
|
readonly readDeadlineMs?: number;
|
|
35
35
|
/** Injected so that deadline is provable without waiting for one. Production uses `setTimeout`. */
|
|
36
36
|
readonly schedule?: Scheduler;
|
|
37
|
+
/**
|
|
38
|
+
* Changes one window's lane may hold queued. Defaults to `DEFAULT_MAX_LANE_DEPTH`. Past it a
|
|
39
|
+
* change is SHED for that window: the window is marked stale, so the next change queued re-reads
|
|
40
|
+
* it and re-snapshots every subscriber — the documented gap recovery — and the shed is counted
|
|
41
|
+
* (`changesShed`). Without it one slow row policy queued a closure per change, unbounded.
|
|
42
|
+
* Floored at 2: the count includes the running task, and a shed needs one queued behind it.
|
|
43
|
+
*/
|
|
44
|
+
readonly maxLaneDepth?: number;
|
|
37
45
|
}
|
|
38
46
|
|
|
47
|
+
/** Changes one window's lane holds before shedding to a re-read. */
|
|
48
|
+
export const DEFAULT_MAX_LANE_DEPTH = 1_024;
|
|
49
|
+
|
|
39
50
|
/**
|
|
40
51
|
* Live `(query, input)` pairs one node holds. Reached, the next NEW pair is refused with
|
|
41
52
|
* `X_SUBSCRIPTION_LIMIT`; subscribing to a pair that already exists keeps working, because the
|
package/src/live-query.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { type LiveCursor, makeCursor } from './cursor';
|
|
|
13
13
|
import { LiveQueryUnknownError, SubscriptionLimitError } from './errors';
|
|
14
14
|
import type { JsonValue } from './json';
|
|
15
15
|
import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
|
|
16
|
-
import { deliverChange } from './live-deliver';
|
|
16
|
+
import { DeliveryCounts, deliverChange } from './live-deliver';
|
|
17
|
+
import { LiveEntries } from './live-entries';
|
|
17
18
|
import { type FanoutDeps, repairSubscriber, repairWindow, snapshotFrame } from './live-fanout';
|
|
18
19
|
import { floorAfterRead } from './live-floor';
|
|
19
20
|
import { DEFAULT_MAX_ENTRIES, type LiveQueryRegistryOptions } from './live-query-options';
|
|
@@ -27,11 +28,16 @@ import { type Subscriber, SubscriberGate } from './subscriber-gate';
|
|
|
27
28
|
import { SubscriptionBook, subscriptionKey } from './subscription-book';
|
|
28
29
|
import type { Frame } from './sync-protocol';
|
|
29
30
|
|
|
30
|
-
export {
|
|
31
|
+
export {
|
|
32
|
+
DEFAULT_MAX_ENTRIES,
|
|
33
|
+
DEFAULT_MAX_LANE_DEPTH,
|
|
34
|
+
type LiveQueryRegistryOptions,
|
|
35
|
+
} from './live-query-options';
|
|
31
36
|
|
|
32
37
|
export class LiveQueryRegistry {
|
|
33
38
|
readonly #definitions = new Map<string, LiveQueryDefinition>();
|
|
34
|
-
|
|
39
|
+
/** Indexed by the relations each window reads (`live-entries.ts`): a change is work for few. */
|
|
40
|
+
readonly #entries = new LiveEntries();
|
|
35
41
|
/** Keyed by `(socket, sid)`, never by `sid` alone — `subscription-book.ts` owns why. */
|
|
36
42
|
readonly #book: SubscriptionBook;
|
|
37
43
|
readonly #options: LiveQueryRegistryOptions;
|
|
@@ -46,7 +52,7 @@ export class LiveQueryRegistry {
|
|
|
46
52
|
* `!` sorts below every hex digit, so every real change is above it.
|
|
47
53
|
*/
|
|
48
54
|
readonly #origin = `!${uuidV7()}`;
|
|
49
|
-
#
|
|
55
|
+
readonly #counts: DeliveryCounts;
|
|
50
56
|
|
|
51
57
|
constructor(options: LiveQueryRegistryOptions) {
|
|
52
58
|
this.#options = options;
|
|
@@ -60,6 +66,7 @@ export class LiveQueryRegistry {
|
|
|
60
66
|
// The book owns the caps because it is the only thing that can answer them in O(1).
|
|
61
67
|
this.#book = new SubscriptionBook(options);
|
|
62
68
|
this.#fanout = { gate: this.#gate, source: options.source, clock: this.#clock };
|
|
69
|
+
this.#counts = new DeliveryCounts(options.maxLaneDepth);
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
/** `live.rows_denied` for this node: rows a subscriber's policy refused since boot. */
|
|
@@ -74,7 +81,12 @@ export class LiveQueryRegistry {
|
|
|
74
81
|
|
|
75
82
|
/** `live.changes_stale`: changes at or below a window's own lsn, refused rather than folded. */
|
|
76
83
|
get staleChanges(): number {
|
|
77
|
-
return this.#staleChanges;
|
|
84
|
+
return this.#counts.staleChanges;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `live.changes_shed`: window deliveries a full lane refused, each repaired by a re-read. */
|
|
88
|
+
get changesShed(): number {
|
|
89
|
+
return this.#counts.changesShed;
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
/**
|
|
@@ -93,8 +105,8 @@ export class LiveQueryRegistry {
|
|
|
93
105
|
*/
|
|
94
106
|
invalidate(entity?: string): number {
|
|
95
107
|
let marked = 0;
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
const entries = entity === undefined ? this.#entries.values() : this.#entries.readersOf(entity);
|
|
109
|
+
for (const entry of entries) {
|
|
98
110
|
entry.stale = true;
|
|
99
111
|
for (const subscription of entry.subscribers.values()) {
|
|
100
112
|
subscription.socket.markDesynced(subscription.sid);
|
|
@@ -333,9 +345,12 @@ export class LiveQueryRegistry {
|
|
|
333
345
|
/** Fan one change out, every query id in its own lane — `live-deliver.ts` owns why. */
|
|
334
346
|
async deliver(change: ChangeEvent): Promise<number> {
|
|
335
347
|
if (change.lsn > this.#lastLsn) this.#lastLsn = change.lsn;
|
|
336
|
-
return await deliverChange(
|
|
337
|
-
this.#
|
|
338
|
-
|
|
348
|
+
return await deliverChange(
|
|
349
|
+
this.#entries.targets(change.table),
|
|
350
|
+
this.#fanout,
|
|
351
|
+
change,
|
|
352
|
+
this.#counts,
|
|
353
|
+
);
|
|
339
354
|
}
|
|
340
355
|
|
|
341
356
|
/**
|
|
@@ -436,7 +451,7 @@ export class LiveQueryRegistry {
|
|
|
436
451
|
schedule: this.#options.schedule,
|
|
437
452
|
tenant,
|
|
438
453
|
});
|
|
439
|
-
this.#entries.
|
|
454
|
+
this.#entries.add(created);
|
|
440
455
|
return created;
|
|
441
456
|
}
|
|
442
457
|
|