@ultimat3/realtime 21.0.0 → 22.1.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 +302 -1009
- package/README.md +130 -26
- package/package.json +4 -4
- package/src/changefeed.ts +7 -1
- package/src/channel-authz.ts +23 -4
- package/src/channel-decl.ts +16 -5
- package/src/channel-describe.ts +7 -5
- package/src/channel-logs.ts +19 -1
- package/src/channel-records.ts +8 -0
- package/src/client-channels.ts +75 -5
- package/src/client.ts +14 -2
- package/src/cursor.ts +5 -0
- package/src/errors.ts +43 -1
- package/src/idb-fake.ts +24 -4
- package/src/idb-types.ts +7 -0
- package/src/index.ts +1 -1
- package/src/live-definition.ts +5 -1
- package/src/live-fanout.ts +51 -2
- package/src/live-query.ts +11 -0
- package/src/live-replicator.ts +160 -0
- package/src/local-store-idb.ts +89 -15
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +76 -21
- package/src/page-outbox.ts +80 -10
- package/src/page-socket.ts +39 -8
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-identifier.ts +23 -0
- package/src/pg-preflight.ts +32 -45
- package/src/pg-publication.ts +95 -0
- package/src/pg-replication.ts +21 -7
- package/src/pg-socket.ts +139 -53
- package/src/pg-tls.ts +124 -0
- package/src/pg-wire.ts +65 -16
- package/src/policy-fake.ts +14 -0
- package/src/query-window.ts +35 -21
- package/src/replication-errors.ts +29 -16
- package/src/replicator.ts +13 -3
- package/src/server.ts +8 -3
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +15 -3
- package/src/socket-host.ts +103 -4
- package/src/socket-idle.ts +21 -0
- package/src/socket.ts +41 -38
- package/src/subscriber-gate.ts +92 -3
- package/src/sync-node-contract.ts +6 -0
- package/src/sync-node.ts +3 -7
- package/src/sync-origin.ts +33 -0
- package/src/sync-upgrade.ts +33 -9
- package/src/thundering-herd.ts +21 -11
- package/src/transport-env.ts +55 -14
- package/src/use-mutation.ts +13 -0
- package/src/use-query.ts +10 -5
package/src/pg-socket.ts
CHANGED
|
@@ -2,15 +2,23 @@
|
|
|
2
2
|
// and the SSLRequest handshake that has to happen before the first protocol byte. Bun pushes bytes
|
|
3
3
|
// at handlers while the connection pulls messages, so a queue sits between them.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
6
|
+
import { ReplicationFailedError, ReplicationProtocolError, ReplicationTlsError } from './errors';
|
|
7
|
+
import { judgeHandshake, parseSsl, rootCertificate, type SslMode } from './pg-tls';
|
|
6
8
|
import { type PgStream, sslRequest } from './pg-wire';
|
|
7
9
|
|
|
10
|
+
export type { SslMode } from './pg-tls';
|
|
11
|
+
|
|
8
12
|
/** Structural view of Bun's socket, declared here so the contract does not need bun-types. */
|
|
9
13
|
export interface SocketLike {
|
|
10
14
|
write(data: Uint8Array): number;
|
|
11
15
|
end(): void;
|
|
12
16
|
upgradeTLS(options: {
|
|
13
|
-
readonly tls: {
|
|
17
|
+
readonly tls: {
|
|
18
|
+
readonly serverName: string;
|
|
19
|
+
readonly rejectUnauthorized: boolean;
|
|
20
|
+
readonly ca?: string;
|
|
21
|
+
};
|
|
14
22
|
readonly socket: SocketHandlers;
|
|
15
23
|
}): readonly SocketLike[];
|
|
16
24
|
}
|
|
@@ -21,6 +29,8 @@ export interface SocketHandlers {
|
|
|
21
29
|
end(): void;
|
|
22
30
|
drain(): void;
|
|
23
31
|
error(socket: SocketLike, error: Error): void;
|
|
32
|
+
/** TLS sockets only: Bun reports the verification it did instead of acting on it. */
|
|
33
|
+
handshake?(socket: SocketLike, success: boolean, authorizationError: Error | null): void;
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
export interface BunConnect {
|
|
@@ -31,9 +41,6 @@ export interface BunConnect {
|
|
|
31
41
|
}): Promise<SocketLike>;
|
|
32
42
|
}
|
|
33
43
|
|
|
34
|
-
/** `disable` never offers TLS, `prefer` accepts a refusal, `require` treats one as a failure. */
|
|
35
|
-
export type SslMode = 'disable' | 'prefer' | 'require';
|
|
36
|
-
|
|
37
44
|
export interface PgTarget {
|
|
38
45
|
readonly host: string;
|
|
39
46
|
readonly port: number;
|
|
@@ -41,10 +48,10 @@ export interface PgTarget {
|
|
|
41
48
|
readonly user: string;
|
|
42
49
|
readonly password: string | undefined;
|
|
43
50
|
readonly ssl: SslMode;
|
|
51
|
+
/** `sslrootcert`: a PEM path, `'system'`, or absent for the runtime's trust store. */
|
|
52
|
+
readonly rootCert?: string | undefined;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
|
-
const SSL_MODES = new Set<string>(['disable', 'prefer', 'require']);
|
|
47
|
-
|
|
48
55
|
/** `postgres://user:pass@host:5432/db?sslmode=require`. The one place a connection URL is read. */
|
|
49
56
|
export function parsePgUrl(url: string): PgTarget {
|
|
50
57
|
let parsed: URL;
|
|
@@ -67,14 +74,7 @@ export function parsePgUrl(url: string): PgTarget {
|
|
|
67
74
|
fix: 'set DATABASE_URL to postgres://user:password@host:5432/database',
|
|
68
75
|
});
|
|
69
76
|
}
|
|
70
|
-
const
|
|
71
|
-
if (!SSL_MODES.has(mode)) {
|
|
72
|
-
throw new ReplicationFailedError({
|
|
73
|
-
stage: 'connect',
|
|
74
|
-
detail: `sslmode=${mode} is not one of disable, prefer, require`,
|
|
75
|
-
fix: 'use ?sslmode=require for a managed database, ?sslmode=disable for a local one',
|
|
76
|
-
});
|
|
77
|
-
}
|
|
77
|
+
const { ssl, rootCert } = parseSsl(parsed.searchParams);
|
|
78
78
|
const database = decodeURIComponent(parsed.pathname.replace(/^\//, ''));
|
|
79
79
|
return {
|
|
80
80
|
host: parsed.hostname,
|
|
@@ -82,7 +82,8 @@ export function parsePgUrl(url: string): PgTarget {
|
|
|
82
82
|
database: database === '' ? 'postgres' : database,
|
|
83
83
|
user: decodeURIComponent(parsed.username) || 'postgres',
|
|
84
84
|
password: parsed.password === '' ? undefined : decodeURIComponent(parsed.password),
|
|
85
|
-
ssl
|
|
85
|
+
ssl,
|
|
86
|
+
rootCert,
|
|
86
87
|
};
|
|
87
88
|
}
|
|
88
89
|
|
|
@@ -156,8 +157,24 @@ export const bunPgStream = (target: PgTarget): Promise<PgStream> =>
|
|
|
156
157
|
* happen here rather than in the message layer.
|
|
157
158
|
*/
|
|
158
159
|
export async function pgStreamOver(runtime: BunConnect, target: PgTarget): Promise<PgStream> {
|
|
160
|
+
// Before any socket exists: a trust anchor that cannot be read is a setting, not a connection,
|
|
161
|
+
// and refused after the connect it left the raw socket open — one descriptor per retry.
|
|
162
|
+
const ca = target.ssl === 'disable' ? undefined : await rootCertificate(target.rootCert);
|
|
159
163
|
const queue = new ChunkQueue();
|
|
160
164
|
let draining: (() => void) | undefined;
|
|
165
|
+
/**
|
|
166
|
+
* Set the moment the socket is upgraded. Bun keeps calling the RAW socket's handlers after
|
|
167
|
+
* `upgradeTLS` — with the ciphertext — so those handlers go deaf here, or the reader receives
|
|
168
|
+
* TLS records as protocol bytes: a read that never completes, or "closed with 2007 bytes of a
|
|
169
|
+
* partial message".
|
|
170
|
+
*/
|
|
171
|
+
let upgraded = false;
|
|
172
|
+
|
|
173
|
+
const resume = (): void => {
|
|
174
|
+
const parked = draining;
|
|
175
|
+
draining = undefined;
|
|
176
|
+
parked?.();
|
|
177
|
+
};
|
|
161
178
|
|
|
162
179
|
/**
|
|
163
180
|
* A write parked for `drain` can never get one from a socket that is gone, so it is released
|
|
@@ -165,31 +182,35 @@ export async function pgStreamOver(runtime: BunConnect, target: PgTarget): Promi
|
|
|
165
182
|
* side ends cleanly because an EOF that matters is already an error one layer up.
|
|
166
183
|
*/
|
|
167
184
|
const died = (): void => {
|
|
168
|
-
|
|
169
|
-
draining = undefined;
|
|
170
|
-
resume?.();
|
|
185
|
+
resume();
|
|
171
186
|
queue.end();
|
|
172
187
|
};
|
|
173
188
|
|
|
189
|
+
const connectFailure = (error: Error): ReplicationFailedError =>
|
|
190
|
+
new ReplicationFailedError({
|
|
191
|
+
stage: 'connect',
|
|
192
|
+
detail: error.message,
|
|
193
|
+
fix: `open the route to ${target.host}:${target.port}, then: x doctor db`,
|
|
194
|
+
});
|
|
195
|
+
|
|
174
196
|
const handlers: SocketHandlers = {
|
|
175
197
|
// Copied, not retained: Bun promises nothing about the chunk's contents — or that it is even
|
|
176
198
|
// the same buffer — once the handler returns, and this one outlives it in the queue.
|
|
177
|
-
data: (_socket, data) =>
|
|
178
|
-
|
|
179
|
-
|
|
199
|
+
data: (_socket, data) => {
|
|
200
|
+
if (!upgraded) queue.push(data.slice());
|
|
201
|
+
},
|
|
202
|
+
close: () => {
|
|
203
|
+
if (!upgraded) died();
|
|
204
|
+
},
|
|
205
|
+
end: () => {
|
|
206
|
+
if (!upgraded) died();
|
|
207
|
+
},
|
|
180
208
|
drain: () => {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
209
|
+
if (!upgraded) resume();
|
|
210
|
+
},
|
|
211
|
+
error: (_socket, error) => {
|
|
212
|
+
if (!upgraded) queue.fail(connectFailure(error));
|
|
184
213
|
},
|
|
185
|
-
error: (_socket, error) =>
|
|
186
|
-
queue.fail(
|
|
187
|
-
new ReplicationFailedError({
|
|
188
|
-
stage: 'connect',
|
|
189
|
-
detail: error.message,
|
|
190
|
-
fix: `open the route to ${target.host}:${target.port}, then: x doctor db`,
|
|
191
|
-
}),
|
|
192
|
-
),
|
|
193
214
|
};
|
|
194
215
|
|
|
195
216
|
let socket = await runtime.connect({
|
|
@@ -219,7 +240,86 @@ export async function pgStreamOver(runtime: BunConnect, target: PgTarget): Promi
|
|
|
219
240
|
}
|
|
220
241
|
};
|
|
221
242
|
|
|
222
|
-
|
|
243
|
+
/**
|
|
244
|
+
* The upgrade, awaited to its HANDSHAKE rather than assumed: the runtime is told never to
|
|
245
|
+
* reject (`rejectUnauthorized: false`), because libpq's `prefer` and `require` verify nothing,
|
|
246
|
+
* and `judgeHandshake` decides what its report means under the mode that was asked for. A
|
|
247
|
+
* failure is `X_REPLICATION_TLS` here, never a refused write one step later.
|
|
248
|
+
*/
|
|
249
|
+
const upgrade = async (): Promise<SocketLike> => {
|
|
250
|
+
let settle: ((failure: ReplicationTlsError | undefined) => void) | undefined;
|
|
251
|
+
const handshake = new Promise<ReplicationTlsError | undefined>((resolve) => {
|
|
252
|
+
settle = resolve;
|
|
253
|
+
});
|
|
254
|
+
const decide = (failure: ReplicationTlsError | undefined): void => {
|
|
255
|
+
const once = settle;
|
|
256
|
+
settle = undefined;
|
|
257
|
+
once?.(failure);
|
|
258
|
+
};
|
|
259
|
+
const lost = (detail: string): ReplicationTlsError =>
|
|
260
|
+
new ReplicationTlsError({
|
|
261
|
+
detail,
|
|
262
|
+
fix: `confirm ${target.host}:${target.port} is postgres with ssl=on, or use ?sslmode=disable if it does not speak TLS`,
|
|
263
|
+
});
|
|
264
|
+
const tlsHandlers: SocketHandlers = {
|
|
265
|
+
data: (_socket, data) => queue.push(data.slice()),
|
|
266
|
+
close: () => {
|
|
267
|
+
decide(lost('the server closed the connection during the TLS handshake'));
|
|
268
|
+
died();
|
|
269
|
+
},
|
|
270
|
+
end: () => {
|
|
271
|
+
decide(lost('the server ended the connection during the TLS handshake'));
|
|
272
|
+
died();
|
|
273
|
+
},
|
|
274
|
+
drain: resume,
|
|
275
|
+
error: (_socket, error) => {
|
|
276
|
+
decide(lost(`the TLS handshake failed: ${renderThrowable(error)}`));
|
|
277
|
+
queue.fail(connectFailure(error));
|
|
278
|
+
},
|
|
279
|
+
handshake: (_socket, _success, authorizationError) =>
|
|
280
|
+
decide(judgeHandshake(target, authorizationError)),
|
|
281
|
+
};
|
|
282
|
+
upgraded = true;
|
|
283
|
+
// Bun hands back `[raw, tls]`; every later read and write goes through the second one.
|
|
284
|
+
const tls = socket.upgradeTLS({
|
|
285
|
+
tls: {
|
|
286
|
+
serverName: target.host,
|
|
287
|
+
rejectUnauthorized: false,
|
|
288
|
+
...(ca === undefined ? {} : { ca }),
|
|
289
|
+
},
|
|
290
|
+
socket: tlsHandlers,
|
|
291
|
+
})[1];
|
|
292
|
+
if (tls === undefined) {
|
|
293
|
+
throw new ReplicationFailedError({
|
|
294
|
+
stage: 'ssl',
|
|
295
|
+
detail: 'the runtime returned no TLS socket for the upgrade',
|
|
296
|
+
fix: 'bun upgrade # in-band TLS needs bun >= 1.3',
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
const failure = await handshake;
|
|
300
|
+
if (failure !== undefined) {
|
|
301
|
+
tls.end();
|
|
302
|
+
throw failure;
|
|
303
|
+
}
|
|
304
|
+
return tls;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Every refusal below owns the socket it opened: the caller never receives a stream to close.
|
|
308
|
+
try {
|
|
309
|
+
await negotiate();
|
|
310
|
+
} catch (failure) {
|
|
311
|
+
socket.end();
|
|
312
|
+
throw failure;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
read: () => queue.read(),
|
|
317
|
+
write: flush,
|
|
318
|
+
close: () => socket.end(),
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
async function negotiate(): Promise<void> {
|
|
322
|
+
if (target.ssl === 'disable') return;
|
|
223
323
|
await flush(sslRequest());
|
|
224
324
|
const answer = (await queue.read()) ?? new Uint8Array(0);
|
|
225
325
|
const verdict = answer[0];
|
|
@@ -240,29 +340,15 @@ export async function pgStreamOver(runtime: BunConnect, target: PgTarget): Promi
|
|
|
240
340
|
fix: 'point the replication URL at postgres itself — a proxy answers like this',
|
|
241
341
|
});
|
|
242
342
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
throw new ReplicationFailedError({
|
|
248
|
-
stage: 'ssl',
|
|
249
|
-
detail: 'the runtime returned no TLS socket for the upgrade',
|
|
250
|
-
fix: 'bun upgrade # in-band TLS needs bun >= 1.3',
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
socket = upgraded;
|
|
254
|
-
} else if (target.ssl === 'require') {
|
|
343
|
+
socket = await upgrade();
|
|
344
|
+
} else if (target.ssl !== 'allow' && target.ssl !== 'prefer') {
|
|
345
|
+
// `X_REPLICATION_FAILED`, as it always was: a server that says no is a connection outcome,
|
|
346
|
+
// and moving it to the TLS code would break whoever already matches on it.
|
|
255
347
|
throw new ReplicationFailedError({
|
|
256
348
|
stage: 'ssl',
|
|
257
|
-
detail:
|
|
349
|
+
detail: `the server refused TLS but sslmode=${target.ssl} was asked for`,
|
|
258
350
|
fix: `enable ssl on ${target.host}, or use ?sslmode=prefer to accept a cleartext session`,
|
|
259
351
|
});
|
|
260
352
|
}
|
|
261
353
|
}
|
|
262
|
-
|
|
263
|
-
return {
|
|
264
|
-
read: () => queue.read(),
|
|
265
|
-
write: flush,
|
|
266
|
-
close: () => socket.end(),
|
|
267
|
-
};
|
|
268
354
|
}
|
package/src/pg-tls.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Single responsibility: libpq's `sslmode` / `sslrootcert` semantics for the replicator's own wire
|
|
2
|
+
// client — which modes exist, which of them VERIFY, what a handshake's authorization error means
|
|
3
|
+
// under each, and where the trust anchor comes from. The socket mechanics are `pg-socket.ts`'s.
|
|
4
|
+
|
|
5
|
+
import { renderFixShellArg, renderThrowable, stringField } from '@ultimat3/core';
|
|
6
|
+
import { ReplicationFailedError, ReplicationTlsError } from './errors';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* libpq's six. `disable` never offers TLS; `allow`, `prefer` and `require` encrypt and verify
|
|
10
|
+
* NOTHING; `verify-ca` checks the chain; `verify-full` checks the chain and the host name.
|
|
11
|
+
*/
|
|
12
|
+
export type SslMode = 'disable' | 'allow' | 'prefer' | 'require' | 'verify-ca' | 'verify-full';
|
|
13
|
+
|
|
14
|
+
const SSL_MODES: readonly SslMode[] = [
|
|
15
|
+
'disable',
|
|
16
|
+
'allow',
|
|
17
|
+
'prefer',
|
|
18
|
+
'require',
|
|
19
|
+
'verify-ca',
|
|
20
|
+
'verify-full',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const isSslMode = (value: string): value is SslMode =>
|
|
24
|
+
(SSL_MODES as readonly string[]).includes(value);
|
|
25
|
+
|
|
26
|
+
/** A path to a PEM file, `'system'` for the runtime's store, or `undefined` for its default. */
|
|
27
|
+
export interface SslSettings {
|
|
28
|
+
readonly ssl: SslMode;
|
|
29
|
+
readonly rootCert: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The connection URL's TLS half, with libpq's two couplings: `require` with a root certificate
|
|
34
|
+
* FILE behaves as `verify-ca` (a CA the operator named is one they meant to be checked against),
|
|
35
|
+
* and `sslrootcert=system` defaults to `verify-full` and refuses anything weaker — the system
|
|
36
|
+
* store trusts every public CA, so only the host name makes it mean anything.
|
|
37
|
+
*/
|
|
38
|
+
export function parseSsl(params: URLSearchParams): SslSettings {
|
|
39
|
+
const given = params.get('sslmode');
|
|
40
|
+
const rootCert = params.get('sslrootcert') ?? undefined;
|
|
41
|
+
const mode = given ?? (rootCert === 'system' ? 'verify-full' : 'prefer');
|
|
42
|
+
if (!isSslMode(mode)) {
|
|
43
|
+
throw new ReplicationFailedError({
|
|
44
|
+
stage: 'connect',
|
|
45
|
+
detail: `sslmode=${mode} is not one of ${SSL_MODES.join(', ')}`,
|
|
46
|
+
fix: 'use ?sslmode=verify-full for a managed database, ?sslmode=disable for a local one',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (rootCert === 'system' && mode !== 'verify-full') {
|
|
50
|
+
throw new ReplicationFailedError({
|
|
51
|
+
stage: 'connect',
|
|
52
|
+
detail: `sslrootcert=system trusts every public CA, so sslmode=${mode} would prove nothing`,
|
|
53
|
+
fix: 'use ?sslrootcert=system&sslmode=verify-full, or name your CA: ?sslrootcert=/path/ca.crt',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (mode === 'require' && rootCert !== undefined && rootCert !== 'system') {
|
|
57
|
+
return { ssl: 'verify-ca', rootCert };
|
|
58
|
+
}
|
|
59
|
+
return { ssl: mode, rootCert };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether a mode asks the runtime for the verification at all. */
|
|
63
|
+
export const verifies = (ssl: SslMode): boolean => ssl === 'verify-ca' || ssl === 'verify-full';
|
|
64
|
+
|
|
65
|
+
/** OpenSSL's code for a certificate whose chain verified but whose names do not include the host. */
|
|
66
|
+
const HOST_MISMATCH = 'ERR_TLS_CERT_ALTNAME_INVALID';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The verdict on a completed handshake. The runtime is always asked NOT to reject
|
|
70
|
+
* (`rejectUnauthorized: false`) and reports what it found instead, chain errors ahead of the host
|
|
71
|
+
* name; this decides what that report means under `ssl`. `undefined` is "carry on".
|
|
72
|
+
*/
|
|
73
|
+
export function judgeHandshake(
|
|
74
|
+
target: { readonly ssl: SslMode; readonly host: string; readonly port: number },
|
|
75
|
+
authorizationError: unknown,
|
|
76
|
+
): ReplicationTlsError | undefined {
|
|
77
|
+
if (!verifies(target.ssl)) return undefined;
|
|
78
|
+
if (authorizationError === null || authorizationError === undefined) return undefined;
|
|
79
|
+
const code =
|
|
80
|
+
stringField(authorizationError, 'code') ??
|
|
81
|
+
(typeof authorizationError === 'string'
|
|
82
|
+
? authorizationError
|
|
83
|
+
: renderThrowable(authorizationError));
|
|
84
|
+
if (code === HOST_MISMATCH) {
|
|
85
|
+
if (target.ssl === 'verify-ca') return undefined;
|
|
86
|
+
return new ReplicationTlsError({
|
|
87
|
+
detail: `the server certificate does not name ${target.host} (${code}) and sslmode=verify-full checks it`,
|
|
88
|
+
// The target's own host and port, so the pasted command reaches this server; a host a shell
|
|
89
|
+
// would misread becomes the libpq env pair instead, which a shell expands rather than runs.
|
|
90
|
+
fix: `openssl s_client -starttls postgres -connect ${renderFixShellArg(`${target.host}:${target.port}`, '"$PGHOST:$PGPORT"')} </dev/null | openssl x509 -noout -ext subjectAltName # connect with a name it lists, or use ?sslmode=verify-ca to check the chain alone`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return new ReplicationTlsError({
|
|
94
|
+
detail: `the server certificate did not verify (${code}) and sslmode=${target.ssl} checks it`,
|
|
95
|
+
fix: 'pass the server CA with ?sslrootcert=/path/to/ca.crt, or use ?sslmode=require to encrypt without verifying',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The trust anchor `upgradeTLS` gets as `ca`: a named file REPLACES the runtime store, as libpq's
|
|
101
|
+
* `root.crt` does; `system` and absent both leave the runtime's store (which honours
|
|
102
|
+
* `NODE_EXTRA_CA_CERTS`). libpq reads `~/.postgresql/root.crt` when nothing is named — a container
|
|
103
|
+
* has no such home, so this build never looks there.
|
|
104
|
+
*/
|
|
105
|
+
export async function rootCertificate(rootCert: string | undefined): Promise<string | undefined> {
|
|
106
|
+
if (rootCert === undefined || rootCert === 'system') return undefined;
|
|
107
|
+
const file = Bun.file(rootCert);
|
|
108
|
+
if (!(await file.exists())) {
|
|
109
|
+
throw new ReplicationTlsError({
|
|
110
|
+
detail: 'sslrootcert names a file that does not exist',
|
|
111
|
+
fix: 'mount the server CA and point ?sslrootcert= at it, or use ?sslrootcert=system for a public CA',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// `exists()` is not readability: a secret mounted 0600 for another user exists and still refuses
|
|
115
|
+
// the read, which escaped as a raw runtime error with no code.
|
|
116
|
+
try {
|
|
117
|
+
return await file.text();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new ReplicationTlsError({
|
|
120
|
+
detail: `sslrootcert names a file this process cannot read: ${renderThrowable(error)}`,
|
|
121
|
+
fix: 'id -u # the replicator runs as this user: mount the CA readable by it, or use ?sslrootcert=system for a public CA',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/pg-wire.ts
CHANGED
|
@@ -36,7 +36,17 @@ const MAX_MESSAGE_BYTES = 64 * 1024 * 1024;
|
|
|
36
36
|
*/
|
|
37
37
|
export class MessageReader {
|
|
38
38
|
readonly #stream: PgStream;
|
|
39
|
+
/** Bytes of messages whose length is not yet known, or complete ones not yet taken. */
|
|
39
40
|
#buffer: Uint8Array = new Uint8Array(0);
|
|
41
|
+
/**
|
|
42
|
+
* One message whose length IS known and whose bytes are still arriving: allocated once at its
|
|
43
|
+
* full size and filled in place. Joining every chunk onto what was held re-copied the whole
|
|
44
|
+
* message per chunk — quadratic, measured at 5.2 s of blocked event loop for one 32 MB CopyData
|
|
45
|
+
* on the replication connection every live window depends on.
|
|
46
|
+
*/
|
|
47
|
+
#pending: { readonly bytes: Uint8Array; filled: number } | null = null;
|
|
48
|
+
/** Messages completed out of `#pending`, in arrival order, ahead of anything in `#buffer`. */
|
|
49
|
+
readonly #ready: Uint8Array[] = [];
|
|
40
50
|
|
|
41
51
|
constructor(stream: PgStream) {
|
|
42
52
|
this.#stream = stream;
|
|
@@ -44,7 +54,8 @@ export class MessageReader {
|
|
|
44
54
|
|
|
45
55
|
/** Bytes already read but not yet consumed — what a reconnect would have to replay. */
|
|
46
56
|
get buffered(): number {
|
|
47
|
-
|
|
57
|
+
const ready = this.#ready.reduce((sum, bytes) => sum + bytes.length, 0);
|
|
58
|
+
return this.#buffer.length + (this.#pending?.filled ?? 0) + ready;
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
/** The next complete message, or `undefined` at a clean EOF. */
|
|
@@ -54,10 +65,11 @@ export class MessageReader {
|
|
|
54
65
|
if (framed !== undefined) return framed;
|
|
55
66
|
const chunk = await this.#stream.read();
|
|
56
67
|
if (chunk === undefined) {
|
|
57
|
-
|
|
68
|
+
const held = this.buffered;
|
|
69
|
+
if (held === 0) return undefined;
|
|
58
70
|
throw new ReplicationProtocolError({
|
|
59
71
|
stage: 'read',
|
|
60
|
-
detail: `the connection closed with ${
|
|
72
|
+
detail: `the connection closed with ${held} bytes of a partial message`,
|
|
61
73
|
fix: 'x doctor db — the backend was terminated mid-message; the server log names the reason',
|
|
62
74
|
});
|
|
63
75
|
}
|
|
@@ -66,18 +78,33 @@ export class MessageReader {
|
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
#append(chunk: Uint8Array): void {
|
|
81
|
+
let rest = chunk;
|
|
82
|
+
const pending = this.#pending;
|
|
83
|
+
if (pending !== null) {
|
|
84
|
+
const take = Math.min(pending.bytes.length - pending.filled, rest.length);
|
|
85
|
+
pending.bytes.set(rest.subarray(0, take), pending.filled);
|
|
86
|
+
pending.filled += take;
|
|
87
|
+
rest = rest.subarray(take);
|
|
88
|
+
if (pending.filled < pending.bytes.length) return;
|
|
89
|
+
this.#ready.push(pending.bytes);
|
|
90
|
+
this.#pending = null;
|
|
91
|
+
}
|
|
92
|
+
if (rest.length === 0) return;
|
|
69
93
|
if (this.#buffer.length === 0) {
|
|
70
|
-
this.#buffer =
|
|
94
|
+
this.#buffer = rest;
|
|
71
95
|
return;
|
|
72
96
|
}
|
|
73
|
-
|
|
97
|
+
// Only ever a few bytes are held here: a message whose length is known moves to `#pending`.
|
|
98
|
+
const joined = new Uint8Array(this.#buffer.length + rest.length);
|
|
74
99
|
joined.set(this.#buffer, 0);
|
|
75
|
-
joined.set(
|
|
100
|
+
joined.set(rest, this.#buffer.length);
|
|
76
101
|
this.#buffer = joined;
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
/** A message is `tag` + Int32 length that counts itself but not the tag. */
|
|
80
105
|
#take(): PgMessage | undefined {
|
|
106
|
+
const done = this.#ready.shift();
|
|
107
|
+
if (done !== undefined) return messageOf(done);
|
|
81
108
|
const buffer = this.#buffer;
|
|
82
109
|
if (buffer.length < 5) return undefined;
|
|
83
110
|
const length = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getInt32(
|
|
@@ -92,16 +119,24 @@ export class MessageReader {
|
|
|
92
119
|
});
|
|
93
120
|
}
|
|
94
121
|
const total = length + 1;
|
|
95
|
-
if (buffer.length < total)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
122
|
+
if (buffer.length < total) {
|
|
123
|
+
// The length is known, so the message gets its one allocation now and fills in place.
|
|
124
|
+
const bytes = new Uint8Array(total);
|
|
125
|
+
bytes.set(buffer, 0);
|
|
126
|
+
this.#pending = { bytes, filled: buffer.length };
|
|
127
|
+
this.#buffer = new Uint8Array(0);
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
100
130
|
this.#buffer = buffer.subarray(total);
|
|
101
|
-
return
|
|
131
|
+
return messageOf(buffer.subarray(0, total));
|
|
102
132
|
}
|
|
103
133
|
}
|
|
104
134
|
|
|
135
|
+
const messageOf = (whole: Uint8Array): PgMessage => ({
|
|
136
|
+
tag: String.fromCharCode(whole[0] ?? 0),
|
|
137
|
+
body: whole.subarray(5),
|
|
138
|
+
});
|
|
139
|
+
|
|
105
140
|
/** `tag` + Int32 length + body — the shape of every frontend message except the startup packet. */
|
|
106
141
|
export const frame = (tag: string, body: Uint8Array): Uint8Array =>
|
|
107
142
|
new ByteWriter(body.length + 5)
|
|
@@ -178,10 +213,23 @@ export const describeFields = (fields: Readonly<Record<string, string>>): string
|
|
|
178
213
|
* not: `changefeed-env -> changefeed -> pg-replication -> pg-wire` is already a chain, so reading
|
|
179
214
|
* the constant here would close it into a cycle. Not re-exported from `index.ts`.
|
|
180
215
|
*/
|
|
216
|
+
export const REPLICATION_EXPOSURE_DOC =
|
|
217
|
+
'https://github.com/developerz-ai/ultimate/blob/main/docs/ops/01-kubernetes.md#replication-is-a-cluster-wide-grant';
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* What granting `REPLICATION` costs, said wherever a fix hands the grant over. The attribute is
|
|
221
|
+
* CLUSTER-wide: a `replication=database` session may run `BASE_BACKUP` or `START_REPLICATION
|
|
222
|
+
* PHYSICAL` with no database check, so on a shared cluster the app role could copy every database,
|
|
223
|
+
* `pg_authid` included, drop other slots and exhaust the walsenders.
|
|
224
|
+
*/
|
|
225
|
+
export const REPLICATION_GRANT_WARNING =
|
|
226
|
+
`only on a Postgres cluster dedicated to this app — on a shared cluster REPLICATION lets this ` +
|
|
227
|
+
`role copy every database (${REPLICATION_EXPOSURE_DOC})`;
|
|
228
|
+
|
|
181
229
|
export const FIXES: Readonly<Record<string, string>> = {
|
|
182
230
|
'28P01': 'correct the password in the replication URL — the server refused the credentials',
|
|
183
231
|
'28000': 'add a `host replication <user> <cidr> scram-sha-256` line to pg_hba.conf and reload',
|
|
184
|
-
'42501':
|
|
232
|
+
'42501': `ALTER ROLE <user> WITH REPLICATION; -- ${REPLICATION_GRANT_WARNING}`,
|
|
185
233
|
'55006': 'another replicator holds the slot — exactly one replicator per database, by design',
|
|
186
234
|
// NOT `x db replication init`, which this line said until 2026-08-20 and which is not a command:
|
|
187
235
|
// `x db` takes gen, migrate, reset, seed, studio, branch and backfill. It shipped because a fix
|
|
@@ -189,10 +237,11 @@ export const FIXES: Readonly<Record<string, string>> = {
|
|
|
189
237
|
// the half of #97 that outlived the three log-injection holes. The publication is the operator's
|
|
190
238
|
// to create; the slot the replicator creates for itself on its next start.
|
|
191
239
|
'42704':
|
|
192
|
-
'psql "$REPLICATION_URL" -c "CREATE PUBLICATION x_changes FOR
|
|
193
|
-
" # x_changes is the default name; use REPLICATION_PUBLICATION's value where it is set. " +
|
|
240
|
+
'psql "$REPLICATION_URL" -c "CREATE PUBLICATION x_changes FOR TABLE <every entity table>"' +
|
|
241
|
+
" # x_changes is the default name; use REPLICATION_PUBLICATION's value where it is set, and FOR TABLE because FOR ALL TABLES needs a superuser. " +
|
|
194
242
|
"The slot is the replicator's own and it creates one on its next start",
|
|
195
|
-
'0A000':
|
|
243
|
+
'0A000':
|
|
244
|
+
"set wal_level=logical in the server configuration (postgresql.conf, or your managed provider's database flags) and restart the server",
|
|
196
245
|
};
|
|
197
246
|
|
|
198
247
|
/** What a SQLSTATE this table has no entry for is answered with. */
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// A policy that admits every caller, for the tests that declare a channel whose authz is not
|
|
2
|
+
// their subject. Structural, never built with `@ultimat3/policy`: realtime reaches that package
|
|
3
|
+
// only through `@ultimat3/query`'s `guard`, and a direct import would be a second authz path.
|
|
4
|
+
// An app says the same thing with `allow('public')`, which is what this stands in for.
|
|
5
|
+
|
|
6
|
+
import type { QueryPolicy } from '@ultimat3/query';
|
|
7
|
+
|
|
8
|
+
export const OPEN_POLICY: QueryPolicy = Object.freeze({
|
|
9
|
+
kind: 'allow',
|
|
10
|
+
label: 'public',
|
|
11
|
+
permissions: [],
|
|
12
|
+
children: [],
|
|
13
|
+
run: () => ({ allowed: true as const }),
|
|
14
|
+
});
|
package/src/query-window.ts
CHANGED
|
@@ -125,29 +125,43 @@ export function createEntry(
|
|
|
125
125
|
export async function fillWindow(
|
|
126
126
|
entry: QueryEntry,
|
|
127
127
|
): Promise<{ rows: readonly Row[]; lsn: string }> {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
128
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
129
|
+
// Read before `startRead` clears it: a second caller arriving during the read joins it and is
|
|
130
|
+
// not the one that forced it, which is what keeps one forced read from becoming N.
|
|
131
|
+
const forced = entry.stale;
|
|
132
|
+
const pending = forced || entry.reading === null ? startRead(entry) : entry.reading;
|
|
133
|
+
const result = await pending.result;
|
|
134
|
+
const again = await entry.lock.run(async () => {
|
|
135
|
+
// Two rules, and neither can stand in for the other. Against another READ it is identity —
|
|
136
|
+
// the same check `startRead` makes on `entry.reading` one function down, and the one
|
|
137
|
+
// `packages/cache/src/single-flight.ts` makes for the same reason — because an lsn cannot
|
|
138
|
+
// order two reads at all: a definition with no lsn provider answers `''` for both, and
|
|
139
|
+
// `'' >= ''` let the older one overwrite the gap repair the newer one had just landed, with
|
|
140
|
+
// `stale` already cleared by its issue and therefore nothing left to re-read. Against a
|
|
141
|
+
// CHANGE it is still the lsn, because a fanout moved `entry.lsn` forwards while this read was
|
|
142
|
+
// in flight and rewinding to what the read saw hands that subscriber rows the fanout has
|
|
143
|
+
// moved past — except for a forced read, which was issued *because* what is under it is
|
|
144
|
+
// wrong, and for the FIRST read, which has no window under it to rewind: a fanout never
|
|
145
|
+
// patches a window no read has landed in (`live-fanout.ts`), it marks it stale instead.
|
|
146
|
+
const first = entry.applied === 0;
|
|
147
|
+
if (isNewestRead(entry, pending) && (forced || first || result.lsn >= entry.lsn)) {
|
|
148
|
+
applyRead(entry, pending, result);
|
|
149
|
+
}
|
|
150
|
+
// A change reached this window while its first read was in flight and could not be folded,
|
|
151
|
+
// so what just landed may predate it: read once more before serving anyone a partial window.
|
|
152
|
+
return first && entry.stale && attempt < COLD_REREADS;
|
|
153
|
+
});
|
|
154
|
+
if (!again) return { rows: entry.rows, lsn: entry.lsn };
|
|
155
|
+
}
|
|
149
156
|
}
|
|
150
157
|
|
|
158
|
+
/**
|
|
159
|
+
* How many times a cold window re-reads because writes kept landing during its read. Bounded: a
|
|
160
|
+
* table written faster than it can be read would otherwise never serve a subscriber, and after the
|
|
161
|
+
* bound the window stays `stale`, so the next change re-reads it anyway.
|
|
162
|
+
*/
|
|
163
|
+
const COLD_REREADS = 3;
|
|
164
|
+
|
|
151
165
|
/**
|
|
152
166
|
* The same replacement, for a caller that is already holding the lane. A fanout cannot call
|
|
153
167
|
* `fillWindow` — that takes the entry's own lane, and a lane is not reentrant — so the one path
|