@ultimat3/realtime 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/package.json +36 -0
  4. package/src/change-buffer.ts +69 -0
  5. package/src/changefeed-env.ts +146 -0
  6. package/src/changefeed.ts +191 -0
  7. package/src/channel.ts +181 -0
  8. package/src/client.ts +439 -0
  9. package/src/cursor.ts +188 -0
  10. package/src/errors.ts +253 -0
  11. package/src/fanout.ts +159 -0
  12. package/src/hooks.ts +230 -0
  13. package/src/index.ts +328 -0
  14. package/src/json.ts +76 -0
  15. package/src/live-definition.ts +144 -0
  16. package/src/live-query.ts +449 -0
  17. package/src/local-store.ts +188 -0
  18. package/src/matcher-bridge.ts +169 -0
  19. package/src/nats-commands.ts +97 -0
  20. package/src/nats-connection-fixture.ts +105 -0
  21. package/src/nats-connection.ts +464 -0
  22. package/src/nats-fake.ts +431 -0
  23. package/src/nats-jetstream.ts +226 -0
  24. package/src/nats-kv.ts +157 -0
  25. package/src/nats-protocol.ts +222 -0
  26. package/src/nats-socket.ts +236 -0
  27. package/src/nats-transport.ts +257 -0
  28. package/src/offline-queue.ts +206 -0
  29. package/src/pg-advisory-lock.ts +98 -0
  30. package/src/pg-auth.ts +300 -0
  31. package/src/pg-bytes.ts +185 -0
  32. package/src/pg-connection-fixture.ts +215 -0
  33. package/src/pg-connection.ts +337 -0
  34. package/src/pg-entity-row.ts +130 -0
  35. package/src/pg-replication-fixture.ts +261 -0
  36. package/src/pg-replication.ts +396 -0
  37. package/src/pg-socket.ts +265 -0
  38. package/src/pg-wire.ts +192 -0
  39. package/src/pgoutput.ts +297 -0
  40. package/src/policy-gate.ts +56 -0
  41. package/src/presence.ts +219 -0
  42. package/src/rebase.ts +198 -0
  43. package/src/replicator.ts +185 -0
  44. package/src/socket.ts +208 -0
  45. package/src/sync-node.ts +400 -0
  46. package/src/sync-protocol.ts +376 -0
  47. package/src/thundering-herd.ts +141 -0
  48. package/src/transport-env.ts +104 -0
package/src/pg-auth.ts ADDED
@@ -0,0 +1,300 @@
1
+ // Single responsibility: Postgres authentication computations — MD5 legacy hashing and the
2
+ // SCRAM-SHA-256 SASL exchange (RFC 5802 + RFC 7677). Pure crypto over bytes in, bytes out: the
3
+ // socket, the message envelope and the AuthenticationXxx dispatch belong to pg-wire.ts, not here.
4
+
5
+ import { ReplicationFailedError, ReplicationProtocolError } from './errors';
6
+ import type { Rng } from './thundering-herd';
7
+
8
+ export const SCRAM_SHA_256 = 'SCRAM-SHA-256';
9
+
10
+ // No channel binding: `n,,` is the gs2-header for "client does not support channel binding".
11
+ // `c=biws` in the final message is that same 3-byte header, base64'd — fixed, so it is spelled
12
+ // out where used rather than recomputed on every exchange.
13
+ const GS2_HEADER = 'n,,';
14
+
15
+ const encoder = new TextEncoder();
16
+ const decoder = new TextDecoder();
17
+
18
+ /** One SASL exchange. Created per connection; never reused across connections. */
19
+ export interface ScramSession {
20
+ readonly mechanism: string;
21
+ /** `n,,n=,r=<nonce>` — the payload of SASLInitialResponse (the wire framing is not yours). */
22
+ clientFirst(): Uint8Array;
23
+ /** server-first-message in, client-final-message out. */
24
+ clientFinal(serverFirst: Uint8Array): Promise<Uint8Array>;
25
+ /** server-final-message. Resolves when the server proved it knows the password; throws otherwise. */
26
+ verify(serverFinal: Uint8Array): Promise<void>;
27
+ }
28
+
29
+ class ScramSha256Session implements ScramSession {
30
+ readonly mechanism = SCRAM_SHA_256;
31
+ readonly #password: string;
32
+ readonly #clientNonce: string;
33
+ readonly #clientFirstBare: string;
34
+ #serverSignature: Uint8Array | undefined;
35
+ #finalized = false;
36
+
37
+ constructor(args: { password: string; nonce: string }) {
38
+ this.#password = args.password;
39
+ this.#clientNonce = args.nonce;
40
+ // The username is deliberately empty: Postgres takes it from the startup packet and only
41
+ // wants a SASLprep'd empty `n=` here, per its SCRAM implementation.
42
+ this.#clientFirstBare = `n=,r=${args.nonce}`;
43
+ }
44
+
45
+ clientFirst(): Uint8Array {
46
+ return encoder.encode(`${GS2_HEADER}${this.#clientFirstBare}`);
47
+ }
48
+
49
+ async clientFinal(serverFirst: Uint8Array): Promise<Uint8Array> {
50
+ if (this.#finalized) {
51
+ throw new ReplicationProtocolError({
52
+ stage: 'auth',
53
+ detail: 'clientFinal() was already called on this SCRAM session; a session is single-use',
54
+ fix: 'call scramSession() again to start a fresh exchange for the next connection attempt',
55
+ });
56
+ }
57
+ this.#finalized = true;
58
+
59
+ const serverFirstText = decoder.decode(serverFirst);
60
+ const { nonce: serverNonce, salt, iterations } = parseServerFirst(serverFirstText);
61
+ // The server must echo our nonce back as a prefix of the combined one — the RFC 5802 check
62
+ // that stands between this exchange and a server that never actually saw our client-first.
63
+ if (!serverNonce.startsWith(this.#clientNonce)) {
64
+ throw new ReplicationProtocolError({
65
+ stage: 'auth',
66
+ detail: `server nonce "${serverNonce}" does not extend client nonce "${this.#clientNonce}"`,
67
+ fix: 'x doctor db — the server did not echo the client nonce; check for a proxy on the replication URL',
68
+ });
69
+ }
70
+
71
+ const saltedPassword = await pbkdf2(this.#password, salt, iterations);
72
+ const clientKey = await hmac(saltedPassword, 'Client Key');
73
+ const storedKey = await sha256(clientKey);
74
+
75
+ const clientFinalWithoutProof = `c=biws,r=${serverNonce}`;
76
+ const authMessage = `${this.#clientFirstBare},${serverFirstText},${clientFinalWithoutProof}`;
77
+
78
+ const clientSignature = await hmac(storedKey, authMessage);
79
+ const clientProof = xorBytes(clientKey, clientSignature);
80
+
81
+ const serverKey = await hmac(saltedPassword, 'Server Key');
82
+ this.#serverSignature = await hmac(serverKey, authMessage);
83
+
84
+ return encoder.encode(`${clientFinalWithoutProof},p=${bytesToBase64(clientProof)}`);
85
+ }
86
+
87
+ async verify(serverFinal: Uint8Array): Promise<void> {
88
+ if (!this.#serverSignature) {
89
+ throw new ReplicationProtocolError({
90
+ stage: 'auth',
91
+ detail: 'verify() was called before clientFinal() produced a server signature to check',
92
+ fix: 'call clientFinal() with the server-first-message before verify()',
93
+ });
94
+ }
95
+
96
+ const text = decoder.decode(serverFinal);
97
+ const attrs = parseAttributes(text);
98
+
99
+ const serverError = attrs.get('e');
100
+ if (serverError !== undefined) {
101
+ throw new ReplicationFailedError({
102
+ stage: 'auth',
103
+ detail:
104
+ `server rejected the SCRAM exchange: ${serverError} — the password in DATABASE_URL ` +
105
+ "is not this role's, so either that value or the role itself has to change",
106
+ fix: 'psql "$DATABASE_URL" -c "ALTER ROLE <user> WITH PASSWORD \'<new>\'"',
107
+ });
108
+ }
109
+
110
+ const proof = attrs.get('v');
111
+ if (proof === undefined) {
112
+ throw new ReplicationProtocolError({
113
+ stage: 'auth',
114
+ detail: `server-final-message has neither "v=" nor "e=": "${text}"`,
115
+ });
116
+ }
117
+ // Every byte, no early return: a mismatch found sooner than a mismatch found later must take
118
+ // the same time, or the comparison itself becomes an oracle for guessing the right proof.
119
+ if (!constantTimeEqual(decodeBase64('server signature', proof), this.#serverSignature)) {
120
+ throw new ReplicationFailedError({
121
+ stage: 'auth',
122
+ detail:
123
+ 'server-final-message signature does not match — the server could not prove it knows ' +
124
+ 'the password, so DATABASE_URL points at something other than postgres, or the role ' +
125
+ 'password changed under this connection',
126
+ fix: 'psql "$DATABASE_URL" -c "SELECT version()"',
127
+ });
128
+ }
129
+ }
130
+ }
131
+
132
+ export function scramSession(args: { password: string; nonce: string }): ScramSession {
133
+ return new ScramSha256Session(args);
134
+ }
135
+
136
+ /**
137
+ * 18 random bytes, base64 — the client nonce. RFC 5802 §5.1 requires a fresh nonce per exchange
138
+ * and points at RFC 4086 for what "random" has to mean there, so the default source is the
139
+ * CSPRNG: a predictable nonce lets an attacker who has recorded one exchange replay a proof
140
+ * against a client whose next nonce it can guess. `Rng` is the deterministic *test* seam and
141
+ * nothing else — production passes no argument.
142
+ */
143
+ export function scramNonce(rng?: Rng): string {
144
+ const bytes = new Uint8Array(18);
145
+ if (rng === undefined) {
146
+ crypto.getRandomValues(bytes);
147
+ } else {
148
+ for (let index = 0; index < bytes.length; index += 1) {
149
+ bytes[index] = Math.floor(rng() * 256) & 0xff;
150
+ }
151
+ }
152
+ // Base64's alphabet never includes `,`, so the nonce can never collide with the attribute
153
+ // separator it will be embedded next to on the wire — nothing further to escape.
154
+ return bytesToBase64(bytes);
155
+ }
156
+
157
+ /**
158
+ * `md5` + md5(md5(password + user) + salt) — the AuthenticationMD5Password answer. The inner
159
+ * digest feeds the outer one as its *hex text*, not its raw bytes: that double encoding is
160
+ * Postgres's wire format, not a choice made here. MD5 has no WebCrypto entry, so this is the
161
+ * one spot in the module that reaches for `Bun.CryptoHasher` — the hash algorithm is legacy and
162
+ * the server's own choice, not one this module would otherwise make.
163
+ */
164
+ export function md5Password(args: { user: string; password: string; salt: Uint8Array }): string {
165
+ const inner = new Bun.CryptoHasher('md5').update(args.password + args.user).digest('hex');
166
+ const outer = new Bun.CryptoHasher('md5').update(inner).update(args.salt).digest('hex');
167
+ return `md5${outer}`;
168
+ }
169
+
170
+ /** Picks SCRAM-SHA-256 out of the mechanism list the server offered, or throws. */
171
+ export function chooseMechanism(offered: readonly string[]): string {
172
+ if (offered.includes(SCRAM_SHA_256)) return SCRAM_SHA_256;
173
+ throw new ReplicationProtocolError({
174
+ stage: 'auth',
175
+ detail:
176
+ offered.length === 0
177
+ ? 'the server offered no SASL mechanisms'
178
+ : `the server only offered ${offered.join(', ')} — channel binding ("-PLUS") is not supported here`,
179
+ fix: 'set password_encryption = scram-sha-256 on the server and recreate the role password',
180
+ });
181
+ }
182
+
183
+ /**
184
+ * The iteration count is the server's to choose, and `pbkdf2()` runs it before this client has
185
+ * proved anything about the peer — so an unbounded `i=` is a peer spending our CPU at will, on
186
+ * the connect path, where nothing above imposes a deadline. RFC 7677 §4 sets the floor at 4096
187
+ * and a real postgres sends exactly that, so two orders of magnitude of headroom refuses the
188
+ * attack without ever refusing a server.
189
+ */
190
+ const MAX_SCRAM_ITERATIONS = 1_000_000;
191
+
192
+ interface ServerFirst {
193
+ readonly nonce: string;
194
+ readonly salt: Uint8Array;
195
+ readonly iterations: number;
196
+ }
197
+
198
+ function parseServerFirst(text: string): ServerFirst {
199
+ const attrs = parseAttributes(text);
200
+ const nonce = attrs.get('r');
201
+ const saltB64 = attrs.get('s');
202
+ const iterationsText = attrs.get('i');
203
+ if (nonce === undefined || saltB64 === undefined || iterationsText === undefined) {
204
+ throw new ReplicationProtocolError({
205
+ stage: 'auth',
206
+ detail: `server-first-message is missing r=, s= or i=: "${text}"`,
207
+ });
208
+ }
209
+ if (!/^[1-9]\d*$/.test(iterationsText)) {
210
+ throw new ReplicationProtocolError({
211
+ stage: 'auth',
212
+ detail: `server-first-message iteration count "${iterationsText}" is not a positive integer`,
213
+ });
214
+ }
215
+ const iterations = Number.parseInt(iterationsText, 10);
216
+ if (iterations > MAX_SCRAM_ITERATIONS) {
217
+ throw new ReplicationProtocolError({
218
+ stage: 'auth',
219
+ detail: `server-first-message asked for ${iterationsText} PBKDF2 iterations, above the ${MAX_SCRAM_ITERATIONS} ceiling`,
220
+ fix: 'x doctor db — point the replication URL at postgres itself; a real server asks for 4096',
221
+ });
222
+ }
223
+ const salt = decodeBase64('salt', saltB64);
224
+ return { nonce, salt, iterations };
225
+ }
226
+
227
+ /** `k=v` pairs split on `,`. Unrecognised keys are ignored — RFC 5802 reserves them for extensions. */
228
+ function parseAttributes(message: string): Map<string, string> {
229
+ const attrs = new Map<string, string>();
230
+ for (const segment of message.split(',')) {
231
+ const at = segment.indexOf('=');
232
+ if (at < 0) continue;
233
+ attrs.set(segment.slice(0, at), segment.slice(at + 1));
234
+ }
235
+ return attrs;
236
+ }
237
+
238
+ async function pbkdf2(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
239
+ const key = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, [
240
+ 'deriveBits',
241
+ ]);
242
+ const bits = await crypto.subtle.deriveBits(
243
+ // Copied into a fresh buffer: WebCrypto's `BufferSource` excludes a SharedArrayBuffer-backed
244
+ // view, and these values arrive as subarrays of the read buffer.
245
+ { name: 'PBKDF2', hash: 'SHA-256', salt: new Uint8Array(salt), iterations },
246
+ key,
247
+ 256,
248
+ );
249
+ return new Uint8Array(bits);
250
+ }
251
+
252
+ async function hmac(key: Uint8Array, data: string): Promise<Uint8Array> {
253
+ const cryptoKey = await crypto.subtle.importKey(
254
+ 'raw',
255
+ new Uint8Array(key),
256
+ { name: 'HMAC', hash: 'SHA-256' },
257
+ false,
258
+ ['sign'],
259
+ );
260
+ const mac = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(data));
261
+ return new Uint8Array(mac);
262
+ }
263
+
264
+ async function sha256(data: Uint8Array): Promise<Uint8Array> {
265
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(data)));
266
+ }
267
+
268
+ function xorBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
269
+ const out = new Uint8Array(a.length);
270
+ for (let index = 0; index < a.length; index += 1) out[index] = (a[index] ?? 0) ^ (b[index] ?? 0);
271
+ return out;
272
+ }
273
+
274
+ /** Every byte, no early return — a timing side-channel on the server proof is a MITM's foothold. */
275
+ function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
276
+ const length = Math.max(a.length, b.length);
277
+ let diff = a.length ^ b.length;
278
+ for (let index = 0; index < length; index += 1) {
279
+ diff |= (a[index] ?? 0) ^ (b[index] ?? 0);
280
+ }
281
+ return diff === 0;
282
+ }
283
+
284
+ function bytesToBase64(bytes: Uint8Array): string {
285
+ let binary = '';
286
+ for (const byte of bytes) binary += String.fromCharCode(byte);
287
+ return btoa(binary);
288
+ }
289
+
290
+ /** A server that sends a non-base64 attribute is a wire-format bug, not an auth failure. */
291
+ function decodeBase64(label: string, value: string): Uint8Array {
292
+ try {
293
+ return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
294
+ } catch {
295
+ throw new ReplicationProtocolError({
296
+ stage: 'auth',
297
+ detail: `${label} "${value}" is not valid base64`,
298
+ });
299
+ }
300
+ }
@@ -0,0 +1,185 @@
1
+ // Single responsibility: the byte primitives every Postgres frame is built from — big-endian
2
+ // integers, NUL-terminated strings, length-prefixed blobs. Shared by the wire framing, the auth
3
+ // handshake and the pgoutput decoder so none of them owns a second copy of "read an Int32".
4
+ // A read past the end is `X_REPLICATION_PROTOCOL`, never a silent `NaN` or an empty string.
5
+
6
+ import { ReplicationProtocolError } from './errors';
7
+
8
+ const decoder = new TextDecoder();
9
+ const encoder = new TextEncoder();
10
+
11
+ /** Cursor over a frame. Every read advances it; overrun throws rather than wrapping around. */
12
+ export class ByteReader {
13
+ readonly #bytes: Uint8Array;
14
+ readonly #view: DataView;
15
+ readonly #stage: string;
16
+ #at: number;
17
+
18
+ constructor(bytes: Uint8Array, stage = 'decode') {
19
+ this.#bytes = bytes;
20
+ this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
21
+ this.#stage = stage;
22
+ this.#at = 0;
23
+ }
24
+
25
+ get offset(): number {
26
+ return this.#at;
27
+ }
28
+
29
+ get remaining(): number {
30
+ return this.#bytes.length - this.#at;
31
+ }
32
+
33
+ #need(count: number): number {
34
+ const at = this.#at;
35
+ if (at + count > this.#bytes.length) {
36
+ throw new ReplicationProtocolError({
37
+ stage: this.#stage,
38
+ detail: `a message ended after ${this.#bytes.length} bytes while reading ${count} more at offset ${at}`,
39
+ });
40
+ }
41
+ this.#at = at + count;
42
+ return at;
43
+ }
44
+
45
+ uint8(): number {
46
+ return this.#view.getUint8(this.#need(1));
47
+ }
48
+
49
+ int16(): number {
50
+ return this.#view.getInt16(this.#need(2), false);
51
+ }
52
+
53
+ int32(): number {
54
+ return this.#view.getInt32(this.#need(4), false);
55
+ }
56
+
57
+ uint32(): number {
58
+ return this.#view.getUint32(this.#need(4), false);
59
+ }
60
+
61
+ int64(): bigint {
62
+ return this.#view.getBigInt64(this.#need(8), false);
63
+ }
64
+
65
+ uint64(): bigint {
66
+ return this.#view.getBigUint64(this.#need(8), false);
67
+ }
68
+
69
+ /** A single byte as its ASCII character — the tag every Postgres message leads with. */
70
+ tag(): string {
71
+ return String.fromCharCode(this.uint8());
72
+ }
73
+
74
+ take(count: number): Uint8Array {
75
+ const at = this.#need(count);
76
+ return this.#bytes.subarray(at, at + count);
77
+ }
78
+
79
+ /** The whole tail, without copying. */
80
+ rest(): Uint8Array {
81
+ return this.take(this.remaining);
82
+ }
83
+
84
+ utf8(count: number): string {
85
+ return decoder.decode(this.take(count));
86
+ }
87
+
88
+ /** NUL-terminated string. A frame that never terminates one is a truncated frame. */
89
+ cstring(): string {
90
+ const end = this.#bytes.indexOf(0, this.#at);
91
+ if (end < 0) {
92
+ throw new ReplicationProtocolError({
93
+ stage: this.#stage,
94
+ detail: `an unterminated string started at offset ${this.#at}`,
95
+ });
96
+ }
97
+ const value = decoder.decode(this.#bytes.subarray(this.#at, end));
98
+ this.#at = end + 1;
99
+ return value;
100
+ }
101
+ }
102
+
103
+ /** Builder for a frame. Grows geometrically; `finish()` hands back exactly the bytes written. */
104
+ export class ByteWriter {
105
+ #bytes: Uint8Array;
106
+ #dataView: DataView;
107
+ #at = 0;
108
+
109
+ constructor(capacity = 128) {
110
+ this.#bytes = new Uint8Array(capacity);
111
+ this.#dataView = new DataView(this.#bytes.buffer);
112
+ }
113
+
114
+ get length(): number {
115
+ return this.#at;
116
+ }
117
+
118
+ /** Reserves `count` bytes and returns where they start — growing, and re-viewing, if needed. */
119
+ #room(count: number): number {
120
+ const at = this.#at;
121
+ if (at + count > this.#bytes.length) {
122
+ const grown = new Uint8Array(Math.max(this.#bytes.length * 2, at + count));
123
+ grown.set(this.#bytes.subarray(0, at));
124
+ this.#bytes = grown;
125
+ this.#dataView = new DataView(grown.buffer);
126
+ }
127
+ this.#at = at + count;
128
+ return at;
129
+ }
130
+
131
+ uint8(value: number): this {
132
+ this.#bytes[this.#room(1)] = value & 0xff;
133
+ return this;
134
+ }
135
+
136
+ int16(value: number): this {
137
+ // `#room` may replace the buffer, so the offset is taken before the view is touched.
138
+ const at = this.#room(2);
139
+ this.#dataView.setInt16(at, value, false);
140
+ return this;
141
+ }
142
+
143
+ int32(value: number): this {
144
+ const at = this.#room(4);
145
+ this.#dataView.setInt32(at, value, false);
146
+ return this;
147
+ }
148
+
149
+ int64(value: bigint): this {
150
+ const at = this.#room(8);
151
+ this.#dataView.setBigInt64(at, value, false);
152
+ return this;
153
+ }
154
+
155
+ raw(bytes: Uint8Array): this {
156
+ const at = this.#room(bytes.length);
157
+ this.#bytes.set(bytes, at);
158
+ return this;
159
+ }
160
+
161
+ utf8(value: string): this {
162
+ return this.raw(encoder.encode(value));
163
+ }
164
+
165
+ cstring(value: string): this {
166
+ return this.utf8(value).uint8(0);
167
+ }
168
+
169
+ finish(): Uint8Array {
170
+ return this.#bytes.slice(0, this.#at);
171
+ }
172
+ }
173
+
174
+ /** `0/16B3748` — how Postgres prints an LSN on the wire and in `pg_replication_slots`. */
175
+ export const printLsn = (position: bigint): string =>
176
+ `${(position >> 32n).toString(16).toUpperCase()}/${(position & 0xffffffffn).toString(16).toUpperCase()}`;
177
+
178
+ /** Postgres timestamps in the replication stream are µs since 2000-01-01, not the Unix epoch. */
179
+ export const PG_EPOCH_MS = 946_684_800_000;
180
+
181
+ export const pgTimestampToEpochMs = (micros: bigint): number =>
182
+ Number(micros / 1000n) + PG_EPOCH_MS;
183
+
184
+ export const epochMsToPgTimestamp = (epochMs: number): bigint =>
185
+ BigInt(Math.trunc(epochMs) - PG_EPOCH_MS) * 1000n;
@@ -0,0 +1,215 @@
1
+ // Single responsibility: the one scripted Postgres server this package's `PgConnection` tests run
2
+ // against — a fake `PgStream` plus a builder per backend message. Two test files need it, and two
3
+ // private copies would be two chances to script a frame the real server never sends.
4
+ // Not part of the public API — `index.ts` deliberately does not re-export it.
5
+
6
+ import { ByteReader, ByteWriter } from './pg-bytes';
7
+ import { PgConnection, type PgConnectionOptions } from './pg-connection';
8
+ import { frame, type PgStream } from './pg-wire';
9
+
10
+ export const encoder = new TextEncoder();
11
+ export const decoder = new TextDecoder();
12
+
13
+ /**
14
+ * A scriptable fake `PgStream`. `push()` queues chunks for `read()`; once the queue is empty and
15
+ * the stream has not `end()`-ed, `read()` parks until the next `push()`/`end()` — the shape a test
16
+ * needs to script a real request/response handshake. `write()` records every byte the client sent.
17
+ */
18
+ export class FakeStream implements PgStream {
19
+ readonly writes: Uint8Array[] = [];
20
+ readonly #queue: Uint8Array[] = [];
21
+ #ended = false;
22
+ #parked: ((chunk: Uint8Array | undefined) => void) | undefined;
23
+ #closed = false;
24
+ #nextWriteError: Error | undefined;
25
+ #closeError: Error | undefined;
26
+
27
+ push(...chunks: readonly Uint8Array[]): void {
28
+ for (const chunk of chunks) {
29
+ const parked = this.#parked;
30
+ if (parked !== undefined) {
31
+ this.#parked = undefined;
32
+ parked(chunk);
33
+ } else {
34
+ this.#queue.push(chunk);
35
+ }
36
+ }
37
+ }
38
+
39
+ /** Clean EOF: releases a parked `read()` with `undefined`, and every one after it. */
40
+ end(): void {
41
+ this.#ended = true;
42
+ const parked = this.#parked;
43
+ if (parked !== undefined) {
44
+ this.#parked = undefined;
45
+ parked(undefined);
46
+ }
47
+ }
48
+
49
+ throwOnNextWrite(error: Error): void {
50
+ this.#nextWriteError = error;
51
+ }
52
+
53
+ /** A socket whose `close()` throws — the failure that must not replace the one being reported. */
54
+ throwOnClose(error: Error): void {
55
+ this.#closeError = error;
56
+ }
57
+
58
+ get closed(): boolean {
59
+ return this.#closed;
60
+ }
61
+
62
+ read(): Promise<Uint8Array | undefined> {
63
+ const next = this.#queue.shift();
64
+ if (next !== undefined) return Promise.resolve(next);
65
+ if (this.#ended) return Promise.resolve(undefined);
66
+ return new Promise((resolve) => {
67
+ this.#parked = resolve;
68
+ });
69
+ }
70
+
71
+ write(bytes: Uint8Array): Promise<void> {
72
+ this.writes.push(bytes);
73
+ const error = this.#nextWriteError;
74
+ if (error !== undefined) {
75
+ this.#nextWriteError = undefined;
76
+ return Promise.reject(error);
77
+ }
78
+ return Promise.resolve();
79
+ }
80
+
81
+ close(): void {
82
+ this.#closed = true;
83
+ if (this.#closeError !== undefined) throw this.#closeError;
84
+ }
85
+ }
86
+
87
+ // --- Backend message builders: one per row of the wire table, `frame()` owns tag + length -------
88
+
89
+ export const AUTH_OK = 0;
90
+ export const AUTH_CLEARTEXT = 3;
91
+ export const AUTH_MD5 = 5;
92
+ export const AUTH_SASL = 10;
93
+ export const AUTH_SASL_CONTINUE = 11;
94
+ export const AUTH_SASL_FINAL = 12;
95
+ export const AUTH_GSSAPI = 7;
96
+
97
+ export const authMethod = (method: number, extra: Uint8Array = new Uint8Array(0)): Uint8Array =>
98
+ frame('R', new ByteWriter().int32(method).raw(extra).finish());
99
+ export const authOk = (): Uint8Array => authMethod(AUTH_OK);
100
+ export const authCleartext = (): Uint8Array => authMethod(AUTH_CLEARTEXT);
101
+ export const authMd5 = (salt: Uint8Array): Uint8Array => authMethod(AUTH_MD5, salt);
102
+ export const authGssapi = (): Uint8Array => authMethod(AUTH_GSSAPI);
103
+ export const authSaslContinue = (payload: Uint8Array): Uint8Array =>
104
+ authMethod(AUTH_SASL_CONTINUE, payload);
105
+ export const authSaslFinal = (payload: Uint8Array): Uint8Array =>
106
+ authMethod(AUTH_SASL_FINAL, payload);
107
+ export const authSasl = (...mechanisms: readonly string[]): Uint8Array => {
108
+ const writer = new ByteWriter().int32(AUTH_SASL);
109
+ for (const mechanism of mechanisms) writer.cstring(mechanism);
110
+ return frame('R', writer.uint8(0).finish());
111
+ };
112
+
113
+ export const parameterStatus = (name: string, value: string): Uint8Array =>
114
+ frame('S', new ByteWriter().cstring(name).cstring(value).finish());
115
+
116
+ export const backendKeyData = (pid: number, secret: number): Uint8Array =>
117
+ frame('K', new ByteWriter().int32(pid).int32(secret).finish());
118
+
119
+ export const READY_IDLE = 'I'.charCodeAt(0);
120
+ export const readyForQuery = (): Uint8Array =>
121
+ frame('Z', new ByteWriter().uint8(READY_IDLE).finish());
122
+
123
+ export const rowDescriptionStub = (): Uint8Array => frame('T', new Uint8Array(0));
124
+
125
+ export const dataRow = (...values: readonly (string | null)[]): Uint8Array => {
126
+ const writer = new ByteWriter().int16(values.length);
127
+ for (const value of values) {
128
+ if (value === null) {
129
+ writer.int32(-1);
130
+ } else {
131
+ const bytes = encoder.encode(value);
132
+ writer.int32(bytes.length).raw(bytes);
133
+ }
134
+ }
135
+ return frame('D', writer.finish());
136
+ };
137
+
138
+ export const commandComplete = (tag: string): Uint8Array =>
139
+ frame('C', new ByteWriter().cstring(tag).finish());
140
+
141
+ export const fieldedMessage = (
142
+ tag: 'E' | 'N',
143
+ fields: Readonly<Record<string, string>>,
144
+ ): Uint8Array => {
145
+ const writer = new ByteWriter();
146
+ for (const [code, value] of Object.entries(fields)) {
147
+ writer.uint8(code.charCodeAt(0)).cstring(value);
148
+ }
149
+ return frame(tag, writer.uint8(0).finish());
150
+ };
151
+ export const errorResponse = (fields: Readonly<Record<string, string>>): Uint8Array =>
152
+ fieldedMessage('E', fields);
153
+ export const noticeResponse = (fields: Readonly<Record<string, string>>): Uint8Array =>
154
+ fieldedMessage('N', fields);
155
+
156
+ export const copyBothResponse = (): Uint8Array =>
157
+ frame('W', new ByteWriter().uint8(0).int16(0).finish());
158
+ export const copyData = (payload: Uint8Array): Uint8Array => frame('d', payload);
159
+ export const copyDoneFrame = (): Uint8Array => frame('c', new Uint8Array(0));
160
+
161
+ /** One frontend `tag` + Int32 length + body frame — the shape `frame()` builds. */
162
+ export const decodeFrame = (bytes: Uint8Array): { tag: string; body: Uint8Array } => {
163
+ const reader = new ByteReader(bytes);
164
+ const tag = reader.tag();
165
+ const length = reader.int32();
166
+ return { tag, body: reader.take(length - 4) };
167
+ };
168
+
169
+ /** The tagless startup packet: Int32 length, Int32 version, then key/value cstrings to `\0`. */
170
+ export const decodeStartup = (
171
+ bytes: Uint8Array,
172
+ ): { version: number; params: Record<string, string> } => {
173
+ const reader = new ByteReader(bytes);
174
+ reader.int32(); // total length — implied by `bytes.length`, not needed to check the shape
175
+ const version = reader.int32();
176
+ const params: Record<string, string> = {};
177
+ for (;;) {
178
+ const key = reader.cstring();
179
+ if (key === '') break;
180
+ params[key] = reader.cstring();
181
+ }
182
+ return { version, params };
183
+ };
184
+
185
+ export const toBase64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes));
186
+
187
+ // --- Fixtures --------------------------------------------------------------------------------
188
+
189
+ export const opts = (
190
+ stream: PgStream,
191
+ extra?: Partial<PgConnectionOptions>,
192
+ ): PgConnectionOptions => ({
193
+ stream,
194
+ user: 'repluser',
195
+ database: 'app',
196
+ ...extra,
197
+ });
198
+
199
+ /** A connection past the handshake, via the cheapest auth method — trust. */
200
+ export async function openTrusted(
201
+ stream: FakeStream,
202
+ extra?: Partial<PgConnectionOptions>,
203
+ ): Promise<PgConnection> {
204
+ stream.push(authOk(), readyForQuery());
205
+ return PgConnection.open(opts(stream, extra));
206
+ }
207
+
208
+ export const START_REPLICATION_SQL = 'START_REPLICATION SLOT s LOGICAL 0/0';
209
+
210
+ export async function openInCopyBoth(stream: FakeStream): Promise<PgConnection> {
211
+ const connection = await openTrusted(stream);
212
+ stream.push(copyBothResponse());
213
+ await connection.startCopyBoth(START_REPLICATION_SQL);
214
+ return connection;
215
+ }