@ultimat3/realtime 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/package.json +36 -0
  4. package/src/change-buffer.ts +69 -0
  5. package/src/changefeed-env.ts +146 -0
  6. package/src/changefeed.ts +191 -0
  7. package/src/channel.ts +181 -0
  8. package/src/client.ts +439 -0
  9. package/src/cursor.ts +188 -0
  10. package/src/errors.ts +253 -0
  11. package/src/fanout.ts +159 -0
  12. package/src/hooks.ts +230 -0
  13. package/src/index.ts +328 -0
  14. package/src/json.ts +76 -0
  15. package/src/live-definition.ts +144 -0
  16. package/src/live-query.ts +449 -0
  17. package/src/local-store.ts +188 -0
  18. package/src/matcher-bridge.ts +169 -0
  19. package/src/nats-commands.ts +97 -0
  20. package/src/nats-connection-fixture.ts +105 -0
  21. package/src/nats-connection.ts +464 -0
  22. package/src/nats-fake.ts +431 -0
  23. package/src/nats-jetstream.ts +226 -0
  24. package/src/nats-kv.ts +157 -0
  25. package/src/nats-protocol.ts +222 -0
  26. package/src/nats-socket.ts +236 -0
  27. package/src/nats-transport.ts +257 -0
  28. package/src/offline-queue.ts +206 -0
  29. package/src/pg-advisory-lock.ts +98 -0
  30. package/src/pg-auth.ts +300 -0
  31. package/src/pg-bytes.ts +185 -0
  32. package/src/pg-connection-fixture.ts +215 -0
  33. package/src/pg-connection.ts +337 -0
  34. package/src/pg-entity-row.ts +130 -0
  35. package/src/pg-replication-fixture.ts +261 -0
  36. package/src/pg-replication.ts +396 -0
  37. package/src/pg-socket.ts +265 -0
  38. package/src/pg-wire.ts +192 -0
  39. package/src/pgoutput.ts +297 -0
  40. package/src/policy-gate.ts +56 -0
  41. package/src/presence.ts +219 -0
  42. package/src/rebase.ts +198 -0
  43. package/src/replicator.ts +185 -0
  44. package/src/socket.ts +208 -0
  45. package/src/sync-node.ts +400 -0
  46. package/src/sync-protocol.ts +376 -0
  47. package/src/thundering-herd.ts +141 -0
  48. package/src/transport-env.ts +104 -0
@@ -0,0 +1,431 @@
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.
4
+
5
+ import { type Clock, systemClock } from '@ultimat3/core';
6
+ import { subjectMatches } from './fanout';
7
+ import type { NatsHeaders } from './nats-protocol';
8
+ import { parseHeaders } from './nats-protocol';
9
+ import type { NatsStream } from './nats-socket';
10
+
11
+ const encoder = new TextEncoder();
12
+ const decoder = new TextDecoder();
13
+
14
+ export interface FakeNatsOptions {
15
+ readonly version?: string;
16
+ readonly maxPayload?: number;
17
+ readonly tlsRequired?: boolean;
18
+ readonly clock?: Clock;
19
+ }
20
+
21
+ interface StoredMessage {
22
+ readonly subject: string;
23
+ readonly payload: string;
24
+ readonly headers: ReadonlyMap<string, string>;
25
+ readonly seq: number;
26
+ readonly writtenAt: number;
27
+ readonly expiresAt: number | undefined;
28
+ }
29
+
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
+ };
41
+
42
+ const crlf = (text: string): Uint8Array => encoder.encode(`${text}\r\n`);
43
+
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`;
48
+ };
49
+
50
+ const msgFrame = (
51
+ 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`);
68
+ };
69
+
70
+ const CR = 0x0d;
71
+ const LF = 0x0a;
72
+
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
+ };
79
+
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
+ }
117
+ }
118
+
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>() };
139
+ 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)),
145
+ };
146
+ }
147
+ }
148
+
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;
156
+ }
157
+
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;
164
+ readonly #clock: Clock;
165
+ #seq = 0;
166
+ #creates = 0;
167
+
168
+ constructor(options: FakeNatsOptions = {}) {
169
+ this.#options = options;
170
+ this.#clock = options.clock ?? systemClock;
171
+ }
172
+
173
+ get connections(): number {
174
+ return [...this.#clients].filter((client) => client.open).length;
175
+ }
176
+
177
+ /**
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".
180
+ */
181
+ get streamCreates(): number {
182
+ return this.#creates;
183
+ }
184
+
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);
188
+ }
189
+
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;
195
+ }
196
+
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);
200
+ }
201
+
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())}`));
223
+ 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
+ }
239
+ },
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
+ };
254
+ }
255
+
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);
280
+ }
281
+ }
282
+
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);
293
+ }
294
+
295
+ #route(
296
+ 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
+ }
308
+ }
309
+ }
310
+
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);
314
+ }
315
+
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 });
329
+ }
330
+
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);
340
+ }
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
+ ]);
355
+ }
356
+
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;
381
+ }
382
+ this.#reply(command, {
383
+ error: { code: 503, err_code: 0, description: `no responder for ${subject}` },
384
+ });
385
+ }
386
+
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;
391
+ 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()),
412
+ );
413
+ if (matched.length === 0) {
414
+ this.#route(reply, '', undefined, new Map(), '404 No Results');
415
+ return;
416
+ }
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');
427
+ }
428
+ }
429
+
430
+ /** One connected client stream against a fresh server — the one-liner most tests want. */
431
+ export const fakeNatsStream = (server: FakeNatsServer): NatsStream => server.connect();
@@ -0,0 +1,226 @@
1
+ // Single responsibility: the JetStream API calls the bus needs — the KV bucket's stream, and the
2
+ // direct reads that answer "what is under this prefix" in one round trip. Every subject here is
3
+ // built from a validated bucket name, because a stream name goes straight into a request subject.
4
+
5
+ import { TransportProtocolError, TransportUnavailableError } from './errors';
6
+ import type { NatsConnection } from './nats-connection';
7
+ import type { NatsHeaders, NatsMessage } from './nats-protocol';
8
+
9
+ const encoder = new TextEncoder();
10
+ const decoder = new TextDecoder();
11
+
12
+ /** Batch direct get and per-message TTL both landed in 2.11; without them there is no KV presence. */
13
+ const MIN_SERVER = { major: 2, minor: 11 } as const;
14
+
15
+ /** Interpolated into a stream name and a subject, so this regex is a security boundary. */
16
+ const BUCKET = /^[a-zA-Z0-9_-]+$/;
17
+
18
+ const STATUS_NOT_FOUND = 404;
19
+ const STATUS_EOB = 204;
20
+
21
+ export interface JsError {
22
+ readonly code: number;
23
+ readonly errCode: number;
24
+ readonly description: string;
25
+ }
26
+
27
+ /** One direct-get hit: the KV key, its bytes, and when the server wrote it. */
28
+ export interface KvRecord {
29
+ readonly key: string;
30
+ readonly value: string;
31
+ /** The server's own write time — the one clock every node agrees on. */
32
+ readonly writtenAt: number | undefined;
33
+ /** `DEL`/`PURGE` marks a tombstone; a live value has none. */
34
+ readonly operation: string | undefined;
35
+ }
36
+
37
+ export function assertBucket(bucket: string): void {
38
+ if (!BUCKET.test(bucket)) {
39
+ throw new TransportProtocolError({
40
+ transport: 'nats',
41
+ stage: 'bucket',
42
+ detail: `"${bucket}" is not a bucket name: letters, digits, "-" and "_" only`,
43
+ fix: 'set NATS_KV_BUCKET to a name matching [a-zA-Z0-9_-]+, then restart',
44
+ });
45
+ }
46
+ }
47
+
48
+ /** `2.11.17` → `{ major: 2, minor: 11 }`. A version we cannot read is treated as too old. */
49
+ export function assertServerVersion(version: string): void {
50
+ const [major = 0, minor = 0] = version.split('.').map((part) => Number.parseInt(part, 10) || 0);
51
+ if (major > MIN_SERVER.major || (major === MIN_SERVER.major && minor >= MIN_SERVER.minor)) return;
52
+ throw new TransportProtocolError({
53
+ transport: 'nats',
54
+ stage: 'jetstream',
55
+ detail: `nats-server ${version || '<unknown>'} is older than ${MIN_SERVER.major}.${MIN_SERVER.minor}, which is where batch direct get and per-message TTL landed`,
56
+ fix: `run nats:${MIN_SERVER.major}.${MIN_SERVER.minor}-alpine or newer with JetStream enabled (\`nats-server -js\`)`,
57
+ });
58
+ }
59
+
60
+ const asObject = (message: NatsMessage, subject: string): Record<string, unknown> => {
61
+ let parsed: unknown;
62
+ try {
63
+ parsed = JSON.parse(decoder.decode(message.payload));
64
+ } catch {
65
+ throw new TransportProtocolError({
66
+ transport: 'nats',
67
+ stage: 'jetstream',
68
+ detail: `${subject} answered with something that is not json`,
69
+ });
70
+ }
71
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
72
+ throw new TransportProtocolError({
73
+ transport: 'nats',
74
+ stage: 'jetstream',
75
+ detail: `${subject} answered with a ${Array.isArray(parsed) ? 'list' : typeof parsed}`,
76
+ });
77
+ }
78
+ return parsed as Record<string, unknown>;
79
+ };
80
+
81
+ const errorOf = (body: Record<string, unknown>): JsError | undefined => {
82
+ const raw = body['error'];
83
+ if (typeof raw !== 'object' || raw === null) return undefined;
84
+ const fields = raw as Record<string, unknown>;
85
+ return {
86
+ code: typeof fields['code'] === 'number' ? fields['code'] : 0,
87
+ errCode: typeof fields['err_code'] === 'number' ? fields['err_code'] : 0,
88
+ description: typeof fields['description'] === 'string' ? fields['description'] : 'unknown',
89
+ };
90
+ };
91
+
92
+ /** One JetStream API call. The API always answers with json, and reports failure inside it. */
93
+ export async function jsRequest(
94
+ connection: NatsConnection,
95
+ subject: string,
96
+ body: unknown,
97
+ ): Promise<{ readonly data: Record<string, unknown>; readonly error: JsError | undefined }> {
98
+ const reply = await connection.request(subject, encoder.encode(JSON.stringify(body ?? {})));
99
+ const data = asObject(reply, subject);
100
+ return { data, error: errorOf(data) };
101
+ }
102
+
103
+ /** `jsRequest`, but a JetStream error is thrown rather than returned. */
104
+ export async function jsCall(
105
+ connection: NatsConnection,
106
+ subject: string,
107
+ body: unknown,
108
+ ): Promise<Record<string, unknown>> {
109
+ const { data, error } = await jsRequest(connection, subject, body);
110
+ if (error === undefined) return data;
111
+ throw new TransportUnavailableError({
112
+ transport: 'nats',
113
+ reason: `${subject} failed: ${error.description} (code ${error.code}/${error.errCode})`,
114
+ });
115
+ }
116
+
117
+ export const kvStream = (bucket: string): string => `KV_${bucket}`;
118
+ export const kvSubject = (bucket: string, key: string): string => `$KV.${bucket}.${key}`;
119
+
120
+ /**
121
+ * Create the bucket's stream when it is missing, and leave an existing one alone. Doing it here
122
+ * rather than in an ops runbook is what lets `x dev` and a fresh cluster boot the same way.
123
+ */
124
+ export async function ensureKvBucket(
125
+ connection: NatsConnection,
126
+ bucket: string,
127
+ ttlMs: number,
128
+ ): Promise<void> {
129
+ assertBucket(bucket);
130
+ assertServerVersion(connection.info.version);
131
+ const stream = kvStream(bucket);
132
+ const info = await jsRequest(connection, `$JS.API.STREAM.INFO.${stream}`, {});
133
+ if (info.error === undefined) return;
134
+ if (info.error.code !== STATUS_NOT_FOUND) {
135
+ throw new TransportUnavailableError({
136
+ transport: 'nats',
137
+ reason: `could not read stream ${stream}: ${info.error.description}`,
138
+ });
139
+ }
140
+ await jsCall(connection, `$JS.API.STREAM.CREATE.${stream}`, {
141
+ name: stream,
142
+ subjects: [`$KV.${bucket}.>`],
143
+ // History of one: presence is a current value, never a log. `discard: new` keeps a full
144
+ // bucket from silently dropping the oldest member instead of refusing the newest write.
145
+ max_msgs_per_subject: 1,
146
+ discard: 'new',
147
+ deny_delete: true,
148
+ allow_direct: true,
149
+ allow_rollup_hdrs: true,
150
+ allow_msg_ttl: true,
151
+ // A whole-stream ceiling as well as the per-message TTL: a node that dies mid-put must not be
152
+ // able to leave a member behind forever, whatever happens to its heartbeats.
153
+ max_age: Math.max(ttlMs, 60_000) * 1_000_000,
154
+ storage: 'file',
155
+ num_replicas: 1,
156
+ });
157
+ }
158
+
159
+ const recordOf = (message: NatsMessage, bucket: string): KvRecord | undefined => {
160
+ const subject = message.headers.get('nats-subject');
161
+ if (subject === undefined) return undefined;
162
+ const stamp = message.headers.get('nats-time-stamp');
163
+ const writtenAt = stamp === undefined ? undefined : Date.parse(stamp);
164
+ return {
165
+ key: subject.slice(`$KV.${bucket}.`.length),
166
+ value: decoder.decode(message.payload),
167
+ writtenAt: writtenAt === undefined || Number.isNaN(writtenAt) ? undefined : writtenAt,
168
+ operation: message.headers.get('kv-operation'),
169
+ };
170
+ };
171
+
172
+ /** The current value for one key, or `undefined` when the server has none. */
173
+ export async function kvGet(
174
+ connection: NatsConnection,
175
+ bucket: string,
176
+ key: string,
177
+ ): Promise<KvRecord | undefined> {
178
+ const subject = `$JS.API.DIRECT.GET.${kvStream(bucket)}.${kvSubject(bucket, key)}`;
179
+ const reply = await connection.request(subject, new Uint8Array(0));
180
+ if (reply.status === STATUS_NOT_FOUND) return undefined;
181
+ return recordOf(reply, bucket);
182
+ }
183
+
184
+ /**
185
+ * Every current value under a wildcard, in one request. A batch direct read answers with the
186
+ * messages and then an empty `204 EOB`; a prefix nobody has written answers `404` and nothing else.
187
+ */
188
+ export async function kvLast(
189
+ connection: NatsConnection,
190
+ bucket: string,
191
+ filter: string,
192
+ batch = 1_000,
193
+ ): Promise<readonly KvRecord[]> {
194
+ const subject = `$JS.API.DIRECT.GET.${kvStream(bucket)}`;
195
+ const body = { multi_last: [kvSubject(bucket, filter)], batch };
196
+ const replies = await connection.requestMany(subject, encoder.encode(JSON.stringify(body)), {
197
+ until: (message) => message.status === STATUS_EOB || message.status === STATUS_NOT_FOUND,
198
+ });
199
+ const records: KvRecord[] = [];
200
+ for (const reply of replies) {
201
+ if (reply.status !== undefined) continue;
202
+ const record = recordOf(reply, bucket);
203
+ if (record) records.push(record);
204
+ }
205
+ return records;
206
+ }
207
+
208
+ /** A KV write is a publish that waits for JetStream's ack — a lost put must not read as stored. */
209
+ export async function kvWrite(
210
+ connection: NatsConnection,
211
+ bucket: string,
212
+ key: string,
213
+ value: string,
214
+ headers: NatsHeaders,
215
+ ): Promise<void> {
216
+ const subject = kvSubject(bucket, key);
217
+ const reply = await connection.request(subject, encoder.encode(value), { headers });
218
+ const body = asObject(reply, subject);
219
+ const error = errorOf(body);
220
+ if (error !== undefined) {
221
+ throw new TransportUnavailableError({
222
+ transport: 'nats',
223
+ reason: `${subject} was not stored: ${error.description} (code ${error.code})`,
224
+ });
225
+ }
226
+ }