@ultimat3/realtime 1.2.0 → 2.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 (61) hide show
  1. package/CLAUDE.md +591 -0
  2. package/README.md +320 -19
  3. package/package.json +6 -3
  4. package/src/apply-patches.ts +60 -0
  5. package/src/change-buffer.ts +77 -11
  6. package/src/channel.ts +174 -19
  7. package/src/client-contract.ts +81 -0
  8. package/src/client-frames.ts +175 -0
  9. package/src/client-heartbeat.ts +77 -0
  10. package/src/client-mutations.ts +114 -0
  11. package/src/client-topics.ts +54 -0
  12. package/src/client.ts +307 -273
  13. package/src/cursor.ts +7 -1
  14. package/src/errors.ts +193 -4
  15. package/src/frame-lanes.ts +58 -0
  16. package/src/hooks.ts +19 -5
  17. package/src/identity-map.ts +141 -0
  18. package/src/index.ts +96 -28
  19. package/src/json.ts +38 -1
  20. package/src/live-contract.ts +67 -0
  21. package/src/live-definition.ts +16 -11
  22. package/src/live-fanout.ts +150 -0
  23. package/src/live-query.ts +215 -268
  24. package/src/live-rows.ts +143 -0
  25. package/src/local-store.ts +86 -43
  26. package/src/nats-client.ts +132 -0
  27. package/src/nats-fake.ts +389 -344
  28. package/src/nats-jetstream.ts +21 -20
  29. package/src/nats-kv.ts +7 -7
  30. package/src/nats-lib-client.ts +210 -0
  31. package/src/nats-transport.ts +109 -138
  32. package/src/offline-queue.ts +146 -30
  33. package/src/pg-entity-row.ts +99 -31
  34. package/src/pg-replication.ts +84 -27
  35. package/src/pg-socket.ts +4 -1
  36. package/src/policy-gate.ts +13 -5
  37. package/src/presence.ts +76 -6
  38. package/src/query-hook.ts +56 -0
  39. package/src/query-window.ts +151 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +170 -14
  43. package/src/subscriber-gate.ts +209 -0
  44. package/src/subscription-book.ts +237 -0
  45. package/src/sync-auth.ts +124 -0
  46. package/src/sync-frames.ts +185 -0
  47. package/src/sync-listen.ts +73 -0
  48. package/src/sync-node.ts +284 -243
  49. package/src/sync-protocol.ts +115 -24
  50. package/src/sync-upgrade.ts +124 -0
  51. package/src/thundering-herd.ts +21 -0
  52. package/src/transport-env.ts +3 -3
  53. package/src/type-pins.ts +72 -0
  54. package/src/window-lock.ts +21 -0
  55. package/src/nats-commands.ts +0 -97
  56. package/src/nats-connection-fixture.ts +0 -105
  57. package/src/nats-connection.ts +0 -464
  58. package/src/nats-protocol.ts +0 -222
  59. package/src/nats-socket.ts +0 -236
  60. package/src/pg-connection-fixture.ts +0 -215
  61. package/src/pg-replication-fixture.ts +0 -261
@@ -1,222 +0,0 @@
1
- // Single responsibility: the NATS client protocol codec — pure encode/decode of the text
2
- // protocol nats-server speaks on port 4222. No sockets, no timers, no randomness: chunks of
3
- // bytes in, whole operations out, so the socket layer can be tested with no network at all.
4
-
5
- import { TransportProtocolError } from './errors';
6
-
7
- const decoder = new TextDecoder();
8
- const CR = 0x0d;
9
- const LF = 0x0a;
10
-
11
- /** A single declared payload is bounded so a corrupt byte count cannot allocate the machine. */
12
- const MAX_PAYLOAD_BYTES = 64 * 1024 * 1024;
13
-
14
- const protocolError = (stage: 'read' | 'headers', detail: string): TransportProtocolError =>
15
- new TransportProtocolError({ transport: 'nats', stage, detail });
16
- const missingArg = (label: string): never => {
17
- throw protocolError('read', `missing ${label}`);
18
- };
19
-
20
- export interface NatsServerInfo {
21
- readonly serverId: string;
22
- readonly version: string;
23
- readonly maxPayload: number;
24
- readonly tlsRequired: boolean;
25
- readonly tlsAvailable: boolean;
26
- readonly authRequired: boolean;
27
- readonly headers: boolean;
28
- readonly nonce: string | undefined;
29
- }
30
- export type NatsHeaders = ReadonlyMap<string, string>;
31
- export interface NatsMessage {
32
- readonly subject: string;
33
- readonly sid: string;
34
- readonly replyTo: string | undefined;
35
- readonly payload: Uint8Array;
36
- readonly headers: NatsHeaders;
37
- /** From `NATS/1.0 <code> <description>`; `undefined` on a plain MSG or a status-less header. */
38
- readonly status: number | undefined;
39
- readonly description: string | undefined;
40
- }
41
- export type NatsOperation =
42
- | { readonly kind: 'info'; readonly info: NatsServerInfo }
43
- | { readonly kind: 'msg'; readonly message: NatsMessage }
44
- | { readonly kind: 'ping' }
45
- | { readonly kind: 'pong' }
46
- | { readonly kind: 'ok' }
47
- | { readonly kind: 'err'; readonly detail: string };
48
-
49
- const EMPTY_HEADERS: NatsHeaders = new Map<string, string>();
50
- const indexOfCrlf = (bytes: Uint8Array, from: number): number => {
51
- for (let i = from; i < bytes.length - 1; i += 1) {
52
- if (bytes[i] === CR && bytes[i + 1] === LF) return i;
53
- }
54
- return -1;
55
- };
56
- export const concatBytes = (...parts: readonly Uint8Array[]): Uint8Array => {
57
- const total = parts.reduce((sum, part) => sum + part.length, 0);
58
- const joined = new Uint8Array(total);
59
- let at = 0;
60
- for (const part of parts) {
61
- joined.set(part, at);
62
- at += part.length;
63
- }
64
- return joined;
65
- };
66
- const splitArgs = (text: string): string[] =>
67
- text.split(/[ \t]+/).filter((part) => part.length > 0);
68
- const parseByteCount = (text: string | undefined): number => {
69
- if (text === undefined || !/^\d+$/.test(text)) {
70
- throw protocolError('read', `expected a byte count, got "${text ?? ''}"`);
71
- }
72
- return Number(text);
73
- };
74
-
75
- const parseInfo = (json: string): NatsServerInfo => {
76
- let parsed: unknown;
77
- try {
78
- parsed = JSON.parse(json);
79
- } catch {
80
- throw protocolError('read', `INFO json did not parse: "${json}"`);
81
- }
82
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
83
- throw protocolError('read', `INFO json was not an object: "${json}"`);
84
- }
85
- const info = parsed as Record<string, unknown>;
86
- return {
87
- serverId: typeof info['server_id'] === 'string' ? info['server_id'] : '',
88
- version: typeof info['version'] === 'string' ? info['version'] : '',
89
- maxPayload: typeof info['max_payload'] === 'number' ? info['max_payload'] : 1_048_576,
90
- tlsRequired: info['tls_required'] === true,
91
- tlsAvailable: info['tls_available'] === true,
92
- authRequired: info['auth_required'] === true,
93
- headers: info['headers'] === true,
94
- nonce: typeof info['nonce'] === 'string' ? info['nonce'] : undefined,
95
- };
96
- };
97
-
98
- /** Parses an HMSG/HPUB header block: `NATS/1.0[ <code> <description>]\r\nKey: Value\r\n...\r\n\r\n`. */
99
- export function parseHeaders(bytes: Uint8Array): {
100
- readonly headers: ReadonlyMap<string, string>;
101
- readonly status: number | undefined;
102
- readonly description: string | undefined;
103
- } {
104
- const lines = decoder.decode(bytes).split('\r\n');
105
- const first = lines[0] ?? '';
106
- if (!first.startsWith('NATS/1.0')) {
107
- throw protocolError('headers', `header block did not start with NATS/1.0: "${first}"`);
108
- }
109
- const statusPart = first.slice('NATS/1.0'.length).trim();
110
- let status: number | undefined;
111
- let description: string | undefined;
112
- if (statusPart.length > 0) {
113
- const spaceAt = statusPart.search(/\s/);
114
- const code = spaceAt < 0 ? statusPart : statusPart.slice(0, spaceAt);
115
- if (!/^\d{3}$/.test(code)) {
116
- throw protocolError('headers', `status code was not 3 digits: "${code}"`);
117
- }
118
- status = Number(code);
119
- description = spaceAt < 0 ? '' : statusPart.slice(spaceAt + 1).trim();
120
- }
121
- const headers = new Map<string, string>();
122
- for (const line of lines.slice(1)) {
123
- if (line.length === 0) continue;
124
- const colonAt = line.indexOf(':');
125
- if (colonAt < 0) continue;
126
- headers.set(line.slice(0, colonAt).trim().toLowerCase(), line.slice(colonAt + 1).trim());
127
- }
128
- return { headers, status, description };
129
- }
130
-
131
- /** Chunks in, whole operations out. A TCP read boundary lands anywhere. */
132
- export class NatsProtocolParser {
133
- #buffer: Uint8Array = new Uint8Array(0);
134
-
135
- push(chunk: Uint8Array): void {
136
- this.#buffer = this.#buffer.length === 0 ? chunk : concatBytes(this.#buffer, chunk);
137
- }
138
-
139
- /** Bytes read but not yet consumed — what a partial frame is holding. */
140
- get buffered(): number {
141
- return this.#buffer.length;
142
- }
143
-
144
- /** The next complete operation, or `undefined` when more bytes are needed. */
145
- next(): NatsOperation | undefined {
146
- const buffer = this.#buffer;
147
- const lineEnd = indexOfCrlf(buffer, 0);
148
- if (lineEnd < 0) return undefined;
149
- const line = decoder.decode(buffer.subarray(0, lineEnd));
150
- const spaceAt = line.search(/[ \t]/);
151
- const verb = (spaceAt < 0 ? line : line.slice(0, spaceAt)).toUpperCase();
152
- const rest = spaceAt < 0 ? '' : line.slice(spaceAt + 1).trim();
153
- if (verb === 'MSG' || verb === 'HMSG') {
154
- return this.#takeMessage(lineEnd, rest, verb === 'HMSG');
155
- }
156
- this.#buffer = buffer.subarray(lineEnd + 2);
157
- switch (verb) {
158
- case 'INFO':
159
- return { kind: 'info', info: parseInfo(rest) };
160
- case 'PING':
161
- return { kind: 'ping' };
162
- case 'PONG':
163
- return { kind: 'pong' };
164
- case '+OK':
165
- return { kind: 'ok' };
166
- case '-ERR': {
167
- const trimmed = rest.trim();
168
- const quoted = trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'");
169
- return { kind: 'err', detail: quoted ? trimmed.slice(1, -1) : trimmed };
170
- }
171
- default:
172
- throw protocolError('read', `unknown verb "${verb}"`);
173
- }
174
- }
175
-
176
- #takeMessage(lineEnd: number, rest: string, headered: boolean): NatsOperation | undefined {
177
- const buffer = this.#buffer;
178
- const args = splitArgs(rest);
179
- const minArgs = headered ? 4 : 3;
180
- const maxArgs = headered ? 5 : 4;
181
- if (args.length !== minArgs && args.length !== maxArgs) {
182
- const verb = headered ? 'HMSG' : 'MSG';
183
- throw protocolError('read', `${verb} wants ${minArgs} or ${maxArgs} args, got "${rest}"`);
184
- }
185
- const hasReply = args.length === maxArgs;
186
- const subject = args[0] ?? missingArg('subject');
187
- const sid = args[1] ?? missingArg('sid');
188
- const replyTo = hasReply ? (args[2] ?? missingArg('reply-to')) : undefined;
189
- const headerBytes = headered ? parseByteCount(args[hasReply ? 3 : 2]) : 0;
190
- const totalBytes = parseByteCount(args[args.length - 1]);
191
- if (headered && totalBytes < headerBytes) {
192
- throw protocolError('read', `HMSG total ${totalBytes} is smaller than header ${headerBytes}`);
193
- }
194
- if (totalBytes > MAX_PAYLOAD_BYTES) {
195
- throw protocolError('read', `payload of ${totalBytes} bytes exceeds ${MAX_PAYLOAD_BYTES}`);
196
- }
197
- const payloadStart = lineEnd + 2;
198
- const headerEnd = payloadStart + headerBytes;
199
- const payloadEnd = payloadStart + totalBytes;
200
- const end = payloadEnd + 2;
201
- if (buffer.length < end) return undefined;
202
- if (buffer[payloadEnd] !== CR || buffer[payloadEnd + 1] !== LF) {
203
- throw protocolError('read', `payload was not followed by CRLF at offset ${payloadEnd}`);
204
- }
205
- const { headers, status, description } = headered
206
- ? parseHeaders(buffer.subarray(payloadStart, headerEnd))
207
- : { headers: EMPTY_HEADERS, status: undefined, description: undefined };
208
- this.#buffer = buffer.subarray(end);
209
- return {
210
- kind: 'msg',
211
- message: {
212
- subject,
213
- sid,
214
- replyTo,
215
- payload: buffer.subarray(headerEnd, payloadEnd),
216
- headers,
217
- status,
218
- description,
219
- },
220
- };
221
- }
222
- }
@@ -1,236 +0,0 @@
1
- // Single responsibility: the one production `NatsStream`, over `Bun.connect` — plus the URL
2
- // parsing. NATS sends its INFO line in cleartext before any TLS decision, so the upgrade is a
3
- // method the caller invokes after reading it, never a handshake negotiated here at connect time.
4
-
5
- import { TransportUnavailableError } from './errors';
6
- import type { BunConnect, SocketHandlers, SocketLike } from './pg-socket';
7
-
8
- export interface NatsTarget {
9
- readonly host: string;
10
- readonly port: number; // default 4222
11
- /** `tls://` demands TLS; `nats://` still upgrades when the server's INFO says it is required. */
12
- readonly tls: boolean;
13
- readonly user: string | undefined;
14
- readonly pass: string | undefined;
15
- /** `nats://token@host` — NATS' single-credential form, mutually exclusive with user/pass. */
16
- readonly token: string | undefined;
17
- }
18
-
19
- const DEFAULT_PORT = 4222;
20
-
21
- /** `nats://user:pass@host:4222`. The one place a bus URL is read. */
22
- export function parseNatsUrl(url: string): NatsTarget {
23
- let parsed: URL;
24
- try {
25
- parsed = new URL(url);
26
- } catch {
27
- throw new TransportUnavailableError({
28
- transport: 'nats',
29
- reason: `"${url}" is not a connection URL`,
30
- });
31
- }
32
- if (parsed.protocol !== 'nats:' && parsed.protocol !== 'tls:') {
33
- throw new TransportUnavailableError({
34
- transport: 'nats',
35
- reason: `the connection URL uses "${parsed.protocol}" rather than nats: or tls:`,
36
- });
37
- }
38
- if (parsed.hostname === '') {
39
- throw new TransportUnavailableError({
40
- transport: 'nats',
41
- reason: `"${url}" has no host`,
42
- });
43
- }
44
- const hasUser = parsed.username !== '';
45
- const hasPass = parsed.password !== '';
46
- // A password with no user matches neither credential form, and dropping it silently connects
47
- // anonymously — the failure then surfaces as the server's own 'Authorization Violation', which
48
- // names nothing about the URL. The URL itself is never echoed back: it holds the secret.
49
- if (!hasUser && hasPass) {
50
- throw new TransportUnavailableError({
51
- transport: 'nats',
52
- reason: `the connection URL for ${parsed.hostname} carries a password with no user`,
53
- fix: 'set the URL to nats://<user>:<pass>@host:4222, or the bare-token form nats://<token>@host:4222',
54
- });
55
- }
56
- const user = hasUser ? decodeURIComponent(parsed.username) : undefined;
57
- const pass = hasPass ? decodeURIComponent(parsed.password) : undefined;
58
- return {
59
- host: parsed.hostname,
60
- port: parsed.port === '' ? DEFAULT_PORT : Number.parseInt(parsed.port, 10),
61
- tls: parsed.protocol === 'tls:',
62
- // A username with no password is NATS' bare-token form; a password makes it user/pass instead.
63
- user: hasUser && hasPass ? user : undefined,
64
- pass: hasUser && hasPass ? pass : undefined,
65
- token: hasUser && !hasPass ? user : undefined,
66
- };
67
- }
68
-
69
- /** The byte pipe a NATS connection runs over. Mirrors `PgStream`, plus the late TLS upgrade. */
70
- export interface NatsStream {
71
- /** The next chunk the server sent, or `undefined` once it closed the connection. */
72
- read(): Promise<Uint8Array | undefined>;
73
- write(bytes: Uint8Array): Promise<void>;
74
- /** In-band TLS. Must be called before any byte other than the server's INFO is exchanged. */
75
- upgradeTls(): void;
76
- close(): void;
77
- }
78
-
79
- interface Waiter {
80
- readonly resolve: (chunk: Uint8Array | undefined) => void;
81
- readonly reject: (error: Error) => void;
82
- }
83
-
84
- /** Chunks the socket pushed, handed out one `read()` at a time — EOF and socket errors included. */
85
- class ChunkQueue {
86
- readonly #chunks: Uint8Array[] = [];
87
- #waiting: Waiter | undefined;
88
- #ended = false;
89
- #failure: Error | undefined;
90
-
91
- push(chunk: Uint8Array): void {
92
- const waiter = this.#take();
93
- if (waiter === undefined) {
94
- this.#chunks.push(chunk);
95
- return;
96
- }
97
- waiter.resolve(chunk);
98
- }
99
-
100
- /** EOF. A reader parked on `read()` is released rather than left hanging forever. */
101
- end(): void {
102
- this.#ended = true;
103
- this.#take()?.resolve(undefined);
104
- }
105
-
106
- fail(error: Error): void {
107
- this.#failure = error;
108
- this.#ended = true;
109
- this.#take()?.reject(error);
110
- }
111
-
112
- read(): Promise<Uint8Array | undefined> {
113
- const next = this.#chunks.shift();
114
- if (next !== undefined) return Promise.resolve(next);
115
- if (this.#failure !== undefined) return Promise.reject(this.#failure);
116
- if (this.#ended) return Promise.resolve(undefined);
117
- if (this.#waiting !== undefined) {
118
- // One reader drives this queue — the handshake, then the session's single read loop.
119
- // Overwriting the parked waiter would strand it: nothing left would ever settle its promise,
120
- // so the caller hangs with no error and no deadline. Refusing the second reader names it.
121
- return Promise.reject(
122
- new TransportUnavailableError({
123
- transport: 'nats',
124
- reason: 'a second read() started while one was already parked on this stream',
125
- fix: 'read a NatsStream from one place only: one stream feeds exactly one read loop',
126
- }),
127
- );
128
- }
129
- return new Promise((resolve, reject) => {
130
- this.#waiting = { resolve, reject };
131
- });
132
- }
133
-
134
- #take(): Waiter | undefined {
135
- const waiter = this.#waiting;
136
- this.#waiting = undefined;
137
- return waiter;
138
- }
139
- }
140
-
141
- export const bunNatsStream = (target: NatsTarget): Promise<NatsStream> =>
142
- natsStreamOver(Bun as unknown as BunConnect, target);
143
-
144
- /** `bunNatsStream` with the runtime handed in, so the whole path runs in a test with no network. */
145
- export async function natsStreamOver(runtime: BunConnect, target: NatsTarget): Promise<NatsStream> {
146
- const queue = new ChunkQueue();
147
- let draining: (() => void) | undefined;
148
- let upgraded = false;
149
-
150
- /**
151
- * A write parked for `drain` can never get one from a socket that is gone, so it is released
152
- * here; it then fails on the next `write` rather than burning a whole deadline first. The read
153
- * side ends cleanly because an EOF that matters is already an error one layer up.
154
- */
155
- const died = (): void => {
156
- const resume = draining;
157
- draining = undefined;
158
- resume?.();
159
- queue.end();
160
- };
161
-
162
- const handlers: SocketHandlers = {
163
- // Copied before it is queued or handed on: Bun owns that buffer and gives no guarantee its
164
- // contents survive the handler returning, while a queued chunk is read a whole tick later.
165
- data: (_socket, data) => queue.push(data.slice()),
166
- close: died,
167
- end: died,
168
- drain: () => {
169
- const resume = draining;
170
- draining = undefined;
171
- resume?.();
172
- },
173
- error: (_socket, error) =>
174
- queue.fail(
175
- new TransportUnavailableError({
176
- transport: 'nats',
177
- reason: `${target.host}:${target.port} — ${error.message}`,
178
- }),
179
- ),
180
- };
181
-
182
- let socket: SocketLike = await runtime.connect({
183
- hostname: target.host,
184
- port: target.port,
185
- socket: handlers,
186
- });
187
-
188
- const write = async (bytes: Uint8Array): Promise<void> => {
189
- let rest = bytes;
190
- while (rest.length > 0) {
191
- const written = socket.write(rest);
192
- if (written >= rest.length) return;
193
- // A negative count is a refusal, not backpressure: no `drain` follows a dead socket, so
194
- // waiting for one would park this write forever.
195
- if (written < 0) {
196
- throw new TransportUnavailableError({
197
- transport: 'nats',
198
- reason: `the socket refused a ${rest.length}-byte write to ${target.host}:${target.port}`,
199
- });
200
- }
201
- if (written > 0) rest = rest.subarray(written);
202
- await new Promise<void>((resolve) => {
203
- draining = resolve;
204
- });
205
- }
206
- };
207
-
208
- return {
209
- read: () => queue.read(),
210
- write,
211
- upgradeTls: () => {
212
- // Marked before the attempt, not after: a failed upgrade still leaves the raw socket in an
213
- // indeterminate TLS-negotiation state, so a retry is refused rather than risking a second
214
- // ClientHello on top of the first.
215
- if (upgraded) {
216
- throw new TransportUnavailableError({
217
- transport: 'nats',
218
- reason: `upgradeTls() was already called for ${target.host}:${target.port}`,
219
- });
220
- }
221
- upgraded = true;
222
- // Bun hands back `[raw, tls]`; every later read and write goes through the second one, and
223
- // the handlers are re-registered because the upgraded socket is a different object.
224
- const next = socket.upgradeTLS({ tls: { serverName: target.host }, socket: handlers })[1];
225
- if (next === undefined) {
226
- throw new TransportUnavailableError({
227
- transport: 'nats',
228
- reason: `the runtime returned no TLS socket for the upgrade to ${target.host}:${target.port}`,
229
- fix: 'bun upgrade # in-band TLS needs bun >= 1.3',
230
- });
231
- }
232
- socket = next;
233
- },
234
- close: () => socket.end(),
235
- };
236
- }
@@ -1,215 +0,0 @@
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
- }