@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,337 @@
1
+ // Single responsibility: one Postgres session over a `PgStream` — startup, authentication, simple
2
+ // queries, and the CopyBoth switch that a replication stream lives inside. It speaks only in
3
+ // messages, never in bytes on a socket, so the whole handshake is driven by hand in the tests.
4
+
5
+ import { logger } from '@ultimat3/core';
6
+ import { ReplicationFailedError, ReplicationProtocolError } from './errors';
7
+ import {
8
+ chooseMechanism,
9
+ md5Password,
10
+ type ScramSession,
11
+ scramNonce,
12
+ scramSession,
13
+ } from './pg-auth';
14
+ import { ByteReader } from './pg-bytes';
15
+ import {
16
+ copyDoneMessage,
17
+ frame,
18
+ MessageReader,
19
+ type PgMessage,
20
+ type PgStream,
21
+ passwordMessage,
22
+ queryMessage,
23
+ responseFields,
24
+ saslInitialResponse,
25
+ saslResponse,
26
+ serverError,
27
+ startupMessage,
28
+ terminateMessage,
29
+ } from './pg-wire';
30
+ import type { Rng } from './thundering-herd';
31
+
32
+ export interface PgConnectionOptions {
33
+ readonly stream: PgStream;
34
+ readonly user: string;
35
+ readonly password?: string | undefined;
36
+ readonly database: string;
37
+ /**
38
+ * `database` opens a logical-replication walsender that *also* answers ordinary SQL — which is
39
+ * what lets one connection check `wal_level` and then stream from a slot.
40
+ */
41
+ readonly replication?: 'database' | undefined;
42
+ readonly applicationName?: string | undefined;
43
+ /** Injected so the SCRAM nonce is deterministic under a seeded test. */
44
+ readonly rng?: Rng | undefined;
45
+ }
46
+
47
+ /** A result set as text, exactly as the wire carries it. `null` is SQL NULL, never `''`. */
48
+ export type PgRows = readonly (readonly (string | null)[])[];
49
+
50
+ const AUTH_OK = 0;
51
+ const AUTH_CLEARTEXT = 3;
52
+ const AUTH_MD5 = 5;
53
+ const AUTH_SASL = 10;
54
+ const AUTH_SASL_CONTINUE = 11;
55
+ const AUTH_SASL_FINAL = 12;
56
+
57
+ const needPassword = (method: string): ReplicationFailedError =>
58
+ new ReplicationFailedError({
59
+ stage: 'auth',
60
+ detail: `the server asked for ${method} but the replication URL carries no password`,
61
+ fix: 'put the credentials in the URL: postgres://user:password@host:5432/db',
62
+ });
63
+
64
+ export class PgConnection {
65
+ readonly #stream: PgStream;
66
+ readonly #reader: MessageReader;
67
+ readonly #parameters = new Map<string, string>();
68
+ #copyBoth = false;
69
+ #closed = false;
70
+
71
+ private constructor(stream: PgStream) {
72
+ this.#stream = stream;
73
+ this.#reader = new MessageReader(stream);
74
+ }
75
+
76
+ /** Startup, authentication, and everything up to the first `ReadyForQuery`. */
77
+ static async open(options: PgConnectionOptions): Promise<PgConnection> {
78
+ const connection = new PgConnection(options.stream);
79
+ const parameters: Record<string, string> = {
80
+ user: options.user,
81
+ database: options.database,
82
+ application_name: options.applicationName ?? 'ultimate-replicator',
83
+ };
84
+ // A walsender rejects most GUCs, so only the two it accepts are sent.
85
+ if (options.replication !== undefined) parameters['replication'] = options.replication;
86
+ // A handshake fails on ordinary conditions — no password, an ErrorResponse, an EOF — and on
87
+ // every one of them the caller gets an exception instead of an object, so nothing is left
88
+ // holding the socket. Closing it here is what stops a retrying supervisor from accumulating
89
+ // one file descriptor per failed attempt.
90
+ try {
91
+ await options.stream.write(startupMessage(parameters));
92
+ await connection.#authenticate(options);
93
+ await connection.#awaitReady();
94
+ } catch (failure) {
95
+ closeQuietly(options.stream);
96
+ throw failure;
97
+ }
98
+ return connection;
99
+ }
100
+
101
+ /** A `ParameterStatus` the server volunteered — `server_version`, `integer_datetimes`, … */
102
+ parameter(name: string): string | undefined {
103
+ return this.#parameters.get(name);
104
+ }
105
+
106
+ get inCopyBoth(): boolean {
107
+ return this.#copyBoth;
108
+ }
109
+
110
+ /** One simple query. Returns the rows as text; a `CommandComplete` with no rows returns `[]`. */
111
+ async query(sql: string): Promise<PgRows> {
112
+ await this.#stream.write(queryMessage(sql));
113
+ const rows: (readonly (string | null)[])[] = [];
114
+ for (;;) {
115
+ const message = await this.#expect('query');
116
+ switch (message.tag) {
117
+ case 'D':
118
+ rows.push(dataRow(message.body));
119
+ break;
120
+ case 'E':
121
+ // Drain to `ReadyForQuery` first: leaving the session mid-result desynchronises reuse.
122
+ await this.#drainToReady();
123
+ throw serverError('query', message.body);
124
+ case 'Z':
125
+ return rows;
126
+ default:
127
+ this.#note(message);
128
+ }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * `START_REPLICATION` — the connection stops being request/response and becomes a duplex copy
134
+ * stream. There is no way back short of closing it, which is why this is one-way on purpose.
135
+ */
136
+ async startCopyBoth(sql: string): Promise<void> {
137
+ await this.#stream.write(queryMessage(sql));
138
+ for (;;) {
139
+ const message = await this.#expect('start-replication');
140
+ if (message.tag === 'W') {
141
+ this.#copyBoth = true;
142
+ return;
143
+ }
144
+ if (message.tag === 'E') throw serverError('start-replication', message.body);
145
+ this.#note(message);
146
+ }
147
+ }
148
+
149
+ /** The next `CopyData` payload, or `undefined` when the server ended the stream. */
150
+ async nextCopyData(): Promise<Uint8Array | undefined> {
151
+ for (;;) {
152
+ const message = await this.#reader.next();
153
+ if (message === undefined) return undefined;
154
+ switch (message.tag) {
155
+ case 'd':
156
+ return message.body;
157
+ case 'c':
158
+ this.#copyBoth = false;
159
+ return undefined;
160
+ case 'E':
161
+ throw serverError('stream', message.body);
162
+ default:
163
+ this.#note(message);
164
+ }
165
+ }
166
+ }
167
+
168
+ /** Frontend `CopyData` — how a standby status update reaches the walsender. */
169
+ async sendCopyData(payload: Uint8Array): Promise<void> {
170
+ await this.#stream.write(frame('d', payload));
171
+ }
172
+
173
+ /**
174
+ * End the copy stream from this side. Saying so is what lets the server release the slot at
175
+ * once; dropping the socket instead leaves it `active` until the backend notices.
176
+ */
177
+ async endCopy(): Promise<void> {
178
+ if (!this.#copyBoth) return;
179
+ await this.#stream.write(copyDoneMessage());
180
+ }
181
+
182
+ async close(): Promise<void> {
183
+ if (this.#closed) return;
184
+ this.#closed = true;
185
+ // Best effort: a walsender that already died does not need to hear the goodbye.
186
+ try {
187
+ await this.#stream.write(terminateMessage());
188
+ } catch {
189
+ // fall through to the socket close below
190
+ }
191
+ this.#stream.close();
192
+ }
193
+
194
+ async #authenticate(options: PgConnectionOptions): Promise<void> {
195
+ let scram: ScramSession | undefined;
196
+ for (;;) {
197
+ const message = await this.#expect('auth');
198
+ if (message.tag === 'E') throw serverError('auth', message.body);
199
+ if (message.tag !== 'R') {
200
+ this.#note(message);
201
+ continue;
202
+ }
203
+ const reader = new ByteReader(message.body, 'auth');
204
+ const method = reader.int32();
205
+ switch (method) {
206
+ case AUTH_OK:
207
+ return;
208
+ case AUTH_CLEARTEXT: {
209
+ const password = options.password ?? '';
210
+ if (password === '') throw needPassword('a cleartext password');
211
+ await this.#stream.write(passwordMessage(password));
212
+ break;
213
+ }
214
+ case AUTH_MD5: {
215
+ const password = options.password ?? '';
216
+ if (password === '') throw needPassword('an md5 password');
217
+ const salt = reader.take(4);
218
+ await this.#stream.write(
219
+ passwordMessage(md5Password({ user: options.user, password, salt })),
220
+ );
221
+ break;
222
+ }
223
+ case AUTH_SASL: {
224
+ const password = options.password ?? '';
225
+ if (password === '') throw needPassword('a SCRAM password');
226
+ const mechanism = chooseMechanism(mechanisms(reader));
227
+ // No `?? Math.random`: an absent `rng` must reach `scramNonce`'s CSPRNG default, which
228
+ // is the only source RFC 5802 allows for a client nonce.
229
+ scram = scramSession({ password, nonce: scramNonce(options.rng) });
230
+ await this.#stream.write(saslInitialResponse(mechanism, scram.clientFirst()));
231
+ break;
232
+ }
233
+ case AUTH_SASL_CONTINUE: {
234
+ if (scram === undefined) throw outOfOrder('SASLContinue');
235
+ await this.#stream.write(saslResponse(await scram.clientFinal(reader.rest())));
236
+ break;
237
+ }
238
+ case AUTH_SASL_FINAL: {
239
+ if (scram === undefined) throw outOfOrder('SASLFinal');
240
+ await scram.verify(reader.rest());
241
+ break;
242
+ }
243
+ default:
244
+ throw new ReplicationProtocolError({
245
+ stage: 'auth',
246
+ detail: `the server asked for authentication method ${method}, which this client does not speak`,
247
+ fix: 'set password_encryption = scram-sha-256 and give the replication role a password',
248
+ });
249
+ }
250
+ }
251
+ }
252
+
253
+ /** Everything between `AuthenticationOk` and the first `ReadyForQuery` is session metadata. */
254
+ async #awaitReady(): Promise<void> {
255
+ for (;;) {
256
+ const message = await this.#expect('startup');
257
+ if (message.tag === 'Z') return;
258
+ if (message.tag === 'E') throw serverError('startup', message.body);
259
+ this.#note(message);
260
+ }
261
+ }
262
+
263
+ async #drainToReady(): Promise<void> {
264
+ for (;;) {
265
+ const message = await this.#reader.next();
266
+ if (message === undefined || message.tag === 'Z') return;
267
+ }
268
+ }
269
+
270
+ async #expect(stage: string): Promise<PgMessage> {
271
+ const message = await this.#reader.next();
272
+ if (message !== undefined) return message;
273
+ throw new ReplicationFailedError({
274
+ stage,
275
+ detail:
276
+ 'the server closed the connection without answering — pg_hba.conf needs a ' +
277
+ '"host replication <user> <cidr> scram-sha-256" line before it will hold one open',
278
+ fix: 'psql "$DATABASE_URL" -c "SELECT pg_reload_conf()" -c "TABLE pg_hba_file_rules"',
279
+ });
280
+ }
281
+
282
+ /** Messages that carry session state or server chatter, in one place so nothing is dropped. */
283
+ #note(message: PgMessage): void {
284
+ switch (message.tag) {
285
+ case 'S': {
286
+ const reader = new ByteReader(message.body, 'parameter-status');
287
+ this.#parameters.set(reader.cstring(), reader.cstring());
288
+ return;
289
+ }
290
+ case 'N':
291
+ logger.warn('postgres notice', responseFields(message.body));
292
+ return;
293
+ // BackendKeyData, RowDescription, CommandComplete, EmptyQuery, NoticeResponse, Notification:
294
+ // nothing downstream reads them, and dropping them silently is the point of this branch.
295
+ default:
296
+ return;
297
+ }
298
+ }
299
+ }
300
+
301
+ const outOfOrder = (what: string): ReplicationProtocolError =>
302
+ new ReplicationProtocolError({
303
+ stage: 'auth',
304
+ detail: `the server sent ${what} before it offered a SASL mechanism`,
305
+ fix: 'x doctor db — the SASL exchange arrived out of order; check for a pooler or proxy between this client and postgres',
306
+ });
307
+
308
+ /** A `close()` that throws must not replace the handshake failure that is worth reporting. */
309
+ const closeQuietly = (stream: PgStream): void => {
310
+ try {
311
+ stream.close();
312
+ } catch {
313
+ // the original failure is the one the caller needs
314
+ }
315
+ };
316
+
317
+ /** The mechanism list is cstrings until an empty one. */
318
+ const mechanisms = (reader: ByteReader): readonly string[] => {
319
+ const offered: string[] = [];
320
+ for (;;) {
321
+ const name = reader.cstring();
322
+ if (name === '') return offered;
323
+ offered.push(name);
324
+ }
325
+ };
326
+
327
+ /** `DataRow`: Int16 column count, then Int32 length (-1 = NULL) + that many bytes, per column. */
328
+ const dataRow = (body: Uint8Array): readonly (string | null)[] => {
329
+ const reader = new ByteReader(body, 'data-row');
330
+ const count = reader.int16();
331
+ const values: (string | null)[] = [];
332
+ for (let index = 0; index < count; index += 1) {
333
+ const length = reader.int32();
334
+ values.push(length < 0 ? null : reader.utf8(length));
335
+ }
336
+ return values;
337
+ };
@@ -0,0 +1,130 @@
1
+ // Physical Postgres row -> entity-row shaping: snake_case columns become camelCase properties,
2
+ // and a `<p>_minor` / `<p>_currency` column pair folds into one `<p>: Money`-shaped property.
3
+ // The inverse of the camelCasing here is `@ultimat3/entity`'s `column.ts#snake` — not imported
4
+ // (a tier-3 package may not reach across to tier-2), so the round trip is pinned by a test instead.
5
+
6
+ import { ReplicationProtocolError } from './errors';
7
+ import type { JsonObject, JsonValue } from './json';
8
+
9
+ /** `org_id` -> `orgId`, `published_at` -> `publishedAt`. The inverse of `@ultimat3/entity`'s `snake()`. */
10
+ export function camel(column: string): string {
11
+ const [head = '', ...tail] = column.split('_');
12
+ return head + tail.map(capitalize).join('');
13
+ }
14
+
15
+ /** A leading, trailing, or doubled underscore produces an empty part; it contributes nothing. */
16
+ function capitalize(part: string): string {
17
+ return part.charAt(0).toUpperCase() + part.slice(1);
18
+ }
19
+
20
+ interface MoneyPair {
21
+ readonly property: string;
22
+ readonly minorKey: string;
23
+ readonly currencyKey: string;
24
+ readonly minor: number;
25
+ readonly currency: string;
26
+ }
27
+
28
+ /** `price_minor` / `price_currency` -> `price`; any other column name has no money prefix. */
29
+ function moneyPrefix(column: string): string | null {
30
+ if (column.endsWith('_minor')) return column.slice(0, -'_minor'.length);
31
+ if (column.endsWith('_currency')) return column.slice(0, -'_currency'.length);
32
+ return null;
33
+ }
34
+
35
+ /**
36
+ * `Money` is `{ minor: number; currency: string }` everywhere in the framework, so `minor` is
37
+ * normalised here rather than passed through: `pgoutput` decodes an int8 as text once it leaves
38
+ * `Number.isSafeInteger` range and a numeric as text always, which would otherwise make one column
39
+ * a number on one row and a string on the next. A value no JS number holds exactly is not money
40
+ * this pipeline can carry, and saying so is better than shipping a `minor` the contract forbids.
41
+ */
42
+ function moneyMinor(column: string, value: number | string): number {
43
+ const minor =
44
+ typeof value === 'number' ? value : /^-?\d+$/.test(value) ? Number(value) : Number.NaN;
45
+ if (Number.isSafeInteger(minor)) return minor;
46
+ throw new ReplicationProtocolError({
47
+ stage: 'value',
48
+ detail: `column "${column}" carries "${value}", which is not a whole number of minor units`,
49
+ fix: `store ${column} as a bigint inside ±2^53 — Money.minor is a number, never a float or a bigint`,
50
+ });
51
+ }
52
+
53
+ /**
54
+ * `name` is one half of a `<p>_minor` / `<p>_currency` pair, or null if it is not part of one.
55
+ * Both halves must be present *and* typed like money — a null currency (an unset money value) is
56
+ * not "half a pair", it simply is not a pair, so both columns fall through as ordinary values.
57
+ */
58
+ function moneyPairAt(
59
+ physical: Readonly<Record<string, JsonValue>>,
60
+ name: string,
61
+ ): MoneyPair | null {
62
+ const prefix = moneyPrefix(name);
63
+ if (prefix === null) return null;
64
+
65
+ const minorKey = `${prefix}_minor`;
66
+ const currencyKey = `${prefix}_currency`;
67
+ if (!Object.hasOwn(physical, minorKey) || !Object.hasOwn(physical, currencyKey)) return null;
68
+
69
+ const minor = physical[minorKey];
70
+ const currency = physical[currencyKey];
71
+ if ((typeof minor === 'number' || typeof minor === 'string') && typeof currency === 'string') {
72
+ return {
73
+ property: camel(prefix),
74
+ minorKey,
75
+ currencyKey,
76
+ minor: moneyMinor(minorKey, minor),
77
+ currency,
78
+ };
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * `camel()` is not injective — `a_b` and `a__b` both give `aB`, `x` and `x_` both give `x` — and
85
+ * `entityRow` writes by assignment, so without this the second column would silently overwrite the
86
+ * first and the row would be short one value with nothing to read about it.
87
+ */
88
+ function claim(taken: Map<string, string>, property: string, column: string): void {
89
+ const first = taken.get(property);
90
+ if (first !== undefined) {
91
+ throw new ReplicationProtocolError({
92
+ stage: 'value',
93
+ detail: `columns "${first}" and "${column}" both map to the entity property "${property}"`,
94
+ fix: `rename one of them — two columns cannot share one property once camelCased`,
95
+ });
96
+ }
97
+ taken.set(property, column);
98
+ }
99
+
100
+ /**
101
+ * A physical postgres row -> the row shape the rest of the pipeline is written against.
102
+ * Two things are not one-to-one and both live here: the column is snake_case while the entity
103
+ * property is camelCase, and money is one property over the two columns `<p>_minor`/`<p>_currency`.
104
+ */
105
+ export function entityRow(physical: Readonly<Record<string, JsonValue>>): JsonObject {
106
+ const row: JsonObject = {};
107
+ // Column order in is key order out; a folded money property lands wherever its earlier half
108
+ // (whichever of _minor/_currency the source happened to emit first) would otherwise have sat.
109
+ const consumed = new Set<string>();
110
+ // Which column produced each property, so a collision names both sides rather than losing one.
111
+ const taken = new Map<string, string>();
112
+
113
+ for (const name of Object.keys(physical)) {
114
+ if (consumed.has(name)) continue;
115
+
116
+ const money = moneyPairAt(physical, name);
117
+ if (money !== null) {
118
+ claim(taken, money.property, `${money.minorKey}/${money.currencyKey}`);
119
+ row[money.property] = { minor: money.minor, currency: money.currency };
120
+ consumed.add(money.minorKey);
121
+ consumed.add(money.currencyKey);
122
+ continue;
123
+ }
124
+
125
+ const property = camel(name);
126
+ claim(taken, property, name);
127
+ row[property] = physical[name] ?? null;
128
+ }
129
+ return row;
130
+ }