@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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Single responsibility: the one scripted walsender this package's replication tests run against —
|
|
2
|
+
// pgoutput message builders, a fake `PgStream` that answers the exact preflight sequence, and the
|
|
3
|
+
// `start()` that boots a feed over it. Kept out of the test file so neither outgrows its ceiling.
|
|
4
|
+
// Not part of the public API — `index.ts` deliberately does not re-export it.
|
|
5
|
+
|
|
6
|
+
import { frozenClock } from '@ultimat3/core';
|
|
7
|
+
import type { ChangeEvent } from './changefeed';
|
|
8
|
+
import { PgLogicalReplicationFeed } from './changefeed';
|
|
9
|
+
import { ByteReader, ByteWriter, pgTimestampToEpochMs } from './pg-bytes';
|
|
10
|
+
import type { PgStream } from './pg-wire';
|
|
11
|
+
import { frame } from './pg-wire';
|
|
12
|
+
|
|
13
|
+
// ---- pgoutput fixtures ------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export const POSTS_OID = 16_384;
|
|
16
|
+
export const OTHER_OID = 16_385;
|
|
17
|
+
export const TEXT = 25;
|
|
18
|
+
export const INT8 = 20;
|
|
19
|
+
|
|
20
|
+
export interface FixtureColumn {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly key?: boolean;
|
|
23
|
+
/** Type oid, `TEXT` unless a case is specifically about how another type decodes. */
|
|
24
|
+
readonly type?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const relation = (
|
|
28
|
+
oid: number,
|
|
29
|
+
name: string,
|
|
30
|
+
columns: readonly FixtureColumn[],
|
|
31
|
+
): Uint8Array => {
|
|
32
|
+
const writer = new ByteWriter()
|
|
33
|
+
.uint8(0x52)
|
|
34
|
+
.int32(oid)
|
|
35
|
+
.cstring('public')
|
|
36
|
+
.cstring(name)
|
|
37
|
+
.uint8(0x66)
|
|
38
|
+
.int16(columns.length);
|
|
39
|
+
for (const column of columns) {
|
|
40
|
+
writer
|
|
41
|
+
.uint8(column.key === true ? 1 : 0)
|
|
42
|
+
.cstring(column.name)
|
|
43
|
+
.int32(column.type ?? TEXT)
|
|
44
|
+
.int32(-1);
|
|
45
|
+
}
|
|
46
|
+
return writer.finish();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const tuple = (writer: ByteWriter, values: readonly (string | null)[]): ByteWriter => {
|
|
50
|
+
writer.int16(values.length);
|
|
51
|
+
for (const value of values) {
|
|
52
|
+
if (value === null) writer.uint8(0x6e);
|
|
53
|
+
else writer.uint8(0x74).int32(value.length).utf8(value);
|
|
54
|
+
}
|
|
55
|
+
return writer;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const begin = (commitLsn: bigint, at: bigint, xid: number): Uint8Array =>
|
|
59
|
+
new ByteWriter().uint8(0x42).int64(commitLsn).int64(at).int32(xid).finish();
|
|
60
|
+
|
|
61
|
+
export const commit = (commitLsn: bigint, endLsn: bigint, at: bigint): Uint8Array =>
|
|
62
|
+
new ByteWriter().uint8(0x43).uint8(0).int64(commitLsn).int64(endLsn).int64(at).finish();
|
|
63
|
+
|
|
64
|
+
export const insert = (oid: number, values: readonly (string | null)[]): Uint8Array =>
|
|
65
|
+
tuple(new ByteWriter().uint8(0x49).int32(oid).uint8(0x4e), values).finish();
|
|
66
|
+
|
|
67
|
+
export const update = (
|
|
68
|
+
oid: number,
|
|
69
|
+
before: readonly (string | null)[] | null,
|
|
70
|
+
after: readonly (string | null)[],
|
|
71
|
+
): Uint8Array => {
|
|
72
|
+
const writer = new ByteWriter().uint8(0x55).int32(oid);
|
|
73
|
+
if (before !== null) tuple(writer.uint8(0x4f), before);
|
|
74
|
+
return tuple(writer.uint8(0x4e), after).finish();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const remove = (oid: number, before: readonly (string | null)[]): Uint8Array =>
|
|
78
|
+
tuple(new ByteWriter().uint8(0x44).int32(oid).uint8(0x4f), before).finish();
|
|
79
|
+
|
|
80
|
+
/** `w` XLogData, wrapped as the `d` CopyData message the walsender sends it in. */
|
|
81
|
+
export const xlog = (payload: Uint8Array, walEnd = 0n): Uint8Array =>
|
|
82
|
+
frame(
|
|
83
|
+
'd',
|
|
84
|
+
new ByteWriter().uint8(0x77).int64(walEnd).int64(walEnd).int64(0n).raw(payload).finish(),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
export const keepalive = (walEnd: bigint, replyRequested: number): Uint8Array =>
|
|
88
|
+
frame('d', new ByteWriter().uint8(0x6b).int64(walEnd).int64(0n).uint8(replyRequested).finish());
|
|
89
|
+
|
|
90
|
+
// ---- a scripted walsender ---------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export const dataRow = (values: readonly (string | null)[]): Uint8Array => {
|
|
93
|
+
const writer = new ByteWriter().int16(values.length);
|
|
94
|
+
for (const value of values) {
|
|
95
|
+
if (value === null) writer.int32(-1);
|
|
96
|
+
else writer.int32(value.length).utf8(value);
|
|
97
|
+
}
|
|
98
|
+
return frame('D', writer.finish());
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const ready = (): Uint8Array => frame('Z', new Uint8Array([0x49]));
|
|
102
|
+
export const complete = (): Uint8Array => frame('C', new ByteWriter().cstring('SELECT 1').finish());
|
|
103
|
+
|
|
104
|
+
export const joined = (...parts: readonly Uint8Array[]): Uint8Array => {
|
|
105
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
106
|
+
const out = new Uint8Array(total);
|
|
107
|
+
let at = 0;
|
|
108
|
+
for (const part of parts) {
|
|
109
|
+
out.set(part, at);
|
|
110
|
+
at += part.length;
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export interface ServerScript {
|
|
116
|
+
readonly walLevel?: string;
|
|
117
|
+
readonly publicationExists?: boolean;
|
|
118
|
+
/** `null` = no slot yet, so the feed has to create one. */
|
|
119
|
+
readonly slotPlugin?: string | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Answers the exact command sequence the feed issues, and records what it sent back — enough to
|
|
124
|
+
* drive the whole preflight and then inject WAL by hand.
|
|
125
|
+
*/
|
|
126
|
+
export class FakeWalsender implements PgStream {
|
|
127
|
+
readonly queries: string[] = [];
|
|
128
|
+
readonly standby: { position: bigint; at: number }[] = [];
|
|
129
|
+
closed = false;
|
|
130
|
+
// `undefined` is EOF: `PgStream.read()` already returns `Uint8Array | undefined`, so the queue
|
|
131
|
+
// holds it honestly rather than letting `close()` cast one type into the other.
|
|
132
|
+
readonly #chunks: (Uint8Array | undefined)[] = [];
|
|
133
|
+
readonly #script: ServerScript;
|
|
134
|
+
#waiting: ((chunk: Uint8Array | undefined) => void) | null = null;
|
|
135
|
+
|
|
136
|
+
constructor(script: ServerScript = {}) {
|
|
137
|
+
this.#script = script;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
push(chunk: Uint8Array | undefined): void {
|
|
141
|
+
const waiter = this.#waiting;
|
|
142
|
+
this.#waiting = null;
|
|
143
|
+
if (waiter === null) this.#chunks.push(chunk);
|
|
144
|
+
else waiter(chunk);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
read(): Promise<Uint8Array | undefined> {
|
|
148
|
+
if (this.#chunks.length > 0) return Promise.resolve(this.#chunks.shift());
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
this.#waiting = resolve;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
write(bytes: Uint8Array): Promise<void> {
|
|
155
|
+
// The startup packet is untagged, so a leading protocol version is how it is recognised.
|
|
156
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
157
|
+
if (bytes.length >= 8 && view.getInt32(4, false) === 196_608) {
|
|
158
|
+
this.push(joined(frame('R', new ByteWriter().int32(0).finish()), ready()));
|
|
159
|
+
return Promise.resolve();
|
|
160
|
+
}
|
|
161
|
+
const reader = new ByteReader(bytes, 'client');
|
|
162
|
+
const tag = reader.tag();
|
|
163
|
+
reader.int32();
|
|
164
|
+
if (tag === 'Q') this.#answer(reader.cstring());
|
|
165
|
+
if (tag === 'd') this.#recordStandby(reader.rest());
|
|
166
|
+
return Promise.resolve();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
close(): void {
|
|
170
|
+
this.closed = true;
|
|
171
|
+
this.push(undefined);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#recordStandby(payload: Uint8Array): void {
|
|
175
|
+
const reader = new ByteReader(payload, 'standby');
|
|
176
|
+
if (reader.tag() !== 'r') return;
|
|
177
|
+
const position = reader.int64();
|
|
178
|
+
reader.int64();
|
|
179
|
+
reader.int64();
|
|
180
|
+
this.standby.push({ position, at: pgTimestampToEpochMs(reader.int64()) });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#answer(sql: string): void {
|
|
184
|
+
this.queries.push(sql);
|
|
185
|
+
if (sql.startsWith('START_REPLICATION')) {
|
|
186
|
+
this.push(frame('W', new ByteWriter().uint8(0).int16(0).finish()));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (sql === 'SHOW wal_level') {
|
|
190
|
+
this.push(joined(dataRow([this.#script.walLevel ?? 'logical']), complete(), ready()));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (sql.includes('pg_publication')) {
|
|
194
|
+
const rows = this.#script.publicationExists === false ? [] : [dataRow(['1'])];
|
|
195
|
+
this.push(joined(...rows, complete(), ready()));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (sql.includes('pg_replication_slots')) {
|
|
199
|
+
const plugin = this.#script.slotPlugin === undefined ? 'pgoutput' : this.#script.slotPlugin;
|
|
200
|
+
const rows = plugin === null ? [] : [dataRow([plugin])];
|
|
201
|
+
this.push(joined(...rows, complete(), ready()));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
this.push(joined(dataRow(['ok']), complete(), ready()));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const POST_COLUMNS: readonly FixtureColumn[] = [
|
|
209
|
+
{ name: 'id', key: true },
|
|
210
|
+
{ name: 'title' },
|
|
211
|
+
{ name: 'org_id' },
|
|
212
|
+
{ name: 'price_minor' },
|
|
213
|
+
{ name: 'price_currency' },
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
export interface Started {
|
|
217
|
+
readonly feed: PgLogicalReplicationFeed;
|
|
218
|
+
readonly server: FakeWalsender;
|
|
219
|
+
readonly events: ChangeEvent[];
|
|
220
|
+
/** Resolves once `count` events have been delivered. */
|
|
221
|
+
settled(count: number): Promise<void>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const start = async (
|
|
225
|
+
options: {
|
|
226
|
+
script?: ServerScript;
|
|
227
|
+
from?: string;
|
|
228
|
+
entities?: readonly string[];
|
|
229
|
+
statusIntervalMs?: number;
|
|
230
|
+
} = {},
|
|
231
|
+
): Promise<Started> => {
|
|
232
|
+
const server = new FakeWalsender(options.script);
|
|
233
|
+
const events: ChangeEvent[] = [];
|
|
234
|
+
const feed = new PgLogicalReplicationFeed({
|
|
235
|
+
url: 'postgres://replicator:secret@db.test:5432/app',
|
|
236
|
+
slot: 'ultimate_slot',
|
|
237
|
+
publication: 'ultimate_pub',
|
|
238
|
+
entities: options.entities ?? ['posts'],
|
|
239
|
+
clock: frozenClock('2026-08-09T12:00:00.000Z'),
|
|
240
|
+
stream: () => Promise.resolve(server),
|
|
241
|
+
...(options.statusIntervalMs === undefined
|
|
242
|
+
? {}
|
|
243
|
+
: { statusIntervalMs: options.statusIntervalMs }),
|
|
244
|
+
});
|
|
245
|
+
await feed.start(
|
|
246
|
+
options.from === undefined
|
|
247
|
+
? { onChange: (event) => void events.push(event) }
|
|
248
|
+
: { from: options.from, onChange: (event) => void events.push(event) },
|
|
249
|
+
);
|
|
250
|
+
return {
|
|
251
|
+
feed,
|
|
252
|
+
server,
|
|
253
|
+
events,
|
|
254
|
+
// Nothing here is real I/O, so draining the microtask queue is a deterministic "let the pump
|
|
255
|
+
// finish" — including the commit that follows the last row.
|
|
256
|
+
settled: async (count) => {
|
|
257
|
+
for (let tick = 0; tick < 200 && events.length < count; tick += 1) await Promise.resolve();
|
|
258
|
+
for (let tick = 0; tick < 50; tick += 1) await Promise.resolve();
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
};
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Single responsibility: turn a Postgres logical-replication slot into ordered `ChangeEvent`s —
|
|
2
|
+
// preflight the three things that are always misconfigured, START_REPLICATION, decode pgoutput,
|
|
3
|
+
// and keep the slot confirmed. The connection, the framing and the pgoutput decode live next door;
|
|
4
|
+
// what is decided here is *ordering*, because the lsn is the only authority the pipeline has.
|
|
5
|
+
|
|
6
|
+
import { type Clock, logger, systemClock } from '@ultimat3/core';
|
|
7
|
+
import type { ChangeEvent, ChangeOp, PgLogicalReplicationOptions } from './changefeed';
|
|
8
|
+
import { ReplicationFailedError, ReplicationProtocolError } from './errors';
|
|
9
|
+
import { isRow, type JsonObject, type Row } from './json';
|
|
10
|
+
import { ByteReader, ByteWriter, epochMsToPgTimestamp, printLsn } from './pg-bytes';
|
|
11
|
+
import { PgConnection } from './pg-connection';
|
|
12
|
+
import { entityRow } from './pg-entity-row';
|
|
13
|
+
import { bunPgStream, parsePgUrl } from './pg-socket';
|
|
14
|
+
import { PgOutputDecoder, type PgOutputMessage, type PgRelation } from './pgoutput';
|
|
15
|
+
|
|
16
|
+
/** Identifiers reach a simple query unparameterised, so the charset is the injection boundary. */
|
|
17
|
+
const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
|
18
|
+
|
|
19
|
+
const DEFAULT_STATUS_INTERVAL_MS = 10_000;
|
|
20
|
+
|
|
21
|
+
/** `r` — the standby status update, the only frontend message a walsender listens for. */
|
|
22
|
+
const STANDBY_STATUS = 0x72;
|
|
23
|
+
|
|
24
|
+
export interface ReplicationStreamStats {
|
|
25
|
+
readonly delivered: number;
|
|
26
|
+
/** Rows for a table outside the entity list — the publication is wider than the app is. */
|
|
27
|
+
readonly skipped: number;
|
|
28
|
+
/** Rows replayed from before the resume position, dropped so `onChange` sees each one once. */
|
|
29
|
+
readonly replayed: number;
|
|
30
|
+
/**
|
|
31
|
+
* Why the pump stopped, or `null` while it is live. The read loop cannot throw into a caller —
|
|
32
|
+
* nothing awaits it — so this is the one place `/readyz` and a test can see that it died at all.
|
|
33
|
+
*/
|
|
34
|
+
readonly failure: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Transaction {
|
|
38
|
+
readonly commitLsn: bigint;
|
|
39
|
+
readonly commitAt: number;
|
|
40
|
+
readonly xid: number;
|
|
41
|
+
/** Position of the next row inside this transaction. Reproducible, which is what makes it usable. */
|
|
42
|
+
sequence: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `<commit lsn><position in transaction>`, both zero-padded hex, so string order is stream order.
|
|
47
|
+
*
|
|
48
|
+
* The commit lsn alone is not enough: every row of one transaction shares it, and the replicator
|
|
49
|
+
* drops anything that does not strictly increase — a five-row insert would deliver one row. The
|
|
50
|
+
* per-record WAL position is not enough either: logical decoding emits *transactions* in commit
|
|
51
|
+
* order, so a later-committing transaction can carry lower record positions than an earlier one.
|
|
52
|
+
* The pair is monotonic in the order changes are delivered and identical on replay, which is what
|
|
53
|
+
* makes an at-least-once redelivery deduplicate instead of duplicating.
|
|
54
|
+
*/
|
|
55
|
+
export const changeLsn = (commitLsn: bigint, sequence: number): string =>
|
|
56
|
+
commitLsn.toString(16).padStart(16, '0') + sequence.toString(16).padStart(8, '0');
|
|
57
|
+
|
|
58
|
+
/** The commit position inside a change lsn — where a resume asks the server to restart. */
|
|
59
|
+
export const commitPositionOf = (lsn: string): bigint => BigInt(`0x${lsn.slice(0, 16) || '0'}`);
|
|
60
|
+
|
|
61
|
+
const assertIdentifier = (kind: string, value: string): string => {
|
|
62
|
+
if (IDENTIFIER.test(value)) return value;
|
|
63
|
+
throw new ReplicationFailedError({
|
|
64
|
+
stage: 'preflight',
|
|
65
|
+
detail: `${kind} "${value}" is not a lower-case postgres identifier`,
|
|
66
|
+
fix: `rename the ${kind} to match [a-z_][a-z0-9_]* — it is interpolated into a replication command`,
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export interface ReplicationStreamHandlers {
|
|
71
|
+
readonly from?: string | undefined;
|
|
72
|
+
onChange(event: ChangeEvent): void | Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One slot, one connection, one process. Started by `PgLogicalReplicationFeed`, which is itself
|
|
77
|
+
* held by the single `replicator` role — the advisory lock upstream is what keeps that "one" true.
|
|
78
|
+
*/
|
|
79
|
+
export class PgReplicationStream {
|
|
80
|
+
readonly #options: PgLogicalReplicationOptions;
|
|
81
|
+
readonly #clock: Clock;
|
|
82
|
+
readonly #entities: ReadonlySet<string>;
|
|
83
|
+
readonly #slot: string;
|
|
84
|
+
readonly #publication: string;
|
|
85
|
+
readonly #decoder = new PgOutputDecoder();
|
|
86
|
+
#connection: PgConnection | null = null;
|
|
87
|
+
#timer: ReturnType<typeof setInterval> | null = null;
|
|
88
|
+
#writing: Promise<void> = Promise.resolve();
|
|
89
|
+
#transaction: Transaction | null = null;
|
|
90
|
+
#confirmed = 0n;
|
|
91
|
+
#lastLsn: string | null = null;
|
|
92
|
+
#running = false;
|
|
93
|
+
#pump: Promise<void> | null = null;
|
|
94
|
+
#delivered = 0;
|
|
95
|
+
#skipped = 0;
|
|
96
|
+
#replayed = 0;
|
|
97
|
+
#failure: string | null = null;
|
|
98
|
+
|
|
99
|
+
constructor(options: PgLogicalReplicationOptions) {
|
|
100
|
+
this.#options = options;
|
|
101
|
+
this.#clock = options.clock ?? systemClock;
|
|
102
|
+
this.#entities = new Set(options.entities.map((name) => assertIdentifier('entity', name)));
|
|
103
|
+
// All three names are checked here rather than in `start()`: a mistyped REPLICATION_SLOT is a
|
|
104
|
+
// boot-time fact, and refusing it at the first WAL read means a replicator that reported
|
|
105
|
+
// itself started and then never delivered a change.
|
|
106
|
+
this.#slot = assertIdentifier('slot', options.slot);
|
|
107
|
+
this.#publication = assertIdentifier('publication', options.publication);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
lastLsn(): string | null {
|
|
111
|
+
return this.#lastLsn;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
stats(): ReplicationStreamStats {
|
|
115
|
+
return {
|
|
116
|
+
delivered: this.#delivered,
|
|
117
|
+
skipped: this.#skipped,
|
|
118
|
+
replayed: this.#replayed,
|
|
119
|
+
failure: this.#failure,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Resolves once the stream is live. Delivery continues on the pump until `stop()`. */
|
|
124
|
+
async start(handlers: ReplicationStreamHandlers): Promise<void> {
|
|
125
|
+
if (this.#running) return;
|
|
126
|
+
const slot = this.#slot;
|
|
127
|
+
const publication = this.#publication;
|
|
128
|
+
const target = parsePgUrl(this.#options.url);
|
|
129
|
+
const stream = await (this.#options.stream ?? bunPgStream)(target);
|
|
130
|
+
const connection = await PgConnection.open({
|
|
131
|
+
stream,
|
|
132
|
+
user: target.user,
|
|
133
|
+
password: target.password,
|
|
134
|
+
database: target.database,
|
|
135
|
+
replication: 'database',
|
|
136
|
+
applicationName: `ultimate-replicator:${slot}`,
|
|
137
|
+
rng: this.#options.rng,
|
|
138
|
+
});
|
|
139
|
+
this.#connection = connection;
|
|
140
|
+
try {
|
|
141
|
+
await preflight(connection, slot, publication);
|
|
142
|
+
const from = handlers.from;
|
|
143
|
+
this.#confirmed = from === undefined ? 0n : commitPositionOf(from);
|
|
144
|
+
await connection.startCopyBoth(
|
|
145
|
+
`START_REPLICATION SLOT ${slot} LOGICAL ${printLsn(this.#confirmed)} ` +
|
|
146
|
+
`(proto_version '1', publication_names '${publication}')`,
|
|
147
|
+
);
|
|
148
|
+
} catch (failure) {
|
|
149
|
+
await this.stop();
|
|
150
|
+
throw failure;
|
|
151
|
+
}
|
|
152
|
+
this.#running = true;
|
|
153
|
+
this.#timer = setInterval(() => {
|
|
154
|
+
void this.#confirm();
|
|
155
|
+
}, this.#options.statusIntervalMs ?? DEFAULT_STATUS_INTERVAL_MS);
|
|
156
|
+
// A pending timer must not be what keeps `x dev` alive after the app is done with it.
|
|
157
|
+
this.#timer.unref?.();
|
|
158
|
+
this.#pump = this.#drain(connection, handlers);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async stop(): Promise<void> {
|
|
162
|
+
this.#running = false;
|
|
163
|
+
if (this.#timer !== null) {
|
|
164
|
+
clearInterval(this.#timer);
|
|
165
|
+
this.#timer = null;
|
|
166
|
+
}
|
|
167
|
+
const connection = this.#connection;
|
|
168
|
+
this.#connection = null;
|
|
169
|
+
if (connection === null) return;
|
|
170
|
+
// Confirming before the goodbye is what stops a restart from replaying the whole window.
|
|
171
|
+
if (connection.inCopyBoth) {
|
|
172
|
+
await this.#confirm(connection);
|
|
173
|
+
await connection.endCopy();
|
|
174
|
+
}
|
|
175
|
+
await connection.close();
|
|
176
|
+
const pump = this.#pump;
|
|
177
|
+
this.#pump = null;
|
|
178
|
+
if (pump !== null) await pump;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The read loop. It owns no timers and no state beyond the current transaction. */
|
|
182
|
+
async #drain(connection: PgConnection, handlers: ReplicationStreamHandlers): Promise<void> {
|
|
183
|
+
try {
|
|
184
|
+
for (;;) {
|
|
185
|
+
const payload = await connection.nextCopyData();
|
|
186
|
+
if (payload === undefined) return;
|
|
187
|
+
const reader = new ByteReader(payload, 'copy-data');
|
|
188
|
+
const tag = reader.tag();
|
|
189
|
+
if (tag === 'w') {
|
|
190
|
+
reader.int64();
|
|
191
|
+
reader.int64();
|
|
192
|
+
reader.int64();
|
|
193
|
+
await this.#apply(this.#decoder.decode(reader.rest()), handlers);
|
|
194
|
+
} else if (tag === 'k') {
|
|
195
|
+
const walEnd = reader.int64();
|
|
196
|
+
reader.int64();
|
|
197
|
+
const replyRequested = reader.uint8();
|
|
198
|
+
// Only safe between transactions: inside one, the undelivered tail is still pending.
|
|
199
|
+
if (this.#transaction === null && walEnd > this.#confirmed) this.#confirmed = walEnd;
|
|
200
|
+
if (replyRequested === 1) await this.#confirm(connection);
|
|
201
|
+
} else {
|
|
202
|
+
throw new ReplicationProtocolError({
|
|
203
|
+
stage: 'stream',
|
|
204
|
+
detail: `the walsender sent CopyData "${tag}", which is neither XLogData nor a keepalive`,
|
|
205
|
+
fix: 'upgrade this package — the server speaks a replication message it does not know',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch (failure) {
|
|
210
|
+
if (!this.#running) return;
|
|
211
|
+
this.#running = false;
|
|
212
|
+
// The supervisor reads /readyz, so the loop records, reports and ends rather than throwing
|
|
213
|
+
// into a timer callback nothing awaits — and the confirm timer stops with the loop it
|
|
214
|
+
// confirms for, or it keeps telling the walsender a dead stream is still keeping up.
|
|
215
|
+
this.#failure = failure instanceof Error ? failure.message : String(failure);
|
|
216
|
+
if (this.#timer !== null) {
|
|
217
|
+
clearInterval(this.#timer);
|
|
218
|
+
this.#timer = null;
|
|
219
|
+
}
|
|
220
|
+
logger.error('replication stream ended', {
|
|
221
|
+
slot: this.#options.slot,
|
|
222
|
+
error: this.#failure,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async #apply(message: PgOutputMessage, handlers: ReplicationStreamHandlers): Promise<void> {
|
|
228
|
+
switch (message.kind) {
|
|
229
|
+
case 'begin':
|
|
230
|
+
this.#transaction = {
|
|
231
|
+
commitLsn: message.commitLsn,
|
|
232
|
+
commitAt: message.commitAt,
|
|
233
|
+
xid: message.xid,
|
|
234
|
+
sequence: 0,
|
|
235
|
+
};
|
|
236
|
+
return;
|
|
237
|
+
case 'commit':
|
|
238
|
+
this.#transaction = null;
|
|
239
|
+
if (message.endLsn > this.#confirmed) this.#confirmed = message.endLsn;
|
|
240
|
+
return;
|
|
241
|
+
case 'insert':
|
|
242
|
+
await this.#deliver('insert', message.relation, null, message.after, handlers);
|
|
243
|
+
return;
|
|
244
|
+
case 'update':
|
|
245
|
+
await this.#deliver('update', message.relation, message.before, message.after, handlers);
|
|
246
|
+
return;
|
|
247
|
+
case 'delete':
|
|
248
|
+
await this.#deliver('delete', message.relation, message.before, null, handlers);
|
|
249
|
+
return;
|
|
250
|
+
default:
|
|
251
|
+
// Relation, truncate, origin, type, logical message: nothing the matcher can act on.
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async #deliver(
|
|
257
|
+
op: ChangeOp,
|
|
258
|
+
relation: PgRelation,
|
|
259
|
+
oldTuple: JsonObject | null,
|
|
260
|
+
newTuple: JsonObject | null,
|
|
261
|
+
handlers: ReplicationStreamHandlers,
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
const transaction = this.#transaction;
|
|
264
|
+
if (transaction === null) {
|
|
265
|
+
throw new ReplicationProtocolError({
|
|
266
|
+
stage: 'stream',
|
|
267
|
+
detail: `a ${op} arrived outside a transaction`,
|
|
268
|
+
fix: 'upgrade this package — the pgoutput stream is framed differently than expected',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
// Counted for every row, selected or not, so the lsn depends on the WAL alone: narrowing the
|
|
272
|
+
// entity list must not renumber a stream a resume cursor already points into.
|
|
273
|
+
transaction.sequence += 1;
|
|
274
|
+
if (!this.#entities.has(relation.name)) {
|
|
275
|
+
this.#skipped += 1;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const lsn = changeLsn(transaction.commitLsn, transaction.sequence);
|
|
279
|
+
// The server restarts at a transaction boundary, so the first transaction after a resume
|
|
280
|
+
// arrives whole; the rows already delivered are dropped here rather than sent twice.
|
|
281
|
+
if (handlers.from !== undefined && lsn <= handlers.from) {
|
|
282
|
+
this.#replayed += 1;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const before = toRow(relation, oldTuple);
|
|
286
|
+
const after = toRow(relation, newTuple);
|
|
287
|
+
const event: ChangeEvent = {
|
|
288
|
+
entity: relation.name,
|
|
289
|
+
op,
|
|
290
|
+
before,
|
|
291
|
+
after,
|
|
292
|
+
lsn,
|
|
293
|
+
txid: transaction.xid.toString(10),
|
|
294
|
+
orgId: tenantOf(after ?? before),
|
|
295
|
+
at: transaction.commitAt,
|
|
296
|
+
};
|
|
297
|
+
await handlers.onChange(event);
|
|
298
|
+
this.#lastLsn = lsn;
|
|
299
|
+
this.#delivered += 1;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Tell the walsender how far we got. Serialized behind one chain because the timer and the read
|
|
304
|
+
* loop both reach it, and two interleaved writes would frame one another's bytes.
|
|
305
|
+
*/
|
|
306
|
+
async #confirm(connection: PgConnection | null = this.#connection): Promise<void> {
|
|
307
|
+
if (connection === null || !connection.inCopyBoth) return;
|
|
308
|
+
const position = this.#confirmed;
|
|
309
|
+
const at = epochMsToPgTimestamp(this.#clock.now().getTime());
|
|
310
|
+
const payload = new ByteWriter(34)
|
|
311
|
+
.uint8(STANDBY_STATUS)
|
|
312
|
+
.int64(position)
|
|
313
|
+
.int64(position)
|
|
314
|
+
.int64(position)
|
|
315
|
+
.int64(at)
|
|
316
|
+
.uint8(0)
|
|
317
|
+
.finish();
|
|
318
|
+
this.#writing = this.#writing.then(
|
|
319
|
+
() => connection.sendCopyData(payload),
|
|
320
|
+
() => undefined,
|
|
321
|
+
);
|
|
322
|
+
await this.#writing;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The three misconfigurations that produce an unreadable server message if left to the server.
|
|
328
|
+
* `slot` and `publication` are interpolated into simple queries, so the `IDENTIFIER` charset is the
|
|
329
|
+
* injection boundary; re-asserted here rather than trusted, so the guarantee travels with the
|
|
330
|
+
* function instead of living only in `start()`.
|
|
331
|
+
*/
|
|
332
|
+
async function preflight(
|
|
333
|
+
connection: PgConnection,
|
|
334
|
+
slot: string,
|
|
335
|
+
publication: string,
|
|
336
|
+
): Promise<void> {
|
|
337
|
+
assertIdentifier('slot', slot);
|
|
338
|
+
assertIdentifier('publication', publication);
|
|
339
|
+
const [walLevel] = await connection.query('SHOW wal_level');
|
|
340
|
+
if (walLevel?.[0] !== 'logical') {
|
|
341
|
+
throw new ReplicationFailedError({
|
|
342
|
+
stage: 'preflight',
|
|
343
|
+
detail: `wal_level is "${walLevel?.[0] ?? 'unknown'}", so the server writes no logical WAL`,
|
|
344
|
+
fix: "ALTER SYSTEM SET wal_level = 'logical'; -- then restart postgres",
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
const publications = await connection.query(
|
|
348
|
+
`SELECT 1 FROM pg_publication WHERE pubname = '${publication}'`,
|
|
349
|
+
);
|
|
350
|
+
if (publications.length === 0) {
|
|
351
|
+
throw new ReplicationFailedError({
|
|
352
|
+
stage: 'preflight',
|
|
353
|
+
detail: `no publication named "${publication}" exists`,
|
|
354
|
+
fix: `CREATE PUBLICATION ${publication} FOR ALL TABLES;`,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const [existing] = await connection.query(
|
|
358
|
+
`SELECT plugin FROM pg_replication_slots WHERE slot_name = '${slot}'`,
|
|
359
|
+
);
|
|
360
|
+
if (existing === undefined) {
|
|
361
|
+
// Plain SQL rather than CREATE_REPLICATION_SLOT: the replication command exports a snapshot
|
|
362
|
+
// that pins xmin for the session, and its option syntax changed in postgres 15.
|
|
363
|
+
await connection.query(`SELECT pg_create_logical_replication_slot('${slot}', 'pgoutput')`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (existing[0] !== 'pgoutput') {
|
|
367
|
+
throw new ReplicationFailedError({
|
|
368
|
+
stage: 'preflight',
|
|
369
|
+
detail: `slot "${slot}" decodes with "${existing[0] ?? 'unknown'}", not pgoutput`,
|
|
370
|
+
fix: `SELECT pg_drop_replication_slot('${slot}'); -- then start the replicator again`,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** A physical tuple becomes the row the matcher's predicates are written against, or nothing. */
|
|
376
|
+
function toRow(relation: PgRelation, physical: JsonObject | null): Row | null {
|
|
377
|
+
if (physical === null) return null;
|
|
378
|
+
const row = entityRow(physical);
|
|
379
|
+
// A bigserial id decodes as a number inside `Number.isSafeInteger` range and as text outside it,
|
|
380
|
+
// so the same table would otherwise identify small rows by number and large ones by string.
|
|
381
|
+
// `Row.id`, `RowPatch.id` and every cursor are text: the identity is normalised once, here.
|
|
382
|
+
const id = row['id'];
|
|
383
|
+
if (typeof id === 'number' && Number.isSafeInteger(id)) row['id'] = String(id);
|
|
384
|
+
if (isRow(row)) return row;
|
|
385
|
+
throw new ReplicationProtocolError({
|
|
386
|
+
stage: 'stream',
|
|
387
|
+
detail: `table "${relation.name}" replicated a row with no text id column`,
|
|
388
|
+
fix: `give ${relation.name} an id column, or drop it from the publication and the entity list`,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The tenant, hoisted out of the row so fanout filters without parsing it. */
|
|
393
|
+
const tenantOf = (row: Row | null): string | null => {
|
|
394
|
+
const orgId = row?.['orgId'];
|
|
395
|
+
return typeof orgId === 'string' ? orgId : null;
|
|
396
|
+
};
|