@ultimat3/realtime 1.2.0 → 3.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 +641 -0
- package/README.md +336 -19
- package/package.json +6 -3
- package/src/apply-patches.ts +60 -0
- package/src/change-buffer.ts +77 -11
- package/src/channel.ts +202 -20
- package/src/client-contract.ts +81 -0
- package/src/client-frames.ts +175 -0
- package/src/client-heartbeat.ts +77 -0
- package/src/client-mutations.ts +114 -0
- package/src/client-topics.ts +54 -0
- package/src/client.ts +307 -273
- package/src/cursor.ts +7 -1
- package/src/errors.ts +193 -4
- package/src/frame-lanes.ts +58 -0
- package/src/hooks.ts +19 -5
- package/src/identity-map.ts +141 -0
- package/src/index.ts +99 -28
- package/src/json.ts +38 -1
- package/src/live-contract.ts +67 -0
- package/src/live-definition.ts +16 -11
- package/src/live-fanout.ts +150 -0
- package/src/live-query.ts +215 -268
- package/src/live-rows.ts +143 -0
- package/src/local-store.ts +86 -43
- package/src/nats-client.ts +132 -0
- package/src/nats-fake.ts +389 -344
- package/src/nats-jetstream.ts +21 -20
- package/src/nats-kv.ts +7 -7
- package/src/nats-lib-client.ts +210 -0
- package/src/nats-transport.ts +109 -138
- package/src/offline-queue.ts +146 -30
- package/src/pg-entity-row.ts +99 -31
- package/src/pg-replication.ts +84 -27
- package/src/pg-socket.ts +4 -1
- package/src/policy-gate.ts +13 -5
- package/src/presence.ts +76 -6
- package/src/query-hook.ts +56 -0
- package/src/query-window.ts +187 -0
- package/src/rebase.ts +68 -8
- package/src/replicator.ts +84 -11
- package/src/socket.ts +225 -34
- package/src/subscriber-gate.ts +209 -0
- package/src/subscription-book.ts +237 -0
- package/src/sync-auth.ts +124 -0
- package/src/sync-frames.ts +185 -0
- package/src/sync-listen.ts +73 -0
- package/src/sync-node.ts +324 -248
- package/src/sync-protocol.ts +115 -24
- package/src/sync-upgrade.ts +124 -0
- package/src/thundering-herd.ts +21 -0
- package/src/transport-env.ts +3 -3
- package/src/type-pins.ts +72 -0
- package/src/window-lock.ts +21 -0
- package/src/nats-commands.ts +0 -97
- package/src/nats-connection-fixture.ts +0 -105
- package/src/nats-connection.ts +0 -464
- package/src/nats-protocol.ts +0 -222
- package/src/nats-socket.ts +0 -236
- package/src/pg-connection-fixture.ts +0 -215
- package/src/pg-replication-fixture.ts +0 -261
package/src/nats-fake.ts
CHANGED
|
@@ -1,431 +1,476 @@
|
|
|
1
|
-
// Single responsibility: an in-memory nats-server — core routing plus the slice of
|
|
2
|
-
// bus uses
|
|
3
|
-
//
|
|
1
|
+
// Single responsibility: an in-memory nats-server — core subject routing plus the slice of
|
|
2
|
+
// JetStream KV the bus uses — implementing `NatsClient` directly. The `nats` library owns the wire
|
|
3
|
+
// now, so the fake emulates the server one level above it: tests get a multi-node bus under a
|
|
4
|
+
// sealed network, and the live test against a real server is what proves this fake is not lying.
|
|
4
5
|
|
|
5
6
|
import { type Clock, systemClock } from '@ultimat3/core';
|
|
7
|
+
import { TransportUnavailableError } from './errors';
|
|
6
8
|
import { subjectMatches } from './fanout';
|
|
7
|
-
import type {
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
import type {
|
|
10
|
+
NatsClient,
|
|
11
|
+
NatsClientOptions,
|
|
12
|
+
NatsConnect,
|
|
13
|
+
NatsHeaders,
|
|
14
|
+
NatsMessage,
|
|
15
|
+
NatsMessageHandler,
|
|
16
|
+
NatsRequestManyOptions,
|
|
17
|
+
NatsRequestOptions,
|
|
18
|
+
NatsSubscription,
|
|
19
|
+
} from './nats-client';
|
|
10
20
|
|
|
11
21
|
const encoder = new TextEncoder();
|
|
12
22
|
const decoder = new TextDecoder();
|
|
23
|
+
const EMPTY = new Uint8Array(0);
|
|
24
|
+
|
|
25
|
+
const DEFAULT_VERSION = '2.11.17';
|
|
26
|
+
const DEFAULT_URL = 'nats://fake.test:4222';
|
|
27
|
+
const DEFAULT_BATCH = 1_000;
|
|
28
|
+
|
|
29
|
+
const STATUS_OK = 0;
|
|
30
|
+
const STATUS_EOB = 204;
|
|
31
|
+
const STATUS_NOT_FOUND = 404;
|
|
32
|
+
|
|
33
|
+
const STREAM_INFO = '$JS.API.STREAM.INFO.';
|
|
34
|
+
const STREAM_CREATE = '$JS.API.STREAM.CREATE.';
|
|
35
|
+
const DIRECT_GET = '$JS.API.DIRECT.GET.';
|
|
36
|
+
const KV_PREFIX = '$KV.';
|
|
37
|
+
|
|
38
|
+
const NS_PER_MS = 1_000_000;
|
|
39
|
+
const MS_PER_SECOND = 1_000;
|
|
13
40
|
|
|
14
41
|
export interface FakeNatsOptions {
|
|
42
|
+
/** What `NatsClient.version` answers — the string `assertServerVersion` reads. */
|
|
15
43
|
readonly version?: string;
|
|
16
|
-
|
|
17
|
-
readonly tlsRequired?: boolean;
|
|
44
|
+
/** TTL and `max_age` are judged by this clock, so a test advances time instead of sleeping. */
|
|
18
45
|
readonly clock?: Clock;
|
|
19
46
|
}
|
|
20
47
|
|
|
21
48
|
interface StoredMessage {
|
|
22
49
|
readonly subject: string;
|
|
23
|
-
readonly payload:
|
|
50
|
+
readonly payload: Uint8Array;
|
|
51
|
+
/** The write's own headers, keys lowercased — a later read hands `KV-Operation` back from here. */
|
|
24
52
|
readonly headers: ReadonlyMap<string, string>;
|
|
25
53
|
readonly seq: number;
|
|
26
54
|
readonly writtenAt: number;
|
|
27
55
|
readonly expiresAt: number | undefined;
|
|
28
56
|
}
|
|
29
57
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
readonly payload: string;
|
|
40
|
-
};
|
|
58
|
+
interface StreamRecord {
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly config: Record<string, unknown>;
|
|
61
|
+
readonly subjects: readonly string[];
|
|
62
|
+
/** `max_msgs_per_subject: 1` — a write replaces the subject's current message rather than logging. */
|
|
63
|
+
readonly history1: boolean;
|
|
64
|
+
/** `max_age`, as milliseconds: the whole-stream ceiling over every per-message TTL. */
|
|
65
|
+
readonly maxAgeMs: number | undefined;
|
|
66
|
+
}
|
|
41
67
|
|
|
42
|
-
|
|
68
|
+
/** What a client is allowed to ask of the broker it came from. Nothing else crosses the seam. */
|
|
69
|
+
interface BrokerPort {
|
|
70
|
+
readonly version: string;
|
|
71
|
+
up(): boolean;
|
|
72
|
+
publish(subject: string, payload: Uint8Array): void;
|
|
73
|
+
request(subject: string, payload: Uint8Array, headers: NatsHeaders | undefined): NatsMessage;
|
|
74
|
+
requestMany(
|
|
75
|
+
subject: string,
|
|
76
|
+
payload: Uint8Array,
|
|
77
|
+
until: (message: NatsMessage) => boolean,
|
|
78
|
+
): readonly NatsMessage[];
|
|
79
|
+
release(client: FakeNatsClient): void;
|
|
80
|
+
}
|
|
43
81
|
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
for (const [
|
|
47
|
-
return
|
|
82
|
+
const lowercased = (headers: NatsHeaders | undefined): ReadonlyMap<string, string> => {
|
|
83
|
+
const map = new Map<string, string>();
|
|
84
|
+
for (const [name, value] of headers ?? []) map.set(name.toLowerCase(), value);
|
|
85
|
+
return map;
|
|
48
86
|
};
|
|
49
87
|
|
|
50
|
-
|
|
88
|
+
/** `headers` must already be lowercase-keyed: `header()` is case-insensitive by lowering the ask. */
|
|
89
|
+
const message = (
|
|
51
90
|
subject: string,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
91
|
+
payload: Uint8Array,
|
|
92
|
+
status: number,
|
|
93
|
+
headers: ReadonlyMap<string, string>,
|
|
94
|
+
): NatsMessage => ({
|
|
95
|
+
subject,
|
|
96
|
+
payload,
|
|
97
|
+
status,
|
|
98
|
+
header: (name: string) => headers.get(name.toLowerCase()),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const json = (subject: string, body: unknown): NatsMessage =>
|
|
102
|
+
message(subject, encoder.encode(JSON.stringify(body)), STATUS_OK, new Map());
|
|
103
|
+
|
|
104
|
+
const unavailable = (reason: string): TransportUnavailableError =>
|
|
105
|
+
new TransportUnavailableError({ transport: 'nats', reason });
|
|
106
|
+
|
|
107
|
+
/** A request body is always json here; anything unreadable is treated as `{}`, as the server does. */
|
|
108
|
+
const bodyOf = (payload: Uint8Array): Record<string, unknown> => {
|
|
109
|
+
const text = decoder.decode(payload);
|
|
110
|
+
if (text === '') return {};
|
|
111
|
+
try {
|
|
112
|
+
const parsed: unknown = JSON.parse(text);
|
|
113
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
|
|
114
|
+
return parsed as Record<string, unknown>;
|
|
115
|
+
} catch {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
68
118
|
};
|
|
69
119
|
|
|
70
|
-
const
|
|
71
|
-
|
|
120
|
+
const stringList = (value: unknown): readonly string[] =>
|
|
121
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
|
122
|
+
|
|
123
|
+
const numberOr = (value: unknown, fallback: number): number =>
|
|
124
|
+
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
125
|
+
|
|
126
|
+
/** One simulated node's connection. Not exported: callers only ever see it as a `NatsClient`. */
|
|
127
|
+
class FakeNatsClient implements NatsClient {
|
|
128
|
+
readonly #broker: BrokerPort;
|
|
129
|
+
readonly #options: NatsClientOptions;
|
|
130
|
+
readonly #subscriptions = new Set<{
|
|
131
|
+
readonly pattern: string;
|
|
132
|
+
readonly handler: NatsMessageHandler;
|
|
133
|
+
}>();
|
|
134
|
+
#closed = false;
|
|
135
|
+
#dropped: boolean;
|
|
136
|
+
|
|
137
|
+
constructor(broker: BrokerPort, options: NatsClientOptions) {
|
|
138
|
+
this.#broker = broker;
|
|
139
|
+
this.#options = options;
|
|
140
|
+
this.#dropped = !broker.up();
|
|
141
|
+
}
|
|
72
142
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
merged.set(right, left.length);
|
|
77
|
-
return merged;
|
|
78
|
-
};
|
|
143
|
+
get version(): string {
|
|
144
|
+
return this.#broker.version;
|
|
145
|
+
}
|
|
79
146
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
* buffered in bytes for the same reason it is: a control line counts bytes, a string counts UTF-16
|
|
83
|
-
* code units, and any multi-byte character makes the two disagree — the payload is then sliced
|
|
84
|
-
* short and every command behind it is misread. Decoding per chunk is the same bug twice over,
|
|
85
|
-
* because a character split across two writes decodes to U+FFFD.
|
|
86
|
-
*/
|
|
87
|
-
class ClientCommandReader {
|
|
88
|
-
#buffer: Uint8Array = new Uint8Array(0);
|
|
89
|
-
|
|
90
|
-
push(chunk: Uint8Array): void {
|
|
91
|
-
this.#buffer = this.#buffer.length === 0 ? chunk : concat(this.#buffer, chunk);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
next(): ClientCommand | undefined {
|
|
95
|
-
const lineEnd = this.#lineEnd();
|
|
96
|
-
if (lineEnd < 0) return undefined;
|
|
97
|
-
const line = decoder.decode(this.#buffer.subarray(0, lineEnd));
|
|
98
|
-
const args = line.split(/[ \t]+/).filter((part) => part.length > 0);
|
|
99
|
-
const verb = (args[0] ?? '').toUpperCase();
|
|
100
|
-
if (verb === 'PUB' || verb === 'HPUB') return this.#takePub(lineEnd, args, verb === 'HPUB');
|
|
101
|
-
this.#buffer = this.#buffer.subarray(lineEnd + 2);
|
|
102
|
-
switch (verb) {
|
|
103
|
-
case 'CONNECT':
|
|
104
|
-
return { kind: 'connect' };
|
|
105
|
-
case 'PING':
|
|
106
|
-
return { kind: 'ping' };
|
|
107
|
-
case 'PONG':
|
|
108
|
-
return { kind: 'pong' };
|
|
109
|
-
case 'SUB':
|
|
110
|
-
// `SUB <subject> [queue] <sid>` — the sid is always last.
|
|
111
|
-
return { kind: 'sub', subject: args[1] ?? '', sid: args[args.length - 1] ?? '' };
|
|
112
|
-
case 'UNSUB':
|
|
113
|
-
return { kind: 'unsub', sid: args[1] ?? '' };
|
|
114
|
-
default:
|
|
115
|
-
return undefined;
|
|
116
|
-
}
|
|
147
|
+
get connected(): boolean {
|
|
148
|
+
return !this.#closed && !this.#dropped;
|
|
117
149
|
}
|
|
118
150
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
const headerBytes = headered ? Number(args[args.length - 2] ?? '0') : 0;
|
|
132
|
-
const start = lineEnd + 2;
|
|
133
|
-
if (this.#buffer.length < start + total + 2) return undefined;
|
|
134
|
-
const body = this.#buffer.subarray(start, start + total);
|
|
135
|
-
this.#buffer = this.#buffer.subarray(start + total + 2);
|
|
136
|
-
const parsed = headered
|
|
137
|
-
? parseHeaders(body.subarray(0, headerBytes))
|
|
138
|
-
: { headers: new Map<string, string>() };
|
|
151
|
+
publish(subject: string, payload: Uint8Array): void {
|
|
152
|
+
this.#assertOpen();
|
|
153
|
+
// A publish into a dropped connection is lost, exactly as a real disconnect loses it.
|
|
154
|
+
if (this.#dropped) return;
|
|
155
|
+
this.#broker.publish(subject, payload);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
subscribe(subject: string, handler: NatsMessageHandler): NatsSubscription {
|
|
159
|
+
this.#assertOpen();
|
|
160
|
+
// The subscription is held across a drop: re-establishing it is what the library is bought for.
|
|
161
|
+
const entry = { pattern: subject, handler };
|
|
162
|
+
this.#subscriptions.add(entry);
|
|
139
163
|
return {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
headers: parsed.headers,
|
|
144
|
-
payload: decoder.decode(body.subarray(headerBytes)),
|
|
164
|
+
unsubscribe: () => {
|
|
165
|
+
this.#subscriptions.delete(entry);
|
|
166
|
+
},
|
|
145
167
|
};
|
|
146
168
|
}
|
|
147
|
-
}
|
|
148
169
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
170
|
+
async request(
|
|
171
|
+
subject: string,
|
|
172
|
+
payload: Uint8Array,
|
|
173
|
+
options?: NatsRequestOptions,
|
|
174
|
+
): Promise<NatsMessage> {
|
|
175
|
+
this.#assertOpen();
|
|
176
|
+
this.#assertLive();
|
|
177
|
+
return this.#broker.request(subject, payload, options?.headers);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async requestMany(
|
|
181
|
+
subject: string,
|
|
182
|
+
payload: Uint8Array,
|
|
183
|
+
options: NatsRequestManyOptions,
|
|
184
|
+
): Promise<readonly NatsMessage[]> {
|
|
185
|
+
this.#assertOpen();
|
|
186
|
+
this.#assertLive();
|
|
187
|
+
return this.#broker.requestMany(subject, payload, options.until);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async close(): Promise<void> {
|
|
191
|
+
if (this.#closed) return;
|
|
192
|
+
this.#closed = true;
|
|
193
|
+
this.#subscriptions.clear();
|
|
194
|
+
this.#broker.release(this);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
deliver(delivered: NatsMessage): void {
|
|
198
|
+
if (!this.connected) return;
|
|
199
|
+
for (const entry of [...this.#subscriptions]) {
|
|
200
|
+
if (!subjectMatches(entry.pattern, delivered.subject)) continue;
|
|
201
|
+
try {
|
|
202
|
+
entry.handler(delivered);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// A subscriber that throws is the subscriber's problem, never the publisher's.
|
|
205
|
+
this.#options.onError?.(error);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
markDropped(error: unknown): void {
|
|
211
|
+
if (this.#closed || this.#dropped) return;
|
|
212
|
+
this.#dropped = true;
|
|
213
|
+
this.#options.onError?.(error);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
markRestored(): void {
|
|
217
|
+
if (this.#closed || !this.#dropped) return;
|
|
218
|
+
this.#dropped = false;
|
|
219
|
+
this.#options.onReconnect?.();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
get closed(): boolean {
|
|
223
|
+
return this.#closed;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#assertOpen(): void {
|
|
227
|
+
if (this.#closed) throw unavailable('the client is closed');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A request has an answer to wait for, so a dropped connection fails it rather than losing it. */
|
|
231
|
+
#assertLive(): void {
|
|
232
|
+
if (this.#dropped) throw unavailable('the connection dropped');
|
|
233
|
+
}
|
|
156
234
|
}
|
|
157
235
|
|
|
158
|
-
/** An in-memory nats-server
|
|
159
|
-
export class
|
|
160
|
-
readonly #clients = new Set<FakeClient>();
|
|
161
|
-
readonly #streams = new Map<string, Record<string, unknown>>();
|
|
162
|
-
readonly #messages = new Map<string, StoredMessage>();
|
|
163
|
-
readonly #options: FakeNatsOptions;
|
|
236
|
+
/** An in-memory nats-server: core subject routing plus the slice of JetStream KV the bus uses. */
|
|
237
|
+
export class FakeNatsBroker {
|
|
164
238
|
readonly #clock: Clock;
|
|
239
|
+
readonly #version: string;
|
|
240
|
+
readonly #clients = new Set<FakeNatsClient>();
|
|
241
|
+
readonly #streams = new Map<string, StreamRecord>();
|
|
242
|
+
readonly #failures: { readonly needle: string; remaining: number }[] = [];
|
|
243
|
+
#messages: StoredMessage[] = [];
|
|
165
244
|
#seq = 0;
|
|
166
|
-
#
|
|
245
|
+
#up = true;
|
|
246
|
+
|
|
247
|
+
/** Refuse the next dial: `fakeNatsConnect` rejects while this is true. */
|
|
248
|
+
offline = false;
|
|
167
249
|
|
|
168
250
|
constructor(options: FakeNatsOptions = {}) {
|
|
169
|
-
this.#options = options;
|
|
170
251
|
this.#clock = options.clock ?? systemClock;
|
|
252
|
+
this.#version = options.version ?? DEFAULT_VERSION;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** One client per simulated node. Every client on one broker sees every other's publishes. */
|
|
256
|
+
client(options: NatsClientOptions = { url: DEFAULT_URL }): NatsClient {
|
|
257
|
+
const client = new FakeNatsClient(this.#port(), options);
|
|
258
|
+
this.#clients.add(client);
|
|
259
|
+
return client;
|
|
171
260
|
}
|
|
172
261
|
|
|
173
|
-
get
|
|
174
|
-
return [...this.#clients]
|
|
262
|
+
get clients(): readonly NatsClient[] {
|
|
263
|
+
return [...this.#clients];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Streams currently declared — so a test asserts bucket creation rather than inferring it. */
|
|
267
|
+
get streams(): readonly string[] {
|
|
268
|
+
return [...this.#streams.keys()];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The config body a create declared, verbatim — a bucket's history, direct reads, TTL, max_age. */
|
|
272
|
+
streamConfig(stream: string): Record<string, unknown> | undefined {
|
|
273
|
+
return this.#streams.get(stream)?.config;
|
|
175
274
|
}
|
|
176
275
|
|
|
177
276
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
277
|
+
* The node came back with nothing in it: every stream and every message is gone, and the clients
|
|
278
|
+
* are untouched. That is the state a bucket-asserting reconnect has to survive.
|
|
180
279
|
*/
|
|
181
|
-
|
|
182
|
-
|
|
280
|
+
forget(): void {
|
|
281
|
+
this.#streams.clear();
|
|
282
|
+
this.#messages = [];
|
|
283
|
+
this.#seq = 0;
|
|
183
284
|
}
|
|
184
285
|
|
|
185
|
-
/** The
|
|
186
|
-
|
|
187
|
-
|
|
286
|
+
/** The connection dropped underneath every client. */
|
|
287
|
+
drop(reason = 'the bus went away'): void {
|
|
288
|
+
this.#up = false;
|
|
289
|
+
for (const client of this.#clients) client.markDropped(unavailable(reason));
|
|
188
290
|
}
|
|
189
291
|
|
|
190
|
-
/**
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
for (const
|
|
194
|
-
return live;
|
|
292
|
+
/** The library got it back. */
|
|
293
|
+
restore(): void {
|
|
294
|
+
this.#up = true;
|
|
295
|
+
for (const client of this.#clients) client.markRestored();
|
|
195
296
|
}
|
|
196
297
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
|
|
298
|
+
/** The next `count` requests whose subject contains `needle` fail as an unreachable bus would. */
|
|
299
|
+
fail(needle: string, count = 1): void {
|
|
300
|
+
this.#failures.push({ needle, remaining: count });
|
|
200
301
|
}
|
|
201
302
|
|
|
202
|
-
|
|
203
|
-
const queue: Uint8Array[] = [];
|
|
204
|
-
let waiting: ((chunk: Uint8Array | undefined) => void) | undefined;
|
|
205
|
-
const client: FakeClient = {
|
|
206
|
-
reader: new ClientCommandReader(),
|
|
207
|
-
subscriptions: new Map(),
|
|
208
|
-
open: true,
|
|
209
|
-
push: (bytes) => {
|
|
210
|
-
const waiter = waiting;
|
|
211
|
-
waiting = undefined;
|
|
212
|
-
if (waiter) waiter(bytes);
|
|
213
|
-
else queue.push(bytes);
|
|
214
|
-
},
|
|
215
|
-
end: () => {
|
|
216
|
-
const waiter = waiting;
|
|
217
|
-
waiting = undefined;
|
|
218
|
-
waiter?.(undefined);
|
|
219
|
-
},
|
|
220
|
-
};
|
|
221
|
-
this.#clients.add(client);
|
|
222
|
-
client.push(crlf(`INFO ${JSON.stringify(this.#info())}`));
|
|
303
|
+
#port(): BrokerPort {
|
|
223
304
|
return {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
write: async (bytes) => {
|
|
232
|
-
if (!client.open) return;
|
|
233
|
-
client.reader.push(bytes);
|
|
234
|
-
for (;;) {
|
|
235
|
-
const command = client.reader.next();
|
|
236
|
-
if (command === undefined) return;
|
|
237
|
-
this.#handle(client, command);
|
|
238
|
-
}
|
|
305
|
+
version: this.#version,
|
|
306
|
+
up: () => this.#up,
|
|
307
|
+
publish: (subject, payload) => this.#publish(subject, payload),
|
|
308
|
+
request: (subject, payload, headers) => this.#request(subject, payload, headers),
|
|
309
|
+
requestMany: (subject, payload, until) => this.#requestMany(subject, payload, until),
|
|
310
|
+
release: (client) => {
|
|
311
|
+
this.#clients.delete(client);
|
|
239
312
|
},
|
|
240
|
-
upgradeTls: () => undefined,
|
|
241
|
-
close: () => this.#drop(client),
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
#info(): Record<string, unknown> {
|
|
246
|
-
return {
|
|
247
|
-
server_id: 'FAKE',
|
|
248
|
-
version: this.#options.version ?? '2.11.0',
|
|
249
|
-
max_payload: this.#options.maxPayload ?? 1_048_576,
|
|
250
|
-
headers: true,
|
|
251
|
-
proto: 1,
|
|
252
|
-
...(this.#options.tlsRequired === true ? { tls_required: true } : {}),
|
|
253
313
|
};
|
|
254
314
|
}
|
|
255
315
|
|
|
256
|
-
#
|
|
257
|
-
|
|
258
|
-
client
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
client.end();
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
#handle(client: FakeClient, command: ClientCommand): void {
|
|
265
|
-
switch (command.kind) {
|
|
266
|
-
case 'connect':
|
|
267
|
-
case 'pong':
|
|
268
|
-
return;
|
|
269
|
-
case 'ping':
|
|
270
|
-
client.push(crlf('PONG'));
|
|
271
|
-
return;
|
|
272
|
-
case 'sub':
|
|
273
|
-
client.subscriptions.set(command.sid, command.subject);
|
|
274
|
-
return;
|
|
275
|
-
case 'unsub':
|
|
276
|
-
client.subscriptions.delete(command.sid);
|
|
277
|
-
return;
|
|
278
|
-
case 'pub':
|
|
279
|
-
this.#publish(command);
|
|
316
|
+
#publish(subject: string, payload: Uint8Array): void {
|
|
317
|
+
const delivered = message(subject, payload, STATUS_OK, new Map());
|
|
318
|
+
for (const client of [...this.#clients]) {
|
|
319
|
+
if (client.closed) continue;
|
|
320
|
+
client.deliver(delivered);
|
|
280
321
|
}
|
|
281
322
|
}
|
|
282
323
|
|
|
283
|
-
#
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (
|
|
289
|
-
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
this.#route(command.subject, command.payload, command.replyTo, undefined);
|
|
324
|
+
#request(subject: string, payload: Uint8Array, headers: NatsHeaders | undefined): NatsMessage {
|
|
325
|
+
this.#maybeFail(subject);
|
|
326
|
+
if (subject.startsWith(STREAM_INFO)) return this.#streamInfo(subject);
|
|
327
|
+
if (subject.startsWith(STREAM_CREATE)) return this.#streamCreate(subject, payload);
|
|
328
|
+
if (subject.startsWith(DIRECT_GET)) return this.#directGet(subject);
|
|
329
|
+
if (subject.startsWith(KV_PREFIX)) return this.#store(subject, payload, headers);
|
|
330
|
+
throw unavailable(`no responders for ${subject}`);
|
|
293
331
|
}
|
|
294
332
|
|
|
295
|
-
#
|
|
333
|
+
#requestMany(
|
|
296
334
|
subject: string,
|
|
297
|
-
payload:
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
335
|
+
payload: Uint8Array,
|
|
336
|
+
until: (candidate: NatsMessage) => boolean,
|
|
337
|
+
): readonly NatsMessage[] {
|
|
338
|
+
this.#maybeFail(subject);
|
|
339
|
+
const body = bodyOf(payload);
|
|
340
|
+
const filters = subject.startsWith(DIRECT_GET) ? stringList(body['multi_last']) : [];
|
|
341
|
+
if (filters.length === 0) throw unavailable(`no responders for ${subject}`);
|
|
342
|
+
const batch = numberOr(body['batch'], DEFAULT_BATCH);
|
|
343
|
+
const matched = this.#current()
|
|
344
|
+
.filter((stored) => filters.some((filter) => subjectMatches(filter, stored.subject)))
|
|
345
|
+
.slice(0, batch);
|
|
346
|
+
const replies = matched.map((stored) => this.#replyFor(stored));
|
|
347
|
+
// A batch always terminates: `204 EOB` behind results, `404` when the filter matched nothing.
|
|
348
|
+
replies.push(
|
|
349
|
+
message(subject, EMPTY, matched.length > 0 ? STATUS_EOB : STATUS_NOT_FOUND, new Map()),
|
|
350
|
+
);
|
|
351
|
+
const collected: NatsMessage[] = [];
|
|
352
|
+
for (const reply of replies) {
|
|
353
|
+
if (until(reply)) break;
|
|
354
|
+
collected.push(reply);
|
|
308
355
|
}
|
|
356
|
+
return collected;
|
|
309
357
|
}
|
|
310
358
|
|
|
311
|
-
#
|
|
312
|
-
|
|
313
|
-
|
|
359
|
+
#maybeFail(subject: string): void {
|
|
360
|
+
const failure = this.#failures.find((entry) => subject.includes(entry.needle));
|
|
361
|
+
if (failure === undefined) return;
|
|
362
|
+
failure.remaining -= 1;
|
|
363
|
+
if (failure.remaining <= 0) this.#failures.splice(this.#failures.indexOf(failure), 1);
|
|
364
|
+
throw unavailable(`${subject} was refused`);
|
|
314
365
|
}
|
|
315
366
|
|
|
316
|
-
#
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
writtenAt: now,
|
|
326
|
-
expiresAt: ttl > 0 ? now + ttl * 1_000 : undefined,
|
|
327
|
-
});
|
|
328
|
-
this.#reply(command, { stream: 'KV', seq: this.#seq });
|
|
367
|
+
#streamInfo(subject: string): NatsMessage {
|
|
368
|
+
const name = subject.slice(STREAM_INFO.length);
|
|
369
|
+
const stream = this.#streams.get(name);
|
|
370
|
+
return json(
|
|
371
|
+
subject,
|
|
372
|
+
stream === undefined
|
|
373
|
+
? { error: { code: STATUS_NOT_FOUND, err_code: 10_059, description: 'stream not found' } }
|
|
374
|
+
: { config: { name } },
|
|
375
|
+
);
|
|
329
376
|
}
|
|
330
377
|
|
|
331
|
-
#
|
|
332
|
-
const
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
378
|
+
#streamCreate(subject: string, payload: Uint8Array): NatsMessage {
|
|
379
|
+
const name = subject.slice(STREAM_CREATE.length);
|
|
380
|
+
const config = bodyOf(payload);
|
|
381
|
+
const subjects = stringList(config['subjects']);
|
|
382
|
+
const existing = this.#streams.get(name);
|
|
383
|
+
// Same name, different subjects is the one create a server refuses outright.
|
|
384
|
+
if (existing !== undefined && existing.subjects.join(' ') !== subjects.join(' ')) {
|
|
385
|
+
return json(subject, {
|
|
386
|
+
error: { code: 400, err_code: 10_065, description: 'stream name already in use' },
|
|
387
|
+
});
|
|
340
388
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
['
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
...(message.headers.get('kv-operation') === undefined
|
|
351
|
-
? []
|
|
352
|
-
: ([['KV-Operation', message.headers.get('kv-operation') ?? '']] as const)),
|
|
353
|
-
...extra,
|
|
354
|
-
]);
|
|
389
|
+
const maxAge = numberOr(config['max_age'], 0);
|
|
390
|
+
this.#streams.set(name, {
|
|
391
|
+
name,
|
|
392
|
+
config,
|
|
393
|
+
subjects,
|
|
394
|
+
history1: config['max_msgs_per_subject'] === 1,
|
|
395
|
+
maxAgeMs: maxAge > 0 ? maxAge / NS_PER_MS : undefined,
|
|
396
|
+
});
|
|
397
|
+
return json(subject, { config: { name } });
|
|
355
398
|
}
|
|
356
399
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
this.#reply(
|
|
363
|
-
command,
|
|
364
|
-
config === undefined
|
|
365
|
-
? { error: { code: 404, err_code: 10_059, description: 'stream not found' } }
|
|
366
|
-
: { config },
|
|
367
|
-
);
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
if (subject.startsWith('$JS.API.STREAM.CREATE.')) {
|
|
371
|
-
const name = subject.slice('$JS.API.STREAM.CREATE.'.length);
|
|
372
|
-
const config: unknown = JSON.parse(command.payload || '{}');
|
|
373
|
-
this.#creates += 1;
|
|
374
|
-
this.#streams.set(name, config as Record<string, unknown>);
|
|
375
|
-
this.#reply(command, { config });
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
378
|
-
if (subject.startsWith('$JS.API.DIRECT.GET.')) {
|
|
379
|
-
this.#directGet(command, subject.slice('$JS.API.DIRECT.GET.'.length));
|
|
380
|
-
return;
|
|
400
|
+
/** A KV write: a publish that waits for JetStream's ack, so an unstored put never reads as one. */
|
|
401
|
+
#store(subject: string, payload: Uint8Array, headers: NatsHeaders | undefined): NatsMessage {
|
|
402
|
+
const stream = this.#streamFor(subject);
|
|
403
|
+
if (stream === undefined) {
|
|
404
|
+
return json(subject, { error: { code: 503, description: 'no responders' } });
|
|
381
405
|
}
|
|
382
|
-
|
|
383
|
-
|
|
406
|
+
const written = lowercased(headers);
|
|
407
|
+
const now = this.#clock.now().getTime();
|
|
408
|
+
const ttlSeconds = Number.parseInt(written.get('nats-ttl') ?? '', 10);
|
|
409
|
+
const perMessage =
|
|
410
|
+
Number.isNaN(ttlSeconds) || ttlSeconds <= 0 ? undefined : now + ttlSeconds * MS_PER_SECOND;
|
|
411
|
+
const ceiling = stream.maxAgeMs === undefined ? undefined : now + stream.maxAgeMs;
|
|
412
|
+
const limits = [perMessage, ceiling].filter((at): at is number => at !== undefined);
|
|
413
|
+
this.#seq += 1;
|
|
414
|
+
if (stream.history1) this.#messages = this.#messages.filter((m) => m.subject !== subject);
|
|
415
|
+
this.#messages.push({
|
|
416
|
+
subject,
|
|
417
|
+
payload,
|
|
418
|
+
headers: written,
|
|
419
|
+
seq: this.#seq,
|
|
420
|
+
writtenAt: now,
|
|
421
|
+
expiresAt: limits.length === 0 ? undefined : Math.min(...limits),
|
|
384
422
|
});
|
|
423
|
+
return json(subject, { stream: stream.name, seq: this.#seq });
|
|
385
424
|
}
|
|
386
425
|
|
|
387
|
-
/** `…GET.<stream>.<subject>` is one key;
|
|
388
|
-
#directGet(
|
|
389
|
-
const
|
|
390
|
-
if (reply === undefined) return;
|
|
426
|
+
/** `…GET.<stream>.<subject>` is one exact key; the batch form goes through `requestMany`. */
|
|
427
|
+
#directGet(subject: string): NatsMessage {
|
|
428
|
+
const tail = subject.slice(DIRECT_GET.length);
|
|
391
429
|
const dot = tail.indexOf('.');
|
|
392
|
-
if (dot
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
430
|
+
if (dot < 0) throw unavailable(`no responders for ${subject}`);
|
|
431
|
+
const wanted = tail.slice(dot + 1);
|
|
432
|
+
const found = this.#current().findLast((stored) => stored.subject === wanted);
|
|
433
|
+
if (found === undefined) return message(subject, EMPTY, STATUS_NOT_FOUND, new Map());
|
|
434
|
+
return this.#replyFor(found);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#replyFor(stored: StoredMessage): NatsMessage {
|
|
438
|
+
const operation = stored.headers.get('kv-operation');
|
|
439
|
+
const headers = new Map<string, string>([
|
|
440
|
+
['nats-subject', stored.subject],
|
|
441
|
+
['nats-sequence', String(stored.seq)],
|
|
442
|
+
// The server's own write time, in the one format `Date.parse` round-trips.
|
|
443
|
+
['nats-time-stamp', new Date(stored.writtenAt).toISOString()],
|
|
444
|
+
]);
|
|
445
|
+
if (operation !== undefined) headers.set('kv-operation', operation);
|
|
446
|
+
return message(stored.subject, stored.payload, STATUS_OK, headers);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Every unexpired message, in write order, one per subject: expiry is judged, never swept. */
|
|
450
|
+
#current(): readonly StoredMessage[] {
|
|
451
|
+
const now = this.#clock.now().getTime();
|
|
452
|
+
this.#messages = this.#messages.filter(
|
|
453
|
+
(stored) => stored.expiresAt === undefined || stored.expiresAt > now,
|
|
412
454
|
);
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
455
|
+
const latest = new Map<string, StoredMessage>();
|
|
456
|
+
for (const stored of this.#messages) latest.set(stored.subject, stored);
|
|
457
|
+
return [...latest.values()];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
#streamFor(subject: string): StreamRecord | undefined {
|
|
461
|
+
for (const stream of this.#streams.values()) {
|
|
462
|
+
if (stream.subjects.some((pattern) => subjectMatches(pattern, subject))) return stream;
|
|
416
463
|
}
|
|
417
|
-
|
|
418
|
-
const pending = String(matched.length - index - 1);
|
|
419
|
-
this.#route(
|
|
420
|
-
reply,
|
|
421
|
-
message.payload,
|
|
422
|
-
undefined,
|
|
423
|
-
this.#headersFor(message, [['Nats-Num-Pending', pending]]),
|
|
424
|
-
);
|
|
425
|
-
});
|
|
426
|
-
this.#route(reply, '', undefined, new Map([['Nats-Num-Pending', '0']]), '204 EOB');
|
|
464
|
+
return undefined;
|
|
427
465
|
}
|
|
428
466
|
}
|
|
429
467
|
|
|
430
|
-
/**
|
|
431
|
-
export const
|
|
468
|
+
/** The `NatsConnect` a test injects in place of the real client. */
|
|
469
|
+
export const fakeNatsConnect =
|
|
470
|
+
(broker: FakeNatsBroker): NatsConnect =>
|
|
471
|
+
async (options: NatsClientOptions): Promise<NatsClient> => {
|
|
472
|
+
if (broker.offline) {
|
|
473
|
+
throw unavailable(`${options.url} refused the connection`);
|
|
474
|
+
}
|
|
475
|
+
return broker.client(options);
|
|
476
|
+
};
|