@ultimat3/realtime 23.0.0 → 25.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 +118 -126
- package/README.md +229 -54
- package/package.json +9 -6
- package/src/advisory-lock.ts +62 -0
- package/src/boot.ts +25 -15
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +9 -9
- package/src/changefeed.ts +81 -19
- package/src/channel-authz.ts +48 -17
- package/src/channel-describe.ts +1 -1
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +36 -15
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +35 -4
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +148 -48
- package/src/client-channels.ts +12 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +16 -3
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +30 -2
- package/src/cursor.ts +11 -0
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +113 -105
- package/src/fanout.ts +13 -0
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +25 -7
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +34 -9
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +122 -29
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +44 -0
- package/src/live-query.ts +96 -175
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +37 -6
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +149 -15
- package/src/offline-queue.ts +53 -5
- package/src/page-errors.ts +25 -9
- package/src/page-outbox.ts +125 -54
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +54 -5
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +126 -22
- 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 +50 -51
- package/src/pg-socket.ts +26 -7
- package/src/pg-tls.ts +18 -0
- package/src/pg-values.ts +29 -14
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +87 -10
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +17 -5
- package/src/record-persister.ts +7 -2
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +11 -7
- package/src/replicator-envelope.ts +160 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +313 -191
- package/src/server.ts +45 -23
- package/src/socket-defaults.ts +20 -0
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/socket.ts +14 -19
- package/src/subscriber-gate.ts +11 -1
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-auth.ts +47 -2
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +74 -21
- package/src/sync-node-bounds.ts +16 -1
- package/src/sync-node-contract.ts +36 -4
- package/src/sync-node.ts +163 -82
- package/src/sync-origin.ts +27 -12
- package/src/sync-protocol.ts +86 -16
- package/src/sync-upgrade.ts +115 -8
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +41 -20
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +18 -9
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/pg-advisory-lock.ts
CHANGED
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
// is scoped to this connection's Postgres *session*, so acquiring means keeping the connection
|
|
4
4
|
// open and releasing means closing it: no lease, no renewal, no fencing token, ever.
|
|
5
5
|
|
|
6
|
+
import type { AdvisoryLock } from './advisory-lock';
|
|
6
7
|
import { ReplicationFailedError } from './errors';
|
|
7
8
|
import { PgConnection, type PgRows } from './pg-connection';
|
|
8
9
|
import { bunPgStream, type PgTarget, parsePgUrl } from './pg-socket';
|
|
9
10
|
import type { PgStream } from './pg-wire';
|
|
10
|
-
import type { AdvisoryLock } from './replicator';
|
|
11
11
|
import type { Rng } from './thundering-herd';
|
|
12
12
|
|
|
13
13
|
/** A key reaches a simple query unparameterised, so its charset is the injection boundary. */
|
|
14
14
|
const KEY_PATTERN = /^[A-Za-z0-9:_.-]+$/;
|
|
15
15
|
|
|
16
|
-
export interface
|
|
16
|
+
export interface PostgresAdvisoryLockOptions {
|
|
17
17
|
/** Connection string for the database whose replicator this is. */
|
|
18
18
|
readonly url: string;
|
|
19
19
|
/** Lock identity, e.g. `x:replicator:<slot>`. Hashed by Postgres, not by us. */
|
|
@@ -30,9 +30,9 @@ export interface PgAdvisoryLockOptions {
|
|
|
30
30
|
* already holds the key would need a matching second `pg_advisory_unlock`, and `release()` only
|
|
31
31
|
* ever issues one, so taking a second grant here would be a leak, not a no-op.
|
|
32
32
|
*/
|
|
33
|
-
export class
|
|
33
|
+
export class PostgresAdvisoryLock implements AdvisoryLock {
|
|
34
34
|
readonly key: string;
|
|
35
|
-
readonly #options:
|
|
35
|
+
readonly #options: PostgresAdvisoryLockOptions;
|
|
36
36
|
#connection: PgConnection | null = null;
|
|
37
37
|
/**
|
|
38
38
|
* The acquisition in flight, if any. `#connection` cannot answer "am I already taking this" —
|
|
@@ -45,8 +45,9 @@ export class PgAdvisoryLock implements AdvisoryLock {
|
|
|
45
45
|
* the process dies. A standby then never takes over a slot whose owner has already stopped.
|
|
46
46
|
*/
|
|
47
47
|
#acquiring: Promise<boolean> | null = null;
|
|
48
|
+
readonly #lostListeners = new Set<(reason: string) => void>();
|
|
48
49
|
|
|
49
|
-
constructor(options:
|
|
50
|
+
constructor(options: PostgresAdvisoryLockOptions) {
|
|
50
51
|
if (!KEY_PATTERN.test(options.key)) {
|
|
51
52
|
throw new ReplicationFailedError({
|
|
52
53
|
stage: 'preflight',
|
|
@@ -91,6 +92,7 @@ export class PgAdvisoryLock implements AdvisoryLock {
|
|
|
91
92
|
database: target.database,
|
|
92
93
|
applicationName: `ultimate-replicator-lock:${this.key}`,
|
|
93
94
|
rng: this.#options.rng,
|
|
95
|
+
ssl: target.ssl,
|
|
94
96
|
});
|
|
95
97
|
let rows: PgRows;
|
|
96
98
|
try {
|
|
@@ -110,9 +112,51 @@ export class PgAdvisoryLock implements AdvisoryLock {
|
|
|
110
112
|
// `release()` — which is exactly what lets a crashed replicator free the slot with nothing
|
|
111
113
|
// left to clean up.
|
|
112
114
|
this.#connection = connection;
|
|
115
|
+
// The session is idle from here on, and an idle session has no reader: without this a server
|
|
116
|
+
// that ended it was never noticed, and `tryAcquire()` went on answering `true` from memory
|
|
117
|
+
// for a lock Postgres had already handed to another process.
|
|
118
|
+
connection.watchIdle((reason) => this.#lost(connection, reason));
|
|
113
119
|
return true;
|
|
114
120
|
}
|
|
115
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Drop the session without the unlock statement. The socket is closed outright — no goodbye is
|
|
124
|
+
* written, because a write to a black-holed socket is one more thing that never settles — and
|
|
125
|
+
* Postgres releases the lock when the session ends. An acquisition still in flight is dropped
|
|
126
|
+
* the moment it lands.
|
|
127
|
+
*/
|
|
128
|
+
abandon(): void {
|
|
129
|
+
const acquiring = this.#acquiring;
|
|
130
|
+
if (acquiring !== null) {
|
|
131
|
+
// Voided: its own caller has the outcome; this only makes sure nothing stays held after it.
|
|
132
|
+
void acquiring.then(
|
|
133
|
+
() => this.abandon(),
|
|
134
|
+
() => undefined,
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const connection = this.#connection;
|
|
139
|
+
if (connection === null) return;
|
|
140
|
+
this.#connection = null;
|
|
141
|
+
connection.destroy();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
onLost(listener: (reason: string) => void): () => void {
|
|
145
|
+
this.#lostListeners.add(listener);
|
|
146
|
+
return () => {
|
|
147
|
+
this.#lostListeners.delete(listener);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The session ended underneath the lock. A release that got there first owns the goodbye. */
|
|
152
|
+
#lost(connection: PgConnection, reason: string): void {
|
|
153
|
+
if (this.#connection !== connection) return;
|
|
154
|
+
this.#connection = null;
|
|
155
|
+
// The socket is already gone: nothing is said to it, and the listeners hear first.
|
|
156
|
+
connection.destroy();
|
|
157
|
+
for (const listener of [...this.#lostListeners]) listener(reason);
|
|
158
|
+
}
|
|
159
|
+
|
|
116
160
|
async release(): Promise<void> {
|
|
117
161
|
// A release that arrives mid-acquisition waits it out. `#connection` is still `null` there, so
|
|
118
162
|
// the early return below would answer "nothing to release" and the session would open behind
|
|
@@ -132,3 +176,8 @@ export class PgAdvisoryLock implements AdvisoryLock {
|
|
|
132
176
|
}
|
|
133
177
|
}
|
|
134
178
|
}
|
|
179
|
+
|
|
180
|
+
/** The one way to build the session lock — the twin of `memoryAdvisoryLock()`; the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
|
|
181
|
+
export function postgresAdvisoryLock(options: PostgresAdvisoryLockOptions): PostgresAdvisoryLock {
|
|
182
|
+
return new PostgresAdvisoryLock(options);
|
|
183
|
+
}
|
package/src/pg-auth.ts
CHANGED
|
@@ -63,8 +63,10 @@ class ScramSha256Session implements ScramSession {
|
|
|
63
63
|
if (!serverNonce.startsWith(this.#clientNonce)) {
|
|
64
64
|
throw new ReplicationProtocolError({
|
|
65
65
|
stage: 'auth',
|
|
66
|
-
detail:
|
|
67
|
-
|
|
66
|
+
detail:
|
|
67
|
+
`server nonce "${serverNonce}" does not extend client nonce "${this.#clientNonce}" — ` +
|
|
68
|
+
'the server did not echo the client nonce; check for a proxy on the replication URL',
|
|
69
|
+
fix: 'x doctor --json',
|
|
68
70
|
});
|
|
69
71
|
}
|
|
70
72
|
|
|
@@ -216,8 +218,11 @@ function parseServerFirst(text: string): ServerFirst {
|
|
|
216
218
|
if (iterations > MAX_SCRAM_ITERATIONS) {
|
|
217
219
|
throw new ReplicationProtocolError({
|
|
218
220
|
stage: 'auth',
|
|
219
|
-
detail:
|
|
220
|
-
|
|
221
|
+
detail:
|
|
222
|
+
`server-first-message asked for ${iterationsText} PBKDF2 iterations, above the ` +
|
|
223
|
+
`${MAX_SCRAM_ITERATIONS} ceiling — a real server asks for 4096, so point the replication ` +
|
|
224
|
+
'URL at postgres itself',
|
|
225
|
+
fix: 'x doctor --json',
|
|
221
226
|
});
|
|
222
227
|
}
|
|
223
228
|
const salt = decodeBase64('salt', saltB64);
|
package/src/pg-bytes.ts
CHANGED
|
@@ -129,7 +129,10 @@ export class ByteWriter {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
uint8(value: number): this {
|
|
132
|
-
this.#bytes[this.#room(1)]
|
|
132
|
+
// The offset FIRST: `#room` may replace `#bytes`, and `this.#bytes[this.#room(1)]` reads the
|
|
133
|
+
// old buffer before it does — the byte that caused a growth was written into the discard.
|
|
134
|
+
const at = this.#room(1);
|
|
135
|
+
this.#bytes[at] = value & 0xff;
|
|
133
136
|
return this;
|
|
134
137
|
}
|
|
135
138
|
|
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;
|
|
@@ -94,7 +118,13 @@ export class PgConnection {
|
|
|
94
118
|
// trips exactly on servers whose default is not already 3. A server that refuses one
|
|
95
119
|
// answers `ErrorResponse` during startup, so the replicator fails to boot with the server's
|
|
96
120
|
// own words — never a warning that a feed then goes on mis-sorting behind.
|
|
97
|
-
|
|
121
|
+
//
|
|
122
|
+
// `TimeZone=UTC` is the one pin libpqwalreceiver does not send, and it closes the same hole
|
|
123
|
+
// from the other side: a `timestamptz` is written in the SESSION's zone, so a server, database
|
|
124
|
+
// or role default like `America/New_York` writes a pre-1883 instant at its LMT offset,
|
|
125
|
+
// `-04:56:02`. The decoder reads that too, but UTC text is the one form with no ambient zone
|
|
126
|
+
// in it — and a client option outranks every server-side default, `ALTER ROLE … SET` included.
|
|
127
|
+
options: '-c datestyle=ISO -c intervalstyle=postgres -c extra_float_digits=3 -c TimeZone=UTC',
|
|
98
128
|
};
|
|
99
129
|
if (options.replication !== undefined) parameters['replication'] = options.replication;
|
|
100
130
|
// A handshake fails on ordinary conditions — no password, an ErrorResponse, an EOF — and on
|
|
@@ -123,26 +153,82 @@ export class PgConnection {
|
|
|
123
153
|
|
|
124
154
|
/** One simple query. Returns the rows as text; a `CommandComplete` with no rows returns `[]`. */
|
|
125
155
|
async query(sql: string): Promise<PgRows> {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
156
|
+
this.#querying = true;
|
|
157
|
+
try {
|
|
158
|
+
await this.#stream.write(queryMessage(sql));
|
|
159
|
+
const rows: (readonly (string | null)[])[] = [];
|
|
160
|
+
for (;;) {
|
|
161
|
+
const message = await this.#expect('query');
|
|
162
|
+
switch (message.tag) {
|
|
163
|
+
case 'D':
|
|
164
|
+
rows.push(dataRow(message.body));
|
|
165
|
+
break;
|
|
166
|
+
case 'E':
|
|
167
|
+
// Drain to `ReadyForQuery` first: leaving the session mid-result desynchronises reuse.
|
|
168
|
+
await this.#drainToReady();
|
|
169
|
+
throw serverError('query', message.body);
|
|
170
|
+
case 'Z':
|
|
171
|
+
return rows;
|
|
172
|
+
default:
|
|
173
|
+
this.#note(message);
|
|
174
|
+
}
|
|
142
175
|
}
|
|
176
|
+
} finally {
|
|
177
|
+
this.#querying = false;
|
|
178
|
+
this.#park();
|
|
143
179
|
}
|
|
144
180
|
}
|
|
145
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Watch a session that is doing NOTHING. An idle connection has no reader, so a server that
|
|
184
|
+
* ended it — a failover, `pg_terminate_backend`, a dropped socket — was noticed by the next
|
|
185
|
+
* statement, which for a session held only for its advisory lock is never. This parks one read
|
|
186
|
+
* between queries: EOF, a read error or an ErrorResponse calls `onEnd` once, with why.
|
|
187
|
+
*
|
|
188
|
+
* Never for `close()`. A `query()` takes the parked read over rather than racing it — the stream
|
|
189
|
+
* allows one reader — and the watch resumes when the query is done.
|
|
190
|
+
*/
|
|
191
|
+
watchIdle(onEnd: (reason: string) => void): void {
|
|
192
|
+
this.#onIdleEnd = onEnd;
|
|
193
|
+
this.#park();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#park(): void {
|
|
197
|
+
if (this.#closed || this.#querying || this.#copyBoth) return;
|
|
198
|
+
if (this.#onIdleEnd === undefined || this.#idle !== undefined) return;
|
|
199
|
+
const parked = this.#reader.next();
|
|
200
|
+
this.#idle = parked;
|
|
201
|
+
const ended = (reason: string): void => {
|
|
202
|
+
const onEnd = this.#onIdleEnd;
|
|
203
|
+
this.#onIdleEnd = undefined;
|
|
204
|
+
if (!this.#closed) onEnd?.(reason);
|
|
205
|
+
};
|
|
206
|
+
parked.then(
|
|
207
|
+
(message) => {
|
|
208
|
+
// A query took this read over: the message is its answer, not this watch's.
|
|
209
|
+
if (this.#idle !== parked) return;
|
|
210
|
+
this.#idle = undefined;
|
|
211
|
+
if (message === undefined) return ended('the server closed the connection');
|
|
212
|
+
if (message.tag === 'E') return ended(renderThrowable(serverError('idle', message.body)));
|
|
213
|
+
this.#note(message);
|
|
214
|
+
this.#park();
|
|
215
|
+
},
|
|
216
|
+
(failure: unknown) => {
|
|
217
|
+
if (this.#idle !== parked) return;
|
|
218
|
+
this.#idle = undefined;
|
|
219
|
+
ended(renderThrowable(failure));
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The next message: the read an idle watch already parked, when there is one. */
|
|
225
|
+
#next(): Promise<PgMessage | undefined> {
|
|
226
|
+
const parked = this.#idle;
|
|
227
|
+
if (parked === undefined) return this.#reader.next();
|
|
228
|
+
this.#idle = undefined;
|
|
229
|
+
return parked;
|
|
230
|
+
}
|
|
231
|
+
|
|
146
232
|
/**
|
|
147
233
|
* `START_REPLICATION` — the connection stops being request/response and becomes a duplex copy
|
|
148
234
|
* stream. There is no way back short of closing it, which is why this is one-way on purpose.
|
|
@@ -205,6 +291,18 @@ export class PgConnection {
|
|
|
205
291
|
this.#stream.close();
|
|
206
292
|
}
|
|
207
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Close the socket and say nothing — no `Terminate`, nothing awaited. For a session that may
|
|
296
|
+
* already be dead: `close()` writes a goodbye first, and a write to a black-holed socket is a
|
|
297
|
+
* promise nothing settles. The server ends the session when the socket goes, which releases
|
|
298
|
+
* whatever the session held. Never reported to an idle watch.
|
|
299
|
+
*/
|
|
300
|
+
destroy(): void {
|
|
301
|
+
if (this.#closed) return;
|
|
302
|
+
this.#closed = true;
|
|
303
|
+
closeQuietly(this.#stream);
|
|
304
|
+
}
|
|
305
|
+
|
|
208
306
|
async #authenticate(options: PgConnectionOptions): Promise<void> {
|
|
209
307
|
let scram: ScramSession | undefined;
|
|
210
308
|
for (;;) {
|
|
@@ -222,12 +320,16 @@ export class PgConnection {
|
|
|
222
320
|
case AUTH_CLEARTEXT: {
|
|
223
321
|
const password = options.password ?? '';
|
|
224
322
|
if (password === '') throw needPassword('a cleartext password');
|
|
323
|
+
if (!answersWeakAuth(options.ssl)) {
|
|
324
|
+
throw weakAuthRefused('a cleartext password', options.ssl);
|
|
325
|
+
}
|
|
225
326
|
await this.#stream.write(passwordMessage(password));
|
|
226
327
|
break;
|
|
227
328
|
}
|
|
228
329
|
case AUTH_MD5: {
|
|
229
330
|
const password = options.password ?? '';
|
|
230
331
|
if (password === '') throw needPassword('an md5 password');
|
|
332
|
+
if (!answersWeakAuth(options.ssl)) throw weakAuthRefused('an md5 password', options.ssl);
|
|
231
333
|
const salt = reader.take(4);
|
|
232
334
|
await this.#stream.write(
|
|
233
335
|
passwordMessage(md5Password({ user: options.user, password, salt })),
|
|
@@ -276,13 +378,13 @@ export class PgConnection {
|
|
|
276
378
|
|
|
277
379
|
async #drainToReady(): Promise<void> {
|
|
278
380
|
for (;;) {
|
|
279
|
-
const message = await this.#
|
|
381
|
+
const message = await this.#next();
|
|
280
382
|
if (message === undefined || message.tag === 'Z') return;
|
|
281
383
|
}
|
|
282
384
|
}
|
|
283
385
|
|
|
284
386
|
async #expect(stage: string): Promise<PgMessage> {
|
|
285
|
-
const message = await this.#
|
|
387
|
+
const message = await this.#next();
|
|
286
388
|
if (message !== undefined) return message;
|
|
287
389
|
throw new ReplicationFailedError({
|
|
288
390
|
stage,
|
|
@@ -315,8 +417,10 @@ export class PgConnection {
|
|
|
315
417
|
const outOfOrder = (what: string): ReplicationProtocolError =>
|
|
316
418
|
new ReplicationProtocolError({
|
|
317
419
|
stage: 'auth',
|
|
318
|
-
detail:
|
|
319
|
-
|
|
420
|
+
detail:
|
|
421
|
+
`the server sent ${what} before it offered a SASL mechanism — the exchange arrived out of ` +
|
|
422
|
+
'order; check for a pooler or proxy between this client and postgres',
|
|
423
|
+
fix: 'x doctor --json',
|
|
320
424
|
});
|
|
321
425
|
|
|
322
426
|
/** A `close()` that throws must not replace the handshake failure that is worth reporting. */
|
package/src/pg-entity-row.ts
CHANGED
|
@@ -58,3 +58,19 @@ export function entityRow(
|
|
|
58
58
|
for (const field of sealedFields(entity)) delete row[field.property];
|
|
59
59
|
return row;
|
|
60
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();
|
|
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
|
+
};
|