@ultimat3/realtime 11.0.0 → 11.2.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 CHANGED
@@ -397,6 +397,34 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
397
397
  liveHookFor(liveFeed)` runs at import; `registerQueries()` stamps the name later, at boot.
398
398
  - Type claims about the hook go in `type-pins.ts`, never in a `.test.ts` — `tsconfig.json` excludes
399
399
  test files, so `tsc -b` never reads one and an assertion written there can never fail.
400
+ - **`backoffDelay` is `@ultimat3/core`'s, and `attempt + 1` is the whole of the seam** (`As of
401
+ 2026-08-23`). A client counts its first reconnect as attempt **0** and core counts the first wait
402
+ as attempt **1**, so dropping the shift doubles every reconnect delay in the framework —
403
+ silently, and only under the load this file exists to survive.
404
+ `thundering-herd-core-parity.test.ts` pins it with the numbers (`[500, 1_000, 2_000, 4_000]`) and
405
+ with 17,280 comparisons across every jitter mode, base, cap, factor, attempt and roll.
406
+ `JitterMode` is core's type re-exported and `Rng` is core's `Random` — never a second spelling of
407
+ either. `drainPlan()` and `AcceptBudget` are NOT backoff and keep their own arithmetic: a slot in
408
+ a spread and a token bucket's refusal delay are different questions. `bun run flight-copies` is the
409
+ guard: a second curve-and-jitter function anywhere in `packages/*/src` is a build error, matched
410
+ on the literal shape rather than the name.
411
+ - **The SHARED window read carries a deadline, and it frees the SLOT — it cannot cancel the read**
412
+ (`As of 2026-08-23`). One `definition.snapshot` that never settled pinned `entry.reading` for the
413
+ life of the process, and every later cold subscriber joined a promise nothing would ever resolve:
414
+ one wedged read took every future subscriber of that query id with it. `startRead` now races the
415
+ read against `entry.schedule(…, entry.readDeadlineMs)`, default `DEFAULT_READ_DEADLINE_MS` (30s),
416
+ injectable per entry and through `new LiveQueryRegistry({ readDeadlineMs, schedule })`. Three
417
+ rules, each with its own test in `query-window.test.ts` and each proven by mutation:
418
+ - **A race, not just an eviction.** Freeing the slot alone leaves every caller ALREADY joined
419
+ awaiting a promise nothing settles; they are told instead, with `X_TIMEOUT`.
420
+ - **`X_TIMEOUT`, not a silent empty window.** A superseded read is discarded silently because a
421
+ strictly better window already exists to serve from; a timed-out read has none, and serving
422
+ `rows: []` as the whole result set is the exact fault `live-query.ts`'s `#read` refuses. The
423
+ rejecting-read path (`readSnapshot`'s catch) is the shape this matches, not the superseded one.
424
+ - **`stale` is put back**, for `readSnapshot`'s reason: a read that did not answer must not leave
425
+ the window looking authoritative.
426
+ There is **no "off" spelling** — a shared read with no deadline is the defect itself — and a `0`,
427
+ negative, `NaN` or `Infinity` value falls back to the default rather than to "now".
400
428
  - The client owns its own reconnect: a closed socket arms **one** timer through the injected
401
429
  `Scheduler`, and that timer calls `connect()`. `reconnectAt` is the render half and never the
402
430
  mechanism — publishing it without arming anything is exactly the bug that shipped. Rules that
@@ -764,7 +792,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
764
792
  | `nats-lib-client.ts` | the `nats` adapter — **the only file in the repo that imports `nats`** |
765
793
  | `nats-jetstream.ts` / `nats-kv.ts` / `nats-transport.ts` | the JetStream KV bucket, presence over it, and the production `Transport` — all three written against the port |
766
794
  | `nats-fake.ts` | an in-memory bus implementing the port — server semantics, not wire bytes; the only way to prove multi-node fanout under a sealed network |
767
- | `cursor.ts` / `change-buffer.ts` / `thundering-herd.ts` | reconnect — the highest-risk area |
795
+ | `cursor.ts` / `change-buffer.ts` / `thundering-herd.ts` | reconnect — the highest-risk area. `thundering-herd.ts`'s backoff is core's, shifted 0-based to 1-based; the drain plan and the accept budget are its own |
768
796
  | `identity-map.ts` | the client's single source of truth: one row value per `(scope, id)`, its holds and its batched change notification |
769
797
  | `live-rows.ts` | one subscription's window over that map — its scope, its order, its retain/release, and `Registration` itself |
770
798
  | `local-store.ts` / `offline-queue.ts` / `rebase.ts` | tier 3 |
package/README.md CHANGED
@@ -196,6 +196,7 @@ wire.
196
196
  | live subscriptions per socket | 128 | `new LiveQueryRegistry({ maxPerSocket })` | `X_SUBSCRIPTION_LIMIT` |
197
197
  | live subscriptions per tenant | unset | `new LiveQueryRegistry({ maxPerTenant, tenantOf })` — **both**, or it arms nothing | `X_SUBSCRIPTION_LIMIT` |
198
198
  | distinct `(query, input)` pairs per node | 10,000 | `new LiveQueryRegistry({ maxEntries })` | `X_SUBSCRIPTION_LIMIT` |
199
+ | how long one entry's SHARED snapshot read may hold its slot | 30s | `new LiveQueryRegistry({ readDeadlineMs })` | `X_TIMEOUT`, to that read's caller AND every subscriber joined to it |
199
200
  | channel topics per socket | 64 | `new ChannelHub({ maxTopicsPerSocket })` | `X_SUBSCRIPTION_LIMIT` |
200
201
  | distinct channel topics per node | 10,000 | `new ChannelHub({ maxTopicsPerNode })` | `X_SUBSCRIPTION_LIMIT` |
201
202
  | outbound bytes buffered on one socket | 1 MiB | `createSyncNode({ maxBufferedBytes })` | the frame is dropped and `send` answers `false` |
@@ -543,7 +544,11 @@ wire twice by a reconnect that raced an ack.
543
544
  `X_REPLICATION_FAILED` · `X_REPLICATION_PROTOCOL` · `X_REPLICATOR_SLOT_HELD` ·
544
545
  `X_LIVE_CLIENT_MISSING` · `X_LIVE_SERVER_RENDER` · `X_LIVE_QUERY_UNKNOWN` ·
545
546
  `X_LIVE_REPLICA_IDENTITY` ·
546
- `X_SOCKET_UNAUTHENTICATED` · `X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED`
547
+ `X_SOCKET_UNAUTHENTICATED` · `X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED` ·
548
+ `X_TIMEOUT`
549
+
550
+ `X_NOT_IMPLEMENTED` and `X_TIMEOUT` are **borrowed** from `@ultimat3/core`, which owns and titles
551
+ them — `REALTIME_BORROWED_ERROR_CODES`. Everything else on that list is realtime's own.
547
552
 
548
553
  Topics deny by default: a topic with no matching guard is forbidden. An authz hole is not a config
549
554
  option someone forgot to set.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "11.0.0",
3
+ "version": "11.2.0",
4
4
  "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,8 +36,8 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/core": "11.0.0",
40
- "@ultimat3/query": "11.0.0",
39
+ "@ultimat3/core": "11.2.0",
40
+ "@ultimat3/query": "11.2.0",
41
41
  "nats": "2.29.3"
42
42
  }
43
43
  }
package/src/errors.ts CHANGED
@@ -32,8 +32,13 @@ export const REALTIME_OWNED_ERROR_CODES = [
32
32
  * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s, and `X_FORBIDDEN` — thrown by the surface denials this
33
33
  * package renders — is `@ultimat3/policy`'s. Neither is titled here: the owner writes the one title
34
34
  * every surface renders, and a copy kept alongside it is a copy that goes stale unnoticed.
35
+ *
36
+ * `X_TIMEOUT` is core's too — titled in `CORE_CODE_TITLES` and classified `retryable` there, which
37
+ * is what a blown deadline owes a caller. Borrowed rather than owned for the same reason
38
+ * `@ultimat3/http` borrows it (`HTTP_BORROWED_ERROR_CODES`): the concept is core's, and a title
39
+ * registered here would throw `X_ERROR_CODE_DUPLICATE` at import.
35
40
  */
36
- export const REALTIME_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
41
+ export const REALTIME_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED', 'X_TIMEOUT'] as const;
37
42
 
38
43
  /**
39
44
  * The two codes an authz **decision** carries. Everything else a gate throws — a rule that reached
@@ -407,6 +412,24 @@ export class SocketAuthUnavailableError extends RealtimeError {
407
412
  }
408
413
  }
409
414
 
415
+ /**
416
+ * The shared window read for one live query id blew its deadline.
417
+ *
418
+ * Freeing the slot alone would leave every caller ALREADY joined to that read waiting on a promise
419
+ * nothing will ever settle, so they are TOLD instead — the same answer this file's other
420
+ * non-answering read gives, and the reason `fillWindow` needed no new branch to carry it. The read
421
+ * itself is not cancellable from here and this does not pretend to have stopped it.
422
+ */
423
+ export class WindowReadTimeoutError extends RealtimeError {
424
+ constructor(args: { qid: string; afterMs: number }) {
425
+ super({
426
+ code: 'X_TIMEOUT',
427
+ cause: `the shared snapshot read for live query "${args.qid}" did not answer within ${args.afterMs}ms, so the window slot it held was released`,
428
+ fix: 'x doctor --json # then raise readDeadlineMs on the live query registry, or fix the snapshot read that stopped answering',
429
+ });
430
+ }
431
+ }
432
+
410
433
  /** Deep infrastructure that is interface-complete but not wired. Carries the exact next step. */
411
434
  export class NotImplementedError extends RealtimeError {
412
435
  constructor(args: { what: string; fix: string }) {
package/src/index.ts CHANGED
@@ -60,6 +60,7 @@ export {
60
60
  TopicForbiddenError,
61
61
  TransportProtocolError,
62
62
  TransportUnavailableError,
63
+ WindowReadTimeoutError,
63
64
  } from './errors';
64
65
  // ---- the client hooks --------------------------------------------------------------------------
65
66
  export {
package/src/live-query.ts CHANGED
@@ -26,6 +26,7 @@ import type { SyncSocket } from './socket';
26
26
  import { type Subscriber, SubscriberGate, type SubscriberGateOptions } from './subscriber-gate';
27
27
  import { SubscriptionBook, subscriptionKey } from './subscription-book';
28
28
  import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
29
+ import type { Scheduler } from './thundering-herd';
29
30
 
30
31
  export interface LiveQueryRegistryOptions extends SubscriberGateOptions {
31
32
  readonly source: ResumeSource;
@@ -40,6 +41,15 @@ export interface LiveQueryRegistryOptions extends SubscriberGateOptions {
40
41
  * a `WindowLock` and a fanout target each — until the process dies.
41
42
  */
42
43
  readonly maxEntries?: number;
44
+ /**
45
+ * How long one entry's SHARED snapshot read may hold its slot. Defaults to
46
+ * `DEFAULT_READ_DEADLINE_MS`. Without it a `definition.snapshot` that never settles pinned the
47
+ * slot for the life of the process and every later cold subscriber joined a promise nothing
48
+ * would resolve — one wedged read taking every future subscriber of that query id with it.
49
+ */
50
+ readonly readDeadlineMs?: number;
51
+ /** Injected so that deadline is provable without waiting for one. Production uses `setTimeout`. */
52
+ readonly schedule?: Scheduler;
43
53
  }
44
54
 
45
55
  /**
@@ -384,7 +394,10 @@ export class LiveQueryRegistry {
384
394
  knob: 'maxEntries',
385
395
  });
386
396
  }
387
- const created = createEntry(qid, definition, input, definition.matcher(input));
397
+ const created = createEntry(qid, definition, input, definition.matcher(input), {
398
+ readDeadlineMs: this.#options.readDeadlineMs,
399
+ schedule: this.#options.schedule,
400
+ });
388
401
  this.#entries.set(qid, created);
389
402
  return created;
390
403
  }
@@ -2,11 +2,30 @@
2
2
  // read once for N subscribers, and how one that is known to be wrong is replaced. The authz
3
3
  // decision is never here — `live-query.ts` owns that, once per subscriber, over what this returns.
4
4
 
5
+ import { WindowReadTimeoutError } from './errors';
5
6
  import type { JsonValue, Row } from './json';
6
7
  import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
7
8
  import type { IncrementalMatcher, SubscriptionShape } from './matcher-bridge';
9
+ import type { Scheduler } from './thundering-herd';
10
+ import { timeoutScheduler } from './thundering-herd';
8
11
  import { WindowLock } from './window-lock';
9
12
 
13
+ /**
14
+ * How long the SHARED read may hold the slot before it is released. On by default and with no
15
+ * "off" spelling: a shared read with no deadline is exactly the defect this closes — one
16
+ * `definition.snapshot` that never settles pinned `entry.reading` for the life of the process and
17
+ * every later cold subscriber joined a promise nothing would resolve. A caller that wants a longer
18
+ * one names a bigger number.
19
+ */
20
+ export const DEFAULT_READ_DEADLINE_MS = 30_000;
21
+
22
+ export interface EntryOptions {
23
+ /** Non-finite or non-positive falls back to the default — `0` cannot mean both "now" and "never". */
24
+ readonly readDeadlineMs?: number | undefined;
25
+ /** Injected so a deadline is provable without waiting for one. */
26
+ readonly schedule?: Scheduler | undefined;
27
+ }
28
+
10
29
  export interface QueryEntry {
11
30
  readonly qid: string;
12
31
  readonly definition: LiveQueryDefinition;
@@ -40,6 +59,8 @@ export interface QueryEntry {
40
59
  generation: number;
41
60
  /** The generation of the newest read whose rows are in `rows`. `0` before the first one lands. */
42
61
  applied: number;
62
+ readonly readDeadlineMs: number;
63
+ readonly schedule: Scheduler;
43
64
  }
44
65
 
45
66
  /**
@@ -57,6 +78,7 @@ export function createEntry(
57
78
  definition: LiveQueryDefinition,
58
79
  input: JsonValue,
59
80
  matcher: IncrementalMatcher,
81
+ options?: EntryOptions,
60
82
  ): QueryEntry {
61
83
  return {
62
84
  qid,
@@ -83,6 +105,8 @@ export function createEntry(
83
105
  reading: null,
84
106
  generation: 0,
85
107
  applied: 0,
108
+ readDeadlineMs: usableDeadline(options?.readDeadlineMs),
109
+ schedule: options?.schedule ?? timeoutScheduler,
86
110
  };
87
111
  }
88
112
 
@@ -145,15 +169,54 @@ function applyRead(entry: QueryEntry, pending: PendingRead, result: SnapshotResu
145
169
  if (result.lsn > entry.lsn) entry.lsn = result.lsn;
146
170
  }
147
171
 
148
- /** Publishes the in-flight read, and clears it as it settles — the share is per read, not a cache. */
172
+ function usableDeadline(declared: number | undefined): number {
173
+ if (declared === undefined) return DEFAULT_READ_DEADLINE_MS;
174
+ return Number.isFinite(declared) && declared > 0 ? declared : DEFAULT_READ_DEADLINE_MS;
175
+ }
176
+
177
+ /**
178
+ * Publishes the in-flight read, and clears it as it settles — the share is per read, not a cache.
179
+ *
180
+ * The deadline frees the SLOT. It cannot cancel the read — nothing here can, and pretending
181
+ * otherwise would be a second, false promise — so the abandoned read runs to completion with
182
+ * nobody listening, and the next caller issues its own instead of joining a corpse.
183
+ */
149
184
  function startRead(entry: QueryEntry): PendingRead {
150
185
  // Cleared here rather than when the read lands: the read about to be issued is the one that
151
186
  // answers the staleness, so a second caller must join it instead of forcing another.
152
187
  entry.stale = false;
153
188
  entry.generation += 1;
154
- const reading: PendingRead = { generation: entry.generation, result: readSnapshot(entry) };
189
+
190
+ const read = readSnapshot(entry);
191
+ let expire: () => void = noop;
192
+ const deadline = new Promise<never>((_resolve, reject) => {
193
+ // Assigned synchronously — a Promise executor runs before the constructor returns — so `done`
194
+ // can never fire against the placeholder and leave a timer armed.
195
+ expire = entry.schedule(() => {
196
+ // The same restoration `readSnapshot`'s catch makes, and for the same reason: a read that
197
+ // did not answer must not leave the window looking authoritative. Unconditional even when a
198
+ // NEWER read already holds the slot, which can only cost one extra read — over-reading is
199
+ // safe here and under-reading is the divergence `stale` exists to prevent.
200
+ entry.stale = true;
201
+ reject(new WindowReadTimeoutError({ qid: entry.qid, afterMs: entry.readDeadlineMs }));
202
+ }, entry.readDeadlineMs);
203
+ });
204
+
205
+ const reading: PendingRead = {
206
+ generation: entry.generation,
207
+ // A race, not just an eviction: freeing the slot alone would leave every caller ALREADY joined
208
+ // awaiting a promise nothing settles. `Promise.race` subscribes to both, so a read that
209
+ // rejects AFTER losing the race is still handled and never surfaces as an unhandled rejection.
210
+ result: Promise.race([read, deadline]),
211
+ };
155
212
  entry.reading = reading;
213
+
156
214
  const done = (): void => {
215
+ // An armed timer per completed read keeps the process alive and is a leak in its own right.
216
+ expire();
217
+ // Identity, never presence — the rule the deadline makes load-bearing rather than merely
218
+ // careful: a read that answers after its deadline released the slot must not clear whatever
219
+ // holds it now, or that newer read's joiners share a promise the entry no longer answers for.
157
220
  if (entry.reading === reading) entry.reading = null;
158
221
  };
159
222
  void reading.result.then(done, done);
@@ -185,3 +248,5 @@ export function orgIdOf(input: JsonValue): string | null {
185
248
  const value = input['orgId'];
186
249
  return typeof value === 'string' ? value : null;
187
250
  }
251
+
252
+ const noop = (): void => undefined;
package/src/server.ts CHANGED
@@ -144,6 +144,8 @@ export {
144
144
  } from './presence';
145
145
  export {
146
146
  createEntry,
147
+ DEFAULT_READ_DEADLINE_MS,
148
+ type EntryOptions,
147
149
  fillWindow,
148
150
  orgIdOf,
149
151
  type PendingRead,
@@ -6,13 +6,20 @@
6
6
  // 2. backoffDelay() — 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 Clock, systemClock } from '@ultimat3/core';
9
+ import {
10
+ type Clock,
11
+ backoffDelay as coreBackoffDelay,
12
+ type JitterMode,
13
+ type Random,
14
+ systemClock,
15
+ } from '@ultimat3/core';
10
16
  import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
11
17
 
12
18
  /** Injected so tests are deterministic and `local` mutators stay replayable. */
13
- export type Rng = () => number;
19
+ export type Rng = Random;
14
20
 
15
- export type JitterMode = 'full' | 'equal' | 'none';
21
+ /** Core's, re-exported under this package's name never a second copy of the same three modes. */
22
+ export type { JitterMode };
16
23
 
17
24
  export interface BackoffPolicy {
18
25
  readonly baseMs: number;
@@ -32,21 +39,28 @@ export const defaultBackoff: BackoffPolicy = {
32
39
  jitter: 'full',
33
40
  };
34
41
 
35
- /** Attempt is 0-based. Result is always in `[0, maxMs]`. */
42
+ /**
43
+ * Attempt is 0-BASED. Result is always in `[0, maxMs]`.
44
+ *
45
+ * The arithmetic is core's, and `attempt + 1` is the WHOLE of the seam: a client counts its first
46
+ * reconnect as attempt 0 and core counts the first wait as attempt 1, so dropping the shift would
47
+ * double every reconnect delay in the framework — silently, and only under the load this file
48
+ * exists to survive. `thundering-herd-core-parity.test.ts` pins the numbers.
49
+ */
36
50
  export function backoffDelay(
37
51
  attempt: number,
38
52
  policy: BackoffPolicy = defaultBackoff,
39
53
  rng: Rng = Math.random,
40
54
  ): number {
41
- const ceiling = Math.min(policy.maxMs, policy.baseMs * policy.factor ** Math.max(0, attempt));
42
- switch (policy.jitter) {
43
- case 'none':
44
- return Math.round(ceiling);
45
- case 'equal':
46
- return Math.round(ceiling / 2 + (rng() * ceiling) / 2);
47
- case 'full':
48
- return Math.round(rng() * ceiling);
49
- }
55
+ return coreBackoffDelay({
56
+ attempt: attempt + 1,
57
+ base: policy.baseMs,
58
+ max: policy.maxMs,
59
+ factor: policy.factor,
60
+ curve: 'exponential',
61
+ jitter: policy.jitter,
62
+ random: rng,
63
+ });
50
64
  }
51
65
 
52
66
  /**