@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.
- package/CLAUDE.md +293 -1009
- package/README.md +78 -12
- package/package.json +4 -4
- package/src/changefeed.ts +7 -1
- package/src/channel-authz.ts +23 -4
- package/src/channel-decl.ts +16 -5
- package/src/channel-describe.ts +7 -5
- package/src/channel-logs.ts +19 -1
- package/src/channel-records.ts +8 -0
- package/src/client-channels.ts +75 -5
- package/src/client.ts +14 -2
- package/src/cursor.ts +5 -0
- package/src/errors.ts +21 -0
- package/src/idb-fake.ts +24 -4
- package/src/idb-types.ts +7 -0
- package/src/index.ts +0 -1
- package/src/live-definition.ts +5 -1
- package/src/live-fanout.ts +51 -2
- package/src/live-query.ts +11 -0
- package/src/live-replicator.ts +160 -0
- package/src/local-store-idb.ts +89 -15
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +76 -21
- package/src/page-outbox.ts +80 -10
- package/src/page-socket.ts +39 -8
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-preflight.ts +24 -2
- package/src/pg-replication.ts +19 -6
- package/src/pg-wire.ts +51 -15
- package/src/policy-fake.ts +14 -0
- package/src/query-window.ts +35 -21
- package/src/replicator.ts +13 -3
- package/src/server.ts +8 -3
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +15 -3
- package/src/socket-host.ts +103 -4
- package/src/socket-idle.ts +21 -0
- package/src/socket.ts +41 -38
- package/src/subscriber-gate.ts +92 -3
- package/src/sync-node.ts +2 -7
- package/src/thundering-herd.ts +12 -11
- package/src/transport-env.ts +55 -14
- package/src/use-mutation.ts +13 -0
- package/src/use-query.ts +10 -5
package/src/transport-env.ts
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
|
-
// Single responsibility: environment → fanout transport. The one place a boot
|
|
2
|
-
// process fans changes out inside its own heap or over NATS, so `x dev`, a
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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 (
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/src/use-mutation.ts
CHANGED
|
@@ -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 = (
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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;
|