@ultimat3/realtime 23.0.0 → 24.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 +75 -81
- package/README.md +107 -17
- package/package.json +4 -4
- package/src/advisory-lock.ts +57 -0
- package/src/boot.ts +12 -7
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +2 -2
- package/src/changefeed.ts +39 -1
- package/src/channel-authz.ts +43 -12
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +34 -4
- package/src/channel-records.ts +33 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +143 -44
- package/src/client-channels.ts +10 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +7 -1
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +1 -0
- package/src/cursor.ts +11 -0
- package/src/errors.ts +70 -19
- package/src/fanout.ts +13 -0
- package/src/index.ts +1 -1
- package/src/live-definition.ts +22 -7
- package/src/live-fanout.ts +43 -7
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +38 -0
- package/src/live-query.ts +36 -37
- package/src/nats-client.ts +11 -2
- package/src/nats-transport.ts +22 -0
- package/src/offline-queue.ts +48 -5
- package/src/page-errors.ts +17 -0
- package/src/page-outbox.ts +121 -50
- package/src/pg-advisory-lock.ts +45 -1
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +119 -21
- package/src/pg-entity-row.ts +16 -0
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +44 -45
- package/src/pg-socket.ts +6 -4
- package/src/pg-tls.ts +18 -0
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/presence.ts +85 -8
- package/src/query-window.ts +16 -4
- package/src/record-persister.ts +7 -2
- package/src/replication-errors.ts +8 -4
- package/src/replicator-envelope.ts +110 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +319 -186
- package/src/server.ts +10 -6
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/subscriber-gate.ts +11 -1
- package/src/sync-auth.ts +47 -2
- package/src/sync-frames.ts +73 -20
- package/src/sync-node-bounds.ts +15 -0
- package/src/sync-node-contract.ts +20 -2
- package/src/sync-node.ts +92 -17
- package/src/sync-origin.ts +26 -11
- package/src/sync-protocol.ts +13 -3
- package/src/sync-upgrade.ts +76 -8
- package/src/use-mutation.ts +33 -5
- package/src/wire-channel.ts +13 -4
package/src/query-window.ts
CHANGED
|
@@ -131,9 +131,8 @@ export function createEntry(
|
|
|
131
131
|
* had already been fanned out would rewind every later subscriber to rows the window has moved
|
|
132
132
|
* past, so a stale read is discarded and its caller is served from the newer window instead.
|
|
133
133
|
*/
|
|
134
|
-
export async function fillWindow(
|
|
135
|
-
|
|
136
|
-
): Promise<{ rows: readonly Row[]; lsn: string }> {
|
|
134
|
+
export async function fillWindow(entry: QueryEntry): Promise<FilledWindow> {
|
|
135
|
+
let landed: FilledWindow['landed'] = null;
|
|
137
136
|
for (let attempt = 0; ; attempt += 1) {
|
|
138
137
|
// Read before `startRead` clears it: a second caller arriving during the read joins it and is
|
|
139
138
|
// not the one that forced it, which is what keeps one forced read from becoming N.
|
|
@@ -155,15 +154,28 @@ export async function fillWindow(
|
|
|
155
154
|
const first = entry.applied === 0;
|
|
156
155
|
if (isNewestRead(entry, pending) && (forced || first || result.lsn >= entry.lsn)) {
|
|
157
156
|
applyRead(entry, pending, result);
|
|
157
|
+
// A forced read outranks a first one: what it replaced was known to be wrong.
|
|
158
|
+
landed = forced || landed === 'forced' ? 'forced' : first ? 'first' : landed;
|
|
158
159
|
}
|
|
159
160
|
// A change reached this window while its first read was in flight and could not be folded,
|
|
160
161
|
// so what just landed may predate it: read once more before serving anyone a partial window.
|
|
161
162
|
return first && entry.stale && attempt < COLD_REREADS;
|
|
162
163
|
});
|
|
163
|
-
if (!again) return { rows: entry.rows, lsn: entry.lsn };
|
|
164
|
+
if (!again) return { rows: entry.rows, lsn: entry.lsn, landed };
|
|
164
165
|
}
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The window a caller is served from, and whether THIS call put a read into it. `landed` is what
|
|
170
|
+
* decides the retained ring's floor (`change-buffer.ts`): `null` is a window that was already
|
|
171
|
+
* filled — its patch history is still true, and flooring it would evict every other subscriber's.
|
|
172
|
+
*/
|
|
173
|
+
export interface FilledWindow {
|
|
174
|
+
readonly rows: readonly Row[];
|
|
175
|
+
readonly lsn: string;
|
|
176
|
+
readonly landed: 'first' | 'forced' | null;
|
|
177
|
+
}
|
|
178
|
+
|
|
167
179
|
/**
|
|
168
180
|
* How many times a cold window re-reads because writes kept landing during its read. Bounded: a
|
|
169
181
|
* table written faster than it can be read would otherwise never serve a subscriber, and after the
|
package/src/record-persister.ts
CHANGED
|
@@ -88,10 +88,15 @@ export function recordPersister(options: RecordPersisterOptions): RecordPersiste
|
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
const restore = async (): Promise<number> => {
|
|
91
|
-
const
|
|
91
|
+
const owner = principal();
|
|
92
|
+
const scope = scopeKey(owner);
|
|
92
93
|
if (scope === undefined) return 0;
|
|
94
|
+
const stored = await options.local.rows(scope);
|
|
95
|
+
// The read is async and a principal change can land inside it: its rescope has already cleared
|
|
96
|
+
// the store, so restoring now would hand the previous principal's rows to the next one.
|
|
97
|
+
if (principal() !== owner) return 0;
|
|
93
98
|
let restored = 0;
|
|
94
|
-
for (const [type, rows] of
|
|
99
|
+
for (const [type, rows] of stored) {
|
|
95
100
|
if (!options.types.has(type)) continue;
|
|
96
101
|
options.store.restore(type, rows);
|
|
97
102
|
restored += Object.keys(rows).length;
|
|
@@ -17,10 +17,14 @@ export class ReplicationProtocolError extends RealtimeError {
|
|
|
17
17
|
constructor(args: { stage: string; detail: string; fix?: string }) {
|
|
18
18
|
super({
|
|
19
19
|
code: 'X_REPLICATION_PROTOCOL',
|
|
20
|
-
cause: `
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
// The requirement rides the cause when the caller names no fix of its own: a `fix:` is one
|
|
21
|
+
// command, and `x doctor` has no `db` subcommand for the old line to have been one.
|
|
22
|
+
cause:
|
|
23
|
+
`postgres replication ${args.stage}: ${args.detail}` +
|
|
24
|
+
(args.fix === undefined
|
|
25
|
+
? ' — the server must be postgres >= 14 with a pgoutput publication and wal_level=logical'
|
|
26
|
+
: ''),
|
|
27
|
+
fix: args.fix ?? 'x doctor --json',
|
|
24
28
|
});
|
|
25
29
|
}
|
|
26
30
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The bus envelope, read side: one published change decoded back into a `ChangeEvent` (rows revived
|
|
2
|
+
// through their entity's columns), with the producer and sequence a lost message is visible in —
|
|
3
|
+
// and the detector a `sync` node runs over them. The publisher is `replicator.ts`.
|
|
4
|
+
|
|
5
|
+
import { isWriteDigest } from '@ultimat3/core';
|
|
6
|
+
import type { ChangeEvent } from './changefeed';
|
|
7
|
+
import { reviveBusRow } from './replicator-row';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What the bus actually carries: one change, plus who published it and where in that publisher's
|
|
11
|
+
* stream it sits.
|
|
12
|
+
*
|
|
13
|
+
* Fanout is core NATS — `publish`, no ack, at most once — so a consumer that reads only the change
|
|
14
|
+
* cannot tell "nothing happened" from "eleven changes went past while I was reconnecting". An lsn
|
|
15
|
+
* cannot answer it either: a WAL position is a byte offset, so a legitimate next change is an
|
|
16
|
+
* arbitrary jump forwards. A per-publisher counter is the one number a gap is visible in.
|
|
17
|
+
*
|
|
18
|
+
* Both fields are optional on the wire, so a node reading a publisher that predates them simply
|
|
19
|
+
* detects nothing — the same rule the `snapshot.entity` field follows, on a subject no client sees.
|
|
20
|
+
*/
|
|
21
|
+
export interface ChangeEnvelope {
|
|
22
|
+
readonly change: ChangeEvent;
|
|
23
|
+
/** Monotonic within `producer`, from 1. `null` from a publisher that does not sequence. */
|
|
24
|
+
readonly seq: number | null;
|
|
25
|
+
/** Identifies one replicator *run*. A new one restarts `seq` — and follows a stream that died. */
|
|
26
|
+
readonly producer: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Sync-node side of the bus: decode a published change back into a `ChangeEvent`. */
|
|
30
|
+
export function parseChange(payload: string): ChangeEvent | null {
|
|
31
|
+
return parseEnvelope(payload)?.change ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The same decode, keeping the two fields a gap is visible in. `parseChange` is this, narrowed. */
|
|
35
|
+
export function parseEnvelope(payload: string): ChangeEnvelope | null {
|
|
36
|
+
try {
|
|
37
|
+
const parsed: unknown = JSON.parse(payload);
|
|
38
|
+
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
39
|
+
const shape = parsed as Partial<ChangeEvent> & { seq?: unknown; producer?: unknown };
|
|
40
|
+
if (typeof shape.entity !== 'string' || typeof shape.lsn !== 'string') return null;
|
|
41
|
+
if (
|
|
42
|
+
shape.op !== 'insert' &&
|
|
43
|
+
shape.op !== 'update' &&
|
|
44
|
+
shape.op !== 'delete' &&
|
|
45
|
+
shape.op !== 'truncate'
|
|
46
|
+
) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
change: {
|
|
51
|
+
entity: shape.entity,
|
|
52
|
+
op: shape.op,
|
|
53
|
+
before: reviveBusRow(shape.entity, shape.before ?? null),
|
|
54
|
+
after: reviveBusRow(shape.entity, shape.after ?? null),
|
|
55
|
+
lsn: shape.lsn,
|
|
56
|
+
txid: typeof shape.txid === 'string' ? shape.txid : '',
|
|
57
|
+
orgId: typeof shape.orgId === 'string' ? shape.orgId : null,
|
|
58
|
+
at: typeof shape.at === 'number' ? shape.at : 0,
|
|
59
|
+
// Only the minted shape crosses: a frame decoder refuses anything else, so a malformed
|
|
60
|
+
// label here would cost every member the whole frame rather than one page its echo.
|
|
61
|
+
...(isWriteDigest(shape.write) ? { write: shape.write } : {}),
|
|
62
|
+
...(isNames(shape.omitted) ? { omitted: shape.omitted } : {}),
|
|
63
|
+
},
|
|
64
|
+
seq: typeof shape.seq === 'number' && Number.isFinite(shape.seq) ? shape.seq : null,
|
|
65
|
+
producer: typeof shape.producer === 'string' ? shape.producer : null,
|
|
66
|
+
};
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A non-empty list of property names — the only shape `ChangeEvent.omitted` crosses the bus in. */
|
|
73
|
+
const isNames = (value: unknown): value is readonly string[] =>
|
|
74
|
+
Array.isArray(value) && value.length > 0 && value.every((name) => typeof name === 'string');
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The consume-side twin of the publisher's counter, and the only thing on a `sync` node that can
|
|
78
|
+
* say "this node missed changes". Publish-side duplicate and out-of-order guards already existed;
|
|
79
|
+
* there was no equivalent here, so a NATS blip during a rolling restart was eleven changes that
|
|
80
|
+
* simply never happened as far as every subscriber on this node could tell.
|
|
81
|
+
*
|
|
82
|
+
* Per producer, because a replicator restart legitimately rewinds the counter. A repeat or a
|
|
83
|
+
* reordering is *not* reported as a gap — the window's own lsn guard refuses those — and neither is
|
|
84
|
+
* the first message this node ever reads: a node that joined late has missed everything by
|
|
85
|
+
* definition, and every subscription it holds started after it did.
|
|
86
|
+
*
|
|
87
|
+
* A NEW producer after a known one IS a gap. The run before it died — that is why there is a new
|
|
88
|
+
* one — and nothing says its last messages reached this node: the tail of a dead stream has no
|
|
89
|
+
* later sequence number to be missed against. One re-read per replicator restart is the price.
|
|
90
|
+
*/
|
|
91
|
+
export class SeqGapDetector {
|
|
92
|
+
readonly #next = new Map<string, number>();
|
|
93
|
+
|
|
94
|
+
/** `true` when at least one message between the last one and this one was never delivered. */
|
|
95
|
+
observe(envelope: ChangeEnvelope): boolean {
|
|
96
|
+
const { producer, seq } = envelope;
|
|
97
|
+
if (producer === null || seq === null) return false;
|
|
98
|
+
const expected = this.#next.get(producer);
|
|
99
|
+
const succeeds = expected === undefined && this.#next.size > 0;
|
|
100
|
+
// Never backwards: a redelivery must not lower the bar and turn the next legitimate message
|
|
101
|
+
// into a gap of its own.
|
|
102
|
+
this.#next.set(producer, Math.max(expected ?? 0, seq + 1));
|
|
103
|
+
return succeeds || (expected !== undefined && seq > expected);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Producers this node has read. Bounded by replicator restarts, so it is swept on a drain. */
|
|
107
|
+
forget(): void {
|
|
108
|
+
this.#next.clear();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Single responsibility: a change's rows across the replicator bus. The bus carries text, a row
|
|
2
|
+
// holds a `Date` and a `Uint8Array`, and `JSON.parse` returns neither — so the publisher writes a
|
|
3
|
+
// form JSON can carry and the sync node reads it back through the ENTITY's own columns.
|
|
4
|
+
|
|
5
|
+
import { entityForTable } from '@ultimat3/entity';
|
|
6
|
+
import type { Row } from './json';
|
|
7
|
+
|
|
8
|
+
const CHUNK = 0x8000;
|
|
9
|
+
|
|
10
|
+
/** Base64 of raw bytes. Chunked: one `String.fromCharCode(...bytes)` overflows the stack at ~100k. */
|
|
11
|
+
function toBase64(bytes: Uint8Array): string {
|
|
12
|
+
let binary = '';
|
|
13
|
+
for (let at = 0; at < bytes.length; at += CHUNK) {
|
|
14
|
+
binary += String.fromCharCode(...bytes.subarray(at, at + CHUNK));
|
|
15
|
+
}
|
|
16
|
+
return btoa(binary);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fromBase64(text: string): Uint8Array {
|
|
20
|
+
const binary = atob(text);
|
|
21
|
+
const bytes = new Uint8Array(binary.length);
|
|
22
|
+
for (let at = 0; at < binary.length; at += 1) bytes[at] = binary.charCodeAt(at);
|
|
23
|
+
return bytes;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Publish side. A `Date` already serialises to the ISO text its column reads back; a `Uint8Array`
|
|
28
|
+
* serialises to `{"0":1,"1":2}`, which nothing reads back, so it crosses as base64. Only a
|
|
29
|
+
* top-level value can be one: `arrayOf()` refuses `bytes()` at declaration.
|
|
30
|
+
*/
|
|
31
|
+
export function busRow(row: Row | null): Row | null {
|
|
32
|
+
if (row === null) return null;
|
|
33
|
+
let out: Record<string, unknown> | undefined;
|
|
34
|
+
for (const [property, value] of Object.entries(row as Record<string, unknown>)) {
|
|
35
|
+
if (!(value instanceof Uint8Array)) continue;
|
|
36
|
+
out ??= { ...row };
|
|
37
|
+
out[property] = toBase64(value);
|
|
38
|
+
}
|
|
39
|
+
return out === undefined ? row : (out as unknown as Row);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Consume side: the row as the replicator held it before it was text. Without this every `Date`
|
|
44
|
+
* reached the matcher as an ISO string, `compareValues(Date, string)` fell to comparing their
|
|
45
|
+
* printed forms, and one edit to one column moved its row to the top of every window ordered by
|
|
46
|
+
* a timestamp — on every deployment with a bus, and in no in-process test, which hands the object
|
|
47
|
+
* over directly.
|
|
48
|
+
*
|
|
49
|
+
* Decided by the column that DECLARED the value, never by what the value looks like: a `text()`
|
|
50
|
+
* holding `2026-08-09T12:00:00.000Z` stays text. `table` is `ChangeEvent.entity`, which on the WAL
|
|
51
|
+
* path is the relation name. A table with no entity here, a property with no column, and a value
|
|
52
|
+
* its column refuses are all handed back as they arrived — a change that crosses unrevived is the
|
|
53
|
+
* old behaviour; a change dropped is a window that silently diverges.
|
|
54
|
+
*/
|
|
55
|
+
export function reviveBusRow(table: string, row: Row | null): Row | null {
|
|
56
|
+
if (row === null) return null;
|
|
57
|
+
const entity = entityForTable(table);
|
|
58
|
+
if (entity === undefined) return row;
|
|
59
|
+
const out: Record<string, unknown> = {};
|
|
60
|
+
for (const [property, value] of Object.entries(row as Record<string, unknown>)) {
|
|
61
|
+
out[property] = value;
|
|
62
|
+
if (value === null || !Object.hasOwn(entity.$columns, property)) continue;
|
|
63
|
+
const column = entity.$columns[property];
|
|
64
|
+
if (column === undefined || column.$meta.sealed !== undefined) continue;
|
|
65
|
+
try {
|
|
66
|
+
out[property] =
|
|
67
|
+
column.$meta.kind === 'bytea' && typeof value === 'string'
|
|
68
|
+
? fromBase64(value)
|
|
69
|
+
: column.$parse(value);
|
|
70
|
+
} catch {
|
|
71
|
+
// Kept as it arrived — see the function comment.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out as unknown as Row;
|
|
75
|
+
}
|