@ultimat3/realtime 1.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/LICENSE +21 -0
- package/README.md +180 -0
- package/package.json +36 -0
- package/src/change-buffer.ts +69 -0
- package/src/changefeed-env.ts +146 -0
- package/src/changefeed.ts +191 -0
- package/src/channel.ts +181 -0
- package/src/client.ts +439 -0
- package/src/cursor.ts +188 -0
- package/src/errors.ts +253 -0
- package/src/fanout.ts +159 -0
- package/src/hooks.ts +230 -0
- package/src/index.ts +328 -0
- package/src/json.ts +76 -0
- package/src/live-definition.ts +144 -0
- package/src/live-query.ts +449 -0
- package/src/local-store.ts +188 -0
- package/src/matcher-bridge.ts +169 -0
- package/src/nats-commands.ts +97 -0
- package/src/nats-connection-fixture.ts +105 -0
- package/src/nats-connection.ts +464 -0
- package/src/nats-fake.ts +431 -0
- package/src/nats-jetstream.ts +226 -0
- package/src/nats-kv.ts +157 -0
- package/src/nats-protocol.ts +222 -0
- package/src/nats-socket.ts +236 -0
- package/src/nats-transport.ts +257 -0
- package/src/offline-queue.ts +206 -0
- package/src/pg-advisory-lock.ts +98 -0
- package/src/pg-auth.ts +300 -0
- package/src/pg-bytes.ts +185 -0
- package/src/pg-connection-fixture.ts +215 -0
- package/src/pg-connection.ts +337 -0
- package/src/pg-entity-row.ts +130 -0
- package/src/pg-replication-fixture.ts +261 -0
- package/src/pg-replication.ts +396 -0
- package/src/pg-socket.ts +265 -0
- package/src/pg-wire.ts +192 -0
- package/src/pgoutput.ts +297 -0
- package/src/policy-gate.ts +56 -0
- package/src/presence.ts +219 -0
- package/src/rebase.ts +198 -0
- package/src/replicator.ts +185 -0
- package/src/socket.ts +208 -0
- package/src/sync-node.ts +400 -0
- package/src/sync-protocol.ts +376 -0
- package/src/thundering-herd.ts +141 -0
- package/src/transport-env.ts +104 -0
package/src/rebase.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Tier 3: the reconcile loop. The server rebases; the client rolls back and reapplies.
|
|
2
|
+
//
|
|
3
|
+
// Truth is always the server — a client is never the merge authority. So reconciliation is exactly:
|
|
4
|
+
// undo the optimistic writes from the acknowledged mutation onward (newest first), land server
|
|
5
|
+
// truth, then replay the still-pending mutators in sequence order. Because `local` is a pure
|
|
6
|
+
// function of `(tx, input)`, that replay is deterministic; that purity rule is the whole reason
|
|
7
|
+
// tier 3 is affordable.
|
|
8
|
+
|
|
9
|
+
import { RebaseConflictError } from './errors';
|
|
10
|
+
import type { Row } from './json';
|
|
11
|
+
import type { LocalStore, LocalTx, TableMap } from './local-store';
|
|
12
|
+
import { type ConflictStrategyName, type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
13
|
+
|
|
14
|
+
export interface MergeArgs {
|
|
15
|
+
/** Local row as the user last saw it, before any rollback. */
|
|
16
|
+
readonly local: Row | undefined;
|
|
17
|
+
/** Local row after the optimistic writes were undone — the shared base. */
|
|
18
|
+
readonly base: Row | undefined;
|
|
19
|
+
/** Server truth. `null` means the server deleted the row. */
|
|
20
|
+
readonly server: Row | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CustomMerge {
|
|
24
|
+
readonly kind: 'custom';
|
|
25
|
+
merge(args: MergeArgs): Row | null | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `conflict: custom(merge)` — exactly the name the mutator contract uses. */
|
|
29
|
+
export function custom(merge: (args: MergeArgs) => Row | null | undefined): CustomMerge {
|
|
30
|
+
return { kind: 'custom', merge };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ConflictStrategy = 'server-wins' | 'last-write-wins' | CustomMerge;
|
|
34
|
+
|
|
35
|
+
export function strategyName(strategy: ConflictStrategy): ConflictStrategyName {
|
|
36
|
+
return typeof strategy === 'string' ? strategy : 'custom';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RebaseEntry<T extends TableMap = TableMap> {
|
|
40
|
+
/** Idempotency key — the same key the offline queue uses. */
|
|
41
|
+
readonly key: string;
|
|
42
|
+
readonly seq: number;
|
|
43
|
+
readonly entity: string;
|
|
44
|
+
readonly strategy: ConflictStrategy;
|
|
45
|
+
/** The mutator's `local` half, curried with its input. Pure, therefore replayable. */
|
|
46
|
+
apply(tx: LocalTx<T>): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Optimistic writes awaiting server truth, ordered by client sequence. */
|
|
50
|
+
export class RebaseLog<T extends TableMap = TableMap> {
|
|
51
|
+
readonly #entries = new Map<string, RebaseEntry<T>>();
|
|
52
|
+
|
|
53
|
+
record(entry: RebaseEntry<T>): void {
|
|
54
|
+
this.#entries.set(entry.key, entry);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get(key: string): RebaseEntry<T> | undefined {
|
|
58
|
+
return this.#entries.get(key);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
drop(key: string): void {
|
|
62
|
+
this.#entries.delete(key);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pending(): readonly RebaseEntry<T>[] {
|
|
66
|
+
return [...this.#entries.values()].sort((a, b) => a.seq - b.seq);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get size(): number {
|
|
70
|
+
return this.#entries.size;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ServerAck {
|
|
75
|
+
readonly key: string;
|
|
76
|
+
readonly entity: string;
|
|
77
|
+
readonly id: string;
|
|
78
|
+
readonly row: Row | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ReconcileOptions {
|
|
82
|
+
/** Field compared by `last-write-wins`. Must be a number (epoch ms) written by the server. */
|
|
83
|
+
readonly clockField?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ReconcileResult {
|
|
87
|
+
readonly strategy: ConflictStrategyName;
|
|
88
|
+
readonly rolledBack: readonly string[];
|
|
89
|
+
readonly reapplied: readonly string[];
|
|
90
|
+
readonly winner: 'server' | 'local' | 'merge';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One acknowledgement, one rebase. Rolls back the acked mutation and every later optimistic write,
|
|
95
|
+
* lands server truth under the mutator's conflict strategy, then replays the rest in sequence order.
|
|
96
|
+
*/
|
|
97
|
+
export function reconcile<T extends TableMap = TableMap>(
|
|
98
|
+
args: {
|
|
99
|
+
store: LocalStore<T>;
|
|
100
|
+
log: RebaseLog<T>;
|
|
101
|
+
ack: ServerAck;
|
|
102
|
+
},
|
|
103
|
+
options: ReconcileOptions = {},
|
|
104
|
+
): ReconcileResult {
|
|
105
|
+
const { store, log, ack } = args;
|
|
106
|
+
const entry = log.get(ack.key);
|
|
107
|
+
const strategy = entry?.strategy ?? 'server-wins';
|
|
108
|
+
const table = store.table(ack.entity);
|
|
109
|
+
const local = table.get(ack.id);
|
|
110
|
+
|
|
111
|
+
// Everything at or after the acked sequence is optimistic and must be undone newest-first.
|
|
112
|
+
const affected = log.pending().filter((candidate) => candidate.seq >= (entry?.seq ?? 0));
|
|
113
|
+
const rolledBack: string[] = [];
|
|
114
|
+
for (const candidate of [...affected].reverse()) {
|
|
115
|
+
store.rollback(candidate.key);
|
|
116
|
+
rolledBack.push(candidate.key);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const base = store.table(ack.entity).get(ack.id);
|
|
120
|
+
const winner = land(store, ack, strategy, { local, base }, options);
|
|
121
|
+
log.drop(ack.key);
|
|
122
|
+
|
|
123
|
+
const reapplied: string[] = [];
|
|
124
|
+
for (const candidate of affected) {
|
|
125
|
+
if (candidate.key === ack.key) continue;
|
|
126
|
+
store.apply(candidate.key, (tx) => candidate.apply(tx));
|
|
127
|
+
reapplied.push(candidate.key);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { strategy: strategyName(strategy), rolledBack, reapplied, winner };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function land<T extends TableMap>(
|
|
134
|
+
store: LocalStore<T>,
|
|
135
|
+
ack: ServerAck,
|
|
136
|
+
strategy: ConflictStrategy,
|
|
137
|
+
rows: { local: Row | undefined; base: Row | undefined },
|
|
138
|
+
options: ReconcileOptions,
|
|
139
|
+
): 'server' | 'local' | 'merge' {
|
|
140
|
+
const table = store.table(ack.entity);
|
|
141
|
+
|
|
142
|
+
if (strategy === 'server-wins') {
|
|
143
|
+
write(store, ack.entity, ack.id, ack.row);
|
|
144
|
+
return 'server';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (strategy === 'last-write-wins') {
|
|
148
|
+
const field = options.clockField ?? 'updatedAt';
|
|
149
|
+
const localAt = numberAt(rows.local, field);
|
|
150
|
+
const serverAt = numberAt(ack.row, field);
|
|
151
|
+
if (localAt !== null && serverAt !== null && localAt > serverAt) {
|
|
152
|
+
// The local write is newer by the server's own clock field: keep it, do not clobber.
|
|
153
|
+
if (rows.local) table.upsert(rows.local);
|
|
154
|
+
return 'local';
|
|
155
|
+
}
|
|
156
|
+
write(store, ack.entity, ack.id, ack.row);
|
|
157
|
+
return 'server';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const merged = strategy.merge({ local: rows.local, base: rows.base, server: ack.row });
|
|
161
|
+
if (merged === undefined) {
|
|
162
|
+
throw new RebaseConflictError({
|
|
163
|
+
key: ack.key,
|
|
164
|
+
entity: ack.entity,
|
|
165
|
+
reason: 'custom(merge) returned undefined; return a row, or null to accept the delete',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
write(store, ack.entity, ack.id, merged);
|
|
169
|
+
return 'merge';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function write<T extends TableMap>(
|
|
173
|
+
store: LocalStore<T>,
|
|
174
|
+
entity: string,
|
|
175
|
+
id: string,
|
|
176
|
+
row: Row | null,
|
|
177
|
+
): void {
|
|
178
|
+
const table = store.table(entity);
|
|
179
|
+
if (row === null) table.delete(id);
|
|
180
|
+
else table.upsert(row);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function numberAt(row: Row | null | undefined, field: string): number | null {
|
|
184
|
+
if (!row) return null;
|
|
185
|
+
const value = row[field];
|
|
186
|
+
return typeof value === 'number' ? value : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function rebaseFrame(ack: ServerAck, strategy: ConflictStrategy): Frame {
|
|
190
|
+
return {
|
|
191
|
+
type: 'rebase',
|
|
192
|
+
v: PROTOCOL_VERSION,
|
|
193
|
+
key: ack.key,
|
|
194
|
+
entity: ack.entity,
|
|
195
|
+
strategy: strategyName(strategy),
|
|
196
|
+
row: ack.row,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// The `replicator` role: consume the change feed, normalize, publish to the transport. Nothing else.
|
|
2
|
+
//
|
|
3
|
+
// **Exactly one per database.** Two replicators on one slot means every change is fanned out twice:
|
|
4
|
+
// duplicate patches, duplicate lsns, and a client digest that can never converge. The invariant is
|
|
5
|
+
// enforced by a Postgres session-level advisory lock, not by deployment discipline:
|
|
6
|
+
//
|
|
7
|
+
// SELECT pg_try_advisory_lock(hashtext('x:replicator:<slot>'))
|
|
8
|
+
//
|
|
9
|
+
// The lock is tied to the *session*, so a crashed replicator releases it automatically — no lease
|
|
10
|
+
// renewal, no fencing token, no split brain. A process that fails to take the lock does not start
|
|
11
|
+
// its feed and reports `/readyz` false; it retries with jittered backoff and takes over the moment
|
|
12
|
+
// the holder dies. Scaling the replicator is therefore always vertical, and that is by design.
|
|
13
|
+
|
|
14
|
+
import { logger, withSpan } from '@ultimat3/core';
|
|
15
|
+
import type { ChangeEvent, ChangeFeed } from './changefeed';
|
|
16
|
+
import type { Transport } from './fanout';
|
|
17
|
+
import { type BackoffPolicy, backoffDelay, defaultBackoff, type Rng } from './thundering-herd';
|
|
18
|
+
|
|
19
|
+
export const CHANGE_SUBJECT_PREFIX = 'x.change';
|
|
20
|
+
|
|
21
|
+
/** Session-scoped mutual exclusion. Postgres-backed in production, in-memory for `x dev`. */
|
|
22
|
+
export interface AdvisoryLock {
|
|
23
|
+
readonly key: string;
|
|
24
|
+
/** `false` means another process holds it — never block, never steal. */
|
|
25
|
+
tryAcquire(): Promise<boolean>;
|
|
26
|
+
release(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Single-process default: correct for `x dev` and tests, useless across containers (by design). */
|
|
30
|
+
export class InMemoryAdvisoryLock implements AdvisoryLock {
|
|
31
|
+
static readonly #held = new Set<string>();
|
|
32
|
+
readonly key: string;
|
|
33
|
+
#mine = false;
|
|
34
|
+
|
|
35
|
+
constructor(key: string) {
|
|
36
|
+
this.key = key;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async tryAcquire(): Promise<boolean> {
|
|
40
|
+
if (InMemoryAdvisoryLock.#held.has(this.key)) return false;
|
|
41
|
+
InMemoryAdvisoryLock.#held.add(this.key);
|
|
42
|
+
this.#mine = true;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async release(): Promise<void> {
|
|
47
|
+
if (!this.#mine) return;
|
|
48
|
+
InMemoryAdvisoryLock.#held.delete(this.key);
|
|
49
|
+
this.#mine = false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `x.change.<entity>.<orgId>` — tenant in the subject, so fanout filters without parsing a row. */
|
|
54
|
+
export function changeSubject(change: ChangeEvent): string {
|
|
55
|
+
return `${CHANGE_SUBJECT_PREFIX}.${change.entity}.${change.orgId ?? '_'}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ReplicatorOptions {
|
|
59
|
+
readonly feed: ChangeFeed;
|
|
60
|
+
readonly transport: Transport;
|
|
61
|
+
readonly lock: AdvisoryLock;
|
|
62
|
+
/** Resume position, normally the last lsn this replicator persisted. */
|
|
63
|
+
readonly from?: string;
|
|
64
|
+
readonly subjectOf?: (change: ChangeEvent) => string;
|
|
65
|
+
readonly backoff?: BackoffPolicy;
|
|
66
|
+
readonly rng?: Rng;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ReplicatorStats {
|
|
70
|
+
readonly published: number;
|
|
71
|
+
readonly skipped: number;
|
|
72
|
+
readonly outOfOrder: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface Replicator {
|
|
76
|
+
/** `false` = another replicator holds the lock; this process must stay `/readyz` false. */
|
|
77
|
+
start(): Promise<boolean>;
|
|
78
|
+
stop(): Promise<void>;
|
|
79
|
+
readonly running: boolean;
|
|
80
|
+
lastLsn(): string | null;
|
|
81
|
+
stats(): ReplicatorStats;
|
|
82
|
+
/** Delay before the next takeover attempt, jittered so N standbys do not collide. */
|
|
83
|
+
retryDelayMs(attempt: number): number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createReplicator(options: ReplicatorOptions): Replicator {
|
|
87
|
+
const subjectOf = options.subjectOf ?? changeSubject;
|
|
88
|
+
const backoff = options.backoff ?? defaultBackoff;
|
|
89
|
+
let running = false;
|
|
90
|
+
let lastLsn: string | null = null;
|
|
91
|
+
let published = 0;
|
|
92
|
+
let skipped = 0;
|
|
93
|
+
let outOfOrder = 0;
|
|
94
|
+
|
|
95
|
+
const onChange = async (raw: ChangeEvent): Promise<void> => {
|
|
96
|
+
const change = normalize(raw);
|
|
97
|
+
if (!change) {
|
|
98
|
+
skipped += 1;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (lastLsn !== null && change.lsn <= lastLsn) {
|
|
102
|
+
// At-least-once delivery is the feed's contract, so a repeat is expected, not an error.
|
|
103
|
+
outOfOrder += 1;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
await withSpan('realtime.replicate', async () => {
|
|
107
|
+
await options.transport.publish(subjectOf(change), JSON.stringify(change));
|
|
108
|
+
lastLsn = change.lsn;
|
|
109
|
+
published += 1;
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
async start(): Promise<boolean> {
|
|
115
|
+
if (running) return true;
|
|
116
|
+
if (!(await options.lock.tryAcquire())) {
|
|
117
|
+
logger.warn('replicator standby: advisory lock held elsewhere', { key: options.lock.key });
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
running = true;
|
|
121
|
+
await options.feed.start(
|
|
122
|
+
options.from === undefined ? { onChange } : { from: options.from, onChange },
|
|
123
|
+
);
|
|
124
|
+
logger.info('replicator started', { source: options.feed.source, key: options.lock.key });
|
|
125
|
+
return true;
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async stop(): Promise<void> {
|
|
129
|
+
if (!running) return;
|
|
130
|
+
running = false;
|
|
131
|
+
await options.feed.stop();
|
|
132
|
+
await options.lock.release();
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
get running(): boolean {
|
|
136
|
+
return running;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
lastLsn(): string | null {
|
|
140
|
+
return lastLsn ?? options.feed.lastLsn();
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
stats(): ReplicatorStats {
|
|
144
|
+
return { published, skipped, outOfOrder };
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
retryDelayMs(attempt: number): number {
|
|
148
|
+
return backoffDelay(attempt, backoff, options.rng ?? Math.random);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Drops events the pipeline cannot use and hoists the tenant id out of the row. */
|
|
154
|
+
export function normalize(change: ChangeEvent): ChangeEvent | null {
|
|
155
|
+
const row = change.after ?? change.before;
|
|
156
|
+
if (!row) return null;
|
|
157
|
+
if (change.op === 'insert' && change.after === null) return null;
|
|
158
|
+
if (change.op === 'delete' && change.before === null) return null;
|
|
159
|
+
if (change.orgId !== null) return change;
|
|
160
|
+
const orgId = row['orgId'];
|
|
161
|
+
return typeof orgId === 'string' ? { ...change, orgId } : change;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Sync-node side of the bus: decode a published change back into a `ChangeEvent`. */
|
|
165
|
+
export function parseChange(payload: string): ChangeEvent | null {
|
|
166
|
+
try {
|
|
167
|
+
const parsed: unknown = JSON.parse(payload);
|
|
168
|
+
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
169
|
+
const shape = parsed as Partial<ChangeEvent>;
|
|
170
|
+
if (typeof shape.entity !== 'string' || typeof shape.lsn !== 'string') return null;
|
|
171
|
+
if (shape.op !== 'insert' && shape.op !== 'update' && shape.op !== 'delete') return null;
|
|
172
|
+
return {
|
|
173
|
+
entity: shape.entity,
|
|
174
|
+
op: shape.op,
|
|
175
|
+
before: shape.before ?? null,
|
|
176
|
+
after: shape.after ?? null,
|
|
177
|
+
lsn: shape.lsn,
|
|
178
|
+
txid: typeof shape.txid === 'string' ? shape.txid : '',
|
|
179
|
+
orgId: typeof shape.orgId === 'string' ? shape.orgId : null,
|
|
180
|
+
at: typeof shape.at === 'number' ? shape.at : 0,
|
|
181
|
+
};
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/socket.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// One WS connection. Bun's WS profile is what makes a million sockets affordable, so this object
|
|
2
|
+
// is deliberately lean: ~8 fields plus two small sets. Budget is ~1KB of JS heap per connection on
|
|
3
|
+
// top of Bun's own per-socket cost — anything richer (row caches, per-socket buffers) belongs in
|
|
4
|
+
// the transport or the change buffer, never here. `sync` is stateless: nothing on this object
|
|
5
|
+
// survives a restart, and nothing needs to.
|
|
6
|
+
|
|
7
|
+
import { type Actor, type Clock, systemClock, uuid } from '@ultimat3/core';
|
|
8
|
+
import { encode, type Frame } from './sync-protocol';
|
|
9
|
+
|
|
10
|
+
export const CLOSE = {
|
|
11
|
+
normal: 1000,
|
|
12
|
+
goingAway: 1001,
|
|
13
|
+
policy: 1008,
|
|
14
|
+
overloaded: 1013,
|
|
15
|
+
versionSkew: 4000,
|
|
16
|
+
idle: 4001,
|
|
17
|
+
drain: 4002,
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
/** The slice of Bun's `ServerWebSocket` this package uses. Structural, so tests need no server. */
|
|
21
|
+
export interface WsLike {
|
|
22
|
+
send(data: string): number;
|
|
23
|
+
close(code?: number, reason?: string): void;
|
|
24
|
+
subscribe(topic: string): void;
|
|
25
|
+
unsubscribe(topic: string): void;
|
|
26
|
+
getBufferedAmount(): number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SyncSocketOptions {
|
|
30
|
+
readonly ws: WsLike;
|
|
31
|
+
/** Build id the *client* reported in `hello`. Version skew is a first-class connection state. */
|
|
32
|
+
readonly clientBuildId: string;
|
|
33
|
+
readonly serverBuildId: string;
|
|
34
|
+
readonly actor?: Actor | null;
|
|
35
|
+
readonly id?: string;
|
|
36
|
+
readonly clock?: Clock;
|
|
37
|
+
/** Frames are dropped rather than queued past this. See `desynced`. */
|
|
38
|
+
readonly maxBufferedBytes?: number;
|
|
39
|
+
readonly maxDroppedFrames?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function actorIdOf(actor: Actor | null): string | null {
|
|
43
|
+
return actor === null ? null : actor.id;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class SyncSocket {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly clientBuildId: string;
|
|
49
|
+
readonly serverBuildId: string;
|
|
50
|
+
readonly openedAt: number;
|
|
51
|
+
/** Channel topics (tier 1). Live-query subscriptions are keyed separately, by sid. */
|
|
52
|
+
readonly topics = new Set<string>();
|
|
53
|
+
/** sid -> qid for live queries (tier 2/3) on this connection. */
|
|
54
|
+
readonly queries = new Map<string, string>();
|
|
55
|
+
/**
|
|
56
|
+
* Subscriptions whose patch stream was interrupted by backpressure. Dropping a patch is safe
|
|
57
|
+
* *only* because the cursor makes a re-snapshot cheap; the drop is recorded here so the next
|
|
58
|
+
* flush re-snapshots instead of silently diverging.
|
|
59
|
+
*/
|
|
60
|
+
readonly desynced = new Set<string>();
|
|
61
|
+
|
|
62
|
+
actor: Actor | null;
|
|
63
|
+
lastSeenAt: number;
|
|
64
|
+
droppedFrames = 0;
|
|
65
|
+
sentFrames = 0;
|
|
66
|
+
|
|
67
|
+
readonly #ws: WsLike;
|
|
68
|
+
readonly #clock: Clock;
|
|
69
|
+
readonly #maxBufferedBytes: number;
|
|
70
|
+
readonly #maxDroppedFrames: number;
|
|
71
|
+
#closed = false;
|
|
72
|
+
|
|
73
|
+
constructor(options: SyncSocketOptions) {
|
|
74
|
+
this.#ws = options.ws;
|
|
75
|
+
this.#clock = options.clock ?? systemClock;
|
|
76
|
+
this.id = options.id ?? uuid();
|
|
77
|
+
this.clientBuildId = options.clientBuildId;
|
|
78
|
+
this.serverBuildId = options.serverBuildId;
|
|
79
|
+
this.actor = options.actor ?? null;
|
|
80
|
+
this.#maxBufferedBytes = options.maxBufferedBytes ?? 1024 * 1024;
|
|
81
|
+
this.#maxDroppedFrames = options.maxDroppedFrames ?? 32;
|
|
82
|
+
this.openedAt = this.#clock.now().getTime();
|
|
83
|
+
this.lastSeenAt = this.openedAt;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get actorId(): string | null {
|
|
87
|
+
return actorIdOf(this.actor);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get closed(): boolean {
|
|
91
|
+
return this.#closed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A skewed client gets an `update-available` frame; it is never silently served a new shape. */
|
|
95
|
+
get skewed(): boolean {
|
|
96
|
+
return this.clientBuildId !== this.serverBuildId;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `false` means the frame was dropped by backpressure — the caller must mark state stale. */
|
|
100
|
+
send(frame: Frame): boolean {
|
|
101
|
+
if (this.#closed) return false;
|
|
102
|
+
if (this.#ws.getBufferedAmount() > this.#maxBufferedBytes) {
|
|
103
|
+
this.droppedFrames += 1;
|
|
104
|
+
if (this.droppedFrames > this.#maxDroppedFrames) {
|
|
105
|
+
this.close(CLOSE.overloaded, 'backpressure');
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
this.#ws.send(encode(frame));
|
|
110
|
+
this.sentFrames += 1;
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Record a dropped or invalidated subscription so the next flush re-snapshots it. */
|
|
115
|
+
markDesynced(sid: string): void {
|
|
116
|
+
this.desynced.add(sid);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
clearDesynced(sid: string): void {
|
|
120
|
+
this.desynced.delete(sid);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
subscribeTopic(topic: string): void {
|
|
124
|
+
this.topics.add(topic);
|
|
125
|
+
this.#ws.subscribe(topic);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
unsubscribeTopic(topic: string): void {
|
|
129
|
+
this.topics.delete(topic);
|
|
130
|
+
this.#ws.unsubscribe(topic);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
touch(): void {
|
|
134
|
+
this.lastSeenAt = this.#clock.now().getTime();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
idleFor(now: number): number {
|
|
138
|
+
return now - this.lastSeenAt;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
close(code: number = CLOSE.normal, reason = ''): void {
|
|
142
|
+
if (this.#closed) return;
|
|
143
|
+
this.#closed = true;
|
|
144
|
+
this.#ws.close(code, reason);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface SocketRegistryOptions {
|
|
149
|
+
readonly clock?: Clock;
|
|
150
|
+
/** Bun also enforces its own `idleTimeout`; this sweep catches half-open connections. */
|
|
151
|
+
readonly idleTimeoutMs?: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Per-node socket table. Intentionally the only in-memory map on a `sync` node. */
|
|
155
|
+
export class SocketRegistry {
|
|
156
|
+
readonly #sockets = new Map<string, SyncSocket>();
|
|
157
|
+
readonly #clock: Clock;
|
|
158
|
+
readonly #idleTimeoutMs: number;
|
|
159
|
+
|
|
160
|
+
constructor(options: SocketRegistryOptions = {}) {
|
|
161
|
+
this.#clock = options.clock ?? systemClock;
|
|
162
|
+
this.#idleTimeoutMs = options.idleTimeoutMs ?? 120_000;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
add(socket: SyncSocket): void {
|
|
166
|
+
this.#sockets.set(socket.id, socket);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
remove(id: string): void {
|
|
170
|
+
this.#sockets.delete(id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
get(id: string): SyncSocket | undefined {
|
|
174
|
+
return this.#sockets.get(id);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
all(): Iterable<SyncSocket> {
|
|
178
|
+
return this.#sockets.values();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
get count(): number {
|
|
182
|
+
return this.#sockets.size;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Closes and returns everything past the idle budget. Called on an interval by `sync-node`. */
|
|
186
|
+
sweepIdle(): SyncSocket[] {
|
|
187
|
+
const now = this.#clock.now().getTime();
|
|
188
|
+
const closed: SyncSocket[] = [];
|
|
189
|
+
for (const socket of this.#sockets.values()) {
|
|
190
|
+
if (socket.idleFor(now) > this.#idleTimeoutMs) {
|
|
191
|
+
socket.close(CLOSE.idle, 'idle timeout');
|
|
192
|
+
this.#sockets.delete(socket.id);
|
|
193
|
+
closed.push(socket);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return closed;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Local delivery for a channel topic. Bun's native pub/sub handles the common case; this is
|
|
200
|
+
* the fallback used when a frame must be filtered per socket (policy, desync bookkeeping). */
|
|
201
|
+
deliver(topic: string, frame: Frame): number {
|
|
202
|
+
let sent = 0;
|
|
203
|
+
for (const socket of this.#sockets.values()) {
|
|
204
|
+
if (socket.topics.has(topic) && socket.send(frame)) sent += 1;
|
|
205
|
+
}
|
|
206
|
+
return sent;
|
|
207
|
+
}
|
|
208
|
+
}
|