@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/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);
|
package/src/pg-tls.ts
CHANGED
|
@@ -62,6 +62,24 @@ export function parseSsl(params: URLSearchParams): SslSettings {
|
|
|
62
62
|
/** Whether a mode asks the runtime for the verification at all. */
|
|
63
63
|
export const verifies = (ssl: SslMode): boolean => ssl === 'verify-ca' || ssl === 'verify-full';
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Whether a cleartext or md5 password request is answered. Cleartext IS the password; md5 is
|
|
67
|
+
* replayable. What each mode actually protects against:
|
|
68
|
+
*
|
|
69
|
+
* - `verify-ca` / `verify-full`: the session is encrypted to a server whose certificate was
|
|
70
|
+
* checked. The only modes that stop an ON-PATH attacker.
|
|
71
|
+
* - `require`: encrypted, to whoever answered. No certificate is verified, so this stops a PASSIVE
|
|
72
|
+
* listener and nothing else — an attacker on the path can terminate the TLS session itself and
|
|
73
|
+
* ask for the password. Answered anyway: it is libpq's behaviour, and managed databases whose
|
|
74
|
+
* CA an app does not hold depend on it. An operator who needs more names a `verify-*` mode.
|
|
75
|
+
* - `disable`: the operator SAYING the session is cleartext — the opt-in.
|
|
76
|
+
* - `allow` / `prefer` (the default, and what an unstated mode is): refused. They do not even
|
|
77
|
+
* promise encryption — a stripped upgrade falls back to cleartext without a word — so they stop
|
|
78
|
+
* neither kind of attacker, and nobody chose that.
|
|
79
|
+
*/
|
|
80
|
+
export const answersWeakAuth = (ssl: SslMode | undefined): boolean =>
|
|
81
|
+
ssl === 'disable' || ssl === 'require' || (ssl !== undefined && verifies(ssl));
|
|
82
|
+
|
|
65
83
|
/** OpenSSL's code for a certificate whose chain verified but whose names do not include the host. */
|
|
66
84
|
const HOST_MISMATCH = 'ERR_TLS_CERT_ALTNAME_INVALID';
|
|
67
85
|
|
package/src/pg-wire.ts
CHANGED
|
@@ -69,8 +69,10 @@ export class MessageReader {
|
|
|
69
69
|
if (held === 0) return undefined;
|
|
70
70
|
throw new ReplicationProtocolError({
|
|
71
71
|
stage: 'read',
|
|
72
|
-
detail:
|
|
73
|
-
|
|
72
|
+
detail:
|
|
73
|
+
`the connection closed with ${held} bytes of a partial message — the backend was ` +
|
|
74
|
+
'terminated mid-message, and the server log names the reason',
|
|
75
|
+
fix: 'x doctor --json',
|
|
74
76
|
});
|
|
75
77
|
}
|
|
76
78
|
this.#append(chunk);
|
|
@@ -245,7 +247,7 @@ export const FIXES: Readonly<Record<string, string>> = {
|
|
|
245
247
|
};
|
|
246
248
|
|
|
247
249
|
/** What a SQLSTATE this table has no entry for is answered with. */
|
|
248
|
-
const GENERIC_FIX = 'x doctor
|
|
250
|
+
const GENERIC_FIX = 'x doctor --json';
|
|
249
251
|
|
|
250
252
|
/**
|
|
251
253
|
* An `ErrorResponse` becomes the one error class whose `fix` names the command to run.
|
package/src/pgoutput.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
// Decodes pgoutput logical-replication messages (protocol version 1
|
|
2
|
-
//
|
|
3
|
-
// per-connection relation cache
|
|
4
|
-
//
|
|
5
|
-
// What a tuple's TEXT means is `pg-values.ts`'s: this file frames messages, that one owns the type
|
|
6
|
-
// catalogue that turns postgres' text into the value a repository row holds.
|
|
1
|
+
// Decodes pgoutput logical-replication messages (protocol version 1) into typed PgOutputMessage
|
|
2
|
+
// values; the stream asks `messages 'true'`, which needs Postgres >= 14. Pure byte decoding, no
|
|
3
|
+
// I/O: a decoder owns the per-connection relation cache later tuples reference by oid. What a
|
|
4
|
+
// tuple's TEXT means is `pg-values.ts`'s — this file frames messages, that one owns the types.
|
|
7
5
|
|
|
8
6
|
import { isWriteDigest, WRITE_ORIGIN_WAL_PREFIX } from '@ultimat3/core';
|
|
9
7
|
import { ReplicationProtocolError } from './errors';
|
|
@@ -48,6 +46,12 @@ export type PgOutputMessage =
|
|
|
48
46
|
readonly relation: PgRelation;
|
|
49
47
|
readonly before: PhysicalRow | null;
|
|
50
48
|
readonly after: PhysicalRow;
|
|
49
|
+
/**
|
|
50
|
+
* Physical columns the new tuple sent as `'u'`: an out-of-line (TOAST) value the UPDATE did
|
|
51
|
+
* not touch, for which Postgres logs no bytes. They are absent from `after`, and this list is
|
|
52
|
+
* what tells "absent" apart from "the whole row".
|
|
53
|
+
*/
|
|
54
|
+
readonly unchanged: readonly string[];
|
|
51
55
|
}
|
|
52
56
|
| { readonly kind: 'delete'; readonly relation: PgRelation; readonly before: PhysicalRow }
|
|
53
57
|
| { readonly kind: 'truncate'; readonly relations: readonly PgRelation[] }
|
|
@@ -67,7 +71,11 @@ export type PgOutputMessage =
|
|
|
67
71
|
* `'u'` standing in for the columns a key-only tuple leaves out — so the column count always
|
|
68
72
|
* matches the relation, and only the per-column byte tells us whether a value is actually there.
|
|
69
73
|
*/
|
|
70
|
-
function decodeTupleData(
|
|
74
|
+
function decodeTupleData(
|
|
75
|
+
reader: ByteReader,
|
|
76
|
+
relation: PgRelation,
|
|
77
|
+
unchanged?: string[],
|
|
78
|
+
): PhysicalRow {
|
|
71
79
|
const count = reader.int16();
|
|
72
80
|
if (count !== relation.columns.length) {
|
|
73
81
|
throw new ReplicationProtocolError({
|
|
@@ -90,6 +98,7 @@ function decodeTupleData(reader: ByteReader, relation: PgRelation): PhysicalRow
|
|
|
90
98
|
if (kind === 'u') {
|
|
91
99
|
// Omitted, not nulled: "unchanged" and "set to null" are different facts about the row,
|
|
92
100
|
// and a missing key is the only encoding that keeps those two facts distinguishable.
|
|
101
|
+
unchanged?.push(column.name);
|
|
93
102
|
continue;
|
|
94
103
|
}
|
|
95
104
|
if (kind === 't') {
|
|
@@ -245,8 +254,9 @@ export class PgOutputDecoder {
|
|
|
245
254
|
detail: `expected the new-tuple marker "N" but got "${marker}"`,
|
|
246
255
|
});
|
|
247
256
|
}
|
|
248
|
-
const
|
|
249
|
-
|
|
257
|
+
const unchanged: string[] = [];
|
|
258
|
+
const after = decodeTupleData(reader, relation, unchanged);
|
|
259
|
+
return { kind: 'update', relation, before, after, unchanged };
|
|
250
260
|
}
|
|
251
261
|
|
|
252
262
|
#decodeDelete(reader: ByteReader): PgOutputMessage {
|
package/src/presence.ts
CHANGED
|
@@ -25,6 +25,13 @@ export const PRESENCE_SWEEP_PREFIX = 'presence.sweep';
|
|
|
25
25
|
*/
|
|
26
26
|
export const DEFAULT_MAX_PRESENCE_MEMBERS = 256;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Member ids the sweep leader ships on its lease, for the other nodes to record. Bounded because
|
|
30
|
+
* the lease is one bus message: past it, a member is known to the leader alone, as every member
|
|
31
|
+
* was before the lease carried a roster.
|
|
32
|
+
*/
|
|
33
|
+
export const PRESENCE_LEASE_ROSTER_LIMIT = 4_096;
|
|
34
|
+
|
|
28
35
|
export interface PresenceOptions {
|
|
29
36
|
readonly transport: Transport;
|
|
30
37
|
/** Optional: without a hub, presence is queryable but silent (no join/leave frames). */
|
|
@@ -65,6 +72,10 @@ export class PresenceRegistry {
|
|
|
65
72
|
readonly #nodeId: string;
|
|
66
73
|
/** Diffing cache only — the truth is always `transport.shared`. Safe to lose. */
|
|
67
74
|
readonly #seen = new Map<string, Set<string>>();
|
|
75
|
+
/** Per topic this node LEADS: the roster on its lease, as written. */
|
|
76
|
+
readonly #shipped = new Map<string, string>();
|
|
77
|
+
/** Per topic this node FOLLOWS: the ids the leader's lease last named. */
|
|
78
|
+
readonly #heard = new Map<string, ReadonlySet<string>>();
|
|
68
79
|
|
|
69
80
|
constructor(options: PresenceOptions) {
|
|
70
81
|
this.#transport = options.transport;
|
|
@@ -100,7 +111,10 @@ export class PresenceRegistry {
|
|
|
100
111
|
return await this.roster(name);
|
|
101
112
|
}
|
|
102
113
|
|
|
103
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* A member's beat: its TTL renewed, nothing announced and nothing read. `false` means the member
|
|
116
|
+
* had already expired: the caller must `join` again, not `heartbeat`.
|
|
117
|
+
*/
|
|
104
118
|
async heartbeat(name: Topic, id: string): Promise<boolean> {
|
|
105
119
|
const alive = await this.#transport.shared.touch(this.#key(name), id, this.#ttlMs);
|
|
106
120
|
if (!alive) this.#track(name).delete(id);
|
|
@@ -190,12 +204,18 @@ export class PresenceRegistry {
|
|
|
190
204
|
// A room nobody is in is not a room. Without this the cache keeps one entry per topic ever
|
|
191
205
|
// subscribed to, for the life of the process, and the sweep walks all of them forever.
|
|
192
206
|
if ((this.#seen.get(name)?.size ?? 0) === 0) {
|
|
193
|
-
this.#
|
|
207
|
+
this.#forget(name);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const lease = await this.#claimSweep(name);
|
|
211
|
+
if (!lease.leads) {
|
|
212
|
+
this.#record(name, lease.roster);
|
|
194
213
|
continue;
|
|
195
214
|
}
|
|
196
|
-
|
|
215
|
+
this.#heard.delete(name);
|
|
197
216
|
gone.push(...(await this.sweep(name)));
|
|
198
|
-
|
|
217
|
+
await this.#ship(name);
|
|
218
|
+
if ((this.#seen.get(name)?.size ?? 0) === 0) this.#forget(name);
|
|
199
219
|
}
|
|
200
220
|
return gone;
|
|
201
221
|
}
|
|
@@ -211,14 +231,59 @@ export class PresenceRegistry {
|
|
|
211
231
|
* different views is a duplicate `leave` for a member who has already gone — which is what a
|
|
212
232
|
* `leave` frame means anyway. What it never produces is nobody sweeping: a claim is re-put every
|
|
213
233
|
* pass, and a dead leader's expires within one TTL.
|
|
234
|
+
*
|
|
235
|
+
* The leader's claim CARRIES the ids it last saw live, and every follower reads it in the read
|
|
236
|
+
* the election already makes. That is what a takeover diffs against: a leader that dies takes
|
|
237
|
+
* its own members with it, and a successor that had skipped every sweep held nothing but its own
|
|
238
|
+
* — so their leaves were never announced, to anyone, until each client reconnected.
|
|
214
239
|
*/
|
|
215
|
-
async #claimSweep(
|
|
240
|
+
async #claimSweep(
|
|
241
|
+
name: Topic,
|
|
242
|
+
): Promise<{ readonly leads: boolean; readonly roster: readonly string[] | null }> {
|
|
216
243
|
const key = `${PRESENCE_SWEEP_PREFIX}.${name}`;
|
|
217
|
-
await this.#transport.shared.put(key, this.#nodeId, '', this.#ttlMs);
|
|
244
|
+
await this.#transport.shared.put(key, this.#nodeId, this.#shipped.get(name) ?? '', this.#ttlMs);
|
|
218
245
|
const claimants = await this.#transport.shared.entries(key);
|
|
219
246
|
let leader = this.#nodeId;
|
|
220
|
-
|
|
221
|
-
|
|
247
|
+
let roster: readonly string[] | null = null;
|
|
248
|
+
for (const claimant of claimants) {
|
|
249
|
+
if (claimant.member > leader) continue;
|
|
250
|
+
leader = claimant.member;
|
|
251
|
+
roster = parseRoster(claimant.value);
|
|
252
|
+
}
|
|
253
|
+
return { leads: leader === this.#nodeId, roster };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A follower's pass: what the leader said is live is recorded, what it stopped saying is not. */
|
|
257
|
+
#record(name: Topic, roster: readonly string[] | null): void {
|
|
258
|
+
this.#shipped.delete(name);
|
|
259
|
+
// A leader that has not swept yet has said nothing: nothing is learned and nothing forgotten.
|
|
260
|
+
if (roster === null) return;
|
|
261
|
+
const tracked = this.#track(name);
|
|
262
|
+
const now = new Set(roster);
|
|
263
|
+
// Dropped by the leader while it is alive means the leader announced it.
|
|
264
|
+
for (const id of this.#heard.get(name) ?? []) if (!now.has(id)) tracked.delete(id);
|
|
265
|
+
for (const id of now) tracked.add(id);
|
|
266
|
+
this.#heard.set(name, now);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The leader's roster, written onto its lease when it changed. */
|
|
270
|
+
async #ship(name: Topic): Promise<void> {
|
|
271
|
+
const ids = [...(this.#seen.get(name) ?? [])].sort().slice(0, PRESENCE_LEASE_ROSTER_LIMIT);
|
|
272
|
+
const value = JSON.stringify(ids);
|
|
273
|
+
if (this.#shipped.get(name) === value) return;
|
|
274
|
+
this.#shipped.set(name, value);
|
|
275
|
+
await this.#transport.shared.put(
|
|
276
|
+
`${PRESENCE_SWEEP_PREFIX}.${name}`,
|
|
277
|
+
this.#nodeId,
|
|
278
|
+
value,
|
|
279
|
+
this.#ttlMs,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#forget(name: Topic): void {
|
|
284
|
+
this.#seen.delete(name);
|
|
285
|
+
this.#shipped.delete(name);
|
|
286
|
+
this.#heard.delete(name);
|
|
222
287
|
}
|
|
223
288
|
|
|
224
289
|
/** Full-set frame for a client that just (re)connected — presence has no delta protocol. */
|
|
@@ -283,6 +348,18 @@ export function presenceFrame(
|
|
|
283
348
|
};
|
|
284
349
|
}
|
|
285
350
|
|
|
351
|
+
/** A lease value as the leader wrote it: a JSON list of member ids, or nothing yet. */
|
|
352
|
+
function parseRoster(value: string): readonly string[] | null {
|
|
353
|
+
if (value === '') return null;
|
|
354
|
+
try {
|
|
355
|
+
const parsed: unknown = JSON.parse(value);
|
|
356
|
+
if (!Array.isArray(parsed)) return null;
|
|
357
|
+
return parsed.filter((id): id is string => typeof id === 'string');
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
286
363
|
function parseMember(id: string, value: string): PresenceMember | null {
|
|
287
364
|
try {
|
|
288
365
|
const parsed: unknown = JSON.parse(value);
|