@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.
Files changed (61) hide show
  1. package/CLAUDE.md +641 -0
  2. package/README.md +336 -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 +202 -20
  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 +99 -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 +187 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +225 -34
  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 +324 -248
  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
@@ -3,8 +3,7 @@
3
3
  // built from a validated bucket name, because a stream name goes straight into a request subject.
4
4
 
5
5
  import { TransportProtocolError, TransportUnavailableError } from './errors';
6
- import type { NatsConnection } from './nats-connection';
7
- import type { NatsHeaders, NatsMessage } from './nats-protocol';
6
+ import type { NatsClient, NatsHeaders, NatsMessage } from './nats-client';
8
7
 
9
8
  const encoder = new TextEncoder();
10
9
  const decoder = new TextDecoder();
@@ -91,22 +90,22 @@ const errorOf = (body: Record<string, unknown>): JsError | undefined => {
91
90
 
92
91
  /** One JetStream API call. The API always answers with json, and reports failure inside it. */
93
92
  export async function jsRequest(
94
- connection: NatsConnection,
93
+ client: NatsClient,
95
94
  subject: string,
96
95
  body: unknown,
97
96
  ): Promise<{ readonly data: Record<string, unknown>; readonly error: JsError | undefined }> {
98
- const reply = await connection.request(subject, encoder.encode(JSON.stringify(body ?? {})));
97
+ const reply = await client.request(subject, encoder.encode(JSON.stringify(body ?? {})));
99
98
  const data = asObject(reply, subject);
100
99
  return { data, error: errorOf(data) };
101
100
  }
102
101
 
103
102
  /** `jsRequest`, but a JetStream error is thrown rather than returned. */
104
103
  export async function jsCall(
105
- connection: NatsConnection,
104
+ client: NatsClient,
106
105
  subject: string,
107
106
  body: unknown,
108
107
  ): Promise<Record<string, unknown>> {
109
- const { data, error } = await jsRequest(connection, subject, body);
108
+ const { data, error } = await jsRequest(client, subject, body);
110
109
  if (error === undefined) return data;
111
110
  throw new TransportUnavailableError({
112
111
  transport: 'nats',
@@ -122,14 +121,14 @@ export const kvSubject = (bucket: string, key: string): string => `$KV.${bucket}
122
121
  * rather than in an ops runbook is what lets `x dev` and a fresh cluster boot the same way.
123
122
  */
124
123
  export async function ensureKvBucket(
125
- connection: NatsConnection,
124
+ client: NatsClient,
126
125
  bucket: string,
127
126
  ttlMs: number,
128
127
  ): Promise<void> {
129
128
  assertBucket(bucket);
130
- assertServerVersion(connection.info.version);
129
+ assertServerVersion(client.version);
131
130
  const stream = kvStream(bucket);
132
- const info = await jsRequest(connection, `$JS.API.STREAM.INFO.${stream}`, {});
131
+ const info = await jsRequest(client, `$JS.API.STREAM.INFO.${stream}`, {});
133
132
  if (info.error === undefined) return;
134
133
  if (info.error.code !== STATUS_NOT_FOUND) {
135
134
  throw new TransportUnavailableError({
@@ -137,7 +136,7 @@ export async function ensureKvBucket(
137
136
  reason: `could not read stream ${stream}: ${info.error.description}`,
138
137
  });
139
138
  }
140
- await jsCall(connection, `$JS.API.STREAM.CREATE.${stream}`, {
139
+ await jsCall(client, `$JS.API.STREAM.CREATE.${stream}`, {
141
140
  name: stream,
142
141
  subjects: [`$KV.${bucket}.>`],
143
142
  // History of one: presence is a current value, never a log. `discard: new` keeps a full
@@ -157,26 +156,26 @@ export async function ensureKvBucket(
157
156
  }
158
157
 
159
158
  const recordOf = (message: NatsMessage, bucket: string): KvRecord | undefined => {
160
- const subject = message.headers.get('nats-subject');
159
+ const subject = message.header('Nats-Subject');
161
160
  if (subject === undefined) return undefined;
162
- const stamp = message.headers.get('nats-time-stamp');
161
+ const stamp = message.header('Nats-Time-Stamp');
163
162
  const writtenAt = stamp === undefined ? undefined : Date.parse(stamp);
164
163
  return {
165
164
  key: subject.slice(`$KV.${bucket}.`.length),
166
165
  value: decoder.decode(message.payload),
167
166
  writtenAt: writtenAt === undefined || Number.isNaN(writtenAt) ? undefined : writtenAt,
168
- operation: message.headers.get('kv-operation'),
167
+ operation: message.header('KV-Operation'),
169
168
  };
170
169
  };
171
170
 
172
171
  /** The current value for one key, or `undefined` when the server has none. */
173
172
  export async function kvGet(
174
- connection: NatsConnection,
173
+ client: NatsClient,
175
174
  bucket: string,
176
175
  key: string,
177
176
  ): Promise<KvRecord | undefined> {
178
177
  const subject = `$JS.API.DIRECT.GET.${kvStream(bucket)}.${kvSubject(bucket, key)}`;
179
- const reply = await connection.request(subject, new Uint8Array(0));
178
+ const reply = await client.request(subject, new Uint8Array(0));
180
179
  if (reply.status === STATUS_NOT_FOUND) return undefined;
181
180
  return recordOf(reply, bucket);
182
181
  }
@@ -186,19 +185,21 @@ export async function kvGet(
186
185
  * messages and then an empty `204 EOB`; a prefix nobody has written answers `404` and nothing else.
187
186
  */
188
187
  export async function kvLast(
189
- connection: NatsConnection,
188
+ client: NatsClient,
190
189
  bucket: string,
191
190
  filter: string,
192
191
  batch = 1_000,
193
192
  ): Promise<readonly KvRecord[]> {
194
193
  const subject = `$JS.API.DIRECT.GET.${kvStream(bucket)}`;
195
194
  const body = { multi_last: [kvSubject(bucket, filter)], batch };
196
- const replies = await connection.requestMany(subject, encoder.encode(JSON.stringify(body)), {
195
+ const replies = await client.requestMany(subject, encoder.encode(JSON.stringify(body)), {
197
196
  until: (message) => message.status === STATUS_EOB || message.status === STATUS_NOT_FOUND,
198
197
  });
199
198
  const records: KvRecord[] = [];
200
199
  for (const reply of replies) {
201
- if (reply.status !== undefined) continue;
200
+ // A status on a batch reply is a marker, never a value — the terminator is filtered by `until`,
201
+ // and anything else the server slips in (a `408` heartbeat) carries no message to read.
202
+ if (reply.status !== 0) continue;
202
203
  const record = recordOf(reply, bucket);
203
204
  if (record) records.push(record);
204
205
  }
@@ -207,14 +208,14 @@ export async function kvLast(
207
208
 
208
209
  /** A KV write is a publish that waits for JetStream's ack — a lost put must not read as stored. */
209
210
  export async function kvWrite(
210
- connection: NatsConnection,
211
+ client: NatsClient,
211
212
  bucket: string,
212
213
  key: string,
213
214
  value: string,
214
215
  headers: NatsHeaders,
215
216
  ): Promise<void> {
216
217
  const subject = kvSubject(bucket, key);
217
- const reply = await connection.request(subject, encoder.encode(value), { headers });
218
+ const reply = await client.request(subject, encoder.encode(value), { headers });
218
219
  const body = asObject(reply, subject);
219
220
  const error = errorOf(body);
220
221
  if (error !== undefined) {
package/src/nats-kv.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { Clock } from '@ultimat3/core';
6
6
  import type { TransportSet, TransportSetEntry } from './fanout';
7
- import type { NatsConnection } from './nats-connection';
7
+ import type { NatsClient } from './nats-client';
8
8
  import { kvGet, kvLast, kvWrite } from './nats-jetstream';
9
9
 
10
10
  /** Per-message TTL is expressed in whole seconds, and must never expire before the logical one. */
@@ -51,8 +51,8 @@ const ttlHeader = (ttlMs: number): ReadonlyMap<string, string> =>
51
51
  new Map([['Nats-TTL', String(Math.ceil(ttlMs / 1_000) + TTL_GRACE_SECONDS)]]);
52
52
 
53
53
  export interface NatsKvSetOptions {
54
- /** The live connection. Awaited per call, because the transport replaces it on a reconnect. */
55
- readonly connection: () => Promise<NatsConnection>;
54
+ /** The live client. Awaited per call: the transport dials lazily and re-dials after a loss. */
55
+ readonly client: () => Promise<NatsClient>;
56
56
  readonly bucket: string;
57
57
  /** Only the fallback when a reply carries no server timestamp; the server's clock is the truth. */
58
58
  readonly clock: Clock;
@@ -69,7 +69,7 @@ export class NatsKvSet implements TransportSet {
69
69
  async put(key: string, member: string, value: string, ttlMs: number): Promise<void> {
70
70
  const stored: StoredValue = { v: value, t: ttlMs };
71
71
  await kvWrite(
72
- await this.#options.connection(),
72
+ await this.#options.client(),
73
73
  this.#options.bucket,
74
74
  this.#key(key, member),
75
75
  JSON.stringify(stored),
@@ -80,7 +80,7 @@ export class NatsKvSet implements TransportSet {
80
80
  /** `false` when the member had already expired: the caller must re-`put`, which is a re-join. */
81
81
  async touch(key: string, member: string, ttlMs: number): Promise<boolean> {
82
82
  const record = await kvGet(
83
- await this.#options.connection(),
83
+ await this.#options.client(),
84
84
  this.#options.bucket,
85
85
  this.#key(key, member),
86
86
  );
@@ -98,7 +98,7 @@ export class NatsKvSet implements TransportSet {
98
98
  */
99
99
  async drop(key: string, member: string): Promise<void> {
100
100
  await kvWrite(
101
- await this.#options.connection(),
101
+ await this.#options.client(),
102
102
  this.#options.bucket,
103
103
  this.#key(key, member),
104
104
  '',
@@ -111,7 +111,7 @@ export class NatsKvSet implements TransportSet {
111
111
 
112
112
  async entries(key: string): Promise<readonly TransportSetEntry[]> {
113
113
  const records = await kvLast(
114
- await this.#options.connection(),
114
+ await this.#options.client(),
115
115
  this.#options.bucket,
116
116
  `${encodeToken(key)}.*`,
117
117
  );
@@ -0,0 +1,210 @@
1
+ // Single responsibility: the one adapter from the `nats` client to this package's port. It is the
2
+ // only file in the repo that imports `nats` — everything else speaks `NatsClient`, so the wire, the
3
+ // reconnect and the TLS upgrade are the library's and stay replaceable.
4
+ //
5
+ // WHERE FAILURES ARE TRANSLATED, and it is deliberately not all here. This file coded the calls
6
+ // that have no synchronous caller frame to catch them: `request`, `requestMany`, the dial, and the
7
+ // background `#watch` that is the only place a lost connection is announced. `publish` and
8
+ // `subscribe` are synchronous and stay raw — `NatsTransport.#translating` codes them, because
9
+ // `NatsTransportOptions.connect` is a PUBLIC injection seam: translating in this class would cover
10
+ // the one client the repo ships and leave every app-supplied one uncovered, and translating in both
11
+ // places would be two answers to one event. `unsubscribe()` is wrapped nowhere on purpose — it is
12
+ // synchronous, returns `void`, and its throw reaches the caller rather than being swallowed.
13
+ //
14
+ // This header said "every failure leaves here as an `UltimateError`" until 2026-08, which a reader
15
+ // took as a guarantee it never was.
16
+
17
+ import { connect, Events, headers, Match, type Msg, type MsgHdrs, type NatsConnection } from 'nats';
18
+ import { TransportUnavailableError } from './errors';
19
+ import {
20
+ DEFAULT_REQUEST_TIMEOUT_MS,
21
+ type NatsClient,
22
+ type NatsClientOptions,
23
+ type NatsHeaders,
24
+ type NatsMessage,
25
+ type NatsMessageHandler,
26
+ type NatsRequestManyOptions,
27
+ type NatsRequestOptions,
28
+ type NatsSubscription,
29
+ type NatsTarget,
30
+ parseNatsUrl,
31
+ } from './nats-client';
32
+
33
+ /** A message that has already left the library: the port's shape, read lazily off the headers. */
34
+ const messageOf = (message: Msg): NatsMessage => ({
35
+ subject: message.subject,
36
+ payload: message.data,
37
+ status: message.headers?.code ?? 0,
38
+ // `MsgHdrs.get` answers '' for a header the server never sent, and every header this package
39
+ // reads is meaningless when empty — so one absent answer, rather than two.
40
+ header: (name: string): string | undefined => {
41
+ const value = message.headers?.get(name, Match.IgnoreCase);
42
+ return value === undefined || value === '' ? undefined : value;
43
+ },
44
+ });
45
+
46
+ const headersOf = (map: NatsHeaders | undefined): MsgHdrs | undefined => {
47
+ if (map === undefined || map.size === 0) return undefined;
48
+ const built = headers();
49
+ for (const [name, value] of map) built.set(name, value);
50
+ return built;
51
+ };
52
+
53
+ const unavailable = (target: NatsTarget, reason: string): TransportUnavailableError =>
54
+ new TransportUnavailableError({
55
+ transport: 'nats',
56
+ // The URL is never echoed back — it carries the credentials.
57
+ reason: `${target.host}:${target.port} — ${reason}`,
58
+ });
59
+
60
+ const describe = (error: unknown): string =>
61
+ error instanceof Error ? error.message : String(error);
62
+
63
+ class LibNatsClient implements NatsClient {
64
+ readonly #connection: NatsConnection;
65
+ readonly #target: NatsTarget;
66
+ readonly #timeoutMs: number;
67
+ readonly #report: (error: unknown) => void;
68
+ #connected = true;
69
+
70
+ constructor(connection: NatsConnection, target: NatsTarget, options: NatsClientOptions) {
71
+ this.#connection = connection;
72
+ this.#target = target;
73
+ this.#timeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
74
+ this.#report = options.onError ?? ((): void => undefined);
75
+ void this.#watch(options);
76
+ }
77
+
78
+ get version(): string {
79
+ return this.#connection.info?.version ?? '';
80
+ }
81
+
82
+ get connected(): boolean {
83
+ return this.#connected && !this.#connection.isClosed();
84
+ }
85
+
86
+ publish(subject: string, payload: Uint8Array): void {
87
+ this.#connection.publish(subject, payload);
88
+ }
89
+
90
+ subscribe(subject: string, handler: NatsMessageHandler): NatsSubscription {
91
+ const subscription = this.#connection.subscribe(subject, {
92
+ // A callback rather than the async iterator: the iterator queues, and a `sync` node that
93
+ // falls behind on one subject must drop nothing silently into a growing buffer.
94
+ callback: (error, message) => {
95
+ // A subscription's own failure — a permissions violation on the subject is the common one —
96
+ // arrives here and nowhere else. Dropping it is a node delivering nothing, silently.
97
+ if (error !== null) this.#report(unavailable(this.#target, `${subject}: ${error.message}`));
98
+ else handler(messageOf(message));
99
+ },
100
+ });
101
+ return { unsubscribe: () => subscription.unsubscribe() };
102
+ }
103
+
104
+ async request(
105
+ subject: string,
106
+ payload: Uint8Array,
107
+ options: NatsRequestOptions = {},
108
+ ): Promise<NatsMessage> {
109
+ const built = headersOf(options.headers);
110
+ try {
111
+ const reply = await this.#connection.request(subject, payload, {
112
+ timeout: this.#timeoutMs,
113
+ ...(built === undefined ? {} : { headers: built }),
114
+ });
115
+ return messageOf(reply);
116
+ } catch (error) {
117
+ throw unavailable(this.#target, `${subject} did not answer: ${describe(error)}`);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * A batch read ends on a message the caller recognises — a `204` end-of-batch or a `404` for a
123
+ * prefix nobody has written. Breaking the loop is what releases the library's inbox subscription,
124
+ * so the terminator is never collected and never awaited past.
125
+ */
126
+ async requestMany(
127
+ subject: string,
128
+ payload: Uint8Array,
129
+ options: NatsRequestManyOptions,
130
+ ): Promise<readonly NatsMessage[]> {
131
+ const collected: NatsMessage[] = [];
132
+ try {
133
+ const replies = await this.#connection.requestMany(subject, payload, {
134
+ maxWait: this.#timeoutMs,
135
+ });
136
+ for await (const reply of replies) {
137
+ const message = messageOf(reply);
138
+ if (options.until(message)) break;
139
+ collected.push(message);
140
+ }
141
+ } catch (error) {
142
+ throw unavailable(this.#target, `${subject} did not answer: ${describe(error)}`);
143
+ }
144
+ return collected;
145
+ }
146
+
147
+ async close(): Promise<void> {
148
+ this.#connected = false;
149
+ await this.#connection.close();
150
+ }
151
+
152
+ /**
153
+ * The library's own status stream is the only place a background loss is announced. Nothing
154
+ * awaits it, so it can neither throw nor end the process: a drop reports and flips `connected`,
155
+ * a reconnect flips it back and tells the transport its cluster may be a new one.
156
+ */
157
+ async #watch(options: NatsClientOptions): Promise<void> {
158
+ const report = options.onError ?? ((): void => undefined);
159
+ try {
160
+ for await (const status of this.#connection.status()) {
161
+ if (status.type === Events.Disconnect) {
162
+ this.#connected = false;
163
+ report(unavailable(this.#target, 'the connection dropped'));
164
+ } else if (status.type === Events.Reconnect) {
165
+ this.#connected = true;
166
+ options.onReconnect?.();
167
+ } else if (status.type === Events.Error) {
168
+ report(unavailable(this.#target, `the server reported ${String(status.data)}`));
169
+ }
170
+ }
171
+ // The iterator ends when the connection is done: either `close()` or a reconnect budget spent.
172
+ this.#connected = false;
173
+ const failure = await this.#connection.closed();
174
+ if (failure !== undefined) report(unavailable(this.#target, describe(failure)));
175
+ } catch (error) {
176
+ this.#connected = false;
177
+ report(unavailable(this.#target, describe(error)));
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * The production `NatsConnect`. The first dial retries on the same budget as a later loss
184
+ * (`waitOnFirstConnect`), so a `sync` container that raced the bus into readiness recovers on its
185
+ * own — and a budget that runs out rejects here rather than leaving a half-live connection behind.
186
+ */
187
+ export const openNatsClient = async (options: NatsClientOptions): Promise<NatsClient> => {
188
+ const target = parseNatsUrl(options.url);
189
+ try {
190
+ const connection = await connect({
191
+ servers: [`${target.host}:${target.port}`],
192
+ name: options.name ?? 'ultimate',
193
+ waitOnFirstConnect: true,
194
+ ...(options.maxReconnectAttempts === undefined
195
+ ? {}
196
+ : { maxReconnectAttempts: options.maxReconnectAttempts }),
197
+ ...(options.reconnectDelay === undefined
198
+ ? {}
199
+ : { reconnectDelayHandler: options.reconnectDelay }),
200
+ // The scheme is the only thing that can demand TLS before the server's INFO is read.
201
+ ...(target.tls ? { tls: {} } : {}),
202
+ ...(target.user === undefined ? {} : { user: target.user }),
203
+ ...(target.pass === undefined ? {} : { pass: target.pass }),
204
+ ...(target.token === undefined ? {} : { token: target.token }),
205
+ });
206
+ return new LibNatsClient(connection, target, options);
207
+ } catch (error) {
208
+ throw unavailable(target, describe(error));
209
+ }
210
+ };