@ultimat3/realtime 1.2.0 → 2.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 (61) hide show
  1. package/CLAUDE.md +591 -0
  2. package/README.md +320 -19
  3. package/package.json +6 -3
  4. package/src/apply-patches.ts +60 -0
  5. package/src/change-buffer.ts +77 -11
  6. package/src/channel.ts +174 -19
  7. package/src/client-contract.ts +81 -0
  8. package/src/client-frames.ts +175 -0
  9. package/src/client-heartbeat.ts +77 -0
  10. package/src/client-mutations.ts +114 -0
  11. package/src/client-topics.ts +54 -0
  12. package/src/client.ts +307 -273
  13. package/src/cursor.ts +7 -1
  14. package/src/errors.ts +193 -4
  15. package/src/frame-lanes.ts +58 -0
  16. package/src/hooks.ts +19 -5
  17. package/src/identity-map.ts +141 -0
  18. package/src/index.ts +96 -28
  19. package/src/json.ts +38 -1
  20. package/src/live-contract.ts +67 -0
  21. package/src/live-definition.ts +16 -11
  22. package/src/live-fanout.ts +150 -0
  23. package/src/live-query.ts +215 -268
  24. package/src/live-rows.ts +143 -0
  25. package/src/local-store.ts +86 -43
  26. package/src/nats-client.ts +132 -0
  27. package/src/nats-fake.ts +389 -344
  28. package/src/nats-jetstream.ts +21 -20
  29. package/src/nats-kv.ts +7 -7
  30. package/src/nats-lib-client.ts +210 -0
  31. package/src/nats-transport.ts +109 -138
  32. package/src/offline-queue.ts +146 -30
  33. package/src/pg-entity-row.ts +99 -31
  34. package/src/pg-replication.ts +84 -27
  35. package/src/pg-socket.ts +4 -1
  36. package/src/policy-gate.ts +13 -5
  37. package/src/presence.ts +76 -6
  38. package/src/query-hook.ts +56 -0
  39. package/src/query-window.ts +151 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +170 -14
  43. package/src/subscriber-gate.ts +209 -0
  44. package/src/subscription-book.ts +237 -0
  45. package/src/sync-auth.ts +124 -0
  46. package/src/sync-frames.ts +185 -0
  47. package/src/sync-listen.ts +73 -0
  48. package/src/sync-node.ts +284 -243
  49. package/src/sync-protocol.ts +115 -24
  50. package/src/sync-upgrade.ts +124 -0
  51. package/src/thundering-herd.ts +21 -0
  52. package/src/transport-env.ts +3 -3
  53. package/src/type-pins.ts +72 -0
  54. package/src/window-lock.ts +21 -0
  55. package/src/nats-commands.ts +0 -97
  56. package/src/nats-connection-fixture.ts +0 -105
  57. package/src/nats-connection.ts +0 -464
  58. package/src/nats-protocol.ts +0 -222
  59. package/src/nats-socket.ts +0 -236
  60. package/src/pg-connection-fixture.ts +0 -215
  61. 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 JetStream KV the
2
- // bus uses. Tests run under a sealed network, so this is the only way to prove multi-node fanout
3
- // without a broker; the live test against a real server is what proves this fake is not lying.
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 { NatsHeaders } from './nats-protocol';
8
- import { parseHeaders } from './nats-protocol';
9
- import type { NatsStream } from './nats-socket';
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
- readonly maxPayload?: number;
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: string;
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
- type ClientCommand =
31
- | { readonly kind: 'connect' | 'ping' | 'pong' }
32
- | { readonly kind: 'sub'; readonly subject: string; readonly sid: string }
33
- | { readonly kind: 'unsub'; readonly sid: string }
34
- | {
35
- readonly kind: 'pub';
36
- readonly subject: string;
37
- readonly replyTo: string | undefined;
38
- readonly headers: ReadonlyMap<string, string>;
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
- const crlf = (text: string): Uint8Array => encoder.encode(`${text}\r\n`);
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 headerBlock = (headers: NatsHeaders, status?: string): string => {
45
- let block = `NATS/1.0${status === undefined ? '' : ` ${status}`}\r\n`;
46
- for (const [key, value] of headers) block += `${key}: ${value}\r\n`;
47
- return `${block}\r\n`;
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
- const msgFrame = (
88
+ /** `headers` must already be lowercase-keyed: `header()` is case-insensitive by lowering the ask. */
89
+ const message = (
51
90
  subject: string,
52
- sid: string,
53
- replyTo: string | undefined,
54
- payload: string,
55
- headers: NatsHeaders | undefined,
56
- status?: string,
57
- ): Uint8Array => {
58
- const body = encoder.encode(payload);
59
- const reply = replyTo === undefined ? '' : ` ${replyTo}`;
60
- if (headers === undefined && status === undefined) {
61
- const control = `MSG ${subject} ${sid}${reply} ${body.length}\r\n`;
62
- return encoder.encode(`${control}${payload}\r\n`);
63
- }
64
- const block = headerBlock(headers ?? new Map(), status);
65
- const blockBytes = encoder.encode(block).length;
66
- const control = `HMSG ${subject} ${sid}${reply} ${blockBytes} ${blockBytes + body.length}\r\n`;
67
- return encoder.encode(`${control}${block}${payload}\r\n`);
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 CR = 0x0d;
71
- const LF = 0x0a;
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
- const concat = (left: Uint8Array, right: Uint8Array): Uint8Array => {
74
- const merged = new Uint8Array(left.length + right.length);
75
- merged.set(left);
76
- merged.set(right, left.length);
77
- return merged;
78
- };
143
+ get version(): string {
144
+ return this.#broker.version;
145
+ }
79
146
 
80
- /**
81
- * Chunks the client wrote, turned into whole commands. The mirror of `NatsProtocolParser`, and
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
- /** Only the head of the buffer is ever a control line: a payload is consumed whole with it. */
120
- #lineEnd(): number {
121
- for (let index = 0; index + 1 < this.#buffer.length; index += 1) {
122
- if (this.#buffer[index] === CR && this.#buffer[index + 1] === LF) return index;
123
- }
124
- return -1;
125
- }
126
-
127
- #takePub(lineEnd: number, args: readonly string[], headered: boolean): ClientCommand | undefined {
128
- const counts = headered ? 2 : 1;
129
- const hasReply = args.length === 2 + counts + 1;
130
- const total = Number(args[args.length - 1] ?? '0');
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
- kind: 'pub',
141
- subject: args[1] ?? '',
142
- replyTo: hasReply ? args[2] : undefined,
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
- interface FakeClient {
150
- readonly reader: ClientCommandReader;
151
- readonly subscriptions: Map<string, string>;
152
- readonly push: (bytes: Uint8Array) => void;
153
- /** EOF for a reader parked on `read()` a dropped client must never leave one hanging. */
154
- readonly end: () => void;
155
- open: boolean;
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. `connect()` hands back a `NatsStream` wired straight to it. */
159
- export class FakeNatsServer {
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
- #creates = 0;
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 connections(): number {
174
- return [...this.#clients].filter((client) => client.open).length;
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
- * STREAM.CREATE calls answered. A bucket that already exists must be left alone, and nothing
179
- * else the client can observe distinguishes "created again" from "found and kept".
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
- get streamCreates(): number {
182
- return this.#creates;
280
+ forget(): void {
281
+ this.#streams.clear();
282
+ this.#messages = [];
283
+ this.#seq = 0;
183
284
  }
184
285
 
185
- /** The config a stream was created with, so a bucket test asserts it rather than infers it. */
186
- streamConfig(stream: string): Readonly<Record<string, unknown>> | undefined {
187
- return this.#streams.get(stream);
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
- /** Every current KV value, tombstones excluded — the assertion surface for a presence test. */
191
- get stored(): ReadonlyMap<string, string> {
192
- const live = new Map<string, string>();
193
- for (const message of this.#live()) live.set(message.subject, message.payload);
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
- /** A bus restart: every connection drops, and the client is expected to re-establish its subs. */
198
- dropAll(): void {
199
- for (const client of [...this.#clients]) this.#drop(client);
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
- connect(): NatsStream {
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
- read: () =>
225
- new Promise((resolve) => {
226
- const next = queue.shift();
227
- if (next !== undefined) resolve(next);
228
- else if (!client.open) resolve(undefined);
229
- else waiting = resolve;
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
- #drop(client: FakeClient): void {
257
- if (!client.open) return;
258
- client.open = false;
259
- client.subscriptions.clear();
260
- this.#clients.delete(client);
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
- #publish(command: Extract<ClientCommand, { kind: 'pub' }>): void {
284
- if (command.subject.startsWith('$JS.API.')) {
285
- this.#jetStream(command);
286
- return;
287
- }
288
- if (command.subject.startsWith('$KV.')) {
289
- this.#store(command);
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
- #route(
333
+ #requestMany(
296
334
  subject: string,
297
- payload: string,
298
- replyTo: string | undefined,
299
- headers: NatsHeaders | undefined,
300
- status?: string,
301
- ): void {
302
- for (const client of this.#clients) {
303
- if (!client.open) continue;
304
- for (const [sid, pattern] of client.subscriptions) {
305
- if (!subjectMatches(pattern, subject)) continue;
306
- client.push(msgFrame(subject, sid, replyTo, payload, headers, status));
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
- #reply(command: Extract<ClientCommand, { kind: 'pub' }>, body: unknown): void {
312
- if (command.replyTo === undefined) return;
313
- this.#route(command.replyTo, JSON.stringify(body), undefined, undefined);
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
- #store(command: Extract<ClientCommand, { kind: 'pub' }>): void {
317
- this.#seq += 1;
318
- const ttl = Number(command.headers.get('nats-ttl') ?? '0');
319
- const now = this.#clock.now().getTime();
320
- this.#messages.set(command.subject, {
321
- subject: command.subject,
322
- payload: command.payload,
323
- headers: command.headers,
324
- seq: this.#seq,
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
- #live(): readonly StoredMessage[] {
332
- const now = this.#clock.now().getTime();
333
- const live: StoredMessage[] = [];
334
- for (const message of this.#messages.values()) {
335
- if (message.expiresAt !== undefined && message.expiresAt <= now) {
336
- this.#messages.delete(message.subject);
337
- continue;
338
- }
339
- if (message.headers.get('kv-operation') === undefined) live.push(message);
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
- return live;
342
- }
343
-
344
- #headersFor(message: StoredMessage, extra: readonly (readonly [string, string])[]): NatsHeaders {
345
- return new Map<string, string>([
346
- ['Nats-Stream', 'KV'],
347
- ['Nats-Subject', message.subject],
348
- ['Nats-Sequence', String(message.seq)],
349
- ['Nats-Time-Stamp', new Date(message.writtenAt).toISOString()],
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
- #jetStream(command: Extract<ClientCommand, { kind: 'pub' }>): void {
358
- const subject = command.subject;
359
- if (subject.startsWith('$JS.API.STREAM.INFO.')) {
360
- const name = subject.slice('$JS.API.STREAM.INFO.'.length);
361
- const config = this.#streams.get(name);
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
- this.#reply(command, {
383
- error: { code: 503, err_code: 0, description: `no responder for ${subject}` },
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; `…GET.<stream>` with a `multi_last` body is a batch. */
388
- #directGet(command: Extract<ClientCommand, { kind: 'pub' }>, tail: string): void {
389
- const reply = command.replyTo;
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 >= 0) {
393
- const wanted = tail.slice(dot + 1);
394
- const found = this.#live().find((message) => message.subject === wanted);
395
- const stale = this.#messages.get(wanted);
396
- if (found === undefined && stale === undefined) {
397
- this.#route(reply, '', undefined, new Map(), '404 Message Not Found');
398
- return;
399
- }
400
- const message = found ?? stale;
401
- if (message === undefined) return;
402
- this.#route(reply, message.payload, undefined, this.#headersFor(message, []));
403
- return;
404
- }
405
- const body: unknown = JSON.parse(command.payload || '{}');
406
- const filters = (body as { multi_last?: unknown }).multi_last;
407
- const patterns = Array.isArray(filters) ? filters.filter((f) => typeof f === 'string') : [];
408
- const matched = [...this.#messages.values()].filter(
409
- (message) =>
410
- patterns.some((pattern) => subjectMatches(pattern, message.subject)) &&
411
- !(message.expiresAt !== undefined && message.expiresAt <= this.#clock.now().getTime()),
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
- if (matched.length === 0) {
414
- this.#route(reply, '', undefined, new Map(), '404 No Results');
415
- return;
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
- matched.forEach((message, index) => {
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
- /** One connected client stream against a fresh server the one-liner most tests want. */
431
- export const fakeNatsStream = (server: FakeNatsServer): NatsStream => server.connect();
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
+ };