@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,257 @@
1
+ // Single responsibility: the production `Transport` — core NATS for fanout, JetStream KV for the
2
+ // shared presence sets. Subscriptions are held as intent rather than as socket state, so a lost
3
+ // connection is re-established and re-subscribed underneath the caller: that is what makes a `sync`
4
+ // node stateless and lets any client resubscribe to any node.
5
+
6
+ import { type Clock, isUltimateError, logger, systemClock } from '@ultimat3/core';
7
+ import { TransportUnavailableError } from './errors';
8
+ import type { Transport, TransportHandler, TransportSet, TransportSubscription } from './fanout';
9
+ import { NatsConnection, type NatsSubscription } from './nats-connection';
10
+ import { ensureKvBucket } from './nats-jetstream';
11
+ import { NatsKvSet } from './nats-kv';
12
+ import { bunNatsStream, type NatsStream, type NatsTarget, parseNatsUrl } from './nats-socket';
13
+ import { type BackoffPolicy, backoffDelay, defaultBackoff, type Rng } from './thundering-herd';
14
+
15
+ const encoder = new TextEncoder();
16
+ const decoder = new TextDecoder();
17
+
18
+ export interface NatsTransportOptions {
19
+ readonly url: string;
20
+ /** KV bucket backing `shared`. Created on first connect when the cluster has none. */
21
+ readonly bucket: string;
22
+ readonly maxReconnectAttempts?: number;
23
+ readonly backoff?: BackoffPolicy;
24
+ readonly clock?: Clock;
25
+ /** Presence TTL, only as the floor for the bucket's whole-stream age limit. */
26
+ readonly presenceTtlMs?: number;
27
+ /** A failing subscriber, or a connection lost in the background, must not break the process. */
28
+ readonly onError?: (error: unknown, subject: string) => void;
29
+ readonly rng?: Rng;
30
+ /** Injected so the whole transport — reconnect included — runs in a test with no network. */
31
+ readonly open?: (target: NatsTarget) => Promise<NatsStream>;
32
+ readonly sleep?: (ms: number) => Promise<void>;
33
+ }
34
+
35
+ interface Wanted {
36
+ readonly subject: string;
37
+ readonly handler: TransportHandler;
38
+ live: NatsSubscription | undefined;
39
+ }
40
+
41
+ const DEFAULT_ATTEMPTS = 10;
42
+
43
+ /** The production bus: NATS subjects for fanout, a JetStream KV bucket for presence. */
44
+ export class NatsTransport implements Transport {
45
+ readonly name = 'nats';
46
+ readonly shared: TransportSet;
47
+ readonly #target: NatsTarget;
48
+ readonly #options: NatsTransportOptions;
49
+ readonly #wanted = new Map<number, Wanted>();
50
+ readonly #backoff: BackoffPolicy;
51
+ readonly #attempts: number;
52
+ readonly #rng: Rng;
53
+ readonly #sleep: (ms: number) => Promise<void>;
54
+ #connection: NatsConnection | undefined;
55
+ #dialing: Promise<NatsConnection> | undefined;
56
+ #next = 0;
57
+ #losses = 0;
58
+ #closed = false;
59
+
60
+ constructor(options: NatsTransportOptions) {
61
+ this.#target = parseNatsUrl(options.url);
62
+ this.#options = options;
63
+ this.#backoff = options.backoff ?? defaultBackoff;
64
+ this.#attempts = options.maxReconnectAttempts ?? DEFAULT_ATTEMPTS;
65
+ this.#rng = options.rng ?? Math.random;
66
+ this.#sleep = options.sleep ?? ((ms) => Bun.sleep(ms));
67
+ this.shared = new NatsKvSet({
68
+ connection: () => this.#ensure(),
69
+ bucket: options.bucket,
70
+ clock: options.clock ?? systemClock,
71
+ });
72
+ }
73
+
74
+ /** Fail fast at boot rather than on the first change: `/readyz` is meant to catch a dead bus. */
75
+ async connect(): Promise<void> {
76
+ await this.#ensure();
77
+ }
78
+
79
+ get connected(): boolean {
80
+ return this.#connection !== undefined && !this.#connection.closed;
81
+ }
82
+
83
+ async publish(subject: string, payload: string): Promise<void> {
84
+ const connection = await this.#ensure();
85
+ await connection.publish(subject, encoder.encode(payload));
86
+ }
87
+
88
+ async subscribe(subject: string, handler: TransportHandler): Promise<TransportSubscription> {
89
+ this.#next += 1;
90
+ const id = this.#next;
91
+ const wanted: Wanted = { subject, handler, live: undefined };
92
+ this.#wanted.set(id, wanted);
93
+ try {
94
+ const connection = await this.#ensure();
95
+ // The dial this may have triggered already re-bound everything it found registered, this
96
+ // one included — binding again here would double every delivery on the subject.
97
+ wanted.live ??= await this.#bind(connection, wanted);
98
+ } catch (error) {
99
+ this.#wanted.delete(id);
100
+ throw error;
101
+ }
102
+ return {
103
+ subject,
104
+ unsubscribe: () => {
105
+ this.#wanted.delete(id);
106
+ void wanted.live?.unsubscribe().catch((error: unknown) => this.#report(error, subject));
107
+ wanted.live = undefined;
108
+ },
109
+ };
110
+ }
111
+
112
+ async close(): Promise<void> {
113
+ this.#closed = true;
114
+ this.#wanted.clear();
115
+ const connection = this.#connection;
116
+ this.#connection = undefined;
117
+ await connection?.close();
118
+ }
119
+
120
+ #bind(connection: NatsConnection, wanted: Wanted): Promise<NatsSubscription> {
121
+ return connection.subscribe(wanted.subject, (message) => {
122
+ try {
123
+ wanted.handler(decoder.decode(message.payload), message.subject);
124
+ } catch (error) {
125
+ this.#report(error, message.subject);
126
+ }
127
+ });
128
+ }
129
+
130
+ #ensure(): Promise<NatsConnection> {
131
+ if (this.#closed) {
132
+ return Promise.reject(
133
+ new TransportUnavailableError({ transport: this.name, reason: 'transport is closed' }),
134
+ );
135
+ }
136
+ const current = this.#connection;
137
+ if (current !== undefined && !current.closed) return Promise.resolve(current);
138
+ this.#dialing ??= this.#dial().finally(() => {
139
+ this.#dialing = undefined;
140
+ });
141
+ return this.#dialing;
142
+ }
143
+
144
+ /** Retry is bounded: a bus that is down for longer than the budget is a readiness failure. */
145
+ async #dial(): Promise<NatsConnection> {
146
+ for (let attempt = 0; ; attempt += 1) {
147
+ try {
148
+ return await this.#establish();
149
+ } catch (error) {
150
+ // A protocol mismatch answers the same on every attempt — a server too old for per-message
151
+ // TTL stays too old — so retrying only delays the one report that names the fix.
152
+ const fatal = isUltimateError(error) && error.code === 'X_TRANSPORT_PROTOCOL';
153
+ if (fatal || this.#closed || attempt >= this.#attempts) {
154
+ throw isUltimateError(error)
155
+ ? error
156
+ : new TransportUnavailableError({
157
+ transport: this.name,
158
+ reason: `${this.#target.host}:${this.#target.port} — ${String(error)}`,
159
+ });
160
+ }
161
+ await this.#sleep(backoffDelay(attempt, this.#backoff, this.#rng));
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * One attempt, published only once it is whole. A connection parked in `#connection` before its
168
+ * bucket and its subscriptions are up answers `connected` for a dial that rejected, keeps its
169
+ * `onClose` — which then clears the connection that replaced it — and leaves half a rebind
170
+ * behind, so the next dial binds the same subject a second time and every change arrives twice.
171
+ * A failed attempt therefore takes its own socket down with it rather than leaking one per retry.
172
+ */
173
+ async #establish(): Promise<NatsConnection> {
174
+ const connection = await this.#open();
175
+ try {
176
+ await ensureKvBucket(connection, this.#options.bucket, this.#options.presenceTtlMs ?? 30_000);
177
+ for (const wanted of this.#wanted.values())
178
+ wanted.live = await this.#bind(connection, wanted);
179
+ // `close()` can land while an attempt is in flight, and it only closes what it can see:
180
+ // publishing now would leave a socket open that nothing will ever close again.
181
+ if (this.#closed) {
182
+ throw new TransportUnavailableError({
183
+ transport: this.name,
184
+ reason: 'transport is closed',
185
+ });
186
+ }
187
+ } catch (error) {
188
+ for (const wanted of this.#wanted.values()) wanted.live = undefined;
189
+ await connection.close();
190
+ throw error;
191
+ }
192
+ this.#connection = connection;
193
+ this.#losses = 0;
194
+ return connection;
195
+ }
196
+
197
+ async #open(): Promise<NatsConnection> {
198
+ const stream = await (this.#options.open ?? bunNatsStream)(this.#target);
199
+ // The connection names itself in its own `onClose` so a drop can be matched against the live
200
+ // one. It goes through a holder rather than a `const`, because a buffered `-ERR` closes the
201
+ // session from inside `open()`, while a `const` binding would still be in its dead zone.
202
+ const held: { connection: NatsConnection | undefined } = { connection: undefined };
203
+ held.connection = await NatsConnection.open({
204
+ stream,
205
+ target: this.#target,
206
+ name: 'ultimate',
207
+ rng: this.#options.rng,
208
+ onClose: (error) => this.#lost(error, held.connection),
209
+ onError: (error) => this.#report(error, this.name),
210
+ });
211
+ return held.connection;
212
+ }
213
+
214
+ /**
215
+ * A lost connection re-dials on its own rather than waiting for the next publish: a `sync` node
216
+ * whose subscriptions are down is silently delivering nothing, which is worse than an error.
217
+ */
218
+ #lost(error: unknown, connection: NatsConnection | undefined): void {
219
+ // Only the live connection may declare a loss. One abandoned mid-dial drops on its own clock,
220
+ // and without this it would clear the subscriptions — and the reconnect budget — of whichever
221
+ // connection replaced it, then report a failure the transport had already recovered from.
222
+ if (connection === undefined || this.#connection !== connection) return;
223
+ this.#connection = undefined;
224
+ for (const wanted of this.#wanted.values()) wanted.live = undefined;
225
+ this.#report(error, this.name);
226
+ if (this.#closed || this.#wanted.size === 0) return;
227
+ this.#losses += 1;
228
+ if (this.#losses > this.#attempts) return;
229
+ void this.#recover();
230
+ }
231
+
232
+ async #recover(): Promise<void> {
233
+ // Backoff first: a server that accepts and immediately drops must not become a hot loop.
234
+ await this.#sleep(backoffDelay(this.#losses - 1, this.#backoff, this.#rng));
235
+ if (this.#closed || this.connected) return;
236
+ await this.#ensure().catch((error: unknown) => this.#report(error, this.name));
237
+ }
238
+
239
+ /**
240
+ * Every background failure lands here — a lost connection, a throwing subscriber, an exhausted
241
+ * reconnect. Dropping it when the caller passed no handler is what turns "no changes arrive"
242
+ * into a debugging session with nothing to read, so the default emits rather than swallows.
243
+ */
244
+ #report(error: unknown, subject: string): void {
245
+ const handler = this.#options.onError;
246
+ if (handler !== undefined) {
247
+ handler(error, subject);
248
+ return;
249
+ }
250
+ logger.error('nats transport error', {
251
+ transport: this.name,
252
+ subject,
253
+ code: isUltimateError(error) ? error.code : undefined,
254
+ error: error instanceof Error ? error.message : String(error),
255
+ });
256
+ }
257
+ }
@@ -0,0 +1,206 @@
1
+ // Tier 3: the durable mutation queue. Two invariants, both enforced here rather than documented:
2
+ //
3
+ // 1. **Order.** Mutations drain in client sequence order and stop at the first failure. A mutator
4
+ // that assumed `like` ran before `unlike` must never see them swapped.
5
+ // 2. **Dedupe.** The idempotency key is the identity of the intent. Re-enqueueing a key that is
6
+ // already queued (double click, replay after a crash) collapses onto the existing entry and
7
+ // never gets a new sequence number.
8
+
9
+ import type { JsonValue } from './json';
10
+ import { type Frame, PROTOCOL_VERSION, type WireError } from './sync-protocol';
11
+
12
+ export type MutationStatus = 'pending' | 'inflight' | 'acked' | 'failed';
13
+
14
+ export interface QueuedMutation {
15
+ /** Idempotency key. Supplied by the mutator, stable across retries and reloads. */
16
+ readonly key: string;
17
+ /** Monotonic per client. Never renumbered — rebase replays in this order. */
18
+ readonly seq: number;
19
+ readonly name: string;
20
+ readonly input: JsonValue;
21
+ readonly enqueuedAt: number;
22
+ attempts: number;
23
+ status: MutationStatus;
24
+ error: WireError | null;
25
+ }
26
+
27
+ export interface QueueState {
28
+ readonly mutations: readonly QueuedMutation[];
29
+ readonly nextSeq: number;
30
+ }
31
+
32
+ /** Durability seam: OPFS/IndexedDB in the browser, memory in tests. */
33
+ export interface QueueStore {
34
+ load(): Promise<QueueState>;
35
+ save(state: QueueState): Promise<void>;
36
+ }
37
+
38
+ export class MemoryQueueStore implements QueueStore {
39
+ #state: QueueState = { mutations: [], nextSeq: 1 };
40
+
41
+ async load(): Promise<QueueState> {
42
+ return this.#state;
43
+ }
44
+
45
+ async save(state: QueueState): Promise<void> {
46
+ this.#state = { mutations: state.mutations.map((m) => ({ ...m })), nextSeq: state.nextSeq };
47
+ }
48
+ }
49
+
50
+ export interface DrainReport {
51
+ readonly sent: number;
52
+ readonly collapsed: number;
53
+ readonly remaining: number;
54
+ readonly stoppedAt: string | null;
55
+ }
56
+
57
+ export type MutationSender = (mutation: QueuedMutation) => Promise<void>;
58
+
59
+ export class OfflineQueue {
60
+ readonly #store: QueueStore;
61
+ #mutations: QueuedMutation[] = [];
62
+ #nextSeq = 1;
63
+ #collapsed = 0;
64
+
65
+ private constructor(store: QueueStore, state: QueueState) {
66
+ this.#store = store;
67
+ this.#mutations = state.mutations.map((mutation) => ({ ...mutation }));
68
+ this.#nextSeq = state.nextSeq;
69
+ }
70
+
71
+ /** Rehydrates from durable storage, so a reload resumes the same queue with the same sequence. */
72
+ static async open(store: QueueStore): Promise<OfflineQueue> {
73
+ return new OfflineQueue(store, await store.load());
74
+ }
75
+
76
+ get size(): number {
77
+ return this.#mutations.length;
78
+ }
79
+
80
+ get collapsed(): number {
81
+ return this.#collapsed;
82
+ }
83
+
84
+ get nextSeq(): number {
85
+ return this.#nextSeq;
86
+ }
87
+
88
+ find(key: string): QueuedMutation | undefined {
89
+ return this.#mutations.find((mutation) => mutation.key === key);
90
+ }
91
+
92
+ /** Sorted by sequence. This is the only order anything downstream is allowed to use. */
93
+ pending(): readonly QueuedMutation[] {
94
+ return this.#mutations
95
+ .filter((mutation) => mutation.status === 'pending' || mutation.status === 'inflight')
96
+ .sort((a, b) => a.seq - b.seq);
97
+ }
98
+
99
+ all(): readonly QueuedMutation[] {
100
+ return [...this.#mutations].sort((a, b) => a.seq - b.seq);
101
+ }
102
+
103
+ async enqueue(args: {
104
+ key: string;
105
+ name: string;
106
+ input: JsonValue;
107
+ at?: number;
108
+ }): Promise<QueuedMutation> {
109
+ const existing = this.find(args.key);
110
+ if (existing) {
111
+ this.#collapsed += 1;
112
+ return existing;
113
+ }
114
+ const mutation: QueuedMutation = {
115
+ key: args.key,
116
+ seq: this.#nextSeq,
117
+ name: args.name,
118
+ input: args.input,
119
+ enqueuedAt: args.at ?? 0,
120
+ attempts: 0,
121
+ status: 'pending',
122
+ error: null,
123
+ };
124
+ this.#nextSeq += 1;
125
+ this.#mutations.push(mutation);
126
+ await this.#persist();
127
+ return mutation;
128
+ }
129
+
130
+ /**
131
+ * Drains in sequence order and stops at the first failure. Continuing past a failure is how a
132
+ * sync engine reorders a user's intent — so it does not continue.
133
+ */
134
+ async drain(send: MutationSender): Promise<DrainReport> {
135
+ let sent = 0;
136
+ for (const mutation of this.pending()) {
137
+ mutation.status = 'inflight';
138
+ mutation.attempts += 1;
139
+ try {
140
+ await send(mutation);
141
+ mutation.status = 'acked';
142
+ mutation.error = null;
143
+ sent += 1;
144
+ } catch (error) {
145
+ mutation.status = 'pending';
146
+ mutation.error = toQueueError(error);
147
+ await this.#persist();
148
+ return {
149
+ sent,
150
+ collapsed: this.#collapsed,
151
+ remaining: this.pending().length,
152
+ stoppedAt: mutation.key,
153
+ };
154
+ }
155
+ }
156
+ await this.#persist();
157
+ return { sent, collapsed: this.#collapsed, remaining: this.pending().length, stoppedAt: null };
158
+ }
159
+
160
+ /** Server acknowledged: the mutation leaves the queue and its rebase entry can be committed. */
161
+ async ack(key: string): Promise<void> {
162
+ const mutation = this.find(key);
163
+ if (!mutation) return;
164
+ mutation.status = 'acked';
165
+ this.#mutations = this.#mutations.filter((candidate) => candidate.key !== key);
166
+ await this.#persist();
167
+ }
168
+
169
+ /** Terminal failure (policy denial, validation): kept for the UI, never retried blindly. */
170
+ async fail(key: string, error: WireError): Promise<void> {
171
+ const mutation = this.find(key);
172
+ if (!mutation) return;
173
+ mutation.status = 'failed';
174
+ mutation.error = error;
175
+ await this.#persist();
176
+ }
177
+
178
+ async clear(): Promise<void> {
179
+ this.#mutations = [];
180
+ await this.#persist();
181
+ }
182
+
183
+ async #persist(): Promise<void> {
184
+ await this.#store.save({ mutations: this.#mutations, nextSeq: this.#nextSeq });
185
+ }
186
+ }
187
+
188
+ export function mutateFrame(mutation: QueuedMutation): Frame {
189
+ return {
190
+ type: 'mutate',
191
+ v: PROTOCOL_VERSION,
192
+ key: mutation.key,
193
+ seq: mutation.seq,
194
+ name: mutation.name,
195
+ input: mutation.input,
196
+ };
197
+ }
198
+
199
+ function toQueueError(error: unknown): WireError {
200
+ const shape = error as { code?: unknown; cause?: unknown; fix?: unknown } | null;
201
+ return {
202
+ code: typeof shape?.code === 'string' ? shape.code : 'X_TRANSPORT_UNAVAILABLE',
203
+ cause: typeof shape?.cause === 'string' ? shape.cause : String(error),
204
+ fix: typeof shape?.fix === 'string' ? shape.fix : 'the queue retries on the next reconnect',
205
+ };
206
+ }
@@ -0,0 +1,98 @@
1
+ // Single responsibility: the production `AdvisoryLock` — backs `replicator.ts`'s "exactly one
2
+ // replicator per database" invariant with `SELECT pg_try_advisory_lock(hashtext(key))`. The lock
3
+ // is scoped to this connection's Postgres *session*, so acquiring means keeping the connection
4
+ // open and releasing means closing it: no lease, no renewal, no fencing token, ever.
5
+
6
+ import { ReplicationFailedError } from './errors';
7
+ import { PgConnection, type PgRows } from './pg-connection';
8
+ import { bunPgStream, type PgTarget, parsePgUrl } from './pg-socket';
9
+ import type { PgStream } from './pg-wire';
10
+ import type { AdvisoryLock } from './replicator';
11
+ import type { Rng } from './thundering-herd';
12
+
13
+ /** A key reaches a simple query unparameterised, so its charset is the injection boundary. */
14
+ const KEY_PATTERN = /^[A-Za-z0-9:_.-]+$/;
15
+
16
+ export interface PgAdvisoryLockOptions {
17
+ /** Connection string for the database whose replicator this is. */
18
+ readonly url: string;
19
+ /** Lock identity, e.g. `x:replicator:<slot>`. Hashed by Postgres, not by us. */
20
+ readonly key: string;
21
+ /** The byte pipe, injected. Defaults to `bunPgStream`; a test drives a scripted server. */
22
+ readonly stream?: ((target: PgTarget) => Promise<PgStream>) | undefined;
23
+ /** Injected so the SCRAM nonce is deterministic under a seeded test. */
24
+ readonly rng?: Rng | undefined;
25
+ }
26
+
27
+ /**
28
+ * One `pg_try_advisory_lock`, held by a connection this class opens for itself. Postgres refcounts
29
+ * a session-level advisory lock per acquisition — a second `pg_try_advisory_lock` on a session that
30
+ * already holds the key would need a matching second `pg_advisory_unlock`, and `release()` only
31
+ * ever issues one, so taking a second grant here would be a leak, not a no-op.
32
+ */
33
+ export class PgAdvisoryLock implements AdvisoryLock {
34
+ readonly key: string;
35
+ readonly #options: PgAdvisoryLockOptions;
36
+ #connection: PgConnection | null = null;
37
+
38
+ constructor(options: PgAdvisoryLockOptions) {
39
+ if (!KEY_PATTERN.test(options.key)) {
40
+ throw new ReplicationFailedError({
41
+ stage: 'preflight',
42
+ detail: `advisory lock key "${options.key}" is not [A-Za-z0-9:_.-]+`,
43
+ fix:
44
+ 'rename the lock key to match [A-Za-z0-9:_.-]+ — it is interpolated into ' +
45
+ 'pg_try_advisory_lock(hashtext(...))',
46
+ });
47
+ }
48
+ this.key = options.key;
49
+ this.#options = options;
50
+ }
51
+
52
+ async tryAcquire(): Promise<boolean> {
53
+ // Already ours — see the class comment on why a second `pg_try_advisory_lock` must not run.
54
+ if (this.#connection !== null) return true;
55
+ const target = parsePgUrl(this.#options.url);
56
+ const stream = await (this.#options.stream ?? bunPgStream)(target);
57
+ // Plain SQL, not `replication: 'database'` — this session runs one statement, never a feed.
58
+ const connection = await PgConnection.open({
59
+ stream,
60
+ user: target.user,
61
+ password: target.password,
62
+ database: target.database,
63
+ applicationName: `ultimate-replicator-lock:${this.key}`,
64
+ rng: this.#options.rng,
65
+ });
66
+ let rows: PgRows;
67
+ try {
68
+ rows = await connection.query(`SELECT pg_try_advisory_lock(hashtext('${this.key}'))`);
69
+ } catch (failure) {
70
+ // "The database refused me" and "another process holds it" are different facts; only the
71
+ // second is a `false`, so a query failure closes what was opened and propagates instead.
72
+ await connection.close();
73
+ throw failure;
74
+ }
75
+ if (rows[0]?.[0] !== 't') {
76
+ // A standby must not hold an idle session for a lock it did not get.
77
+ await connection.close();
78
+ return false;
79
+ }
80
+ // Kept open on purpose: this session *is* the lock, and closing it is the whole of
81
+ // `release()` — which is exactly what lets a crashed replicator free the slot with nothing
82
+ // left to clean up.
83
+ this.#connection = connection;
84
+ return true;
85
+ }
86
+
87
+ async release(): Promise<void> {
88
+ const connection = this.#connection;
89
+ if (connection === null) return;
90
+ this.#connection = null;
91
+ try {
92
+ await connection.query(`SELECT pg_advisory_unlock(hashtext('${this.key}'))`);
93
+ } finally {
94
+ // The close releases the session lock regardless, so it must run even if the unlock did not.
95
+ await connection.close();
96
+ }
97
+ }
98
+ }