@ultimat3/realtime 21.0.0 → 22.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 (47) hide show
  1. package/CLAUDE.md +293 -1009
  2. package/README.md +78 -12
  3. package/package.json +4 -4
  4. package/src/changefeed.ts +7 -1
  5. package/src/channel-authz.ts +23 -4
  6. package/src/channel-decl.ts +16 -5
  7. package/src/channel-describe.ts +7 -5
  8. package/src/channel-logs.ts +19 -1
  9. package/src/channel-records.ts +8 -0
  10. package/src/client-channels.ts +75 -5
  11. package/src/client.ts +14 -2
  12. package/src/cursor.ts +5 -0
  13. package/src/errors.ts +21 -0
  14. package/src/idb-fake.ts +24 -4
  15. package/src/idb-types.ts +7 -0
  16. package/src/index.ts +0 -1
  17. package/src/live-definition.ts +5 -1
  18. package/src/live-fanout.ts +51 -2
  19. package/src/live-query.ts +11 -0
  20. package/src/live-replicator.ts +160 -0
  21. package/src/local-store-idb.ts +89 -15
  22. package/src/matcher-bridge.ts +5 -0
  23. package/src/nats-fake.ts +10 -1
  24. package/src/nats-jetstream.ts +36 -14
  25. package/src/nats-transport.ts +2 -2
  26. package/src/offline-queue.ts +76 -21
  27. package/src/page-outbox.ts +80 -10
  28. package/src/page-socket.ts +39 -8
  29. package/src/pg-entity-row.ts +37 -184
  30. package/src/pg-preflight.ts +24 -2
  31. package/src/pg-replication.ts +19 -6
  32. package/src/pg-wire.ts +51 -15
  33. package/src/policy-fake.ts +14 -0
  34. package/src/query-window.ts +35 -21
  35. package/src/replicator.ts +13 -3
  36. package/src/server.ts +8 -3
  37. package/src/socket-drops.ts +30 -0
  38. package/src/socket-engine.ts +15 -3
  39. package/src/socket-host.ts +103 -4
  40. package/src/socket-idle.ts +21 -0
  41. package/src/socket.ts +41 -38
  42. package/src/subscriber-gate.ts +92 -3
  43. package/src/sync-node.ts +2 -7
  44. package/src/thundering-herd.ts +12 -11
  45. package/src/transport-env.ts +55 -14
  46. package/src/use-mutation.ts +13 -0
  47. package/src/use-query.ts +10 -5
@@ -1,20 +1,30 @@
1
- // Single responsibility: environment → fanout transport. The one place a boot decides whether this
2
- // process fans changes out inside its own heap or over NATS, so `x dev`, a `sync` container and any
3
- // custom host resolve it identically. The KV bucket and the presence TTL are decided here too:
1
+ // Single responsibility: `realtime.transport` + environment → fanout transport. The one place a boot
2
+ // decides whether this process fans changes out inside its own heap or over NATS, so `x dev`, a
3
+ // `sync` container and any custom host resolve it identically. The KV bucket and the presence TTL are decided here too:
4
4
  // the bucket's whole-stream age limit and `PresenceRegistry`'s TTL are the same number seen from
5
5
  // two sides, and a caller that had to pass each one separately could quietly set them apart.
6
6
 
7
- import type { Clock } from '@ultimat3/core';
8
- import { finiteOption } from '@ultimat3/core';
7
+ import type { Clock, RealtimeConfig } from '@ultimat3/core';
8
+ import { ConfigInvalidError, finiteOption } from '@ultimat3/core';
9
9
  import type { Transport } from './fanout';
10
10
  import { InProcessTransport } from './fanout';
11
11
  import type { NatsConnect } from './nats-client';
12
12
  import { assertBucket } from './nats-jetstream';
13
13
  import { NatsTransport } from './nats-transport';
14
14
 
15
- /** The keys read here, and nothing else. Named once so docs and tests cannot drift from the code. */
15
+ /**
16
+ * The keys read here, and nothing else. Named once so docs and tests cannot drift from the code.
17
+ * `NATS_URL` is the conventional bus variable: read under `transport: 'nats'` when `urlEnv` names
18
+ * it, and under `'memory'` only to refuse it — see `selectTransport`.
19
+ */
16
20
  export const TRANSPORT_ENV_KEYS = ['NATS_URL', 'NATS_KV_BUCKET'] as const;
17
21
 
22
+ /** The two fields of `app.config.ts`'s `realtime` section that decide the bus. */
23
+ export type RealtimeTopology = Pick<RealtimeConfig, 'transport' | 'urlEnv'>;
24
+
25
+ /** Named in every refusal, so the reader edits the file the decision lives in. */
26
+ const CONFIG_FILE = 'app.config.ts';
27
+
18
28
  /**
19
29
  * One bucket per deployment, not per cluster: two apps sharing a nats-server would otherwise share
20
30
  * one presence namespace, and a room name that collided would list the other app's members.
@@ -59,13 +69,22 @@ const nonEmpty = (value: string | undefined): string | undefined =>
59
69
  value === undefined || value.trim().length === 0 ? undefined : value.trim();
60
70
 
61
71
  /**
62
- * No url means the in-process transport — the same "an unset variable means the embedded default"
63
- * law the db, mail, storage and replication bindings follow. The bucket name is validated here
64
- * rather than on first connect: a typo'd bucket is a boot that reports a healthy bus and then
65
- * fails every presence write, which is the failure this whole selector exists to move earlier.
72
+ * The config decides the transport and the environment supplies its url — never the other way
73
+ * round. Until 22.0.0 `NATS_URL` alone decided and `realtime.transport` / `realtime.urlEnv` were
74
+ * read by nothing, so `transport: 'nats'` with the variable unset booted the in-process bus and
75
+ * reached no other node, with no error on either side. Both mismatches are refused here:
76
+ *
77
+ * - `'nats'` with the variable `urlEnv` names unset or blank.
78
+ * - `'memory'` with a bus url set (`NATS_URL`, or the variable `urlEnv` names). An operator who set
79
+ * one expected fanout across nodes; keeping every change in this heap instead is the same silent
80
+ * failure seen from the other side, so the two are refused rather than reconciled.
81
+ *
82
+ * The bucket name is validated here rather than on first connect: a typo'd bucket is a boot that
83
+ * reports a healthy bus and then fails every presence write.
66
84
  */
67
85
  export function selectTransport(
68
86
  env: TransportEnvironment,
87
+ topology: RealtimeTopology,
69
88
  options: SelectTransportOptions = {},
70
89
  ): TransportSelection {
71
90
  const presenceTtlMs = finiteOption(
@@ -73,22 +92,32 @@ export function selectTransport(
73
92
  'presenceTtlMs',
74
93
  options.presenceTtlMs ?? DEFAULT_PRESENCE_TTL_MS,
75
94
  );
76
- const url = nonEmpty(env['NATS_URL']);
77
95
 
78
- if (url === undefined) {
96
+ if (topology.transport === 'memory') {
97
+ refuseStrayBusUrl(env, topology);
79
98
  const transport = new InProcessTransport(
80
99
  options.clock === undefined ? {} : { clock: options.clock },
81
100
  );
82
101
  return {
83
102
  transport,
84
103
  mode: 'embedded',
85
- detail: 'in-process fanout — set NATS_URL to reach the other nodes',
104
+ detail: `in-process fanout — set realtime.transport 'nats' in ${CONFIG_FILE} and NATS_URL to reach the other nodes`,
86
105
  bucket: null,
87
106
  presenceTtlMs,
88
107
  connect: () => Promise.resolve(),
89
108
  };
90
109
  }
91
110
 
111
+ const urlEnv = topology.urlEnv ?? 'NATS_URL';
112
+ const url = nonEmpty(env[urlEnv]);
113
+ if (url === undefined) {
114
+ throw new ConfigInvalidError({
115
+ cause: `realtime.transport is 'nats' and realtime.urlEnv names ${urlEnv}, which is unset in this process's environment, so no node would be reachable`,
116
+ fix: `set ${urlEnv} to the nats-server url for every realtime role (web, sync, replicator), or set realtime: { transport: 'memory' } in ${CONFIG_FILE} for a single node`,
117
+ meta: { key: 'realtime.urlEnv', variable: urlEnv },
118
+ });
119
+ }
120
+
92
121
  const bucket = nonEmpty(env['NATS_KV_BUCKET']) ?? DEFAULT_PRESENCE_BUCKET;
93
122
  assertBucket(bucket);
94
123
  const transport = new NatsTransport({
@@ -101,9 +130,21 @@ export function selectTransport(
101
130
  return {
102
131
  transport,
103
132
  mode: 'external',
104
- detail: 'NATS_URL',
133
+ detail: urlEnv,
105
134
  bucket,
106
135
  presenceTtlMs,
107
136
  connect: () => transport.connect(),
108
137
  };
109
138
  }
139
+
140
+ /** `'memory'` with a bus url in the environment: the conflict `selectTransport` refuses. */
141
+ function refuseStrayBusUrl(env: TransportEnvironment, topology: RealtimeTopology): void {
142
+ const names = topology.urlEnv === undefined ? ['NATS_URL'] : ['NATS_URL', topology.urlEnv];
143
+ const set = names.find((name) => nonEmpty(env[name]) !== undefined);
144
+ if (set === undefined) return;
145
+ throw new ConfigInvalidError({
146
+ cause: `${set} is set but realtime.transport is 'memory', so this process would fan out in its own heap and reach no other node`,
147
+ fix: `set realtime: { transport: 'nats', urlEnv: '${set}' } in ${CONFIG_FILE} to use the bus, or unset ${set} for a single node`,
148
+ meta: { key: 'realtime.transport', variable: set },
149
+ });
150
+ }
@@ -134,6 +134,19 @@ export function useMutation(mutator: MutatorLike): Mutate {
134
134
  page.store.push(key, (tx) => mutator.local?.(tx, input), mutator.conflict ?? 'server-wins');
135
135
  }
136
136
  count(writes, mutator.name, 1);
137
+ // Older writes still wait in the outbox: this one queues BEHIND them rather than overtaking
138
+ // them over HTTP — a like queued offline and the unlike made once the network was back could
139
+ // otherwise land swapped. The replay sends the queue in order, this write last, under its key.
140
+ const queued = peekOutbox();
141
+ if (queued !== undefined && queued.pending().length > 0) {
142
+ try {
143
+ await queued.enqueue({ key, name: mutator.name, input });
144
+ void queued.replay().catch(() => undefined);
145
+ return undefined;
146
+ } finally {
147
+ count(writes, mutator.name, -1);
148
+ }
149
+ }
137
150
  let output: unknown;
138
151
  /** The records the answer carried, `type:key` — what the overlay may be settled against. */
139
152
  const carried = new Set<string>();
package/src/use-query.ts CHANGED
@@ -161,7 +161,9 @@ function readAccessor<R extends object>(
161
161
  * read's records in answer order (`records[type]`, which `rowsOf` fills first-seen = data order).
162
162
  * The browser never derives a key: an answer with no records envelope holds its rows itself.
163
163
  */
164
- const fetch = (append: boolean): Promise<{ rows: readonly Row[]; keys: string[] | null }> => {
164
+ const fetch = (
165
+ append: boolean,
166
+ ): Promise<{ rows: readonly Row[]; keys: string[] | null; next?: string | null }> => {
165
167
  let keysOf: string[] | null = null;
166
168
  const onEnvelope = (envelope: RecordEnvelope): void => {
167
169
  const records = type === undefined ? undefined : envelope.records?.[type];
@@ -173,10 +175,12 @@ function readAccessor<R extends object>(
173
175
  }
174
176
  const controls =
175
177
  append && after !== null ? { first: options.first, after } : { first: options.first };
176
- return method.page(input, controls, { onEnvelope }).then((page) => {
177
- after = page.hasNextPage ? page.endCursor : null;
178
- return answered(page.rows as readonly Row[]);
179
- });
178
+ // The page's cursor travels WITH its rows and is taken only where the rows are: a `more()`
179
+ // a refetch superseded wrote its cursor here, before the generation check discarded its rows.
180
+ return method.page(input, controls, { onEnvelope }).then((page) => ({
181
+ ...answered(page.rows as readonly Row[]),
182
+ next: page.hasNextPage ? page.endCursor : null,
183
+ }));
180
184
  };
181
185
 
182
186
  const load = (append = false): void => {
@@ -186,6 +190,7 @@ function readAccessor<R extends object>(
186
190
  fetch(append).then(
187
191
  (answer) => {
188
192
  if (released || mine !== generation) return;
193
+ if (answer.next !== undefined) after = answer.next;
189
194
  if (type === undefined || answer.keys === null) {
190
195
  // Not records — no type named, or no envelope: the list holds its own rows.
191
196
  own = append ? [...own, ...answer.rows] : answer.rows;