@ultimat3/realtime 22.15.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 +89 -102
- package/README.md +140 -20
- 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 +34 -4
- 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-contract.ts +6 -2
- package/src/live-definition.ts +46 -11
- package/src/live-fanout.ts +70 -11
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +38 -0
- package/src/live-query.ts +119 -91
- package/src/live-refusal.ts +21 -0
- package/src/live-replicator.ts +6 -6
- package/src/live-resume.ts +74 -0
- package/src/live-tenant.ts +25 -0
- package/src/nats-client.ts +11 -2
- package/src/nats-open.ts +9 -0
- package/src/nats-transport.ts +23 -1
- 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 +27 -2
- 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 +28 -7
- 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 +11 -7
- 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/pg-connection.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// queries, and the CopyBoth switch that a replication stream lives inside. It speaks only in
|
|
3
3
|
// messages, never in bytes on a socket, so the whole handshake is driven by hand in the tests.
|
|
4
4
|
|
|
5
|
-
import { logger } from '@ultimat3/core';
|
|
5
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
6
6
|
import { ReplicationFailedError, ReplicationProtocolError } from './errors';
|
|
7
7
|
import {
|
|
8
8
|
chooseMechanism,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
scramSession,
|
|
13
13
|
} from './pg-auth';
|
|
14
14
|
import { ByteReader } from './pg-bytes';
|
|
15
|
+
import { answersWeakAuth, type SslMode } from './pg-tls';
|
|
15
16
|
import {
|
|
16
17
|
copyDoneMessage,
|
|
17
18
|
frame,
|
|
@@ -42,6 +43,11 @@ export interface PgConnectionOptions {
|
|
|
42
43
|
readonly applicationName?: string | undefined;
|
|
43
44
|
/** Injected so the SCRAM nonce is deterministic under a seeded test. */
|
|
44
45
|
readonly rng?: Rng | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* The `sslmode` the stream was dialled under — what decides whether a cleartext or md5 password
|
|
48
|
+
* request is answered (`answersWeakAuth`). Unstated is `prefer`, which refuses both.
|
|
49
|
+
*/
|
|
50
|
+
readonly ssl?: SslMode | undefined;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/** A result set as text, exactly as the wire carries it. `null` is SQL NULL, never `''`. */
|
|
@@ -61,12 +67,30 @@ const needPassword = (method: string): ReplicationFailedError =>
|
|
|
61
67
|
fix: 'put the credentials in the URL: postgres://user:password@host:5432/db',
|
|
62
68
|
});
|
|
63
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The server asked for the password in a form the network can use, on a session nothing
|
|
72
|
+
* guarantees is encrypted. Refused before a byte of it is written.
|
|
73
|
+
*/
|
|
74
|
+
const weakAuthRefused = (method: string, ssl: SslMode | undefined): ReplicationFailedError =>
|
|
75
|
+
new ReplicationFailedError({
|
|
76
|
+
stage: 'auth',
|
|
77
|
+
detail:
|
|
78
|
+
`the server asked for ${method} and sslmode=${ssl ?? 'prefer'} does not guarantee an ` +
|
|
79
|
+
'encrypted session — connect with ?sslmode=require (or verify-full), move the role to ' +
|
|
80
|
+
'scram-sha-256, or state ?sslmode=disable to accept a cleartext session',
|
|
81
|
+
fix: 'x doctor --json',
|
|
82
|
+
});
|
|
83
|
+
|
|
64
84
|
export class PgConnection {
|
|
65
85
|
readonly #stream: PgStream;
|
|
66
86
|
readonly #reader: MessageReader;
|
|
67
87
|
readonly #parameters = new Map<string, string>();
|
|
68
88
|
#copyBoth = false;
|
|
69
89
|
#closed = false;
|
|
90
|
+
/** The read parked on an idle session, if one is — see `watchIdle`. A query takes it over. */
|
|
91
|
+
#idle: Promise<PgMessage | undefined> | undefined;
|
|
92
|
+
#onIdleEnd: ((reason: string) => void) | undefined;
|
|
93
|
+
#querying = false;
|
|
70
94
|
|
|
71
95
|
private constructor(stream: PgStream) {
|
|
72
96
|
this.#stream = stream;
|
|
@@ -123,26 +147,82 @@ export class PgConnection {
|
|
|
123
147
|
|
|
124
148
|
/** One simple query. Returns the rows as text; a `CommandComplete` with no rows returns `[]`. */
|
|
125
149
|
async query(sql: string): Promise<PgRows> {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
150
|
+
this.#querying = true;
|
|
151
|
+
try {
|
|
152
|
+
await this.#stream.write(queryMessage(sql));
|
|
153
|
+
const rows: (readonly (string | null)[])[] = [];
|
|
154
|
+
for (;;) {
|
|
155
|
+
const message = await this.#expect('query');
|
|
156
|
+
switch (message.tag) {
|
|
157
|
+
case 'D':
|
|
158
|
+
rows.push(dataRow(message.body));
|
|
159
|
+
break;
|
|
160
|
+
case 'E':
|
|
161
|
+
// Drain to `ReadyForQuery` first: leaving the session mid-result desynchronises reuse.
|
|
162
|
+
await this.#drainToReady();
|
|
163
|
+
throw serverError('query', message.body);
|
|
164
|
+
case 'Z':
|
|
165
|
+
return rows;
|
|
166
|
+
default:
|
|
167
|
+
this.#note(message);
|
|
168
|
+
}
|
|
142
169
|
}
|
|
170
|
+
} finally {
|
|
171
|
+
this.#querying = false;
|
|
172
|
+
this.#park();
|
|
143
173
|
}
|
|
144
174
|
}
|
|
145
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Watch a session that is doing NOTHING. An idle connection has no reader, so a server that
|
|
178
|
+
* ended it — a failover, `pg_terminate_backend`, a dropped socket — was noticed by the next
|
|
179
|
+
* statement, which for a session held only for its advisory lock is never. This parks one read
|
|
180
|
+
* between queries: EOF, a read error or an ErrorResponse calls `onEnd` once, with why.
|
|
181
|
+
*
|
|
182
|
+
* Never for `close()`. A `query()` takes the parked read over rather than racing it — the stream
|
|
183
|
+
* allows one reader — and the watch resumes when the query is done.
|
|
184
|
+
*/
|
|
185
|
+
watchIdle(onEnd: (reason: string) => void): void {
|
|
186
|
+
this.#onIdleEnd = onEnd;
|
|
187
|
+
this.#park();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#park(): void {
|
|
191
|
+
if (this.#closed || this.#querying || this.#copyBoth) return;
|
|
192
|
+
if (this.#onIdleEnd === undefined || this.#idle !== undefined) return;
|
|
193
|
+
const parked = this.#reader.next();
|
|
194
|
+
this.#idle = parked;
|
|
195
|
+
const ended = (reason: string): void => {
|
|
196
|
+
const onEnd = this.#onIdleEnd;
|
|
197
|
+
this.#onIdleEnd = undefined;
|
|
198
|
+
if (!this.#closed) onEnd?.(reason);
|
|
199
|
+
};
|
|
200
|
+
parked.then(
|
|
201
|
+
(message) => {
|
|
202
|
+
// A query took this read over: the message is its answer, not this watch's.
|
|
203
|
+
if (this.#idle !== parked) return;
|
|
204
|
+
this.#idle = undefined;
|
|
205
|
+
if (message === undefined) return ended('the server closed the connection');
|
|
206
|
+
if (message.tag === 'E') return ended(renderThrowable(serverError('idle', message.body)));
|
|
207
|
+
this.#note(message);
|
|
208
|
+
this.#park();
|
|
209
|
+
},
|
|
210
|
+
(failure: unknown) => {
|
|
211
|
+
if (this.#idle !== parked) return;
|
|
212
|
+
this.#idle = undefined;
|
|
213
|
+
ended(renderThrowable(failure));
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The next message: the read an idle watch already parked, when there is one. */
|
|
219
|
+
#next(): Promise<PgMessage | undefined> {
|
|
220
|
+
const parked = this.#idle;
|
|
221
|
+
if (parked === undefined) return this.#reader.next();
|
|
222
|
+
this.#idle = undefined;
|
|
223
|
+
return parked;
|
|
224
|
+
}
|
|
225
|
+
|
|
146
226
|
/**
|
|
147
227
|
* `START_REPLICATION` — the connection stops being request/response and becomes a duplex copy
|
|
148
228
|
* stream. There is no way back short of closing it, which is why this is one-way on purpose.
|
|
@@ -205,6 +285,18 @@ export class PgConnection {
|
|
|
205
285
|
this.#stream.close();
|
|
206
286
|
}
|
|
207
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Close the socket and say nothing — no `Terminate`, nothing awaited. For a session that may
|
|
290
|
+
* already be dead: `close()` writes a goodbye first, and a write to a black-holed socket is a
|
|
291
|
+
* promise nothing settles. The server ends the session when the socket goes, which releases
|
|
292
|
+
* whatever the session held. Never reported to an idle watch.
|
|
293
|
+
*/
|
|
294
|
+
destroy(): void {
|
|
295
|
+
if (this.#closed) return;
|
|
296
|
+
this.#closed = true;
|
|
297
|
+
closeQuietly(this.#stream);
|
|
298
|
+
}
|
|
299
|
+
|
|
208
300
|
async #authenticate(options: PgConnectionOptions): Promise<void> {
|
|
209
301
|
let scram: ScramSession | undefined;
|
|
210
302
|
for (;;) {
|
|
@@ -222,12 +314,16 @@ export class PgConnection {
|
|
|
222
314
|
case AUTH_CLEARTEXT: {
|
|
223
315
|
const password = options.password ?? '';
|
|
224
316
|
if (password === '') throw needPassword('a cleartext password');
|
|
317
|
+
if (!answersWeakAuth(options.ssl)) {
|
|
318
|
+
throw weakAuthRefused('a cleartext password', options.ssl);
|
|
319
|
+
}
|
|
225
320
|
await this.#stream.write(passwordMessage(password));
|
|
226
321
|
break;
|
|
227
322
|
}
|
|
228
323
|
case AUTH_MD5: {
|
|
229
324
|
const password = options.password ?? '';
|
|
230
325
|
if (password === '') throw needPassword('an md5 password');
|
|
326
|
+
if (!answersWeakAuth(options.ssl)) throw weakAuthRefused('an md5 password', options.ssl);
|
|
231
327
|
const salt = reader.take(4);
|
|
232
328
|
await this.#stream.write(
|
|
233
329
|
passwordMessage(md5Password({ user: options.user, password, salt })),
|
|
@@ -276,13 +372,13 @@ export class PgConnection {
|
|
|
276
372
|
|
|
277
373
|
async #drainToReady(): Promise<void> {
|
|
278
374
|
for (;;) {
|
|
279
|
-
const message = await this.#
|
|
375
|
+
const message = await this.#next();
|
|
280
376
|
if (message === undefined || message.tag === 'Z') return;
|
|
281
377
|
}
|
|
282
378
|
}
|
|
283
379
|
|
|
284
380
|
async #expect(stage: string): Promise<PgMessage> {
|
|
285
|
-
const message = await this.#
|
|
381
|
+
const message = await this.#next();
|
|
286
382
|
if (message !== undefined) return message;
|
|
287
383
|
throw new ReplicationFailedError({
|
|
288
384
|
stage,
|
|
@@ -315,8 +411,10 @@ export class PgConnection {
|
|
|
315
411
|
const outOfOrder = (what: string): ReplicationProtocolError =>
|
|
316
412
|
new ReplicationProtocolError({
|
|
317
413
|
stage: 'auth',
|
|
318
|
-
detail:
|
|
319
|
-
|
|
414
|
+
detail:
|
|
415
|
+
`the server sent ${what} before it offered a SASL mechanism — the exchange arrived out of ` +
|
|
416
|
+
'order; check for a pooler or proxy between this client and postgres',
|
|
417
|
+
fix: 'x doctor --json',
|
|
320
418
|
});
|
|
321
419
|
|
|
322
420
|
/** A `close()` that throws must not replace the handshake failure that is worth reporting. */
|
package/src/pg-entity-row.ts
CHANGED
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
// pair folded into money by name. So a nullable money column diverged from the repository shape,
|
|
7
7
|
// two plain columns that happened to be called `x_minor`/`x_currency` were folded into a `Money`,
|
|
8
8
|
// and a `.column()` rename arrived under its physical name. One decoder, the entity's, now.
|
|
9
|
+
//
|
|
10
|
+
// A SEALED column is the one place a live row is not the repository's: the log carries the stored
|
|
11
|
+
// string, and nothing here opens it. It is dropped, so a change — on the bus, in the ring, in a
|
|
12
|
+
// patch frame — names no sealed column at all.
|
|
9
13
|
|
|
10
|
-
import { decodeRow, entityForTable } from '@ultimat3/entity';
|
|
14
|
+
import { decodeRow, entityForTable, sealedFields } from '@ultimat3/entity';
|
|
11
15
|
import { ReplicationProtocolError } from './errors';
|
|
12
16
|
import type { PhysicalRow } from './pg-values';
|
|
13
17
|
import type { PgRelation } from './pgoutput';
|
|
@@ -47,5 +51,26 @@ export function entityRow(
|
|
|
47
51
|
});
|
|
48
52
|
}
|
|
49
53
|
const source = image === 'before' ? replicatedOnly(relation, physical) : physical;
|
|
50
|
-
|
|
54
|
+
const row = decodeRow(entity, source) as PhysicalRow;
|
|
55
|
+
// Not opened and not forwarded. A client never receives a sealed column on any other path, so
|
|
56
|
+
// a patch carrying one would be the only frame that does — and a `lookup` column's stored string
|
|
57
|
+
// is the same for equal plaintexts, which tells every subscriber which rows share a value.
|
|
58
|
+
for (const field of sealedFields(entity)) delete row[field.property];
|
|
59
|
+
return row;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The entity properties a decoded `after` image does not carry — what an UPDATE's unchanged TOAST
|
|
64
|
+
* columns become once the tuple is an entity row. Asked only when the tuple said it left columns
|
|
65
|
+
* out, and answered in the row's own vocabulary: a consumer compares these against the properties
|
|
66
|
+
* it projects, and it has never seen a physical column name. A sealed column is never listed: no
|
|
67
|
+
* change names one, so its absence says nothing.
|
|
68
|
+
*/
|
|
69
|
+
export function omittedProperties(relation: PgRelation, row: PhysicalRow): readonly string[] {
|
|
70
|
+
const entity = entityForTable(relation.name);
|
|
71
|
+
if (entity === undefined) return [];
|
|
72
|
+
const sealed = new Set(sealedFields(entity).map((field) => field.property));
|
|
73
|
+
return Object.keys(entity.$columns)
|
|
74
|
+
.filter((property) => !Object.hasOwn(row, property) && !sealed.has(property))
|
|
75
|
+
.sort();
|
|
51
76
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Single responsibility: which replicated tables need `REPLICA IDENTITY FULL` for a reason the
|
|
2
|
+
// live-query path does not have — a channel with params routes a DELETE by columns of the OLD row.
|
|
3
|
+
|
|
4
|
+
import type { Channel } from './channel-decl';
|
|
5
|
+
import { registeredChannels } from './channel-registry';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `records` tables of every channel declared with params, deduped and sorted.
|
|
9
|
+
*
|
|
10
|
+
* A live query never needs FULL on a keyed table: its shared window holds the whole row, so a
|
|
11
|
+
* delete that names only the key is enough. A channel holds no window. Its removal is routed to
|
|
12
|
+
* the topic the old row's params name (`channel-records.ts`), and under the default identity the
|
|
13
|
+
* old image is the key alone — it names no topic, no `remove` is sent, and every member keeps the
|
|
14
|
+
* deleted record. A channel with no params has one topic and needs nothing.
|
|
15
|
+
*/
|
|
16
|
+
export function paramsChannelTables(
|
|
17
|
+
channels: Iterable<Channel> = registeredChannels(),
|
|
18
|
+
): readonly string[] {
|
|
19
|
+
const tables = new Set<string>();
|
|
20
|
+
for (const declared of channels) {
|
|
21
|
+
if (declared.params.length === 0) continue;
|
|
22
|
+
for (const projection of declared.records) tables.add(projection.table);
|
|
23
|
+
}
|
|
24
|
+
return [...tables].sort();
|
|
25
|
+
}
|
package/src/pg-preflight.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Single responsibility: the
|
|
2
|
-
//
|
|
3
|
-
// ensuring it (`pg-publication.ts`), and the
|
|
4
|
-
// on the default replica identity from starting.
|
|
1
|
+
// Single responsibility: the questions asked of a database BEFORE `START_REPLICATION`. Two refuse
|
|
2
|
+
// the boot with the exact statement that fixes them, one — the publication — is answered by
|
|
3
|
+
// ensuring it (`pg-publication.ts`), and the replica-identity ones warn, because refusing would
|
|
4
|
+
// stop every app on the default replica identity from starting.
|
|
5
5
|
|
|
6
6
|
import { logger } from '@ultimat3/core';
|
|
7
7
|
import { ReplicaIdentityError, ReplicationFailedError } from './errors';
|
|
@@ -22,6 +22,7 @@ export async function preflight(
|
|
|
22
22
|
slot: string,
|
|
23
23
|
publication: string,
|
|
24
24
|
entities: ReadonlySet<string>,
|
|
25
|
+
fullIdentity: ReadonlySet<string> = new Set(),
|
|
25
26
|
): Promise<void> {
|
|
26
27
|
assertIdentifier('slot', slot);
|
|
27
28
|
assertIdentifier('publication', publication);
|
|
@@ -37,6 +38,7 @@ export async function preflight(
|
|
|
37
38
|
}
|
|
38
39
|
await ensurePublication(connection, publication, entities);
|
|
39
40
|
await warnPartialIdentity(connection, entities);
|
|
41
|
+
await warnChannelIdentity(connection, entities, fullIdentity);
|
|
40
42
|
const [existing] = await connection.query(
|
|
41
43
|
`SELECT plugin FROM pg_replication_slots WHERE slot_name = '${slot}'`,
|
|
42
44
|
);
|
|
@@ -102,3 +104,47 @@ async function warnPartialIdentity(
|
|
|
102
104
|
// `@ultimat3/http`'s error-map stage follows, so a log index can be alerted on by code.
|
|
103
105
|
logger.warn(warning.code, { cause: warning.cause, fix: warning.fix, tables });
|
|
104
106
|
}
|
|
107
|
+
|
|
108
|
+
/** The log event a params channel's table without FULL identity is reported under. */
|
|
109
|
+
export const CHANNEL_IDENTITY_EVENT = 'replication.channel_identity_partial';
|
|
110
|
+
|
|
111
|
+
const CHANNEL_IDENTITY_FIX = 'x db gen "replica identity full"';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The tables a channel with params carries that are not `REPLICA IDENTITY FULL`. Their DELETE logs
|
|
115
|
+
* the key alone, the channel reads its topic off columns the key does not include, and the removal
|
|
116
|
+
* is sent to nobody: members keep a record that no longer exists, and nothing else says so.
|
|
117
|
+
*
|
|
118
|
+
* WARNED, like its neighbour, and for the same reason. Only names that are also in the entity list
|
|
119
|
+
* are asked about — that list has been through `assertIdentifier`, which is what makes the
|
|
120
|
+
* interpolation safe; a declared table the feed does not decode is not this stream's to judge.
|
|
121
|
+
*/
|
|
122
|
+
async function warnChannelIdentity(
|
|
123
|
+
connection: PgConnection,
|
|
124
|
+
entities: ReadonlySet<string>,
|
|
125
|
+
fullIdentity: ReadonlySet<string>,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const asked = [...fullIdentity].filter((name) => entities.has(name)).sort();
|
|
128
|
+
if (asked.length === 0) return;
|
|
129
|
+
const names = asked.map((name) => `'${name}'`).join(', ');
|
|
130
|
+
const rows = await connection.query(
|
|
131
|
+
`SELECT c.relname FROM pg_class c WHERE c.relkind = 'r' AND c.relname IN (${names}) ` +
|
|
132
|
+
`AND c.relreplident <> 'f'`,
|
|
133
|
+
);
|
|
134
|
+
const tables = [
|
|
135
|
+
...new Set(
|
|
136
|
+
rows
|
|
137
|
+
.map((row) => row[0])
|
|
138
|
+
.filter((name): name is string => typeof name === 'string' && asked.includes(name)),
|
|
139
|
+
),
|
|
140
|
+
].sort();
|
|
141
|
+
if (tables.length === 0) return;
|
|
142
|
+
logger.warn(CHANNEL_IDENTITY_EVENT, {
|
|
143
|
+
cause:
|
|
144
|
+
`${tables.join(', ')} carry a channel declared with params but are not REPLICA IDENTITY ` +
|
|
145
|
+
'FULL, so a DELETE names no topic and its members keep the deleted record',
|
|
146
|
+
// `x db gen` reads the same declarations this warning does and writes the ALTER per table.
|
|
147
|
+
fix: CHANNEL_IDENTITY_FIX,
|
|
148
|
+
tables,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Single responsibility: the two row images of one replicated change — a physical tuple pair
|
|
2
|
+
// becomes the `before`/`after` the matcher's predicates are written against, plus the properties
|
|
3
|
+
// `after` could not carry. Split out of `pg-replication.ts`, which decides ordering and nothing else.
|
|
4
|
+
|
|
5
|
+
import { ReplicationProtocolError } from './errors';
|
|
6
|
+
import { isRow, type Row } from './json';
|
|
7
|
+
import { entityRow, omittedProperties } from './pg-entity-row';
|
|
8
|
+
import type { PhysicalRow } from './pg-values';
|
|
9
|
+
import type { PgRelation } from './pgoutput';
|
|
10
|
+
|
|
11
|
+
export interface ReplicatedImages {
|
|
12
|
+
readonly before: Row | null;
|
|
13
|
+
readonly after: Row | null;
|
|
14
|
+
/** Properties missing from `after` because Postgres logged no value for them. Usually empty. */
|
|
15
|
+
readonly omitted: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An UPDATE that leaves an out-of-line (TOAST) value untouched logs no bytes for it, so the new
|
|
20
|
+
* tuple arrives without that column. Published as it stood, the change read as the whole row: the
|
|
21
|
+
* window adopted it, and every later snapshot served the row with the column gone.
|
|
22
|
+
*
|
|
23
|
+
* Under REPLICA IDENTITY FULL the old tuple carries the value and the column did not change, so it
|
|
24
|
+
* is copied across and nothing is missing. Under any other identity the old tuple is the key alone
|
|
25
|
+
* — its other columns are NULL placeholders, not values — so the column is NAMED instead, and the
|
|
26
|
+
* consumer re-reads the row rather than adopting a partial one.
|
|
27
|
+
*/
|
|
28
|
+
export function replicatedImages(
|
|
29
|
+
relation: PgRelation,
|
|
30
|
+
oldTuple: PhysicalRow | null,
|
|
31
|
+
newTuple: PhysicalRow | null,
|
|
32
|
+
unchanged: readonly string[],
|
|
33
|
+
): ReplicatedImages {
|
|
34
|
+
const before = toRow(relation, oldTuple, 'before');
|
|
35
|
+
if (newTuple === null || unchanged.length === 0) {
|
|
36
|
+
return { before, after: toRow(relation, newTuple, 'after'), omitted: [] };
|
|
37
|
+
}
|
|
38
|
+
// Null-prototype, as the decoder built it: a column name is off the wire.
|
|
39
|
+
const filled: PhysicalRow = Object.assign(Object.create(null) as PhysicalRow, newTuple);
|
|
40
|
+
let missing = 0;
|
|
41
|
+
for (const name of unchanged) {
|
|
42
|
+
if (relation.replicaIdentity === 'f' && oldTuple !== null && Object.hasOwn(oldTuple, name)) {
|
|
43
|
+
const kept = oldTuple[name];
|
|
44
|
+
if (kept !== undefined) filled[name] = kept;
|
|
45
|
+
} else {
|
|
46
|
+
missing += 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const after = toRow(relation, filled, 'after');
|
|
50
|
+
return {
|
|
51
|
+
before,
|
|
52
|
+
after,
|
|
53
|
+
omitted: missing === 0 || after === null ? [] : omittedProperties(relation, after),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A physical tuple becomes the row the matcher's predicates are written against, or nothing. */
|
|
58
|
+
function toRow(
|
|
59
|
+
relation: PgRelation,
|
|
60
|
+
physical: PhysicalRow | null,
|
|
61
|
+
image: 'before' | 'after',
|
|
62
|
+
): Row | null {
|
|
63
|
+
if (physical === null) return null;
|
|
64
|
+
const row = entityRow(relation, physical, image);
|
|
65
|
+
// A bigserial id decodes as a number inside `Number.isSafeInteger` range and as text outside it,
|
|
66
|
+
// so the same table would otherwise identify small rows by number and large ones by string.
|
|
67
|
+
// `Row.id`, `RowPatch.id` and every cursor are text: the identity is normalised once, here.
|
|
68
|
+
const id = row['id'];
|
|
69
|
+
if (typeof id === 'number' && Number.isSafeInteger(id)) row['id'] = String(id);
|
|
70
|
+
if (isRow(row)) return row;
|
|
71
|
+
throw new ReplicationProtocolError({
|
|
72
|
+
stage: 'stream',
|
|
73
|
+
detail: `table "${relation.name}" replicated a row with no text id column`,
|
|
74
|
+
fix: `give ${relation.name} an id column, or drop it from the publication and the entity list`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The tenant, hoisted out of the row so fanout filters without parsing it. */
|
|
79
|
+
export const tenantOf = (row: Row | null): string | null => {
|
|
80
|
+
const orgId = row?.['orgId'];
|
|
81
|
+
return typeof orgId === 'string' ? orgId : null;
|
|
82
|
+
};
|
package/src/pg-replication.ts
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
import { type Clock, finiteOption, logger, renderThrowable, systemClock } from '@ultimat3/core';
|
|
7
7
|
import type { ChangeEvent, ChangeOp, PgLogicalReplicationOptions } from './changefeed';
|
|
8
8
|
import { ReplicationProtocolError } from './errors';
|
|
9
|
-
import { isRow, type Row } from './json';
|
|
10
9
|
import { ByteReader, ByteWriter, epochMsToPgTimestamp, printLsn } from './pg-bytes';
|
|
11
10
|
import { PgConnection } from './pg-connection';
|
|
12
|
-
import { entityRow } from './pg-entity-row';
|
|
13
11
|
import { assertIdentifier } from './pg-identifier';
|
|
12
|
+
import { paramsChannelTables } from './pg-identity-tables';
|
|
14
13
|
import { preflight } from './pg-preflight';
|
|
14
|
+
import { replicatedImages, tenantOf } from './pg-replication-row';
|
|
15
15
|
import { bunPgStream, parsePgUrl } from './pg-socket';
|
|
16
16
|
import type { PhysicalRow } from './pg-values';
|
|
17
17
|
import { keyedWrite, PgOutputDecoder, type PgOutputMessage, type PgRelation } from './pgoutput';
|
|
@@ -52,10 +52,7 @@ export interface ReplicationStreamStats {
|
|
|
52
52
|
* reporting it. Reset by the first confirm that lands.
|
|
53
53
|
*/
|
|
54
54
|
readonly confirmFailures: number;
|
|
55
|
-
/**
|
|
56
|
-
* Why the pump stopped, or `null` while it is live. The read loop cannot throw into a caller —
|
|
57
|
-
* nothing awaits it — so this is the one place `/readyz` and a test can see that it died at all.
|
|
58
|
-
*/
|
|
55
|
+
/** Why the pump stopped, or `null` while live. Recorded here and ANNOUNCED through `onEnd`. */
|
|
59
56
|
readonly failure: string | null;
|
|
60
57
|
}
|
|
61
58
|
|
|
@@ -88,6 +85,8 @@ export const commitPositionOf = (lsn: string): bigint => BigInt(`0x${lsn.slice(0
|
|
|
88
85
|
export interface ReplicationStreamHandlers {
|
|
89
86
|
readonly from?: string | undefined;
|
|
90
87
|
onChange(event: ChangeEvent): void | Promise<void>;
|
|
88
|
+
/** Called once when the pump dies on its own; never for a `stop()`. */
|
|
89
|
+
readonly onEnd?: ((reason: string) => void) | undefined;
|
|
91
90
|
}
|
|
92
91
|
|
|
93
92
|
/**
|
|
@@ -115,6 +114,7 @@ export class PgReplicationStream {
|
|
|
115
114
|
#partialBefore = 0;
|
|
116
115
|
#confirmFailures = 0;
|
|
117
116
|
#failure: string | null = null;
|
|
117
|
+
#onEnd: ((reason: string) => void) | undefined;
|
|
118
118
|
|
|
119
119
|
constructor(options: PgLogicalReplicationOptions) {
|
|
120
120
|
this.#options = options;
|
|
@@ -166,10 +166,12 @@ export class PgReplicationStream {
|
|
|
166
166
|
replication: 'database',
|
|
167
167
|
applicationName: `ultimate-replicator:${slot}`,
|
|
168
168
|
rng: this.#options.rng,
|
|
169
|
+
ssl: target.ssl,
|
|
169
170
|
});
|
|
170
171
|
this.#connection = connection;
|
|
171
172
|
try {
|
|
172
|
-
|
|
173
|
+
const full = this.#options.fullIdentityTables ?? paramsChannelTables();
|
|
174
|
+
await preflight(connection, slot, publication, this.#entities, new Set(full));
|
|
173
175
|
const from = handlers.from;
|
|
174
176
|
this.#confirmed = from === undefined ? 0n : commitPositionOf(from);
|
|
175
177
|
await connection.startCopyBoth(
|
|
@@ -183,10 +185,10 @@ export class PgReplicationStream {
|
|
|
183
185
|
throw failure;
|
|
184
186
|
}
|
|
185
187
|
this.#running = true;
|
|
186
|
-
// A restart that kept the last death in `stats()`
|
|
187
|
-
// supervisor that reads it never sees the replicator come back.
|
|
188
|
+
// A restart that kept the last death in `stats()` would report a live stream as failed.
|
|
188
189
|
this.#failure = null;
|
|
189
190
|
this.#confirmFailures = 0;
|
|
191
|
+
this.#onEnd = handlers.onEnd;
|
|
190
192
|
this.#timer = setInterval(
|
|
191
193
|
() => {
|
|
192
194
|
void this.#confirmOnTimer();
|
|
@@ -241,6 +243,20 @@ export class PgReplicationStream {
|
|
|
241
243
|
if (failures.length > 0) throw failures[0];
|
|
242
244
|
}
|
|
243
245
|
|
|
246
|
+
/**
|
|
247
|
+
* `stop()` without the goodbye: no confirm, no `CopyDone`, no `Terminate`, nothing awaited — on a
|
|
248
|
+
* black-holed walsender each of those is a write nothing settles. The unconfirmed tail is
|
|
249
|
+
* re-sent to the next stream and dropped by its `from`.
|
|
250
|
+
*/
|
|
251
|
+
abandon(): void {
|
|
252
|
+
this.#running = false;
|
|
253
|
+
this.#onEnd = undefined;
|
|
254
|
+
this.#clearTimer();
|
|
255
|
+
const connection = this.#connection;
|
|
256
|
+
this.#connection = null;
|
|
257
|
+
connection?.destroy();
|
|
258
|
+
}
|
|
259
|
+
|
|
244
260
|
/**
|
|
245
261
|
* The pump's only way out, however it ended — a decode error, or a walsender that said goodbye.
|
|
246
262
|
* The four things it owns go together or not at all, because each one left behind is a dead
|
|
@@ -256,8 +272,7 @@ export class PgReplicationStream {
|
|
|
256
272
|
this.#clearTimer();
|
|
257
273
|
const connection = this.#connection;
|
|
258
274
|
this.#connection = null;
|
|
259
|
-
// The
|
|
260
|
-
// into a promise nothing awaits.
|
|
275
|
+
// The loop records, reports and ends rather than throwing into a promise nothing awaits.
|
|
261
276
|
logger.error('replication stream ended', { slot: this.#options.slot, error: reason });
|
|
262
277
|
try {
|
|
263
278
|
await connection?.close();
|
|
@@ -265,6 +280,14 @@ export class PgReplicationStream {
|
|
|
265
280
|
// The socket is already unusable and `failure` above is the report that matters — a
|
|
266
281
|
// goodbye that throws must not become the rejection `#drain` promised never to produce.
|
|
267
282
|
}
|
|
283
|
+
// Last, so whoever restarts finds the socket already let go. Called, never awaited.
|
|
284
|
+
const onEnd = this.#onEnd;
|
|
285
|
+
this.#onEnd = undefined;
|
|
286
|
+
try {
|
|
287
|
+
onEnd?.(reason);
|
|
288
|
+
} catch (failure) {
|
|
289
|
+
logger.error('replication onEnd listener threw', { error: renderThrowable(failure) });
|
|
290
|
+
}
|
|
268
291
|
}
|
|
269
292
|
|
|
270
293
|
#clearTimer(): void {
|
|
@@ -330,19 +353,21 @@ export class PgReplicationStream {
|
|
|
330
353
|
if (message.endLsn > this.#confirmed) this.#confirmed = message.endLsn;
|
|
331
354
|
return;
|
|
332
355
|
case 'insert':
|
|
333
|
-
await this.#deliver('insert', message.relation, null, message.after, handlers);
|
|
356
|
+
await this.#deliver('insert', message.relation, null, message.after, [], handlers);
|
|
334
357
|
return;
|
|
335
|
-
case 'update':
|
|
336
|
-
|
|
358
|
+
case 'update': {
|
|
359
|
+
const { relation, before, after, unchanged } = message;
|
|
360
|
+
await this.#deliver('update', relation, before, after, unchanged, handlers);
|
|
337
361
|
return;
|
|
362
|
+
}
|
|
338
363
|
case 'delete':
|
|
339
|
-
await this.#deliver('delete', message.relation, message.before, null, handlers);
|
|
364
|
+
await this.#deliver('delete', message.relation, message.before, null, [], handlers);
|
|
340
365
|
return;
|
|
341
366
|
case 'truncate':
|
|
342
367
|
// One change per truncated relation, rowless. It was decoded and DROPPED here, and with the
|
|
343
368
|
// recommended `FOR ALL TABLES` publication every window and every client kept the rows.
|
|
344
369
|
for (const relation of message.relations) {
|
|
345
|
-
await this.#deliver('truncate', relation, null, null, handlers);
|
|
370
|
+
await this.#deliver('truncate', relation, null, null, [], handlers);
|
|
346
371
|
}
|
|
347
372
|
return;
|
|
348
373
|
default:
|
|
@@ -356,6 +381,7 @@ export class PgReplicationStream {
|
|
|
356
381
|
relation: PgRelation,
|
|
357
382
|
oldTuple: PhysicalRow | null,
|
|
358
383
|
newTuple: PhysicalRow | null,
|
|
384
|
+
unchanged: readonly string[],
|
|
359
385
|
handlers: ReplicationStreamHandlers,
|
|
360
386
|
): Promise<void> {
|
|
361
387
|
const transaction = this.#transaction;
|
|
@@ -386,8 +412,7 @@ export class PgReplicationStream {
|
|
|
386
412
|
if ((op === 'update' || op === 'delete') && relation.replicaIdentity !== 'f') {
|
|
387
413
|
this.#partialBefore += 1;
|
|
388
414
|
}
|
|
389
|
-
const before =
|
|
390
|
-
const after = toRow(relation, newTuple, 'after');
|
|
415
|
+
const { before, after, omitted } = replicatedImages(relation, oldTuple, newTuple, unchanged);
|
|
391
416
|
const event: ChangeEvent = {
|
|
392
417
|
entity: relation.name,
|
|
393
418
|
op,
|
|
@@ -398,6 +423,7 @@ export class PgReplicationStream {
|
|
|
398
423
|
orgId: tenantOf(after ?? before),
|
|
399
424
|
at: transaction.commitAt,
|
|
400
425
|
...(transaction.write === undefined ? {} : { write: transaction.write }),
|
|
426
|
+
...(omitted.length === 0 ? {} : { omitted }),
|
|
401
427
|
};
|
|
402
428
|
await handlers.onChange(event);
|
|
403
429
|
this.#lastLsn = lsn;
|
|
@@ -470,30 +496,3 @@ export class PgReplicationStream {
|
|
|
470
496
|
await attempt;
|
|
471
497
|
}
|
|
472
498
|
}
|
|
473
|
-
|
|
474
|
-
/** A physical tuple becomes the row the matcher's predicates are written against, or nothing. */
|
|
475
|
-
function toRow(
|
|
476
|
-
relation: PgRelation,
|
|
477
|
-
physical: PhysicalRow | null,
|
|
478
|
-
image: 'before' | 'after',
|
|
479
|
-
): Row | null {
|
|
480
|
-
if (physical === null) return null;
|
|
481
|
-
const row = entityRow(relation, physical, image);
|
|
482
|
-
// A bigserial id decodes as a number inside `Number.isSafeInteger` range and as text outside it,
|
|
483
|
-
// so the same table would otherwise identify small rows by number and large ones by string.
|
|
484
|
-
// `Row.id`, `RowPatch.id` and every cursor are text: the identity is normalised once, here.
|
|
485
|
-
const id = row['id'];
|
|
486
|
-
if (typeof id === 'number' && Number.isSafeInteger(id)) row['id'] = String(id);
|
|
487
|
-
if (isRow(row)) return row;
|
|
488
|
-
throw new ReplicationProtocolError({
|
|
489
|
-
stage: 'stream',
|
|
490
|
-
detail: `table "${relation.name}" replicated a row with no text id column`,
|
|
491
|
-
fix: `give ${relation.name} an id column, or drop it from the publication and the entity list`,
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/** The tenant, hoisted out of the row so fanout filters without parsing it. */
|
|
496
|
-
const tenantOf = (row: Row | null): string | null => {
|
|
497
|
-
const orgId = row?.['orgId'];
|
|
498
|
-
return typeof orgId === 'string' ? orgId : null;
|
|
499
|
-
};
|
package/src/pg-socket.ts
CHANGED
|
@@ -189,8 +189,8 @@ export async function pgStreamOver(runtime: BunConnect, target: PgTarget): Promi
|
|
|
189
189
|
const connectFailure = (error: Error): ReplicationFailedError =>
|
|
190
190
|
new ReplicationFailedError({
|
|
191
191
|
stage: 'connect',
|
|
192
|
-
detail: error.message
|
|
193
|
-
fix:
|
|
192
|
+
detail: `${error.message} — no route to ${target.host}:${target.port} from this process`,
|
|
193
|
+
fix: 'x doctor --json',
|
|
194
194
|
});
|
|
195
195
|
|
|
196
196
|
const handlers: SocketHandlers = {
|
|
@@ -229,8 +229,10 @@ export async function pgStreamOver(runtime: BunConnect, target: PgTarget): Promi
|
|
|
229
229
|
if (written < 0) {
|
|
230
230
|
throw new ReplicationFailedError({
|
|
231
231
|
stage: 'write',
|
|
232
|
-
detail:
|
|
233
|
-
|
|
232
|
+
detail:
|
|
233
|
+
`the socket refused a ${rest.length}-byte write — the replicator reconnects on its ` +
|
|
234
|
+
'own once the host is back',
|
|
235
|
+
fix: 'x doctor --json',
|
|
234
236
|
});
|
|
235
237
|
}
|
|
236
238
|
if (written > 0) rest = rest.subarray(written);
|