@ultimat3/realtime 23.0.0 → 24.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.
- package/CLAUDE.md +75 -81
- package/README.md +107 -17
- package/package.json +4 -4
- package/src/advisory-lock.ts +57 -0
- package/src/boot.ts +12 -7
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +2 -2
- package/src/changefeed.ts +39 -1
- package/src/channel-authz.ts +43 -12
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +34 -4
- package/src/channel-records.ts +33 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +143 -44
- package/src/client-channels.ts +10 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +7 -1
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +1 -0
- package/src/cursor.ts +11 -0
- package/src/errors.ts +70 -19
- package/src/fanout.ts +13 -0
- package/src/index.ts +1 -1
- package/src/live-definition.ts +22 -7
- package/src/live-fanout.ts +43 -7
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +38 -0
- package/src/live-query.ts +36 -37
- package/src/nats-client.ts +11 -2
- package/src/nats-transport.ts +22 -0
- package/src/offline-queue.ts +48 -5
- package/src/page-errors.ts +17 -0
- package/src/page-outbox.ts +121 -50
- package/src/pg-advisory-lock.ts +45 -1
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +119 -21
- package/src/pg-entity-row.ts +16 -0
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +44 -45
- package/src/pg-socket.ts +6 -4
- package/src/pg-tls.ts +18 -0
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/presence.ts +85 -8
- package/src/query-window.ts +16 -4
- package/src/record-persister.ts +7 -2
- package/src/replication-errors.ts +8 -4
- package/src/replicator-envelope.ts +110 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +319 -186
- package/src/server.ts +10 -6
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/subscriber-gate.ts +11 -1
- package/src/sync-auth.ts +47 -2
- package/src/sync-frames.ts +73 -20
- package/src/sync-node-bounds.ts +15 -0
- package/src/sync-node-contract.ts +20 -2
- package/src/sync-node.ts +92 -17
- package/src/sync-origin.ts +26 -11
- package/src/sync-protocol.ts +13 -3
- package/src/sync-upgrade.ts +76 -8
- package/src/use-mutation.ts +33 -5
- package/src/wire-channel.ts +13 -4
package/src/page-outbox.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The page's ONE outbox: writes a page could not send
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* replay after a lost response is answered from the action's idempotency store and never applied
|
|
6
|
-
* twice. Replayed on open, on `online`, and on the service worker's `OUTBOX_DRAIN_MESSAGE`.
|
|
7
|
-
* A principal change wipes the previous principal's queue: its writes are never sent as the next.
|
|
2
|
+
* The page's ONE outbox: writes a page could not send, persisted under the current principal and
|
|
3
|
+
* replayed IN ORDER over HTTP, each under its own idempotency key. AT LEAST once — an entry leaves
|
|
4
|
+
* the disk when its ack is written. A principal change abandons the pass and wipes the queue.
|
|
8
5
|
*/
|
|
9
6
|
|
|
10
|
-
import type { ClientScope } from '@ultimat3/core/page';
|
|
7
|
+
import type { ClientScope, UltimateError } from '@ultimat3/core/page';
|
|
11
8
|
import {
|
|
12
9
|
actionPath,
|
|
13
10
|
classifyThrown,
|
|
@@ -15,12 +12,14 @@ import {
|
|
|
15
12
|
OUTBOX_DRAIN_MESSAGE,
|
|
16
13
|
onRescope,
|
|
17
14
|
pageClient,
|
|
15
|
+
renderThrowable,
|
|
18
16
|
} from '@ultimat3/core/page';
|
|
19
17
|
import type { LocalStore } from './local-store-idb';
|
|
20
18
|
import { pageLocalStore, scopeKey } from './local-store-idb';
|
|
21
19
|
import type { DrainReport, QueuedMutation, QueueState, QueueStore } from './offline-queue';
|
|
22
20
|
import { MemoryQueueStore, OfflineQueue, toQueueError } from './offline-queue';
|
|
23
21
|
import { OUTBOX_KEY, type OutboxEntry, type OutboxHandle, type OutboxHost } from './outbox-slot';
|
|
22
|
+
import { LocalStoreUnavailableError, OfflineQueueAbandonedError } from './page-errors';
|
|
24
23
|
import { peekPageRealtime } from './page-store';
|
|
25
24
|
import { carriedBy } from './record-store';
|
|
26
25
|
|
|
@@ -63,70 +62,142 @@ export interface OutboxOptions {
|
|
|
63
62
|
* the write queued and the post it liked missing, and showed the old count.
|
|
64
63
|
*/
|
|
65
64
|
readonly beforeEnqueue?: (() => Promise<void>) | undefined;
|
|
65
|
+
/** Where a disk that refused the queue is said, by code. Default `console.warn`. */
|
|
66
|
+
readonly warn?: ((error: UltimateError) => void) | undefined;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
const EMPTY: DrainReport = { sent: 0, collapsed: 0, remaining: 0, stoppedAt: null };
|
|
69
70
|
|
|
71
|
+
/** One principal's open queue, and the scope the drain lock is named after. */
|
|
72
|
+
interface Held {
|
|
73
|
+
readonly queue: OfflineQueue;
|
|
74
|
+
readonly scope: string | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
70
77
|
export function createOutbox(options: OutboxOptions): PageOutbox {
|
|
71
78
|
const principal =
|
|
72
79
|
options.principal ?? ((): ClientScope['principal'] => pageClient().scope.principal);
|
|
73
80
|
const send = options.send ?? sendOverHttp;
|
|
74
81
|
const overlays =
|
|
75
82
|
options.overlays ?? ((): OutboxOverlays | undefined => peekPageRealtime()?.store);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The open queue AND the scope it belongs to, as one value: a pass captures both when it starts,
|
|
85
|
+
* so nothing it does later can be read off a principal that arrived since.
|
|
86
|
+
*/
|
|
87
|
+
let held: Held | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* Bumped by every principal change, SYNCHRONOUSLY. Anything here that resolves a queue after an
|
|
90
|
+
* await compares it first: the answer to "whose queue" must be the one from before the await.
|
|
91
|
+
*/
|
|
92
|
+
let epoch = 0;
|
|
93
|
+
const warn = options.warn ?? ((error: UltimateError): void => console.warn(error));
|
|
94
|
+
const unavailable = (what: string, error: unknown): void =>
|
|
95
|
+
warn(new LocalStoreUnavailableError({ reason: `${what}: ${renderThrowable(error)}` }));
|
|
79
96
|
|
|
97
|
+
/** Never rejects: a queue the disk would not open is a memory queue, said once by code. */
|
|
80
98
|
const open = async (): Promise<void> => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
let ready = open();
|
|
85
|
-
let running: Promise<DrainReport> | undefined;
|
|
86
|
-
/** A trigger landed while a pass ran: one more pass follows it, never one per trigger. */
|
|
87
|
-
let again = false;
|
|
88
|
-
|
|
89
|
-
const deliver = async (mutation: QueuedMutation): Promise<void> => {
|
|
90
|
-
const current = queue;
|
|
91
|
-
const carried = new Set<string>();
|
|
99
|
+
const at = epoch;
|
|
100
|
+
const scope = scopeKey(principal());
|
|
101
|
+
let queue: OfflineQueue;
|
|
92
102
|
try {
|
|
93
|
-
|
|
103
|
+
queue = await OfflineQueue.open(queueStore(await options.local, scope));
|
|
94
104
|
} catch (error) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
unavailable('the outbox could not be opened', error);
|
|
106
|
+
queue = await OfflineQueue.open(new MemoryQueueStore());
|
|
107
|
+
}
|
|
108
|
+
// The principal left while the disk was being read: this queue is the one that left with it,
|
|
109
|
+
// and the handler that saw the change has already chained the next open.
|
|
110
|
+
if (at !== epoch) {
|
|
111
|
+
queue.abandon();
|
|
102
112
|
return;
|
|
103
113
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
held?.queue.abandon();
|
|
115
|
+
held = { queue, scope };
|
|
116
|
+
};
|
|
117
|
+
let ready = open();
|
|
118
|
+
/** The current principal's queue: `ready` is re-made by a rescope, so it is re-read until still. */
|
|
119
|
+
const current = async (): Promise<Held | undefined> => {
|
|
120
|
+
for (;;) {
|
|
121
|
+
const opening = ready;
|
|
122
|
+
await opening;
|
|
123
|
+
if (opening === ready) return held;
|
|
113
124
|
}
|
|
114
125
|
};
|
|
126
|
+
let running: Promise<DrainReport> | undefined;
|
|
127
|
+
/** A trigger landed while a pass ran: one more pass follows it, never one per trigger. */
|
|
128
|
+
let again = false;
|
|
129
|
+
|
|
130
|
+
const deliver =
|
|
131
|
+
(queue: OfflineQueue) =>
|
|
132
|
+
async (mutation: QueuedMutation): Promise<void> => {
|
|
133
|
+
const carried = new Set<string>();
|
|
134
|
+
try {
|
|
135
|
+
await send({ key: mutation.key, name: mutation.name, input: mutation.input }, carried);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const kind = classifyThrown(error);
|
|
138
|
+
// The network, or a server asking to be asked again: stays queued, and the pass stops so
|
|
139
|
+
// nothing behind it overtakes it. So does a queue whose principal left mid-send.
|
|
140
|
+
if (kind === 'retryable' || kind === 'retry-after' || queue.abandoned) throw error;
|
|
141
|
+
// Anything else is the server's decision about this write — kept for the UI, never resent.
|
|
142
|
+
await queue.fail(mutation.key, toQueueError(error));
|
|
143
|
+
overlays()?.drop(mutation.key);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// The principal changed while this was on the wire: its store and its twins are already
|
|
147
|
+
// gone, and what is on the page now is somebody else's.
|
|
148
|
+
if (queue.abandoned) return;
|
|
149
|
+
await queue.ack(mutation.key);
|
|
150
|
+
const store = overlays();
|
|
151
|
+
try {
|
|
152
|
+
// Exactly as a live write settles: a row the answer did not carry keeps its overlay until
|
|
153
|
+
// the server's row for it arrives.
|
|
154
|
+
store?.settle(mutation.key, carried);
|
|
155
|
+
} catch {
|
|
156
|
+
// A custom merge that answered no row: the server's truth stands.
|
|
157
|
+
store?.drop(mutation.key);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
115
160
|
|
|
116
161
|
onRescope((_next, prev) => {
|
|
117
162
|
const gone = scopeKey(prev.principal);
|
|
163
|
+
// SYNCHRONOUSLY, before anything is awaited: a pass parked inside `send` resumes into the next
|
|
164
|
+
// principal's session, and the wipe below reaches the disk but never the pass. Abandoned, it
|
|
165
|
+
// sends nothing more and writes nothing back.
|
|
166
|
+
held?.queue.abandon();
|
|
167
|
+
held = undefined;
|
|
168
|
+
epoch += 1;
|
|
169
|
+
// The single-flight slot is the principal's too: left set, the next principal's trigger JOINED
|
|
170
|
+
// the abandoned pass — handed its report, another principal's key in it — and its own queue
|
|
171
|
+
// was not drained until some later trigger. `settled` only clears a slot that is still its own.
|
|
172
|
+
running = undefined;
|
|
173
|
+
again = false;
|
|
118
174
|
ready = ready.then(async () => {
|
|
119
|
-
|
|
175
|
+
try {
|
|
176
|
+
if (gone !== undefined) await (await options.local).wipe(gone);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
// Still on disk, under the scope that left: unreachable from this principal, and the next
|
|
179
|
+
// boot's `wipeOthers` takes it. Said, never thrown — a rejection here used to poison the
|
|
180
|
+
// chain, so every later enqueue, replay and rescope on this tab failed with it.
|
|
181
|
+
unavailable("the previous principal's outbox could not be wiped", error);
|
|
182
|
+
}
|
|
120
183
|
await open();
|
|
121
184
|
});
|
|
122
185
|
});
|
|
123
186
|
|
|
124
187
|
const self: PageOutbox = {
|
|
125
188
|
enqueue: async (entry) => {
|
|
189
|
+
// WHOSE queue is decided now, before anything is awaited: resolved after the flush below,
|
|
190
|
+
// a write issued under one principal and parked there was queued — and sent — as the next.
|
|
191
|
+
// A queue still opening is waited for, and belongs to this call only if nobody left since.
|
|
192
|
+
const at = epoch;
|
|
193
|
+
const mine = held ?? (await current());
|
|
194
|
+
if (mine === undefined || at !== epoch) {
|
|
195
|
+
throw new OfflineQueueAbandonedError({ name: entry.name });
|
|
196
|
+
}
|
|
126
197
|
// A disk that refused the rows must not also cost the write: the intent still goes on disk.
|
|
127
198
|
await options.beforeEnqueue?.().catch(() => undefined);
|
|
128
|
-
|
|
129
|
-
await queue
|
|
199
|
+
// Abandoned meanwhile, it refuses by the same code.
|
|
200
|
+
await mine.queue.enqueue(entry);
|
|
130
201
|
},
|
|
131
202
|
replay: () => {
|
|
132
203
|
// Single flight: a trigger that lands while a replay is running JOINS it. Open, `online`,
|
|
@@ -140,20 +211,19 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
|
|
|
140
211
|
return running;
|
|
141
212
|
}
|
|
142
213
|
const pass = (async (): Promise<DrainReport> => {
|
|
143
|
-
await
|
|
144
|
-
if (
|
|
214
|
+
const now = await current();
|
|
215
|
+
if (now === undefined) return EMPTY;
|
|
145
216
|
// Checked when the pass STARTS, whoever asked (the socket's reconnect asks too): an
|
|
146
217
|
// attempt the browser already knows cannot leave is a failed request on the wire and
|
|
147
218
|
// nothing more. `online` asks again.
|
|
148
|
-
if (knownOffline()) return { ...EMPTY, remaining: queue.pending().length };
|
|
149
|
-
const draining = queue;
|
|
219
|
+
if (knownOffline()) return { ...EMPTY, remaining: now.queue.pending().length };
|
|
150
220
|
// One tab drains a principal's outbox at a time, and it drains what EVERY tab queued: the
|
|
151
221
|
// queue is re-read under the lock, so a write another tab made since this one opened is
|
|
152
222
|
// sent too, and an `inflight` entry — which only a pass holding this lock could have put
|
|
153
223
|
// on the wire, and it is over — goes back to `pending`.
|
|
154
|
-
return await exclusive(`ultimate-outbox:${scope ?? 'memory'}`, async () => {
|
|
155
|
-
await
|
|
156
|
-
return await
|
|
224
|
+
return await exclusive(`ultimate-outbox:${now.scope ?? 'memory'}`, async () => {
|
|
225
|
+
await now.queue.reload();
|
|
226
|
+
return await now.queue.drain(deliver(now.queue));
|
|
157
227
|
});
|
|
158
228
|
})();
|
|
159
229
|
const settled = (report?: DrainReport): void => {
|
|
@@ -172,9 +242,10 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
|
|
|
172
242
|
return pass;
|
|
173
243
|
},
|
|
174
244
|
get size(): number {
|
|
175
|
-
return queue
|
|
245
|
+
return held?.queue.pending().length ?? 0;
|
|
176
246
|
},
|
|
177
|
-
pending: () =>
|
|
247
|
+
pending: () =>
|
|
248
|
+
(held?.queue.pending() ?? []).map(({ key, name, input }) => ({ key, name, input })),
|
|
178
249
|
get ready(): Promise<void> {
|
|
179
250
|
return ready;
|
|
180
251
|
},
|
package/src/pg-advisory-lock.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// is scoped to this connection's Postgres *session*, so acquiring means keeping the connection
|
|
4
4
|
// open and releasing means closing it: no lease, no renewal, no fencing token, ever.
|
|
5
5
|
|
|
6
|
+
import type { AdvisoryLock } from './advisory-lock';
|
|
6
7
|
import { ReplicationFailedError } from './errors';
|
|
7
8
|
import { PgConnection, type PgRows } from './pg-connection';
|
|
8
9
|
import { bunPgStream, type PgTarget, parsePgUrl } from './pg-socket';
|
|
9
10
|
import type { PgStream } from './pg-wire';
|
|
10
|
-
import type { AdvisoryLock } from './replicator';
|
|
11
11
|
import type { Rng } from './thundering-herd';
|
|
12
12
|
|
|
13
13
|
/** A key reaches a simple query unparameterised, so its charset is the injection boundary. */
|
|
@@ -45,6 +45,7 @@ export class PgAdvisoryLock implements AdvisoryLock {
|
|
|
45
45
|
* the process dies. A standby then never takes over a slot whose owner has already stopped.
|
|
46
46
|
*/
|
|
47
47
|
#acquiring: Promise<boolean> | null = null;
|
|
48
|
+
readonly #lostListeners = new Set<(reason: string) => void>();
|
|
48
49
|
|
|
49
50
|
constructor(options: PgAdvisoryLockOptions) {
|
|
50
51
|
if (!KEY_PATTERN.test(options.key)) {
|
|
@@ -91,6 +92,7 @@ export class PgAdvisoryLock implements AdvisoryLock {
|
|
|
91
92
|
database: target.database,
|
|
92
93
|
applicationName: `ultimate-replicator-lock:${this.key}`,
|
|
93
94
|
rng: this.#options.rng,
|
|
95
|
+
ssl: target.ssl,
|
|
94
96
|
});
|
|
95
97
|
let rows: PgRows;
|
|
96
98
|
try {
|
|
@@ -110,9 +112,51 @@ export class PgAdvisoryLock implements AdvisoryLock {
|
|
|
110
112
|
// `release()` — which is exactly what lets a crashed replicator free the slot with nothing
|
|
111
113
|
// left to clean up.
|
|
112
114
|
this.#connection = connection;
|
|
115
|
+
// The session is idle from here on, and an idle session has no reader: without this a server
|
|
116
|
+
// that ended it was never noticed, and `tryAcquire()` went on answering `true` from memory
|
|
117
|
+
// for a lock Postgres had already handed to another process.
|
|
118
|
+
connection.watchIdle((reason) => this.#lost(connection, reason));
|
|
113
119
|
return true;
|
|
114
120
|
}
|
|
115
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Drop the session without the unlock statement. The socket is closed outright — no goodbye is
|
|
124
|
+
* written, because a write to a black-holed socket is one more thing that never settles — and
|
|
125
|
+
* Postgres releases the lock when the session ends. An acquisition still in flight is dropped
|
|
126
|
+
* the moment it lands.
|
|
127
|
+
*/
|
|
128
|
+
abandon(): void {
|
|
129
|
+
const acquiring = this.#acquiring;
|
|
130
|
+
if (acquiring !== null) {
|
|
131
|
+
// Voided: its own caller has the outcome; this only makes sure nothing stays held after it.
|
|
132
|
+
void acquiring.then(
|
|
133
|
+
() => this.abandon(),
|
|
134
|
+
() => undefined,
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const connection = this.#connection;
|
|
139
|
+
if (connection === null) return;
|
|
140
|
+
this.#connection = null;
|
|
141
|
+
connection.destroy();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
onLost(listener: (reason: string) => void): () => void {
|
|
145
|
+
this.#lostListeners.add(listener);
|
|
146
|
+
return () => {
|
|
147
|
+
this.#lostListeners.delete(listener);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The session ended underneath the lock. A release that got there first owns the goodbye. */
|
|
152
|
+
#lost(connection: PgConnection, reason: string): void {
|
|
153
|
+
if (this.#connection !== connection) return;
|
|
154
|
+
this.#connection = null;
|
|
155
|
+
// The socket is already gone: nothing is said to it, and the listeners hear first.
|
|
156
|
+
connection.destroy();
|
|
157
|
+
for (const listener of [...this.#lostListeners]) listener(reason);
|
|
158
|
+
}
|
|
159
|
+
|
|
116
160
|
async release(): Promise<void> {
|
|
117
161
|
// A release that arrives mid-acquisition waits it out. `#connection` is still `null` there, so
|
|
118
162
|
// the early return below would answer "nothing to release" and the session would open behind
|
package/src/pg-auth.ts
CHANGED
|
@@ -63,8 +63,10 @@ class ScramSha256Session implements ScramSession {
|
|
|
63
63
|
if (!serverNonce.startsWith(this.#clientNonce)) {
|
|
64
64
|
throw new ReplicationProtocolError({
|
|
65
65
|
stage: 'auth',
|
|
66
|
-
detail:
|
|
67
|
-
|
|
66
|
+
detail:
|
|
67
|
+
`server nonce "${serverNonce}" does not extend client nonce "${this.#clientNonce}" — ` +
|
|
68
|
+
'the server did not echo the client nonce; check for a proxy on the replication URL',
|
|
69
|
+
fix: 'x doctor --json',
|
|
68
70
|
});
|
|
69
71
|
}
|
|
70
72
|
|
|
@@ -216,8 +218,11 @@ function parseServerFirst(text: string): ServerFirst {
|
|
|
216
218
|
if (iterations > MAX_SCRAM_ITERATIONS) {
|
|
217
219
|
throw new ReplicationProtocolError({
|
|
218
220
|
stage: 'auth',
|
|
219
|
-
detail:
|
|
220
|
-
|
|
221
|
+
detail:
|
|
222
|
+
`server-first-message asked for ${iterationsText} PBKDF2 iterations, above the ` +
|
|
223
|
+
`${MAX_SCRAM_ITERATIONS} ceiling — a real server asks for 4096, so point the replication ` +
|
|
224
|
+
'URL at postgres itself',
|
|
225
|
+
fix: 'x doctor --json',
|
|
221
226
|
});
|
|
222
227
|
}
|
|
223
228
|
const salt = decodeBase64('salt', saltB64);
|
package/src/pg-bytes.ts
CHANGED
|
@@ -129,7 +129,10 @@ export class ByteWriter {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
uint8(value: number): this {
|
|
132
|
-
this.#bytes[this.#room(1)]
|
|
132
|
+
// The offset FIRST: `#room` may replace `#bytes`, and `this.#bytes[this.#room(1)]` reads the
|
|
133
|
+
// old buffer before it does — the byte that caused a growth was written into the discard.
|
|
134
|
+
const at = this.#room(1);
|
|
135
|
+
this.#bytes[at] = value & 0xff;
|
|
133
136
|
return this;
|
|
134
137
|
}
|
|
135
138
|
|
package/src/pg-connection.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// queries, and the CopyBoth switch that a replication stream lives inside. It speaks only in
|
|
3
3
|
// messages, never in bytes on a socket, so the whole handshake is driven by hand in the tests.
|
|
4
4
|
|
|
5
|
-
import { logger } from '@ultimat3/core';
|
|
5
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
6
6
|
import { ReplicationFailedError, ReplicationProtocolError } from './errors';
|
|
7
7
|
import {
|
|
8
8
|
chooseMechanism,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
scramSession,
|
|
13
13
|
} from './pg-auth';
|
|
14
14
|
import { ByteReader } from './pg-bytes';
|
|
15
|
+
import { answersWeakAuth, type SslMode } from './pg-tls';
|
|
15
16
|
import {
|
|
16
17
|
copyDoneMessage,
|
|
17
18
|
frame,
|
|
@@ -42,6 +43,11 @@ export interface PgConnectionOptions {
|
|
|
42
43
|
readonly applicationName?: string | undefined;
|
|
43
44
|
/** Injected so the SCRAM nonce is deterministic under a seeded test. */
|
|
44
45
|
readonly rng?: Rng | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* The `sslmode` the stream was dialled under — what decides whether a cleartext or md5 password
|
|
48
|
+
* request is answered (`answersWeakAuth`). Unstated is `prefer`, which refuses both.
|
|
49
|
+
*/
|
|
50
|
+
readonly ssl?: SslMode | undefined;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/** A result set as text, exactly as the wire carries it. `null` is SQL NULL, never `''`. */
|
|
@@ -61,12 +67,30 @@ const needPassword = (method: string): ReplicationFailedError =>
|
|
|
61
67
|
fix: 'put the credentials in the URL: postgres://user:password@host:5432/db',
|
|
62
68
|
});
|
|
63
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The server asked for the password in a form the network can use, on a session nothing
|
|
72
|
+
* guarantees is encrypted. Refused before a byte of it is written.
|
|
73
|
+
*/
|
|
74
|
+
const weakAuthRefused = (method: string, ssl: SslMode | undefined): ReplicationFailedError =>
|
|
75
|
+
new ReplicationFailedError({
|
|
76
|
+
stage: 'auth',
|
|
77
|
+
detail:
|
|
78
|
+
`the server asked for ${method} and sslmode=${ssl ?? 'prefer'} does not guarantee an ` +
|
|
79
|
+
'encrypted session — connect with ?sslmode=require (or verify-full), move the role to ' +
|
|
80
|
+
'scram-sha-256, or state ?sslmode=disable to accept a cleartext session',
|
|
81
|
+
fix: 'x doctor --json',
|
|
82
|
+
});
|
|
83
|
+
|
|
64
84
|
export class PgConnection {
|
|
65
85
|
readonly #stream: PgStream;
|
|
66
86
|
readonly #reader: MessageReader;
|
|
67
87
|
readonly #parameters = new Map<string, string>();
|
|
68
88
|
#copyBoth = false;
|
|
69
89
|
#closed = false;
|
|
90
|
+
/** The read parked on an idle session, if one is — see `watchIdle`. A query takes it over. */
|
|
91
|
+
#idle: Promise<PgMessage | undefined> | undefined;
|
|
92
|
+
#onIdleEnd: ((reason: string) => void) | undefined;
|
|
93
|
+
#querying = false;
|
|
70
94
|
|
|
71
95
|
private constructor(stream: PgStream) {
|
|
72
96
|
this.#stream = stream;
|
|
@@ -123,26 +147,82 @@ export class PgConnection {
|
|
|
123
147
|
|
|
124
148
|
/** One simple query. Returns the rows as text; a `CommandComplete` with no rows returns `[]`. */
|
|
125
149
|
async query(sql: string): Promise<PgRows> {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
150
|
+
this.#querying = true;
|
|
151
|
+
try {
|
|
152
|
+
await this.#stream.write(queryMessage(sql));
|
|
153
|
+
const rows: (readonly (string | null)[])[] = [];
|
|
154
|
+
for (;;) {
|
|
155
|
+
const message = await this.#expect('query');
|
|
156
|
+
switch (message.tag) {
|
|
157
|
+
case 'D':
|
|
158
|
+
rows.push(dataRow(message.body));
|
|
159
|
+
break;
|
|
160
|
+
case 'E':
|
|
161
|
+
// Drain to `ReadyForQuery` first: leaving the session mid-result desynchronises reuse.
|
|
162
|
+
await this.#drainToReady();
|
|
163
|
+
throw serverError('query', message.body);
|
|
164
|
+
case 'Z':
|
|
165
|
+
return rows;
|
|
166
|
+
default:
|
|
167
|
+
this.#note(message);
|
|
168
|
+
}
|
|
142
169
|
}
|
|
170
|
+
} finally {
|
|
171
|
+
this.#querying = false;
|
|
172
|
+
this.#park();
|
|
143
173
|
}
|
|
144
174
|
}
|
|
145
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Watch a session that is doing NOTHING. An idle connection has no reader, so a server that
|
|
178
|
+
* ended it — a failover, `pg_terminate_backend`, a dropped socket — was noticed by the next
|
|
179
|
+
* statement, which for a session held only for its advisory lock is never. This parks one read
|
|
180
|
+
* between queries: EOF, a read error or an ErrorResponse calls `onEnd` once, with why.
|
|
181
|
+
*
|
|
182
|
+
* Never for `close()`. A `query()` takes the parked read over rather than racing it — the stream
|
|
183
|
+
* allows one reader — and the watch resumes when the query is done.
|
|
184
|
+
*/
|
|
185
|
+
watchIdle(onEnd: (reason: string) => void): void {
|
|
186
|
+
this.#onIdleEnd = onEnd;
|
|
187
|
+
this.#park();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#park(): void {
|
|
191
|
+
if (this.#closed || this.#querying || this.#copyBoth) return;
|
|
192
|
+
if (this.#onIdleEnd === undefined || this.#idle !== undefined) return;
|
|
193
|
+
const parked = this.#reader.next();
|
|
194
|
+
this.#idle = parked;
|
|
195
|
+
const ended = (reason: string): void => {
|
|
196
|
+
const onEnd = this.#onIdleEnd;
|
|
197
|
+
this.#onIdleEnd = undefined;
|
|
198
|
+
if (!this.#closed) onEnd?.(reason);
|
|
199
|
+
};
|
|
200
|
+
parked.then(
|
|
201
|
+
(message) => {
|
|
202
|
+
// A query took this read over: the message is its answer, not this watch's.
|
|
203
|
+
if (this.#idle !== parked) return;
|
|
204
|
+
this.#idle = undefined;
|
|
205
|
+
if (message === undefined) return ended('the server closed the connection');
|
|
206
|
+
if (message.tag === 'E') return ended(renderThrowable(serverError('idle', message.body)));
|
|
207
|
+
this.#note(message);
|
|
208
|
+
this.#park();
|
|
209
|
+
},
|
|
210
|
+
(failure: unknown) => {
|
|
211
|
+
if (this.#idle !== parked) return;
|
|
212
|
+
this.#idle = undefined;
|
|
213
|
+
ended(renderThrowable(failure));
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The next message: the read an idle watch already parked, when there is one. */
|
|
219
|
+
#next(): Promise<PgMessage | undefined> {
|
|
220
|
+
const parked = this.#idle;
|
|
221
|
+
if (parked === undefined) return this.#reader.next();
|
|
222
|
+
this.#idle = undefined;
|
|
223
|
+
return parked;
|
|
224
|
+
}
|
|
225
|
+
|
|
146
226
|
/**
|
|
147
227
|
* `START_REPLICATION` — the connection stops being request/response and becomes a duplex copy
|
|
148
228
|
* stream. There is no way back short of closing it, which is why this is one-way on purpose.
|
|
@@ -205,6 +285,18 @@ export class PgConnection {
|
|
|
205
285
|
this.#stream.close();
|
|
206
286
|
}
|
|
207
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Close the socket and say nothing — no `Terminate`, nothing awaited. For a session that may
|
|
290
|
+
* already be dead: `close()` writes a goodbye first, and a write to a black-holed socket is a
|
|
291
|
+
* promise nothing settles. The server ends the session when the socket goes, which releases
|
|
292
|
+
* whatever the session held. Never reported to an idle watch.
|
|
293
|
+
*/
|
|
294
|
+
destroy(): void {
|
|
295
|
+
if (this.#closed) return;
|
|
296
|
+
this.#closed = true;
|
|
297
|
+
closeQuietly(this.#stream);
|
|
298
|
+
}
|
|
299
|
+
|
|
208
300
|
async #authenticate(options: PgConnectionOptions): Promise<void> {
|
|
209
301
|
let scram: ScramSession | undefined;
|
|
210
302
|
for (;;) {
|
|
@@ -222,12 +314,16 @@ export class PgConnection {
|
|
|
222
314
|
case AUTH_CLEARTEXT: {
|
|
223
315
|
const password = options.password ?? '';
|
|
224
316
|
if (password === '') throw needPassword('a cleartext password');
|
|
317
|
+
if (!answersWeakAuth(options.ssl)) {
|
|
318
|
+
throw weakAuthRefused('a cleartext password', options.ssl);
|
|
319
|
+
}
|
|
225
320
|
await this.#stream.write(passwordMessage(password));
|
|
226
321
|
break;
|
|
227
322
|
}
|
|
228
323
|
case AUTH_MD5: {
|
|
229
324
|
const password = options.password ?? '';
|
|
230
325
|
if (password === '') throw needPassword('an md5 password');
|
|
326
|
+
if (!answersWeakAuth(options.ssl)) throw weakAuthRefused('an md5 password', options.ssl);
|
|
231
327
|
const salt = reader.take(4);
|
|
232
328
|
await this.#stream.write(
|
|
233
329
|
passwordMessage(md5Password({ user: options.user, password, salt })),
|
|
@@ -276,13 +372,13 @@ export class PgConnection {
|
|
|
276
372
|
|
|
277
373
|
async #drainToReady(): Promise<void> {
|
|
278
374
|
for (;;) {
|
|
279
|
-
const message = await this.#
|
|
375
|
+
const message = await this.#next();
|
|
280
376
|
if (message === undefined || message.tag === 'Z') return;
|
|
281
377
|
}
|
|
282
378
|
}
|
|
283
379
|
|
|
284
380
|
async #expect(stage: string): Promise<PgMessage> {
|
|
285
|
-
const message = await this.#
|
|
381
|
+
const message = await this.#next();
|
|
286
382
|
if (message !== undefined) return message;
|
|
287
383
|
throw new ReplicationFailedError({
|
|
288
384
|
stage,
|
|
@@ -315,8 +411,10 @@ export class PgConnection {
|
|
|
315
411
|
const outOfOrder = (what: string): ReplicationProtocolError =>
|
|
316
412
|
new ReplicationProtocolError({
|
|
317
413
|
stage: 'auth',
|
|
318
|
-
detail:
|
|
319
|
-
|
|
414
|
+
detail:
|
|
415
|
+
`the server sent ${what} before it offered a SASL mechanism — the exchange arrived out of ` +
|
|
416
|
+
'order; check for a pooler or proxy between this client and postgres',
|
|
417
|
+
fix: 'x doctor --json',
|
|
320
418
|
});
|
|
321
419
|
|
|
322
420
|
/** A `close()` that throws must not replace the handshake failure that is worth reporting. */
|
package/src/pg-entity-row.ts
CHANGED
|
@@ -58,3 +58,19 @@ export function entityRow(
|
|
|
58
58
|
for (const field of sealedFields(entity)) delete row[field.property];
|
|
59
59
|
return row;
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The entity properties a decoded `after` image does not carry — what an UPDATE's unchanged TOAST
|
|
64
|
+
* columns become once the tuple is an entity row. Asked only when the tuple said it left columns
|
|
65
|
+
* out, and answered in the row's own vocabulary: a consumer compares these against the properties
|
|
66
|
+
* it projects, and it has never seen a physical column name. A sealed column is never listed: no
|
|
67
|
+
* change names one, so its absence says nothing.
|
|
68
|
+
*/
|
|
69
|
+
export function omittedProperties(relation: PgRelation, row: PhysicalRow): readonly string[] {
|
|
70
|
+
const entity = entityForTable(relation.name);
|
|
71
|
+
if (entity === undefined) return [];
|
|
72
|
+
const sealed = new Set(sealedFields(entity).map((field) => field.property));
|
|
73
|
+
return Object.keys(entity.$columns)
|
|
74
|
+
.filter((property) => !Object.hasOwn(row, property) && !sealed.has(property))
|
|
75
|
+
.sort();
|
|
76
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Single responsibility: which replicated tables need `REPLICA IDENTITY FULL` for a reason the
|
|
2
|
+
// live-query path does not have — a channel with params routes a DELETE by columns of the OLD row.
|
|
3
|
+
|
|
4
|
+
import type { Channel } from './channel-decl';
|
|
5
|
+
import { registeredChannels } from './channel-registry';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `records` tables of every channel declared with params, deduped and sorted.
|
|
9
|
+
*
|
|
10
|
+
* A live query never needs FULL on a keyed table: its shared window holds the whole row, so a
|
|
11
|
+
* delete that names only the key is enough. A channel holds no window. Its removal is routed to
|
|
12
|
+
* the topic the old row's params name (`channel-records.ts`), and under the default identity the
|
|
13
|
+
* old image is the key alone — it names no topic, no `remove` is sent, and every member keeps the
|
|
14
|
+
* deleted record. A channel with no params has one topic and needs nothing.
|
|
15
|
+
*/
|
|
16
|
+
export function paramsChannelTables(
|
|
17
|
+
channels: Iterable<Channel> = registeredChannels(),
|
|
18
|
+
): readonly string[] {
|
|
19
|
+
const tables = new Set<string>();
|
|
20
|
+
for (const declared of channels) {
|
|
21
|
+
if (declared.params.length === 0) continue;
|
|
22
|
+
for (const projection of declared.records) tables.add(projection.table);
|
|
23
|
+
}
|
|
24
|
+
return [...tables].sort();
|
|
25
|
+
}
|