@ultimat3/realtime 23.0.0 → 25.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 +118 -126
- package/README.md +229 -54
- package/package.json +9 -6
- package/src/advisory-lock.ts +62 -0
- package/src/boot.ts +25 -15
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +9 -9
- package/src/changefeed.ts +81 -19
- package/src/channel-authz.ts +48 -17
- package/src/channel-describe.ts +1 -1
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +36 -15
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +35 -4
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +148 -48
- package/src/client-channels.ts +12 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +16 -3
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +30 -2
- package/src/cursor.ts +11 -0
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +113 -105
- package/src/fanout.ts +13 -0
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +25 -7
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +34 -9
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +122 -29
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +44 -0
- package/src/live-query.ts +96 -175
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +37 -6
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +149 -15
- package/src/offline-queue.ts +53 -5
- package/src/page-errors.ts +25 -9
- package/src/page-outbox.ts +125 -54
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +54 -5
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +126 -22
- 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 +50 -51
- package/src/pg-socket.ts +26 -7
- package/src/pg-tls.ts +18 -0
- package/src/pg-values.ts +29 -14
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +87 -10
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +17 -5
- package/src/record-persister.ts +7 -2
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +11 -7
- package/src/replicator-envelope.ts +160 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +313 -191
- package/src/server.ts +45 -23
- package/src/socket-defaults.ts +20 -0
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/socket.ts +14 -19
- package/src/subscriber-gate.ts +11 -1
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-auth.ts +47 -2
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +74 -21
- package/src/sync-node-bounds.ts +16 -1
- package/src/sync-node-contract.ts +36 -4
- package/src/sync-node.ts +163 -82
- package/src/sync-origin.ts +27 -12
- package/src/sync-protocol.ts +86 -16
- package/src/sync-upgrade.ts +115 -8
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +41 -20
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +18 -9
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/live-resume.ts
CHANGED
|
@@ -11,11 +11,12 @@ import {
|
|
|
11
11
|
type ResumeSource,
|
|
12
12
|
resumeFrom,
|
|
13
13
|
} from './cursor';
|
|
14
|
+
import type { RowPatch } from './json';
|
|
14
15
|
import type { SnapshotResult } from './live-contract';
|
|
15
|
-
import { snapshotFrame } from './live-fanout';
|
|
16
|
+
import { type NamedWrite, patchFrame, snapshotFrame } from './live-fanout';
|
|
16
17
|
import { fillWindow, type QueryEntry } from './query-window';
|
|
17
18
|
import type { Subscriber, SubscriberGate } from './subscriber-gate';
|
|
18
|
-
import {
|
|
19
|
+
import type { Frame } from './sync-protocol';
|
|
19
20
|
|
|
20
21
|
export interface ResumeOntoDeps {
|
|
21
22
|
readonly source: ResumeSource;
|
|
@@ -24,6 +25,8 @@ export interface ResumeOntoDeps {
|
|
|
24
25
|
readonly gate: SubscriberGate;
|
|
25
26
|
/** The window, read once for the entry and filtered for THIS subscriber. */
|
|
26
27
|
read(): Promise<SnapshotResult>;
|
|
28
|
+
/** Called before the delta path fills an unread entry — a database read the caller pays for. */
|
|
29
|
+
beforeFill?(): Promise<void>;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/**
|
|
@@ -45,16 +48,23 @@ export async function resumeOnto(
|
|
|
45
48
|
snapshot: () => deps.read(),
|
|
46
49
|
});
|
|
47
50
|
if (resumed.kind !== 'delta') {
|
|
51
|
+
// Read after every retained patch, so it holds their writes; `snapshotFrame` names only those
|
|
52
|
+
// behind a row this subscriber was given. Out of the ring's window there is nothing to name,
|
|
53
|
+
// and a write left unnamed settles on its own HTTP answer.
|
|
54
|
+
const named = retainedWrites(deps.source.since(entry.qid, cursor.lsn) ?? []);
|
|
48
55
|
return {
|
|
49
56
|
cursor: resumed.cursor,
|
|
50
|
-
frame: snapshotFrame(entry, who.sid, resumed.rows, resumed.cursor),
|
|
57
|
+
frame: snapshotFrame(entry, who.sid, resumed.rows, resumed.cursor, named),
|
|
51
58
|
};
|
|
52
59
|
}
|
|
53
60
|
// The gate decides about whole rows out of the shared window, and an entry nothing has read
|
|
54
61
|
// yet has none — every patch would meet an empty window and be withheld. Filling is
|
|
55
62
|
// conditional on purpose: a restart storm resumes onto entries that already hold a live
|
|
56
63
|
// window, and re-reading per resuming subscriber is the cost a delta resume exists to skip.
|
|
57
|
-
if (entry.lsn === '')
|
|
64
|
+
if (entry.lsn === '') {
|
|
65
|
+
await deps.beforeFill?.();
|
|
66
|
+
await fillWindow(entry);
|
|
67
|
+
}
|
|
58
68
|
// The live entry on purpose: a resume runs outside the lane, so the window under it may
|
|
59
69
|
// have moved on — always forwards, and a row whose grant was revoked in the meantime is
|
|
60
70
|
// one this pass must refuse rather than replay from the state it had at the cursor's lsn.
|
|
@@ -67,8 +77,34 @@ export async function resumeOnto(
|
|
|
67
77
|
// another tenant's row id and the instant it went. The leak that branch closes, re-opened
|
|
68
78
|
// one layer up. `live-fanout.ts` advances over `allowed` for exactly this reason.
|
|
69
79
|
const seated = advance(cursor, patches, resumed.cursor.lsn, now);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
const { bare, writes } = namedWrites(patches);
|
|
81
|
+
return { cursor: seated, frame: patchFrame(who.sid, bare, seated.lsn, writes) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The writes behind the patches this subscriber is sent, each named once, and the patches as the
|
|
86
|
+
* wire spells them. Read off the FILTERED list: a write behind a row the gate withheld is another
|
|
87
|
+
* actor's, and naming it here would tell this page that write happened.
|
|
88
|
+
*/
|
|
89
|
+
function namedWrites(patches: readonly RowPatch[]): {
|
|
90
|
+
readonly bare: readonly RowPatch[];
|
|
91
|
+
readonly writes: readonly string[];
|
|
92
|
+
} {
|
|
93
|
+
const writes = new Set<string>();
|
|
94
|
+
const bare = patches.map((patch) => {
|
|
95
|
+
if (patch.write === undefined) return patch;
|
|
96
|
+
writes.add(patch.write);
|
|
97
|
+
const { write: _named, ...rest } = patch;
|
|
98
|
+
return rest;
|
|
99
|
+
});
|
|
100
|
+
return { bare, writes: [...writes] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Each retained write, by the record key of the row its patch made. */
|
|
104
|
+
function retainedWrites(patches: readonly RowPatch[]): readonly NamedWrite[] {
|
|
105
|
+
const named: NamedWrite[] = [];
|
|
106
|
+
for (const patch of patches) {
|
|
107
|
+
if (patch.write !== undefined) named.push({ write: patch.write, key: patch.key ?? patch.id });
|
|
108
|
+
}
|
|
109
|
+
return named;
|
|
74
110
|
}
|
package/src/live-rows.ts
CHANGED
|
@@ -70,6 +70,7 @@ export class RowWindows {
|
|
|
70
70
|
type: string | null,
|
|
71
71
|
rows: readonly Row[],
|
|
72
72
|
keys?: readonly string[],
|
|
73
|
+
writes: readonly string[] = [],
|
|
73
74
|
): void {
|
|
74
75
|
const next = type ?? registration.type;
|
|
75
76
|
// The server's record key where it sent one; a row's `id` IS its key everywhere else.
|
|
@@ -80,12 +81,27 @@ export class RowWindows {
|
|
|
80
81
|
keyed.map(([id]) => id),
|
|
81
82
|
() => {
|
|
82
83
|
for (const [id, row] of keyed) this.#store.merge(next, id, row);
|
|
84
|
+
// Settled in this batch, as `patch` settles: truth that holds the write, never under it.
|
|
85
|
+
settle(
|
|
86
|
+
this.#store,
|
|
87
|
+
writes,
|
|
88
|
+
keyed.map(([id]) => recordKey(next, id)),
|
|
89
|
+
);
|
|
83
90
|
},
|
|
84
91
|
);
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
94
|
+
/**
|
|
95
|
+
* A patch list: values merged into the store, membership and order folded over the ids. `writes`
|
|
96
|
+
* are the writes the frame names (`PatchFrame.writes`): each this page still holds is settled
|
|
97
|
+
* against the rows merged here, INSIDE the same batch — so no holder ever renders the server's
|
|
98
|
+
* row with that write's twin replayed over it (truth plus overlay: one like counted twice).
|
|
99
|
+
*/
|
|
100
|
+
patch(
|
|
101
|
+
registration: Registration,
|
|
102
|
+
sent: readonly RowPatch[],
|
|
103
|
+
writes: readonly string[] = [],
|
|
104
|
+
): void {
|
|
89
105
|
const type = registration.type;
|
|
90
106
|
// A window holds RECORD keys: a patch the server keyed is folded under its key, not its id.
|
|
91
107
|
const patches = sent.map((patch) =>
|
|
@@ -93,10 +109,16 @@ export class RowWindows {
|
|
|
93
109
|
);
|
|
94
110
|
// A `delete` is this window losing the row, never the store losing it: another holder keeps it.
|
|
95
111
|
this.#reseat(registration, type, orderAfterPatches(registration.ids, patches), (held) => {
|
|
112
|
+
// Only a row merged here is truth this frame carried. A `delete` is membership, not the row:
|
|
113
|
+
// a write it names keeps its overlay on that row until the server's next word on it.
|
|
114
|
+
const carried = new Set<RecordKey>();
|
|
96
115
|
for (const patch of patches) {
|
|
97
116
|
if (patch.op === 'delete' || patch.row === null) continue;
|
|
98
|
-
if (held.has(patch.id))
|
|
117
|
+
if (!held.has(patch.id)) continue;
|
|
118
|
+
this.#store.merge(type, patch.id, patch.row);
|
|
119
|
+
carried.add(recordKey(type, patch.id));
|
|
99
120
|
}
|
|
121
|
+
settle(this.#store, writes, carried);
|
|
100
122
|
});
|
|
101
123
|
}
|
|
102
124
|
|
|
@@ -144,3 +166,10 @@ function holds(registration: Registration, changed: ReadonlySet<RecordKey>): boo
|
|
|
144
166
|
}
|
|
145
167
|
return false;
|
|
146
168
|
}
|
|
169
|
+
|
|
170
|
+
/** Each named write this page still holds, settled against the rows the frame merged. */
|
|
171
|
+
function settle(store: RecordStore, writes: readonly string[], merged: Iterable<RecordKey>): void {
|
|
172
|
+
if (writes.length === 0) return;
|
|
173
|
+
const carried = new Set(merged);
|
|
174
|
+
for (const write of writes) store.settleWrite(write, carried);
|
|
175
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// When a live subscribe spends the read's declared `rateLimit:`, and against whom. Split from
|
|
2
|
+
// `live-query.ts` at its size ceiling: the registry decides WHAT is served, this decides what it
|
|
3
|
+
// costs — once per subscribe that reads, on the node's clock, keyed by the socket's own address.
|
|
4
|
+
|
|
5
|
+
import type { Clock } from '@ultimat3/core';
|
|
6
|
+
import type { LiveCursor } from './cursor';
|
|
7
|
+
import type { JsonValue } from './json';
|
|
8
|
+
import type { LiveQueryDefinition } from './live-contract';
|
|
9
|
+
import type { SyncSocket } from './socket';
|
|
10
|
+
|
|
11
|
+
export interface SubscribeArgs {
|
|
12
|
+
readonly socket: SyncSocket;
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly input: JsonValue;
|
|
15
|
+
readonly sid?: string;
|
|
16
|
+
readonly cursor?: LiveCursor | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One subscribe's bill, shared by every pass of it. `subscribe` serves AGAIN when the socket's
|
|
21
|
+
* actor is replaced under it, and that second pass is the same subscription: spending there
|
|
22
|
+
* charged one subscribe twice.
|
|
23
|
+
*/
|
|
24
|
+
export interface Charge {
|
|
25
|
+
due: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Spend once, then never again for this subscribe — called by the registry immediately before
|
|
30
|
+
* any DATABASE READ this subscriber causes: a cold snapshot, a resume that fell back to one, or a
|
|
31
|
+
* delta resume onto an entry nothing has read yet. Only a real in-window delta served out of the
|
|
32
|
+
* ring is free. Never decided from the cursor: its `qid` is computable from the query, the input
|
|
33
|
+
* and the org, so a cursor is a client's claim to a window, not proof it held one — trusting it
|
|
34
|
+
* made every subscribe+unsubscribe cycle a free read. `due` drops before the await, so a pass
|
|
35
|
+
* racing this one (the re-auth retry) cannot pay as well.
|
|
36
|
+
*/
|
|
37
|
+
export async function spendOnce(
|
|
38
|
+
definition: LiveQueryDefinition,
|
|
39
|
+
socket: SyncSocket,
|
|
40
|
+
charge: Charge,
|
|
41
|
+
clock: Clock,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
if (!charge.due) return;
|
|
44
|
+
charge.due = false;
|
|
45
|
+
await definition.spend?.({
|
|
46
|
+
actor: socket.actor,
|
|
47
|
+
clientAddress: socket.clientAddress,
|
|
48
|
+
nowMs: clock.now().getTime(),
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/local-store-idb.ts
CHANGED
|
@@ -115,6 +115,11 @@ export class MemoryLocalStore implements LocalStore {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** The one way to build the in-process store (tests, a browser with no IndexedDB); the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
|
|
119
|
+
export function memoryLocalStore(): MemoryLocalStore {
|
|
120
|
+
return new MemoryLocalStore();
|
|
121
|
+
}
|
|
122
|
+
|
|
118
123
|
class IdbLocalStore implements LocalStore {
|
|
119
124
|
readonly kind = 'indexeddb';
|
|
120
125
|
constructor(private readonly db: IdbDatabaseLike) {}
|
package/src/matcher-bridge.ts
CHANGED
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import { type LiveQuery, match, type Patch } from '@ultimat3/query';
|
|
8
8
|
import type { ChangeEvent } from './changefeed';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
changedColumns,
|
|
11
|
+
isParsedJsonObject,
|
|
12
|
+
type JsonObject,
|
|
13
|
+
type Row,
|
|
14
|
+
type RowPatch,
|
|
15
|
+
} from './json';
|
|
10
16
|
|
|
11
17
|
export interface SubscriptionShape {
|
|
12
18
|
readonly qid: string;
|
|
@@ -37,7 +43,7 @@ export interface IncrementalMatcher {
|
|
|
37
43
|
|
|
38
44
|
/** The cheap pre-filter. Pure, and the only thing that runs for a change nobody subscribed to. */
|
|
39
45
|
export function canAffect(shape: SubscriptionShape, change: ChangeEvent): boolean {
|
|
40
|
-
if (!shape.entities.includes(change.
|
|
46
|
+
if (!shape.entities.includes(change.table)) return false;
|
|
41
47
|
if (shape.orgId !== null && change.orgId !== null && shape.orgId !== change.orgId) return false;
|
|
42
48
|
if (shape.columns && change.op === 'update' && change.after !== null) {
|
|
43
49
|
const touched = Object.keys(changedColumns(change.before, change.after));
|
|
@@ -89,7 +95,7 @@ export function matcherFor(live: LiveQuery, projection?: () => Projection): Incr
|
|
|
89
95
|
const row = change.after ?? change.before;
|
|
90
96
|
if (!row) return NO_CHANGE;
|
|
91
97
|
const patches = match<Row>(live.name, live.shape, rows, {
|
|
92
|
-
entity: change.
|
|
98
|
+
entity: change.table,
|
|
93
99
|
op: change.op,
|
|
94
100
|
row,
|
|
95
101
|
...(change.before === null ? {} : { before: change.before }),
|
|
@@ -200,7 +206,7 @@ export function applyToWindow(rows: readonly Row[], patches: readonly RowPatch[]
|
|
|
200
206
|
export function normalizePatch(candidate: unknown, change: ChangeEvent): RowPatch | null {
|
|
201
207
|
if (candidate === null || candidate === undefined || candidate === false) return null;
|
|
202
208
|
if (candidate === true) return patchFromChange(change);
|
|
203
|
-
if (!
|
|
209
|
+
if (!isParsedJsonObject(candidate)) return null;
|
|
204
210
|
const op = candidate['op'];
|
|
205
211
|
if (op !== 'insert' && op !== 'update' && op !== 'delete') return patchFromChange(change);
|
|
206
212
|
const id = candidate['id'];
|
|
@@ -209,7 +215,7 @@ export function normalizePatch(candidate: unknown, change: ChangeEvent): RowPatc
|
|
|
209
215
|
const base: RowPatch = {
|
|
210
216
|
op,
|
|
211
217
|
id: typeof id === 'string' ? id : (change.after?.id ?? change.before?.id ?? ''),
|
|
212
|
-
row: op === 'delete' ? null :
|
|
218
|
+
row: op === 'delete' ? null : isParsedJsonObject(rowValue) ? rowValue : (change.after ?? null),
|
|
213
219
|
lsn: typeof candidate['lsn'] === 'string' ? candidate['lsn'] : change.lsn,
|
|
214
220
|
};
|
|
215
221
|
if (base.id === '') return null;
|
package/src/nats-client.ts
CHANGED
|
@@ -33,8 +33,8 @@ export interface NatsRequestManyOptions {
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* What the bus needs from a NATS connection, and nothing more. A subscription outlives a reconnect —
|
|
36
|
-
* the client re-establishes it underneath the caller
|
|
37
|
-
*
|
|
36
|
+
* the client re-establishes it underneath the caller. It does not outlive the client: once the
|
|
37
|
+
* reconnect budget is spent (`onClosed`), the caller dials a new client and binds again.
|
|
38
38
|
*/
|
|
39
39
|
export interface NatsClient {
|
|
40
40
|
/** The connected server's version, as `assertServerVersion` reads it. */
|
|
@@ -75,14 +75,38 @@ export interface NatsClientOptions {
|
|
|
75
75
|
readonly onError?: ((error: unknown) => void) | undefined;
|
|
76
76
|
/** The library re-established the connection. The cluster behind it may be a different one. */
|
|
77
77
|
readonly onReconnect?: (() => void) | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* The library gave up: its reconnect budget is spent and this connection is closed for good,
|
|
80
|
+
* subscriptions included. Never called for the caller's own `close()`. The caller re-dials.
|
|
81
|
+
*/
|
|
82
|
+
readonly onClosed?: (() => void) | undefined;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
/** The one seam a test replaces: a client that never touches a socket. */
|
|
81
86
|
export type NatsConnect = (options: NatsClientOptions) => Promise<NatsClient>;
|
|
82
87
|
|
|
83
88
|
export const DEFAULT_NATS_PORT = 4222;
|
|
89
|
+
|
|
90
|
+
const NATS_URL_FIX = 'export NATS_URL=nats://user:password@host:4222';
|
|
84
91
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
85
92
|
|
|
93
|
+
/**
|
|
94
|
+
* One URL credential, percent-decoded. `decodeURIComponent` throws a bare `URIError` on a stray
|
|
95
|
+
* `%` (`50%off`), so a secret pasted unencoded failed boot with no code and no fix. Refused under
|
|
96
|
+
* the part's NAME and the host — never the value: it is the credential, and a cause reaches a log.
|
|
97
|
+
*/
|
|
98
|
+
function decodeCredential(raw: string, part: 'user' | 'password', host: string): string {
|
|
99
|
+
try {
|
|
100
|
+
return decodeURIComponent(raw);
|
|
101
|
+
} catch {
|
|
102
|
+
throw new TransportUnavailableError({
|
|
103
|
+
transport: 'nats',
|
|
104
|
+
reason: `the connection URL for ${host} has a malformed percent-escape in its ${part}`,
|
|
105
|
+
fix: `bun -e 'console.log(encodeURIComponent(process.argv[1]))' '<the raw value>' # then put the result in the ${part} of NATS_URL`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
86
110
|
/**
|
|
87
111
|
* `nats://user:pass@host:4222`. The one place a bus URL is read — the library takes a bare
|
|
88
112
|
* `host:port` plus credentials as options, and never looks at a URL's userinfo.
|
|
@@ -92,9 +116,12 @@ export function parseNatsUrl(url: string): NatsTarget {
|
|
|
92
116
|
try {
|
|
93
117
|
parsed = new URL(url);
|
|
94
118
|
} catch {
|
|
119
|
+
// The variable, never its value: a bus URL carries the credential, and an error is the one
|
|
120
|
+
// thing that reaches a log, `--json`, an agent transcript and a ticket. `parsePgUrl`'s rule.
|
|
95
121
|
throw new TransportUnavailableError({
|
|
96
122
|
transport: 'nats',
|
|
97
|
-
reason:
|
|
123
|
+
reason: 'NATS_URL is not a connection URL',
|
|
124
|
+
fix: NATS_URL_FIX,
|
|
98
125
|
});
|
|
99
126
|
}
|
|
100
127
|
if (parsed.protocol !== 'nats:' && parsed.protocol !== 'tls:') {
|
|
@@ -104,7 +131,11 @@ export function parseNatsUrl(url: string): NatsTarget {
|
|
|
104
131
|
});
|
|
105
132
|
}
|
|
106
133
|
if (parsed.hostname === '') {
|
|
107
|
-
throw new TransportUnavailableError({
|
|
134
|
+
throw new TransportUnavailableError({
|
|
135
|
+
transport: 'nats',
|
|
136
|
+
reason: 'NATS_URL has no host',
|
|
137
|
+
fix: NATS_URL_FIX,
|
|
138
|
+
});
|
|
108
139
|
}
|
|
109
140
|
const hasUser = parsed.username !== '';
|
|
110
141
|
const hasPass = parsed.password !== '';
|
|
@@ -118,8 +149,8 @@ export function parseNatsUrl(url: string): NatsTarget {
|
|
|
118
149
|
fix: 'set the URL to nats://<user>:<pass>@host:4222, or the bare-token form nats://<token>@host:4222',
|
|
119
150
|
});
|
|
120
151
|
}
|
|
121
|
-
const user = hasUser ?
|
|
122
|
-
const pass = hasPass ?
|
|
152
|
+
const user = hasUser ? decodeCredential(parsed.username, 'user', parsed.hostname) : undefined;
|
|
153
|
+
const pass = hasPass ? decodeCredential(parsed.password, 'password', parsed.hostname) : undefined;
|
|
123
154
|
return {
|
|
124
155
|
host: parsed.hostname,
|
|
125
156
|
port: parsed.port === '' ? DEFAULT_NATS_PORT : Number.parseInt(parsed.port, 10),
|
package/src/nats-lib-client.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// WHERE FAILURES ARE TRANSLATED, and it is deliberately not all here. This file coded the calls
|
|
6
6
|
// that have no synchronous caller frame to catch them: `request`, `requestMany`, the dial, and the
|
|
7
|
-
// background `#watch
|
|
7
|
+
// background `#watch`/`#watchClosed`, the only places a lost connection is announced. `publish` and
|
|
8
8
|
// `subscribe` are synchronous and stay raw — `NatsTransport.#translating` codes them, because
|
|
9
9
|
// `NatsTransportOptions.connect` is a PUBLIC injection seam: translating in this class would cover
|
|
10
10
|
// the one client the repo ships and leave every app-supplied one uncovered, and translating in both
|
|
@@ -72,6 +72,8 @@ class LibNatsClient implements NatsClient {
|
|
|
72
72
|
readonly #timeoutMs: number;
|
|
73
73
|
readonly #report: (error: unknown) => void;
|
|
74
74
|
#connected = true;
|
|
75
|
+
/** Our own `close()` ends the status stream too, and that end is not the library giving up. */
|
|
76
|
+
#closing = false;
|
|
75
77
|
|
|
76
78
|
constructor(connection: NatsConnection, target: NatsTarget, options: NatsClientOptions) {
|
|
77
79
|
this.#connection = connection;
|
|
@@ -155,19 +157,22 @@ class LibNatsClient implements NatsClient {
|
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
async close(): Promise<void> {
|
|
160
|
+
this.#closing = true;
|
|
158
161
|
this.#connected = false;
|
|
159
162
|
await this.#connection.close();
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
/**
|
|
163
|
-
* The library's own status stream is the only place a
|
|
166
|
+
* The library's own status stream is the only place a drop or a recovery is announced. Nothing
|
|
164
167
|
* awaits it, so it can neither throw nor end the process: a drop reports and flips `connected`,
|
|
165
168
|
* a reconnect flips it back and tells the transport its cluster may be a new one.
|
|
166
169
|
*/
|
|
167
170
|
async #watch(options: NatsClientOptions): Promise<void> {
|
|
168
171
|
const report = options.onError ?? ((): void => undefined);
|
|
172
|
+
const stream = this.#connection.status();
|
|
173
|
+
void this.#watchClosed(options, stream);
|
|
169
174
|
try {
|
|
170
|
-
for await (const status of
|
|
175
|
+
for await (const status of stream) {
|
|
171
176
|
if (status.type === Events.Disconnect) {
|
|
172
177
|
this.#connected = false;
|
|
173
178
|
report(unavailable(this.#target, 'the connection dropped'));
|
|
@@ -178,17 +183,41 @@ class LibNatsClient implements NatsClient {
|
|
|
178
183
|
report(unavailable(this.#target, `the server reported ${String(status.data)}`));
|
|
179
184
|
}
|
|
180
185
|
}
|
|
181
|
-
// The iterator ends when the connection is done: either `close()` or a reconnect budget spent.
|
|
182
|
-
this.#connected = false;
|
|
183
|
-
const failure = await this.#connection.closed();
|
|
184
|
-
if (failure !== undefined) report(unavailable(this.#target, describe(failure)));
|
|
185
186
|
} catch (error) {
|
|
186
187
|
this.#connected = false;
|
|
187
188
|
report(unavailable(this.#target, describe(error)));
|
|
188
189
|
}
|
|
189
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The end of the connection, read off `closed()` — never off the status stream, which in
|
|
194
|
+
* nats@2.29.3 does NOT end when the connection closes (the library stops its protocol's
|
|
195
|
+
* listeners, not the connection's). A close we did not ask for is the library giving up on its
|
|
196
|
+
* reconnect budget: `onClosed`, so the transport replaces this client rather than handing a dead
|
|
197
|
+
* one out forever. The stream is stopped here, or every replaced client leaks a pending loop.
|
|
198
|
+
*/
|
|
199
|
+
async #watchClosed(options: NatsClientOptions, stream: unknown): Promise<void> {
|
|
200
|
+
let failure: unknown;
|
|
201
|
+
try {
|
|
202
|
+
failure = await this.#connection.closed();
|
|
203
|
+
} catch (error) {
|
|
204
|
+
failure = error;
|
|
205
|
+
}
|
|
206
|
+
this.#connected = false;
|
|
207
|
+
stopStream(stream);
|
|
208
|
+
if (this.#closing) return;
|
|
209
|
+
if (failure !== undefined) options.onError?.(unavailable(this.#target, describe(failure)));
|
|
210
|
+
options.onClosed?.();
|
|
211
|
+
}
|
|
190
212
|
}
|
|
191
213
|
|
|
214
|
+
/** `QueuedIterator.stop()` is the library's, and missing from the `AsyncIterable` it is typed as. */
|
|
215
|
+
const stopStream = (stream: unknown): void => {
|
|
216
|
+
if (typeof stream !== 'object' || stream === null || !('stop' in stream)) return;
|
|
217
|
+
const stop: unknown = stream.stop;
|
|
218
|
+
if (typeof stop === 'function') stop.call(stream);
|
|
219
|
+
};
|
|
220
|
+
|
|
192
221
|
/**
|
|
193
222
|
* The production `NatsConnect`. The first dial retries on the same budget as a later loss
|
|
194
223
|
* (`waitOnFirstConnect`), so a `sync` container that raced the bus into readiness recovers on its
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Single responsibility: the transport's kept list of live subscriptions, so a client the library
|
|
2
|
+
// closed for good can be replaced and every subscription bound again on the new one. The library's
|
|
3
|
+
// own reconnect never reaches this list — it re-establishes the SAME client's subscriptions — so a
|
|
4
|
+
// subscription is bound once per client and a re-bind can never double a delivery.
|
|
5
|
+
|
|
6
|
+
import type { NatsClient, NatsMessageHandler, NatsSubscription } from './nats-client';
|
|
7
|
+
|
|
8
|
+
interface Kept {
|
|
9
|
+
readonly subject: string;
|
|
10
|
+
readonly deliver: NatsMessageHandler;
|
|
11
|
+
/** The binding on the current client; `undefined` once that client is gone or the bind failed. */
|
|
12
|
+
live: NatsSubscription | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A refused bind, handed back rather than thrown: one bad subject must not strand the others. */
|
|
16
|
+
export interface BindFailure {
|
|
17
|
+
readonly subject: string;
|
|
18
|
+
readonly error: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class NatsSubscriptions {
|
|
22
|
+
readonly #kept = new Set<Kept>();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Binds on `client` first and keeps it only once bound: a subscribe the client refused is the
|
|
26
|
+
* caller's error, and keeping it would re-try a refusal on every re-dial. Returns the release.
|
|
27
|
+
*/
|
|
28
|
+
add(
|
|
29
|
+
client: NatsClient,
|
|
30
|
+
subject: string,
|
|
31
|
+
deliver: NatsMessageHandler,
|
|
32
|
+
bind: (call: () => NatsSubscription) => NatsSubscription,
|
|
33
|
+
): () => void {
|
|
34
|
+
const kept: Kept = { subject, deliver, live: undefined };
|
|
35
|
+
kept.live = bind(() => client.subscribe(subject, deliver));
|
|
36
|
+
this.#kept.add(kept);
|
|
37
|
+
return () => {
|
|
38
|
+
if (!this.#kept.delete(kept)) return;
|
|
39
|
+
const live = kept.live;
|
|
40
|
+
kept.live = undefined;
|
|
41
|
+
live?.unsubscribe();
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The client is gone, and every binding with it. Nothing is unsubscribed: there is no wire. */
|
|
46
|
+
orphan(): void {
|
|
47
|
+
for (const kept of this.#kept) kept.live = undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every kept subscription, bound on a NEW client. Returns the ones it refused. */
|
|
51
|
+
bindAll(client: NatsClient): readonly BindFailure[] {
|
|
52
|
+
const failures: BindFailure[] = [];
|
|
53
|
+
for (const kept of this.#kept) {
|
|
54
|
+
if (kept.live !== undefined) continue;
|
|
55
|
+
try {
|
|
56
|
+
kept.live = client.subscribe(kept.subject, kept.deliver);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
failures.push({ subject: kept.subject, error });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return failures;
|
|
62
|
+
}
|
|
63
|
+
}
|