@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
@@ -0,0 +1,265 @@
1
+ // Single responsibility: the one production `PgStream`, over `Bun.connect` — plus the URL parsing
2
+ // and the SSLRequest handshake that has to happen before the first protocol byte. Bun pushes bytes
3
+ // at handlers while the connection pulls messages, so a queue sits between them.
4
+
5
+ import { ReplicationFailedError, ReplicationProtocolError } from './errors';
6
+ import { type PgStream, sslRequest } from './pg-wire';
7
+
8
+ /** Structural view of Bun's socket, declared here so the contract does not need bun-types. */
9
+ export interface SocketLike {
10
+ write(data: Uint8Array): number;
11
+ end(): void;
12
+ upgradeTLS(options: {
13
+ readonly tls: { readonly serverName: string; readonly rejectUnauthorized?: boolean };
14
+ readonly socket: SocketHandlers;
15
+ }): readonly SocketLike[];
16
+ }
17
+
18
+ export interface SocketHandlers {
19
+ data(socket: SocketLike, data: Uint8Array): void;
20
+ close(): void;
21
+ end(): void;
22
+ drain(): void;
23
+ error(socket: SocketLike, error: Error): void;
24
+ }
25
+
26
+ export interface BunConnect {
27
+ connect(options: {
28
+ readonly hostname: string;
29
+ readonly port: number;
30
+ readonly socket: SocketHandlers;
31
+ }): Promise<SocketLike>;
32
+ }
33
+
34
+ /** `disable` never offers TLS, `prefer` accepts a refusal, `require` treats one as a failure. */
35
+ export type SslMode = 'disable' | 'prefer' | 'require';
36
+
37
+ export interface PgTarget {
38
+ readonly host: string;
39
+ readonly port: number;
40
+ readonly database: string;
41
+ readonly user: string;
42
+ readonly password: string | undefined;
43
+ readonly ssl: SslMode;
44
+ }
45
+
46
+ const SSL_MODES = new Set<string>(['disable', 'prefer', 'require']);
47
+
48
+ /** `postgres://user:pass@host:5432/db?sslmode=require`. The one place a connection URL is read. */
49
+ export function parsePgUrl(url: string): PgTarget {
50
+ let parsed: URL;
51
+ try {
52
+ parsed = new URL(url);
53
+ } catch {
54
+ throw new ReplicationFailedError({
55
+ stage: 'connect',
56
+ detail: `"${url}" is not a connection URL`,
57
+ fix: 'set DATABASE_URL to postgres://user:password@host:5432/database',
58
+ });
59
+ }
60
+ if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') {
61
+ throw new ReplicationFailedError({
62
+ stage: 'connect',
63
+ detail: `the connection URL uses "${parsed.protocol}" rather than postgres:`,
64
+ fix: 'set DATABASE_URL to postgres://user:password@host:5432/database',
65
+ });
66
+ }
67
+ const mode = parsed.searchParams.get('sslmode') ?? 'prefer';
68
+ if (!SSL_MODES.has(mode)) {
69
+ throw new ReplicationFailedError({
70
+ stage: 'connect',
71
+ detail: `sslmode=${mode} is not one of disable, prefer, require`,
72
+ fix: 'use ?sslmode=require for a managed database, ?sslmode=disable for a local one',
73
+ });
74
+ }
75
+ const database = decodeURIComponent(parsed.pathname.replace(/^\//, ''));
76
+ return {
77
+ host: parsed.hostname,
78
+ port: parsed.port === '' ? 5432 : Number.parseInt(parsed.port, 10),
79
+ database: database === '' ? 'postgres' : database,
80
+ user: decodeURIComponent(parsed.username) || 'postgres',
81
+ password: parsed.password === '' ? undefined : decodeURIComponent(parsed.password),
82
+ ssl: mode as SslMode,
83
+ };
84
+ }
85
+
86
+ interface Waiter {
87
+ readonly resolve: (chunk: Uint8Array | undefined) => void;
88
+ readonly reject: (error: Error) => void;
89
+ }
90
+
91
+ /** Chunks the socket pushed, handed out one `read()` at a time — EOF and socket errors included. */
92
+ class ChunkQueue {
93
+ readonly #chunks: Uint8Array[] = [];
94
+ #waiting: Waiter | undefined;
95
+ #ended = false;
96
+ #failure: Error | undefined;
97
+
98
+ push(chunk: Uint8Array): void {
99
+ const waiter = this.#take();
100
+ if (waiter === undefined) {
101
+ this.#chunks.push(chunk);
102
+ return;
103
+ }
104
+ waiter.resolve(chunk);
105
+ }
106
+
107
+ /** EOF. A reader parked on `read()` is released rather than left hanging forever. */
108
+ end(): void {
109
+ this.#ended = true;
110
+ this.#take()?.resolve(undefined);
111
+ }
112
+
113
+ fail(error: Error): void {
114
+ this.#failure = error;
115
+ this.#ended = true;
116
+ this.#take()?.reject(error);
117
+ }
118
+
119
+ read(): Promise<Uint8Array | undefined> {
120
+ const next = this.#chunks.shift();
121
+ if (next !== undefined) return Promise.resolve(next);
122
+ if (this.#failure !== undefined) return Promise.reject(this.#failure);
123
+ if (this.#ended) return Promise.resolve(undefined);
124
+ // One slot, one waiter: overwriting it would abandon the first promise unsettled forever, so
125
+ // the single-consumer rule is refused here rather than written down and hoped for.
126
+ if (this.#waiting !== undefined) {
127
+ return Promise.reject(
128
+ new ReplicationProtocolError({
129
+ stage: 'read',
130
+ detail: 'a second read() arrived while one was already parked on this stream',
131
+ fix: 'read this stream from one place only — PgConnection owns it for the whole session',
132
+ }),
133
+ );
134
+ }
135
+ return new Promise((resolve, reject) => {
136
+ this.#waiting = { resolve, reject };
137
+ });
138
+ }
139
+
140
+ #take(): Waiter | undefined {
141
+ const waiter = this.#waiting;
142
+ this.#waiting = undefined;
143
+ return waiter;
144
+ }
145
+ }
146
+
147
+ export const bunPgStream = (target: PgTarget): Promise<PgStream> =>
148
+ pgStreamOver(Bun as unknown as BunConnect, target);
149
+
150
+ /**
151
+ * `bunPgStream` with the runtime handed in. TLS is negotiated in-band: postgres answers the
152
+ * 8-byte SSLRequest with a single `S` or `N` **before** any framed message exists, so it has to
153
+ * happen here rather than in the message layer.
154
+ */
155
+ export async function pgStreamOver(runtime: BunConnect, target: PgTarget): Promise<PgStream> {
156
+ const queue = new ChunkQueue();
157
+ let draining: (() => void) | undefined;
158
+
159
+ /**
160
+ * A write parked for `drain` can never get one from a socket that is gone, so it is released
161
+ * here; it then fails on the next `write` rather than burning a whole deadline first. The read
162
+ * side ends cleanly because an EOF that matters is already an error one layer up.
163
+ */
164
+ const died = (): void => {
165
+ const resume = draining;
166
+ draining = undefined;
167
+ resume?.();
168
+ queue.end();
169
+ };
170
+
171
+ const handlers: SocketHandlers = {
172
+ // Copied, not retained: Bun promises nothing about the chunk's contents — or that it is even
173
+ // the same buffer — once the handler returns, and this one outlives it in the queue.
174
+ data: (_socket, data) => queue.push(data.slice()),
175
+ close: died,
176
+ end: died,
177
+ drain: () => {
178
+ const resume = draining;
179
+ draining = undefined;
180
+ resume?.();
181
+ },
182
+ error: (_socket, error) =>
183
+ queue.fail(
184
+ new ReplicationFailedError({
185
+ stage: 'connect',
186
+ detail: error.message,
187
+ fix: `open the route to ${target.host}:${target.port}, then: x doctor db`,
188
+ }),
189
+ ),
190
+ };
191
+
192
+ let socket = await runtime.connect({
193
+ hostname: target.host,
194
+ port: target.port,
195
+ socket: handlers,
196
+ });
197
+
198
+ const flush = async (bytes: Uint8Array): Promise<void> => {
199
+ let rest = bytes;
200
+ while (rest.length > 0) {
201
+ const written = socket.write(rest);
202
+ if (written >= rest.length) return;
203
+ // A negative count is a refusal, not backpressure: no `drain` follows a dead socket, so
204
+ // waiting for one would park this write forever.
205
+ if (written < 0) {
206
+ throw new ReplicationFailedError({
207
+ stage: 'write',
208
+ detail: `the socket refused a ${rest.length}-byte write`,
209
+ fix: 'the replicator reconnects on its own; confirm the host is up with: x doctor db',
210
+ });
211
+ }
212
+ if (written > 0) rest = rest.subarray(written);
213
+ await new Promise<void>((resolve) => {
214
+ draining = resolve;
215
+ });
216
+ }
217
+ };
218
+
219
+ if (target.ssl !== 'disable') {
220
+ await flush(sslRequest());
221
+ const answer = (await queue.read()) ?? new Uint8Array(0);
222
+ const verdict = answer[0];
223
+ if (verdict === undefined) {
224
+ throw new ReplicationFailedError({
225
+ stage: 'ssl',
226
+ detail: 'the server closed the connection instead of answering the TLS request',
227
+ fix: `use ?sslmode=disable if ${target.host} does not speak TLS`,
228
+ });
229
+ }
230
+ if (verdict === 0x53) {
231
+ // The answer is exactly one byte: anything after it was written before the handshake and
232
+ // would be read as ciphertext, so it is a wrong peer rather than an early arrival.
233
+ if (answer.length > 1) {
234
+ throw new ReplicationProtocolError({
235
+ stage: 'ssl',
236
+ detail: `the server sent ${answer.length - 1} bytes after accepting TLS`,
237
+ fix: 'point the replication URL at postgres itself — a proxy answers like this',
238
+ });
239
+ }
240
+ // Bun hands back `[raw, tls]`; every later read and write goes through the second one, and
241
+ // the handlers are re-registered because the upgraded socket is a different object.
242
+ const upgraded = socket.upgradeTLS({ tls: { serverName: target.host }, socket: handlers })[1];
243
+ if (upgraded === undefined) {
244
+ throw new ReplicationFailedError({
245
+ stage: 'ssl',
246
+ detail: 'the runtime returned no TLS socket for the upgrade',
247
+ fix: 'bun upgrade # in-band TLS needs bun >= 1.3',
248
+ });
249
+ }
250
+ socket = upgraded;
251
+ } else if (target.ssl === 'require') {
252
+ throw new ReplicationFailedError({
253
+ stage: 'ssl',
254
+ detail: 'the server refused TLS but sslmode=require was asked for',
255
+ fix: `enable ssl on ${target.host}, or use ?sslmode=prefer to accept a cleartext session`,
256
+ });
257
+ }
258
+ }
259
+
260
+ return {
261
+ read: () => queue.read(),
262
+ write: flush,
263
+ close: () => socket.end(),
264
+ };
265
+ }
package/src/pg-wire.ts ADDED
@@ -0,0 +1,192 @@
1
+ // Single responsibility: the Postgres v3 message frame — reassemble the length-prefixed backend
2
+ // messages out of whatever chunk sizes the socket hands us, and build the frontend ones. Nothing
3
+ // here knows what a replication slot is; that is `pg-connection.ts`. Nothing here owns a socket;
4
+ // the byte pipe is injected, so the whole protocol runs in a test with no network.
5
+
6
+ import { ReplicationFailedError, ReplicationProtocolError } from './errors';
7
+ import { ByteReader, ByteWriter } from './pg-bytes';
8
+
9
+ /** The byte pipe a connection runs over. `pg-socket.ts` implements it over `Bun.connect`. */
10
+ export interface PgStream {
11
+ /** The next chunk the server sent, or `undefined` once it closed the connection. */
12
+ read(): Promise<Uint8Array | undefined>;
13
+ write(bytes: Uint8Array): Promise<void>;
14
+ close(): void;
15
+ }
16
+
17
+ export interface PgMessage {
18
+ /** The one-byte type code, as its ASCII character. */
19
+ readonly tag: string;
20
+ readonly body: Uint8Array;
21
+ }
22
+
23
+ /** Protocol 3.0, as an Int32 — the number the startup packet leads with instead of a tag. */
24
+ export const PROTOCOL_3_0 = 196_608;
25
+
26
+ /** The magic number that asks for TLS before the startup packet. */
27
+ export const SSL_REQUEST_CODE = 80_877_103;
28
+
29
+ /** A single message is bounded so a corrupt length cannot make us allocate the machine. */
30
+ const MAX_MESSAGE_BYTES = 64 * 1024 * 1024;
31
+
32
+ /**
33
+ * Chunks in, whole messages out. A TCP read boundary lands anywhere — halfway through a length
34
+ * prefix, halfway through a 4MB `CopyData` — so the buffer is the only place that knows how many
35
+ * bytes are still missing, and every other module gets to assume a complete frame.
36
+ */
37
+ export class MessageReader {
38
+ readonly #stream: PgStream;
39
+ #buffer: Uint8Array = new Uint8Array(0);
40
+
41
+ constructor(stream: PgStream) {
42
+ this.#stream = stream;
43
+ }
44
+
45
+ /** Bytes already read but not yet consumed — what a reconnect would have to replay. */
46
+ get buffered(): number {
47
+ return this.#buffer.length;
48
+ }
49
+
50
+ /** The next complete message, or `undefined` at a clean EOF. */
51
+ async next(): Promise<PgMessage | undefined> {
52
+ for (;;) {
53
+ const framed = this.#take();
54
+ if (framed !== undefined) return framed;
55
+ const chunk = await this.#stream.read();
56
+ if (chunk === undefined) {
57
+ if (this.#buffer.length === 0) return undefined;
58
+ throw new ReplicationProtocolError({
59
+ stage: 'read',
60
+ detail: `the connection closed with ${this.#buffer.length} bytes of a partial message`,
61
+ fix: 'x doctor db — the backend was terminated mid-message; the server log names the reason',
62
+ });
63
+ }
64
+ this.#append(chunk);
65
+ }
66
+ }
67
+
68
+ #append(chunk: Uint8Array): void {
69
+ if (this.#buffer.length === 0) {
70
+ this.#buffer = chunk;
71
+ return;
72
+ }
73
+ const joined = new Uint8Array(this.#buffer.length + chunk.length);
74
+ joined.set(this.#buffer, 0);
75
+ joined.set(chunk, this.#buffer.length);
76
+ this.#buffer = joined;
77
+ }
78
+
79
+ /** A message is `tag` + Int32 length that counts itself but not the tag. */
80
+ #take(): PgMessage | undefined {
81
+ const buffer = this.#buffer;
82
+ if (buffer.length < 5) return undefined;
83
+ const length = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getInt32(
84
+ 1,
85
+ false,
86
+ );
87
+ if (length < 4 || length > MAX_MESSAGE_BYTES) {
88
+ throw new ReplicationProtocolError({
89
+ stage: 'read',
90
+ detail: `a message declared a length of ${length} bytes`,
91
+ fix: 'point the replication URL at postgres itself — a proxy or a TLS port frames like this',
92
+ });
93
+ }
94
+ const total = length + 1;
95
+ if (buffer.length < total) return undefined;
96
+ const message: PgMessage = {
97
+ tag: String.fromCharCode(buffer[0] ?? 0),
98
+ body: buffer.subarray(5, total),
99
+ };
100
+ this.#buffer = buffer.subarray(total);
101
+ return message;
102
+ }
103
+ }
104
+
105
+ /** `tag` + Int32 length + body — the shape of every frontend message except the startup packet. */
106
+ export const frame = (tag: string, body: Uint8Array): Uint8Array =>
107
+ new ByteWriter(body.length + 5)
108
+ .uint8(tag.charCodeAt(0))
109
+ .int32(body.length + 4)
110
+ .raw(body)
111
+ .finish();
112
+
113
+ /** The startup packet carries no tag: Int32 length, Int32 version, then `key\0value\0`…`\0`. */
114
+ export const startupMessage = (parameters: Readonly<Record<string, string>>): Uint8Array => {
115
+ const body = new ByteWriter(128).int32(PROTOCOL_3_0);
116
+ for (const [key, value] of Object.entries(parameters)) body.cstring(key).cstring(value);
117
+ const payload = body.uint8(0).finish();
118
+ return new ByteWriter(payload.length + 4)
119
+ .int32(payload.length + 4)
120
+ .raw(payload)
121
+ .finish();
122
+ };
123
+
124
+ /** Asks the server whether it will speak TLS. Same no-tag shape as the startup packet. */
125
+ export const sslRequest = (): Uint8Array =>
126
+ new ByteWriter(8).int32(8).int32(SSL_REQUEST_CODE).finish();
127
+
128
+ export const passwordMessage = (password: string): Uint8Array =>
129
+ frame('p', new ByteWriter(password.length + 1).cstring(password).finish());
130
+
131
+ /** SASLInitialResponse: the mechanism we picked, then the length-prefixed first client message. */
132
+ export const saslInitialResponse = (mechanism: string, initial: Uint8Array): Uint8Array =>
133
+ frame(
134
+ 'p',
135
+ new ByteWriter(mechanism.length + initial.length + 8)
136
+ .cstring(mechanism)
137
+ .int32(initial.length)
138
+ .raw(initial)
139
+ .finish(),
140
+ );
141
+
142
+ export const saslResponse = (payload: Uint8Array): Uint8Array => frame('p', payload);
143
+
144
+ export const queryMessage = (sql: string): Uint8Array =>
145
+ frame('Q', new ByteWriter(sql.length + 1).cstring(sql).finish());
146
+
147
+ export const terminateMessage = (): Uint8Array => frame('X', new Uint8Array(0));
148
+
149
+ export const copyDoneMessage = (): Uint8Array => frame('c', new Uint8Array(0));
150
+
151
+ /**
152
+ * `ErrorResponse` and `NoticeResponse` share a body: `field-code` + String, until a zero byte.
153
+ * `C` is the SQLSTATE, `M` the message, `S` the severity — the three we ever act on.
154
+ */
155
+ export const responseFields = (body: Uint8Array): Readonly<Record<string, string>> => {
156
+ const reader = new ByteReader(body, 'error');
157
+ const fields: Record<string, string> = {};
158
+ while (reader.remaining > 0) {
159
+ const code = reader.tag();
160
+ // The list ends with a bare zero byte rather than another field code.
161
+ if (code === '\0') break;
162
+ fields[code] = reader.cstring();
163
+ }
164
+ return fields;
165
+ };
166
+
167
+ /** One line an operator can act on: `28P01 invalid password for user "x"`. */
168
+ export const describeFields = (fields: Readonly<Record<string, string>>): string =>
169
+ [fields['C'], fields['M'] ?? 'the server reported no message', fields['D'], fields['H']]
170
+ .filter((part): part is string => part !== undefined && part !== '')
171
+ .join(' — ');
172
+
173
+ /** SQLSTATEs worth their own fix line, because the operator's next command differs for each. */
174
+ const FIXES: Readonly<Record<string, string>> = {
175
+ '28P01': 'correct the password in the replication URL — the server refused the credentials',
176
+ '28000': 'add a `host replication <user> <cidr> scram-sha-256` line to pg_hba.conf and reload',
177
+ '42501': 'grant the role REPLICATION: ALTER ROLE <user> WITH REPLICATION',
178
+ '55006': 'another replicator holds the slot — exactly one replicator per database, by design',
179
+ '42704': 'x db replication init — the publication or the slot does not exist yet',
180
+ '0A000': 'set wal_level = logical in postgresql.conf and restart the server',
181
+ };
182
+
183
+ /** An `ErrorResponse` becomes the one error class whose `fix` names the command to run. */
184
+ export const serverError = (stage: string, body: Uint8Array): ReplicationFailedError => {
185
+ const fields = responseFields(body);
186
+ const code = fields['C'] ?? '';
187
+ return new ReplicationFailedError({
188
+ stage,
189
+ detail: describeFields(fields),
190
+ fix: FIXES[code] ?? 'x doctor db — the postgres message above names the object to change',
191
+ });
192
+ };