@ultimat3/realtime 30.9.0 → 31.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 (67) hide show
  1. package/CLAUDE.md +72 -82
  2. package/README.md +36 -40
  3. package/package.json +8 -6
  4. package/src/adapter.ts +14 -0
  5. package/src/apply-patches.ts +8 -5
  6. package/src/boot.ts +15 -2
  7. package/src/changefeed.ts +11 -4
  8. package/src/channel-decl.ts +29 -1
  9. package/src/channel-publish.ts +17 -19
  10. package/src/channel-ref.ts +22 -2
  11. package/src/client-channels.ts +11 -5
  12. package/src/client-contract.ts +2 -2
  13. package/src/cursor.ts +5 -5
  14. package/src/device-agent.ts +2 -2
  15. package/src/device-errors.ts +1 -1
  16. package/src/device-socket.ts +1 -1
  17. package/src/error-titles-server.ts +40 -0
  18. package/src/error-titles.ts +12 -21
  19. package/src/errors.ts +43 -10
  20. package/src/idb-types.ts +6 -0
  21. package/src/index.ts +5 -27
  22. package/src/json.ts +7 -3
  23. package/src/live-contract.ts +4 -4
  24. package/src/live-definition.ts +9 -6
  25. package/src/live-deliver.ts +40 -2
  26. package/src/live-entries.ts +73 -0
  27. package/src/live-fanout.ts +25 -9
  28. package/src/live-patch-encode.ts +30 -0
  29. package/src/live-query-options.ts +11 -0
  30. package/src/live-query.ts +26 -11
  31. package/src/live-replicator.ts +24 -21
  32. package/src/local-store-idb.ts +66 -6
  33. package/src/matcher-bridge.ts +15 -15
  34. package/src/nats-transport.ts +5 -5
  35. package/src/offline-queue-store.ts +69 -0
  36. package/src/offline-queue.ts +91 -91
  37. package/src/page-errors.ts +15 -0
  38. package/src/page-outbox.ts +18 -3
  39. package/src/pg-connection.ts +9 -1
  40. package/src/pg-preflight.ts +21 -5
  41. package/src/pg-replication-dial.ts +95 -0
  42. package/src/pg-replication-liveness.ts +84 -0
  43. package/src/pg-replication-position.ts +42 -0
  44. package/src/pg-replication-row.ts +6 -6
  45. package/src/pg-replication-stats.ts +28 -0
  46. package/src/pg-replication.ts +106 -125
  47. package/src/pg-slot-release.ts +40 -0
  48. package/src/policy-gate.ts +2 -2
  49. package/src/query-window.ts +4 -4
  50. package/src/record-publisher.ts +2 -2
  51. package/src/record-store.ts +7 -5
  52. package/src/replication-errors.ts +40 -2
  53. package/src/replication-resync.ts +38 -0
  54. package/src/replicator-deadline.ts +33 -0
  55. package/src/replicator-reconnect.ts +85 -0
  56. package/src/replicator-resync.ts +30 -0
  57. package/src/replicator-row.ts +5 -5
  58. package/src/replicator-subject.ts +28 -0
  59. package/src/replicator.ts +79 -69
  60. package/src/server.ts +8 -6
  61. package/src/socket-engine.ts +2 -2
  62. package/src/subscriber-gate.ts +14 -10
  63. package/src/sync-bus-handlers.ts +20 -4
  64. package/src/sync-protocol.ts +3 -3
  65. package/src/thundering-herd.ts +17 -24
  66. package/src/transport-env.ts +20 -48
  67. package/src/window-lock.ts +15 -3
package/src/server.ts CHANGED
@@ -2,7 +2,11 @@
2
2
  // live-query registry it fans out through. Split from `index.ts` because `nats` require()s
3
3
  // `stream/web` and the WAL decoder is a Postgres client — one barrel carrying both made the browser
4
4
  // island realtime promises unbuildable. Every name here has exactly one home; the shared
5
- // vocabulary (the wire, the errors, `Row`, the backoff) stays on `@ultimat3/realtime`.
5
+ // vocabulary (the wire, the errors, `LiveRow`, the backoff) stays on `@ultimat3/realtime`.
6
+
7
+ // The server-only codes' titles (`sideEffects`): a process that loads this barrel titles them,
8
+ // whichever class it constructs first.
9
+ import './error-titles-server';
6
10
 
7
11
  export { type AdvisoryLock, type MemoryAdvisoryLock, memoryAdvisoryLock } from './advisory-lock';
8
12
  // ---- the retained change window one node fans out from ------------------------------------------
@@ -45,11 +49,8 @@ export {
45
49
  } from './channel';
46
50
  export { type ChannelDescription, describeChannels } from './channel-describe';
47
51
  // What a job or an action publishes an event with: the process's own bus, no hub needed.
48
- export {
49
- publishChannelEvent,
50
- resetChannelTransport,
51
- setChannelTransport,
52
- } from './channel-publish';
52
+ // Its installer is the boot's, on `./adapter` (`adapter.ts`), never here.
53
+ export { publishChannelEvent } from './channel-publish';
53
54
  export {
54
55
  type AgentDial,
55
56
  type AgentSocket,
@@ -112,6 +113,7 @@ export type {
112
113
  export { type LiveDefinitionOptions, liveQueryDefinition } from './live-definition';
113
114
  export {
114
115
  DEFAULT_MAX_ENTRIES,
116
+ DEFAULT_MAX_LANE_DEPTH,
115
117
  LiveQueryRegistry,
116
118
  type LiveQueryRegistryOptions,
117
119
  } from './live-query';
@@ -12,9 +12,9 @@ import type { AttachedPort, PortLike, PortMessage } from './socket-port';
12
12
  import { PortRouter } from './socket-routes';
13
13
  import { decode, encode, type Frame, PROTOCOL_VERSION } from './sync-protocol';
14
14
  import {
15
- type BackoffPolicy,
16
15
  browserBackoff,
17
16
  policyDelay,
17
+ type ReconnectBackoff,
18
18
  type Rng,
19
19
  type Scheduler,
20
20
  timeoutScheduler,
@@ -28,7 +28,7 @@ export interface SocketEngineOptions {
28
28
  readonly dial: (target: SyncTarget) => ClientSocket;
29
29
  readonly scheduler?: Scheduler;
30
30
  readonly clock?: Clock;
31
- readonly backoff?: BackoffPolicy;
31
+ readonly backoff?: ReconnectBackoff;
32
32
  readonly rng?: Rng;
33
33
  /** The engine's own beat on the real socket, and the unit a silent port is reaped in. */
34
34
  readonly heartbeatMs?: number;
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { Actor } from '@ultimat3/core';
7
7
  import { isPolicyDenial } from './errors';
8
- import type { JsonValue, Row, RowPatch } from './json';
8
+ import type { JsonValue, LiveRow, RowPatch } from './json';
9
9
  import type { LiveQueryDefinition } from './live-contract';
10
10
  import { narrowRow, projectionOf } from './matcher-bridge';
11
11
 
@@ -52,7 +52,7 @@ export interface GateTarget {
52
52
  readonly qid: string;
53
53
  readonly input: JsonValue;
54
54
  readonly definition: LiveQueryDefinition;
55
- readonly rows: readonly Row[];
55
+ readonly rows: readonly LiveRow[];
56
56
  }
57
57
 
58
58
  /**
@@ -62,12 +62,12 @@ export interface GateTarget {
62
62
  * when a caller passes none.
63
63
  */
64
64
  export interface WindowIndex {
65
- readonly byId: ReadonlyMap<string, Row>;
65
+ readonly byId: ReadonlyMap<string, LiveRow>;
66
66
  readonly position: ReadonlyMap<string, number>;
67
67
  }
68
68
 
69
- export function windowIndex(rows: readonly Row[]): WindowIndex {
70
- const byId = new Map<string, Row>();
69
+ export function windowIndex(rows: readonly LiveRow[]): WindowIndex {
70
+ const byId = new Map<string, LiveRow>();
71
71
  const position = new Map<string, number>();
72
72
  rows.forEach((row, at) => {
73
73
  byId.set(row.id, row);
@@ -144,8 +144,12 @@ export class SubscriberGate {
144
144
  * managed to admit: a short result set is indistinguishable from a correct one, and handing it
145
145
  * over is the read silently losing rows.
146
146
  */
147
- async filterRows(target: GateTarget, who: Subscriber, rows: readonly Row[]): Promise<Row[]> {
148
- const out: Row[] = [];
147
+ async filterRows(
148
+ target: GateTarget,
149
+ who: Subscriber,
150
+ rows: readonly LiveRow[],
151
+ ): Promise<LiveRow[]> {
152
+ const out: LiveRow[] = [];
149
153
  for (const row of rows) {
150
154
  if (await this.#visible(target, who, row, 'snapshot')) out.push(row);
151
155
  else this.#denied(target.qid, who, row.id);
@@ -219,7 +223,7 @@ export class SubscriberGate {
219
223
  // failed either: the shared window *is* the result set, so a row it does not hold is a row this
220
224
  // subscriber is not entitled to keep, and one that holds it is told so.
221
225
  if (full === undefined) return holds ? withdrawn(patch) : null;
222
- const row: Row = { ...full, ...patch.row, id: patch.id };
226
+ const row: LiveRow = { ...full, ...patch.row, id: patch.id };
223
227
  if (await this.#visible(target, who, row, 'patch')) {
224
228
  // An update carries the changed columns only. A subscriber who was never sent this row —
225
229
  // denied at its snapshot, admitted by this change — would fold them onto nothing, and its
@@ -247,7 +251,7 @@ export class SubscriberGate {
247
251
  async #visible(
248
252
  target: GateTarget,
249
253
  who: Subscriber,
250
- row: Row,
254
+ row: LiveRow,
251
255
  stage: GateStage,
252
256
  ): Promise<boolean> {
253
257
  try {
@@ -302,7 +306,7 @@ const actorIdOf = (who: Subscriber): string | null => (who.actor === null ? null
302
306
  function rebase(
303
307
  patch: RowPatch,
304
308
  holding: Holding,
305
- rows: readonly Row[],
309
+ rows: readonly LiveRow[],
306
310
  index: WindowIndex,
307
311
  ): RowPatch {
308
312
  if (patch.index === undefined) return patch;
@@ -8,6 +8,7 @@ import type { ChannelHub } from './channel';
8
8
  import { detach } from './detach';
9
9
  import type { LiveQueryRegistry } from './live-query';
10
10
  import { RealtimeError } from './realtime-error';
11
+ import { parseResync } from './replication-resync';
11
12
  import {
12
13
  type ChangeEnvelope,
13
14
  type EnvelopeSource,
@@ -83,6 +84,16 @@ export class ProducerKinds {
83
84
  /** The handler a node subscribes to `CHANGE_SUBJECT_ALL` with. */
84
85
  export function changeHandler(bus: SyncBus): (payload: string) => void {
85
86
  return (payload) => {
87
+ // The replicator's own word that the stream has a hole no sequence shows (its slot was
88
+ // recreated): answered as a reconnect is, because it is the same fact — changes never seen.
89
+ const resync = parseResync(payload);
90
+ if (resync !== null) {
91
+ resyncAll(bus, 'live.replication_resync', {
92
+ reason: resync.reason,
93
+ producer: resync.producer,
94
+ });
95
+ return;
96
+ }
86
97
  const envelope = parseEnvelope(payload);
87
98
  if (!envelope) return;
88
99
  // Before the gap detector: a dropped producer's sequence is no stream this node follows.
@@ -127,9 +138,14 @@ export function changeHandler(bus: SyncBus): (payload: string) => void {
127
138
  export function reconnectHandler(bus: SyncBus): () => void {
128
139
  return () => {
129
140
  bus.outage?.answered();
130
- bus.gaps.forget();
131
- const marked = bus.registry.invalidate();
132
- const channelGaps = bus.hub.invalidate();
133
- logger.warn('live.bus_reconnected', { desynced: marked, channelGaps });
141
+ resyncAll(bus, 'live.bus_reconnected', {});
134
142
  };
135
143
  }
144
+
145
+ /** Every window and every records topic re-read, the producer history forgotten, said once. */
146
+ function resyncAll(bus: SyncBus, event: string, fields: Record<string, string>): void {
147
+ bus.gaps.forget();
148
+ const marked = bus.registry.invalidate();
149
+ const channelGaps = bus.hub.invalidate();
150
+ logger.warn(event, { ...fields, desynced: marked, channelGaps });
151
+ }
@@ -20,7 +20,7 @@ import {
20
20
  isRow,
21
21
  type JsonObject,
22
22
  type JsonValue,
23
- type Row,
23
+ type LiveRow,
24
24
  type RowPatch,
25
25
  } from './json';
26
26
  import { ProtocolVersionError } from './page-errors';
@@ -114,7 +114,7 @@ export interface SnapshotFrame {
114
114
  readonly type: 'snapshot';
115
115
  readonly v: number;
116
116
  readonly sid: string;
117
- readonly rows: readonly Row[];
117
+ readonly rows: readonly LiveRow[];
118
118
  readonly cursor: LiveCursor;
119
119
  /**
120
120
  * The entity every row of this result set belongs to — the client's identity scope, so two
@@ -360,7 +360,7 @@ function writesOf(parsed: JsonObject, label: string): readonly string[] {
360
360
  });
361
361
  }
362
362
 
363
- function row(value: unknown): Row {
363
+ function row(value: unknown): LiveRow {
364
364
  if (!isRow(value)) throw fail('row must be an object with a string "id"');
365
365
  return value;
366
366
  }
@@ -6,6 +6,7 @@
6
6
  // 2. policyDelay() — the client's own jittered retry, for failures nobody scheduled
7
7
  // 3. AcceptBudget — the receiving node's token bucket, so recovery sheds instead of collapsing
8
8
 
9
+ import type { RetryPolicy } from '@ultimat3/core';
9
10
  import {
10
11
  backoffDelay,
11
12
  type Clock,
@@ -22,20 +23,20 @@ export type Rng = Random;
22
23
  /** Core's, re-exported under this package's name — never a second copy of the same three modes. */
23
24
  export type { JitterMode };
24
25
 
25
- export interface BackoffPolicy {
26
- readonly baseMs: number;
27
- readonly maxMs: number;
28
- readonly factor: number;
29
- readonly jitter: JitterMode;
30
- }
26
+ /**
27
+ * A redial's wait: core's `RetryPolicy` without its two bounds. A socket, a NATS link and the
28
+ * replicator redial until they are closed, so `attempts` and `timeBudgetMs` would be fields read by
29
+ * nothing — the shape is core's, projected, never restated (`BackoffPolicy` until 31.0.0).
30
+ */
31
+ export type ReconnectBackoff = Pick<RetryPolicy, 'base' | 'max' | 'factor' | 'curve' | 'jitter'>;
31
32
 
32
33
  /**
33
34
  * `full` jitter by default: it is the only mode that actually decorrelates a herd. `equal` keeps a
34
35
  * floor for latency-sensitive clients; `none` exists for tests and is never a production choice.
35
36
  */
36
- export const defaultBackoff: BackoffPolicy = {
37
- baseMs: 500,
38
- maxMs: 30_000,
37
+ export const defaultBackoff: ReconnectBackoff = {
38
+ base: 500,
39
+ max: 30_000,
39
40
  factor: 2,
40
41
  jitter: 'full',
41
42
  };
@@ -51,16 +52,16 @@ export const BROWSER_RECONNECT_MAX_MS = 4_000;
51
52
  * window rather than at once, and the `AcceptBudget` sheds the excess before any query runs. A
52
53
  * PLANNED restart never reaches this: the drain's `reconnect` frame assigns each socket its slot.
53
54
  */
54
- export const browserBackoff: BackoffPolicy = {
55
- baseMs: 500,
56
- maxMs: BROWSER_RECONNECT_MAX_MS,
55
+ export const browserBackoff: ReconnectBackoff = {
56
+ base: 500,
57
+ max: BROWSER_RECONNECT_MAX_MS,
57
58
  factor: 2,
58
59
  jitter: 'equal',
59
60
  };
60
61
 
61
62
  /**
62
- * A {@link BackoffPolicy} mapped onto `@ultimat3/core`'s `backoffDelay` — the one curve. Attempt is
63
- * 1-BASED, core's count: the wait after the first failure is `attempt: 1` and is `baseMs`.
63
+ * A {@link ReconnectBackoff} through `@ultimat3/core`'s `backoffDelay` — the one curve. Attempt is
64
+ * 1-BASED, core's count: the wait after the first failure is `attempt: 1` and is `base`.
64
65
  *
65
66
  * Internal to this package and never re-exported from the barrel. Until 22.0.0 `.` exported a
66
67
  * 0-based `backoffDelay` of its own that shifted by one before delegating, which made two counting
@@ -68,19 +69,11 @@ export const browserBackoff: BackoffPolicy = {
68
69
  * catch-up retry did) waited twice as long as it meant to, with no error anywhere.
69
70
  */
70
71
  export function policyDelay(
71
- policy: BackoffPolicy,
72
+ policy: ReconnectBackoff,
72
73
  attempt: number,
73
74
  rng: Rng = Math.random,
74
75
  ): number {
75
- return backoffDelay({
76
- attempt,
77
- base: policy.baseMs,
78
- max: policy.maxMs,
79
- factor: policy.factor,
80
- curve: 'exponential',
81
- jitter: policy.jitter,
82
- random: rng,
83
- });
76
+ return backoffDelay({ ...policy, attempt, random: rng });
84
77
  }
85
78
 
86
79
  /**
@@ -1,30 +1,27 @@
1
- // Single responsibility: `realtime.transport` + environment → fanout transport. The one place a boot
1
+ // Single responsibility: environment → fanout transport. The one place a boot
2
2
  // decides whether this process fans changes out inside its own heap or over NATS, so `x dev`, a
3
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, RealtimeConfig } from '@ultimat3/core';
8
- import { ConfigInvalidError, finiteOption } from '@ultimat3/core';
7
+ import type { Clock } from '@ultimat3/core';
8
+ import { 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
- import type { BackoffPolicy } from './thundering-herd';
14
+ import type { ReconnectBackoff } from './thundering-herd';
15
15
 
16
16
  /**
17
17
  * The keys read here, and nothing else. Named once so docs and tests cannot drift from the code.
18
- * `NATS_URL` is the conventional bus variable: read under `transport: 'nats'` when `urlEnv` names
19
- * it, and under `'memory'` only to refuse it — see `selectTransport`.
18
+ * `NATS_URL` set is the bus; unset is the in-process one. Any other bus is `apps/<app>/runtime.ts`'s
19
+ * `transport`, which the boot takes INSTEAD of calling this.
20
20
  */
21
21
  export const TRANSPORT_ENV_KEYS = ['NATS_URL', 'NATS_KV_BUCKET'] as const;
22
22
 
23
- /** The two fields of `app.config.ts`'s `realtime` section that decide the bus. */
24
- export type RealtimeTopology = Pick<RealtimeConfig, 'transport' | 'urlEnv'>;
25
-
26
- /** Named in every refusal, so the reader edits the file the decision lives in. */
27
- const CONFIG_FILE = 'app.config.ts';
23
+ /** Where a bus that is neither NATS nor in-process is handed over. */
24
+ const RUNTIME_FILE = 'apps/<app>/runtime.ts';
28
25
 
29
26
  /**
30
27
  * One bucket per deployment, not per cluster: two apps sharing a nats-server would otherwise share
@@ -96,7 +93,7 @@ export interface SelectTransportOptions {
96
93
  /** Default `BUS_CONNECT_WAIT_MS`. Read under `'sockets'` and `'feed'` only. */
97
94
  readonly connectWithinMs?: number | undefined;
98
95
  /** The dial's retry curve; the transport's own default when unset. */
99
- readonly backoff?: BackoffPolicy | undefined;
96
+ readonly backoff?: ReconnectBackoff | undefined;
100
97
  /** Where the transport's background failures go; its own logger line when unset. */
101
98
  readonly onError?: ((error: unknown, subject: string) => void) | undefined;
102
99
  }
@@ -105,22 +102,19 @@ const nonEmpty = (value: string | undefined): string | undefined =>
105
102
  value === undefined || value.trim().length === 0 ? undefined : value.trim();
106
103
 
107
104
  /**
108
- * The config decides the transport and the environment supplies its url — never the other way
109
- * round. Until 22.0.0 `NATS_URL` alone decided and `realtime.transport` / `realtime.urlEnv` were
110
- * read by nothing, so `transport: 'nats'` with the variable unset booted the in-process bus and
111
- * reached no other node, with no error on either side. Both mismatches are refused here:
105
+ * The environment decides the default bus: `NATS_URL` set (and not blank) is NATS, unset is the
106
+ * in-process bus. Until 31.0.0 `app.config.ts`'s `realtime.transport`/`urlEnv` decided and the env
107
+ * only had to agree — a closed union no app could add a bus to. A bus of the app's own is
108
+ * `transport` in `apps/<app>/runtime.ts`, and the boot never calls this for it.
112
109
  *
113
- * - `'nats'` with the variable `urlEnv` names unset or blank.
114
- * - `'memory'` with a bus url set (`NATS_URL`, or the variable `urlEnv` names). An operator who set
115
- * one expected fanout across nodes; keeping every change in this heap instead is the same silent
116
- * failure seen from the other side, so the two are refused rather than reconciled.
110
+ * The one silent failure left — a `sync` node on the in-process bus with its replicator in another
111
+ * process — is refused at boot by the caller, which knows the roles (`X_REALTIME_TOPOLOGY`).
117
112
  *
118
113
  * The bucket name is validated here rather than on first connect: a typo'd bucket is a boot that
119
114
  * reports a healthy bus and then fails every presence write.
120
115
  */
121
116
  export function selectTransport(
122
117
  env: TransportEnvironment,
123
- topology: RealtimeTopology,
124
118
  options: SelectTransportOptions = {},
125
119
  ): TransportSelection {
126
120
  const presenceTtlMs = finiteOption(
@@ -129,16 +123,16 @@ export function selectTransport(
129
123
  options.presenceTtlMs ?? DEFAULT_PRESENCE_TTL_MS,
130
124
  );
131
125
  const use = options.use ?? 'sockets';
126
+ const url = nonEmpty(env['NATS_URL']);
132
127
 
133
- if (topology.transport === 'memory') {
134
- refuseStrayBusUrl(env, topology);
128
+ if (url === undefined) {
135
129
  const transport = new InProcessTransport(
136
130
  options.clock === undefined ? {} : { clock: options.clock },
137
131
  );
138
132
  return {
139
133
  transport,
140
134
  mode: 'embedded',
141
- detail: `in-process fanout — set realtime.transport 'nats' in ${CONFIG_FILE} and NATS_URL to reach the other nodes`,
135
+ detail: `in-process fanout — set NATS_URL to reach the other nodes, or transport in ${RUNTIME_FILE} for another bus`,
142
136
  bucket: null,
143
137
  presenceTtlMs,
144
138
  use,
@@ -147,16 +141,6 @@ export function selectTransport(
147
141
  };
148
142
  }
149
143
 
150
- const urlEnv = topology.urlEnv ?? 'NATS_URL';
151
- const url = nonEmpty(env[urlEnv]);
152
- if (url === undefined) {
153
- throw new ConfigInvalidError({
154
- cause: `realtime.transport is 'nats' and realtime.urlEnv names ${urlEnv}, which is unset in this process's environment, so no node would be reachable`,
155
- 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`,
156
- meta: { key: 'realtime.urlEnv', variable: urlEnv },
157
- });
158
- }
159
-
160
144
  const bucket = nonEmpty(env['NATS_KV_BUCKET']) ?? DEFAULT_PRESENCE_BUCKET;
161
145
  assertBucket(bucket);
162
146
  const transport = new NatsTransport({
@@ -180,7 +164,7 @@ export function selectTransport(
180
164
  return {
181
165
  transport,
182
166
  mode: 'external',
183
- detail: urlEnv,
167
+ detail: 'NATS_URL',
184
168
  bucket,
185
169
  presenceTtlMs,
186
170
  use,
@@ -192,21 +176,9 @@ export function selectTransport(
192
176
  await transport.connect({
193
177
  withinMs,
194
178
  // The variable, never the url behind it. `-js` only where the bucket is asked for.
195
- fix: `x doctor — then check ${urlEnv} names a reachable nats-server${use === 'sockets' ? ' started with -js (JetStream)' : ''} — this process ${NEEDS[use]} and cannot start without it; the web, worker and scheduler roles boot without one and connect in the background`,
179
+ fix: `x doctor — then check NATS_URL names a reachable nats-server${use === 'sockets' ? ' started with -js (JetStream)' : ''} — this process ${NEEDS[use]} and cannot start without it; the web, worker and scheduler roles boot without one and connect in the background`,
196
180
  });
197
181
  },
198
182
  state: () => (transport.connected ? 'up' : 'connecting'),
199
183
  };
200
184
  }
201
-
202
- /** `'memory'` with a bus url in the environment: the conflict `selectTransport` refuses. */
203
- function refuseStrayBusUrl(env: TransportEnvironment, topology: RealtimeTopology): void {
204
- const names = topology.urlEnv === undefined ? ['NATS_URL'] : ['NATS_URL', topology.urlEnv];
205
- const set = names.find((name) => nonEmpty(env[name]) !== undefined);
206
- if (set === undefined) return;
207
- throw new ConfigInvalidError({
208
- cause: `${set} is set but realtime.transport is 'memory', so this process would fan out in its own heap and reach no other node`,
209
- fix: `set realtime: { transport: 'nats', urlEnv: '${set}' } in ${CONFIG_FILE} to use the bus, or unset ${set} for a single node`,
210
- meta: { key: 'realtime.transport', variable: set },
211
- });
212
- }
@@ -8,14 +8,26 @@
8
8
  /** FIFO, one task at a time. A task that rejects hands its rejection to its own caller and no one else. */
9
9
  export class WindowLock {
10
10
  #tail: Promise<unknown> = Promise.resolve();
11
+ #depth = 0;
12
+
13
+ /**
14
+ * Tasks queued or running. What a caller bounds the lane by (`live-deliver.ts`): every task is
15
+ * a closure holding its change, so a lane behind one slow policy pass is memory nobody capped.
16
+ */
17
+ get depth(): number {
18
+ return this.#depth;
19
+ }
11
20
 
12
21
  run<T>(work: () => Promise<T>): Promise<T> {
22
+ this.#depth += 1;
13
23
  const result = this.#tail.then(work);
14
24
  // The lane chains on a settled shadow, never on `result`: one delivery that threw must not
15
25
  // reject every delivery queued behind it, and an unwatched shadow must not look unhandled.
16
- this.#tail = result.then(ignore, ignore);
26
+ this.#tail = result.then(this.#settled, this.#settled);
17
27
  return result;
18
28
  }
19
- }
20
29
 
21
- const ignore = (): void => undefined;
30
+ readonly #settled = (): void => {
31
+ this.#depth -= 1;
32
+ };
33
+ }