@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
@@ -2,7 +2,8 @@
2
2
  // offline mutation drain are frames in the same union. Moving a route from tier 2 to tier 3 is a
3
3
  // config flag (`persist: true`), never a new protocol — that promise is enforced here.
4
4
 
5
- import type { LiveCursor } from './cursor';
5
+ import { renderThrowable, stringField } from '@ultimat3/core';
6
+ import { CURSOR_ID_LIMIT, type LiveCursor } from './cursor';
6
7
  import { ProtocolVersionError } from './errors';
7
8
  import {
8
9
  isJsonObject,
@@ -15,6 +16,27 @@ import {
15
16
 
16
17
  export const PROTOCOL_VERSION = 1;
17
18
 
19
+ /**
20
+ * What one frame may contain. Hard ceilings a caller cannot widen — the shape
21
+ * `packages/mcp/src/query-limits.ts` uses — because every one of them is read off a socket the
22
+ * node has already paid for: an unbounded `cursor.ids` was consumed raw into a `Set`, and an
23
+ * `input` of arbitrary depth reached `canonicalJson`, which recurses.
24
+ *
25
+ * Every number clears what this node itself produces, or the decoder refuses its own frames on
26
+ * the next reconnect: `cursorIds` is `CURSOR_ID_LIMIT`, `patches` clears
27
+ * `defaultReconnectBudget.maxPatches`.
28
+ */
29
+ export const FRAME_LIMITS = Object.freeze({
30
+ cursorIds: CURSOR_ID_LIMIT,
31
+ patches: 4_096,
32
+ rows: 10_000,
33
+ members: 4_096,
34
+ /** Nesting one `input` may reach. 32 is far past any query's real argument shape. */
35
+ inputDepth: 32,
36
+ /** Values one `input` may hold in total, so a flat-but-enormous object is refused too. */
37
+ inputNodes: 10_000,
38
+ });
39
+
18
40
  export type ConflictStrategyName = 'server-wins' | 'last-write-wins' | 'custom';
19
41
 
20
42
  export interface WireError {
@@ -46,6 +68,17 @@ export type SubscribeTarget =
46
68
  readonly cursor: LiveCursor | null;
47
69
  };
48
70
 
71
+ /**
72
+ * The opening frame, and the heartbeat's. It carries **no cursors**: resume is decided per
73
+ * subscription by `subscribe`, whose target already carries the cursor and whose `(name, input)`
74
+ * is what the node needs to authorize the read and reach the retained window at all. A cursor's
75
+ * `qid` is `qidOf(name, input)` — a digest, not an input — so a resume list here could never be
76
+ * more than a second, unauthorized restatement of that decision, and it cost every reconnect a
77
+ * duplicate copy of up to `CURSOR_ID_LIMIT` ids per subscription during the exact restart storm
78
+ * `thundering-herd.ts` exists to bound. Removing it needs no `PROTOCOL_VERSION` bump: `decode`
79
+ * builds a whitelist, so a node reads an old client's `resume` as absent and an old node reads a
80
+ * new client's omission the way it already read an empty list.
81
+ */
49
82
  export interface HelloFrame {
50
83
  readonly type: 'hello';
51
84
  readonly v: number;
@@ -53,7 +86,6 @@ export interface HelloFrame {
53
86
  /** Server-assigned on the reply, `null` on the client's opening frame. */
54
87
  readonly sessionId: string | null;
55
88
  readonly actorId: string | null;
56
- readonly resume: readonly LiveCursor[];
57
89
  }
58
90
 
59
91
  export interface SubscribeFrame {
@@ -70,6 +102,14 @@ export interface SnapshotFrame {
70
102
  readonly sid: string;
71
103
  readonly rows: readonly Row[];
72
104
  readonly cursor: LiveCursor;
105
+ /**
106
+ * The entity every row of this result set belongs to — the client's identity scope, so two
107
+ * queries returning post #7 hold one row rather than two copies. Optional and **additive**: it
108
+ * is the one thing a browser cannot derive (the shape is compiled server-side from `sql`), and
109
+ * a client that does not receive it keeps its rows in a scope private to that subscription. Both
110
+ * skews are safe in both directions, which is why it carries no `PROTOCOL_VERSION` bump.
111
+ */
112
+ readonly entity?: string;
73
113
  }
74
114
 
75
115
  export interface PatchFrame {
@@ -115,6 +155,14 @@ export interface PresenceFrame {
115
155
  readonly topic: string;
116
156
  readonly op: 'join' | 'leave' | 'update' | 'sync';
117
157
  readonly members: readonly PresenceMember[];
158
+ /**
159
+ * Members in the whole set behind a `sync` frame, which is capped: a 5,000-avatar row is not a UI
160
+ * anyone renders, and the count is what lets a client say "and 4,744 others" without holding
161
+ * them. Optional and **additive**, exactly like `snapshot.entity`: an old node omits it and a new
162
+ * one reads its absence as "this frame is the whole set", so neither skew is unreadable and
163
+ * `PROTOCOL_VERSION` does not move. Never set on a `join`/`leave`/`update` — those are deltas.
164
+ */
165
+ readonly total?: number;
118
166
  }
119
167
 
120
168
  export interface ReconnectFrame {
@@ -185,7 +233,6 @@ export function decode(raw: string | Uint8Array): Frame {
185
233
  buildId: str(parsed, 'buildId'),
186
234
  sessionId: nullableStr(parsed, 'sessionId'),
187
235
  actorId: nullableStr(parsed, 'actorId'),
188
- resume: list(parsed, 'resume').map(cursor),
189
236
  };
190
237
  case 'subscribe':
191
238
  return {
@@ -195,20 +242,23 @@ export function decode(raw: string | Uint8Array): Frame {
195
242
  sid: str(parsed, 'sid'),
196
243
  target: target(parsed['target']),
197
244
  };
198
- case 'snapshot':
199
- return {
245
+ case 'snapshot': {
246
+ const base = {
200
247
  type: 'snapshot',
201
248
  v: PROTOCOL_VERSION,
202
249
  sid: str(parsed, 'sid'),
203
- rows: list(parsed, 'rows').map(row),
250
+ rows: list(parsed, 'rows', FRAME_LIMITS.rows).map(row),
204
251
  cursor: cursor(parsed['cursor']),
205
- };
252
+ } as const;
253
+ const entity = nullableStr(parsed, 'entity');
254
+ return entity === null ? base : { ...base, entity };
255
+ }
206
256
  case 'patch':
207
257
  return {
208
258
  type: 'patch',
209
259
  v: PROTOCOL_VERSION,
210
260
  sid: str(parsed, 'sid'),
211
- patches: list(parsed, 'patches').map(patch),
261
+ patches: list(parsed, 'patches', FRAME_LIMITS.patches).map(patch),
212
262
  lsn: str(parsed, 'lsn'),
213
263
  };
214
264
  case 'mutate':
@@ -218,7 +268,7 @@ export function decode(raw: string | Uint8Array): Frame {
218
268
  key: str(parsed, 'key'),
219
269
  seq: num(parsed, 'seq'),
220
270
  name: str(parsed, 'name'),
221
- input: parsed['input'] ?? null,
271
+ input: bounded(parsed['input'] ?? null, 'input'),
222
272
  };
223
273
  case 'ack':
224
274
  return {
@@ -237,14 +287,16 @@ export function decode(raw: string | Uint8Array): Frame {
237
287
  strategy: pick(parsed, 'strategy', ['server-wins', 'last-write-wins', 'custom'] as const),
238
288
  row: parsed['row'] === null ? null : row(parsed['row']),
239
289
  };
240
- case 'presence':
241
- return {
290
+ case 'presence': {
291
+ const base = {
242
292
  type: 'presence',
243
293
  v: PROTOCOL_VERSION,
244
294
  topic: str(parsed, 'topic'),
245
295
  op: pick(parsed, 'op', ['join', 'leave', 'update', 'sync'] as const),
246
- members: list(parsed, 'members').map(member),
247
- };
296
+ members: list(parsed, 'members', FRAME_LIMITS.members).map(member),
297
+ } as const;
298
+ return parsed['total'] === undefined ? base : { ...base, total: num(parsed, 'total') };
299
+ }
248
300
  case 'reconnect':
249
301
  return {
250
302
  type: 'reconnect',
@@ -261,13 +313,17 @@ export function decode(raw: string | Uint8Array): Frame {
261
313
 
262
314
  /** Project any thrown value onto the wire without losing the error contract's three fields. */
263
315
  export function toWireError(error: unknown): WireError {
264
- const shape = error as { code?: unknown; cause?: unknown; fix?: unknown; docs?: unknown } | null;
265
- const code = typeof shape?.code === 'string' ? shape.code : 'X_PROTOCOL_VERSION';
266
- const cause = typeof shape?.cause === 'string' ? shape.cause : String(error);
267
- const fix = typeof shape?.fix === 'string' ? shape.fix : 'x doctor realtime';
268
- return typeof shape?.docs === 'string'
269
- ? { code, cause, fix, docs: shape.docs }
270
- : { code, cause, fix };
316
+ // The throwable is an app mutator's, a live query's or a policy's, so its `toString` is the
317
+ // app's too: `String()` here raised inside the handler's catch and the socket got no frame at
318
+ // all, which a reconnect cannot repair because the same call throws the same way.
319
+ // `renderThrowable` keeps an Error's own words without trusting `instanceof` or `.message`, and
320
+ // `stringField` makes the four probes above it as total as the fallback they choose between —
321
+ // `shape?.code` was a raw property read on that same app value.
322
+ const code = stringField(error, 'code') ?? 'X_PROTOCOL_VERSION';
323
+ const cause = stringField(error, 'cause') ?? renderThrowable(error);
324
+ const fix = stringField(error, 'fix') ?? 'x doctor realtime';
325
+ const docs = stringField(error, 'docs');
326
+ return docs === undefined ? { code, cause, fix } : { code, cause, fix, docs };
271
327
  }
272
328
 
273
329
  function fail(detail: string): ProtocolVersionError {
@@ -302,10 +358,45 @@ function pick<T extends string>(obj: JsonObject, key: string, allowed: readonly
302
358
  return found;
303
359
  }
304
360
 
305
- function list(obj: JsonObject, key: string): JsonValue[] {
361
+ /**
362
+ * An array field, with the ceiling the caller had to choose. `max` is required rather than
363
+ * defaulted: a new list field on a new frame is a new thing an authenticated socket can make
364
+ * arbitrarily large, and a default would let one ship without anyone deciding its size.
365
+ */
366
+ function list(obj: JsonObject, key: string, max: number, label = key): JsonValue[] {
306
367
  const value = obj[key];
307
368
  if (value === undefined || value === null) return [];
308
- if (!Array.isArray(value)) throw fail(`field "${key}" must be an array`);
369
+ if (!Array.isArray(value)) throw fail(`field "${label}" must be an array`);
370
+ if (value.length > max) {
371
+ throw fail(`field "${label}" carries ${value.length} entries, over the limit of ${max}`);
372
+ }
373
+ return value;
374
+ }
375
+
376
+ /**
377
+ * A client-supplied value, walked ITERATIVELY to its limits. Iteratively because the thing being
378
+ * refused is a stack overflow: `qidOf` -> `canonicalJson` recurses over exactly this value, so a
379
+ * depth check that recursed would be the same crash one frame earlier.
380
+ */
381
+ function bounded(value: JsonValue, label: string): JsonValue {
382
+ const stack: { node: JsonValue; depth: number }[] = [{ node: value, depth: 1 }];
383
+ let seen = 0;
384
+ while (stack.length > 0) {
385
+ // `pop` cannot answer undefined here — the loop guard is the length — and the check is what
386
+ // makes that readable to the compiler without a cast.
387
+ const next = stack.pop();
388
+ if (next === undefined) break;
389
+ seen += 1;
390
+ if (seen > FRAME_LIMITS.inputNodes) {
391
+ throw fail(`field "${label}" holds more than ${FRAME_LIMITS.inputNodes} values`);
392
+ }
393
+ if (next.depth > FRAME_LIMITS.inputDepth) {
394
+ throw fail(`field "${label}" is nested deeper than ${FRAME_LIMITS.inputDepth}`);
395
+ }
396
+ if (next.node === null || typeof next.node !== 'object') continue;
397
+ const children = Array.isArray(next.node) ? next.node : Object.values(next.node);
398
+ for (const child of children) stack.push({ node: child, depth: next.depth + 1 });
399
+ }
309
400
  return value;
310
401
  }
311
402
 
@@ -320,7 +411,7 @@ function cursor(value: unknown): LiveCursor {
320
411
  qid: str(value, 'qid'),
321
412
  lsn: str(value, 'lsn'),
322
413
  digest: str(value, 'digest'),
323
- ids: list(value, 'ids').map((id) => {
414
+ ids: list(value, 'ids', FRAME_LIMITS.cursorIds, 'cursor.ids').map((id) => {
324
415
  if (typeof id !== 'string') throw fail('cursor.ids must be strings');
325
416
  return id;
326
417
  }),
@@ -357,7 +448,7 @@ function target(value: unknown): SubscribeTarget {
357
448
  return {
358
449
  kind,
359
450
  qid: str(value, 'qid'),
360
- input: value['input'] ?? null,
451
+ input: bounded(value['input'] ?? null, 'input'),
361
452
  cursor:
362
453
  value['cursor'] === null || value['cursor'] === undefined ? null : cursor(value['cursor']),
363
454
  };
@@ -0,0 +1,124 @@
1
+ // The node's HTTP surface: health, readiness, load shedding and the authenticated upgrade. Split
2
+ // from `sync-node.ts` because deciding whether a request becomes a websocket is a different job
3
+ // from what the socket then does — the same line `sync-frames.ts` and `sync-listen.ts` already draw.
4
+
5
+ import { healthzPayload, readyzPayload, reportError } from '@ultimat3/core';
6
+ import { SocketAuthUnavailableError, SocketUnauthenticatedError } from './errors';
7
+ import type { SyncAuthenticator, SyncGrant } from './sync-auth';
8
+ import { toWireError } from './sync-protocol';
9
+ import type { AcceptBudget, Rng } from './thundering-herd';
10
+
11
+ /**
12
+ * What the upgrade hands the socket. It carries no actor: the grant does, and one identity written
13
+ * in two places is two that disagree the moment a re-auth renews one of them.
14
+ */
15
+ export interface WsData {
16
+ readonly socketId: string;
17
+ readonly clientBuildId: string;
18
+ }
19
+
20
+ /** Structural view of `Bun.serve`'s server object; keeps this module free of a Bun import. */
21
+ export interface UpgradeTarget {
22
+ upgrade(request: Request, options: { data: WsData }): boolean;
23
+ }
24
+
25
+ /**
26
+ * Everything the decision reads, supplied by the node. `ready` and `socketCount` are functions
27
+ * rather than values because both move while a request is parked inside `authenticate` — reading
28
+ * them once at the top is exactly the staleness this file exists to refuse.
29
+ */
30
+ export interface UpgradeDeps {
31
+ readonly path: string;
32
+ readonly buildId: string;
33
+ readonly maxConnections: number;
34
+ readonly accept: AcceptBudget;
35
+ readonly rng: Rng;
36
+ ready(): boolean;
37
+ socketCount(): number;
38
+ newSocketId(): string;
39
+ readonly authenticate?: SyncAuthenticator | undefined;
40
+ /** Recorded only after the upgrade took: a grant for a socket that never opened is never closed. */
41
+ onGranted(socketId: string, grant: SyncGrant): void;
42
+ }
43
+
44
+ /**
45
+ * `undefined` means the upgrade took and Bun owns the connection now. Async because `authenticate`
46
+ * is: the credential is decided *before* `server.upgrade`, so a refused one never costs a websocket.
47
+ */
48
+ export async function handleUpgrade(
49
+ deps: UpgradeDeps,
50
+ request: Request,
51
+ server: UpgradeTarget,
52
+ ): Promise<Response | undefined> {
53
+ const url = new URL(request.url);
54
+ // Health is the process's, readiness is this node's: a draining node stays healthy while it hands
55
+ // its sockets to the rest of the fleet.
56
+ if (url.pathname === '/healthz') return json(healthzPayload());
57
+ if (url.pathname === '/readyz') {
58
+ const payload = readyzPayload();
59
+ return deps.ready() ? json(payload) : json({ status: 503, body: payload.body });
60
+ }
61
+ if (url.pathname !== deps.path) return new Response('not found', { status: 404 });
62
+ // The count, not the rate. Shed the same way and with the same delay attached: a client refused
63
+ // for a full node and one refused for a fast one have the same next move, and the refusal is
64
+ // decided before `authenticate` so a full node costs no token service call.
65
+ if (deps.socketCount() >= deps.maxConnections || !deps.ready() || !deps.accept.tryAccept()) {
66
+ return shed(deps);
67
+ }
68
+ let grant: SyncGrant | null = null;
69
+ if (deps.authenticate) {
70
+ try {
71
+ grant = await deps.authenticate(request);
72
+ } catch (error) {
73
+ // A failure is not a denial. The token service timing out must not read to a client as "you
74
+ // may not connect" — it is told to come back, and this node is the one that pages.
75
+ reportError(error, { source: 'realtime', scope: { operation: 'sync.authenticate' } });
76
+ return wireErrorResponse(
77
+ 503,
78
+ new SocketAuthUnavailableError({ detail: 'see the node log for the cause' }),
79
+ );
80
+ }
81
+ // The decision, made before a socket exists: an upgrade is the cheapest thing to refuse and the
82
+ // most expensive thing to take back.
83
+ if (grant === null) {
84
+ return wireErrorResponse(
85
+ 401,
86
+ new SocketUnauthenticatedError({ reason: 'authenticate() resolved no actor' }),
87
+ );
88
+ }
89
+ }
90
+ // Asked again, because `authenticate` is app code and awaiting it is awaiting a token service: a
91
+ // request that passed the check above can be parked there when SIGTERM lands, and the `accept`
92
+ // phase is over by the time it gets here. Upgrading then is the one socket that phase exists to
93
+ // refuse — the load balancer has already been told this node is out, so nothing takes it over. No
94
+ // second `tryAccept()`: that budget was spent above.
95
+ if (!deps.ready()) return shed(deps);
96
+ const data: WsData = {
97
+ socketId: deps.newSocketId(),
98
+ clientBuildId: url.searchParams.get('build') ?? deps.buildId,
99
+ };
100
+ if (!server.upgrade(request, { data })) {
101
+ return new Response('expected websocket', { status: 426 });
102
+ }
103
+ if (grant) deps.onGranted(data.socketId, grant);
104
+ return undefined;
105
+ }
106
+
107
+ /** Load shedding with a delay attached: refusing without one just moves the herd next door. */
108
+ function shed(deps: UpgradeDeps): Response {
109
+ return new Response('retry', {
110
+ status: 503,
111
+ headers: { 'retry-after-ms': String(deps.accept.retryAfterMs(deps.rng)) },
112
+ });
113
+ }
114
+
115
+ function wireErrorResponse(status: number, error: unknown): Response {
116
+ return json({ status, body: { error: toWireError(error) } });
117
+ }
118
+
119
+ function json(payload: { status: number; body: unknown }): Response {
120
+ return new Response(JSON.stringify(payload.body), {
121
+ status: payload.status,
122
+ headers: { 'content-type': 'application/json' },
123
+ });
124
+ }
@@ -49,6 +49,22 @@ export function backoffDelay(
49
49
  }
50
50
  }
51
51
 
52
+ /**
53
+ * The timer half of mechanism 2. Returns its own canceller rather than a handle, so nothing has to
54
+ * name a type that differs between Bun, the browser and `node:timers`. Injected because a reconnect
55
+ * only provable by sleeping is a reconnect no test proves — and an unproven one silently did not
56
+ * fire at all until `As of 2026-08`.
57
+ */
58
+ export type Scheduler = (fn: () => void, ms: number) => () => void;
59
+
60
+ /** The production scheduler: the one `setTimeout` on the client's reconnect path. */
61
+ export const timeoutScheduler: Scheduler = (fn, ms) => {
62
+ const handle = setTimeout(fn, ms);
63
+ return () => {
64
+ clearTimeout(handle);
65
+ };
66
+ };
67
+
52
68
  export type ReconnectReason = 'drain' | 'overload' | 'rebalance';
53
69
 
54
70
  export interface DrainPlanEntry {
@@ -131,6 +147,11 @@ export class AcceptBudget {
131
147
  return Math.floor(this.#tokens);
132
148
  }
133
149
 
150
+ /** The sustained rate this bucket was built with — what a refusal has to name to be actionable. */
151
+ get perSecond(): number {
152
+ return this.#perSecond;
153
+ }
154
+
134
155
  #refill(): void {
135
156
  const now = this.#clock.monotonic();
136
157
  const elapsed = now - this.#lastRefill;
@@ -7,8 +7,8 @@
7
7
  import type { Clock } from '@ultimat3/core';
8
8
  import type { Transport } from './fanout';
9
9
  import { InProcessTransport } from './fanout';
10
+ import type { NatsConnect } from './nats-client';
10
11
  import { assertBucket } from './nats-jetstream';
11
- import type { NatsStream, NatsTarget } from './nats-socket';
12
12
  import { NatsTransport } from './nats-transport';
13
13
 
14
14
  /** The keys read here, and nothing else. Named once so docs and tests cannot drift from the code. */
@@ -51,7 +51,7 @@ export interface SelectTransportOptions {
51
51
  readonly presenceTtlMs?: number | undefined;
52
52
  readonly clock?: Clock | undefined;
53
53
  /** Injected so a boot — reconnect included — can be proven with no network. */
54
- readonly open?: ((target: NatsTarget) => Promise<NatsStream>) | undefined;
54
+ readonly connect?: NatsConnect | undefined;
55
55
  }
56
56
 
57
57
  const nonEmpty = (value: string | undefined): string | undefined =>
@@ -91,7 +91,7 @@ export function selectTransport(
91
91
  bucket,
92
92
  presenceTtlMs,
93
93
  ...(options.clock === undefined ? {} : { clock: options.clock }),
94
- ...(options.open === undefined ? {} : { open: options.open }),
94
+ ...(options.connect === undefined ? {} : { connect: options.connect }),
95
95
  });
96
96
  return {
97
97
  transport,
@@ -0,0 +1,72 @@
1
+ // Compile-time pins for the typed query hook. Source, not a `.test.ts`, on purpose:
2
+ // `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a
3
+ // type-level assertion written there can never fail. This module emits nothing and exports
4
+ // nothing anybody imports — a regression is a build error, the only enforcement that counts
5
+ // (axiom 3). What it protects is the whole reason `liveHookFor` exists: `useLiveFeed({ orgId })`
6
+ // carrying the query's own input and row types. Lose that and the hook still runs — it just
7
+ // stops catching the typo that makes a subscription match nothing.
8
+
9
+ import type { Query } from '@ultimat3/query';
10
+ import type { LiveHandle, Unsubscribe } from './client';
11
+ import type { LiveRows } from './hooks';
12
+ import type { LiveQueryHook, LiveQuerySource } from './query-hook';
13
+
14
+ /** Fails to compile when `T` is anything but `true`. The whole mechanism. */
15
+ type Assert<T extends true> = T;
16
+
17
+ type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
18
+
19
+ /** The input type a `Query` accepts, read off its call signature rather than its schema. */
20
+ type InputOf<Q> = Q extends (input: infer I, options?: never) => unknown ? I : never;
21
+
22
+ interface FeedInput {
23
+ readonly orgId: string;
24
+ }
25
+
26
+ interface FeedRow {
27
+ readonly id: string;
28
+ readonly title: string;
29
+ }
30
+
31
+ type FeedHook = LiveQueryHook<FeedInput, FeedRow>;
32
+
33
+ /** The hook takes the query's own input, as a value or as the thunk `useLive` reads once. */
34
+ export type _HookInputIsTheQueryInput = Assert<
35
+ Equals<Parameters<FeedHook>[0], FeedInput | (() => FeedInput)>
36
+ >;
37
+
38
+ /** …and answers in the query's own row type, not the wire's `Row`. */
39
+ export type _HookRowsAreTheQueryRows = Assert<
40
+ Equals<ReturnType<ReturnType<FeedHook>>, readonly FeedRow[]>
41
+ >;
42
+
43
+ /**
44
+ * A wrong key is refused. Written as a negative because that is the failure being pinned: a hook
45
+ * whose input widened to `JsonValue` would still compile everywhere and silently accept `orgIdd`.
46
+ */
47
+ export type _WrongInputKeyIsRefused = Assert<
48
+ [{ readonly orgIdd: string }] extends [Parameters<FeedHook>[0]] ? false : true
49
+ >;
50
+
51
+ /**
52
+ * The seam itself: a declared `@ultimat3/query` `Query` assigns to the structural shape
53
+ * `liveHookFor` binds. Named structurally rather than imported as a value, so this stays the one
54
+ * place a change to `Query` — losing `isLive`, ceasing to be callable — fails, instead of every
55
+ * component call site in every app.
56
+ */
57
+ export type _DeclaredQueryBindsToTheHook = Assert<
58
+ [Query] extends [LiveQuerySource<InputOf<Query>, Record<string, unknown>>] ? true : false
59
+ >;
60
+
61
+ /**
62
+ * The handle `LiveClient.useLive()` returns must stay `Disposable`, or `using sub =
63
+ * client.useLive(...)` silently degrades to "never unsubscribes" the moment someone drops the
64
+ * `[Symbol.dispose]` member while refactoring `unsubscribe`.
65
+ */
66
+ export type _LiveHandleIsDisposable = Assert<[LiveHandle] extends [Disposable] ? true : false>;
67
+
68
+ /** Same pin, one layer up: the hook's callable result set must stay `Disposable` too. */
69
+ export type _LiveRowsIsDisposable = Assert<[LiveRows] extends [Disposable] ? true : false>;
70
+
71
+ /** `channel.subscribe()`'s return must stay both callable and `Disposable`. */
72
+ export type _UnsubscribeIsDisposable = Assert<[Unsubscribe] extends [Disposable] ? true : false>;
@@ -0,0 +1,21 @@
1
+ // One serial lane per live query id, over that query's shared window.
2
+ //
3
+ // The window is a read-modify-write across awaits — match, apply, append, then one policy pass per
4
+ // subscriber — and nothing upstream orders the callers: `sync` fires `void registry.deliver(change)`
5
+ // straight off the bus. Two of those interleaving is one subscriber shown lsn 2 before lsn 1, its
6
+ // cursor then rewound to 1, and a gate deciding about a row against a window that has moved past it.
7
+
8
+ /** FIFO, one task at a time. A task that rejects hands its rejection to its own caller and no one else. */
9
+ export class WindowLock {
10
+ #tail: Promise<unknown> = Promise.resolve();
11
+
12
+ run<T>(work: () => Promise<T>): Promise<T> {
13
+ const result = this.#tail.then(work);
14
+ // The lane chains on a settled shadow, never on `result`: one delivery that threw must not
15
+ // reject every delivery queued behind it, and an unwatched shadow must not look unhandled.
16
+ this.#tail = result.then(ignore, ignore);
17
+ return result;
18
+ }
19
+ }
20
+
21
+ const ignore = (): void => undefined;
@@ -1,97 +0,0 @@
1
- // Single responsibility: the client half of the NATS protocol — the commands a client writes.
2
- // Split from the parser on purpose: encoding is pure string building with no state at all, while
3
- // decoding has to carry a buffer across chunk boundaries, and mixing the two hides both.
4
-
5
- import { TransportProtocolError } from './errors';
6
- import { concatBytes, type NatsHeaders } from './nats-protocol';
7
-
8
- const encoder = new TextEncoder();
9
- const CRLF = '\r\n';
10
-
11
- export interface NatsConnectOptions {
12
- readonly verbose?: boolean; // default false
13
- readonly pedantic?: boolean; // default false
14
- readonly name?: string; // client name, default 'ultimate'
15
- readonly user?: string | undefined;
16
- readonly pass?: string | undefined;
17
- readonly authToken?: string | undefined;
18
- readonly tlsRequired?: boolean; // default false
19
- }
20
- const CLIENT_VERSION = '0.0.1';
21
-
22
- /** `CONNECT {json}\r\n` — the first frame a client sends, before subscribing or publishing. */
23
- export function connectMessage(options: NatsConnectOptions = {}): Uint8Array {
24
- const payload: Record<string, unknown> = {
25
- verbose: options.verbose ?? false,
26
- pedantic: options.pedantic ?? false,
27
- tls_required: options.tlsRequired ?? false,
28
- name: options.name ?? 'ultimate',
29
- lang: 'bun',
30
- version: CLIENT_VERSION,
31
- protocol: 1,
32
- headers: true,
33
- no_responders: true,
34
- };
35
- if (options.user !== undefined && options.pass !== undefined) {
36
- payload['user'] = options.user;
37
- payload['pass'] = options.pass;
38
- }
39
- if (options.authToken !== undefined) payload['auth_token'] = options.authToken;
40
- return encoder.encode(`CONNECT ${JSON.stringify(payload)}${CRLF}`);
41
- }
42
-
43
- /**
44
- * A header line ends at the first CRLF, so a break inside a key or value closes the line early and
45
- * everything after it is read as a fresh command. A security boundary, not a style rule.
46
- */
47
- const HEADER_BREAK = /[\r\n]/;
48
-
49
- const encodeHeaderBlock = (headers: NatsHeaders): Uint8Array => {
50
- let block = `NATS/1.0${CRLF}`;
51
- for (const [key, value] of headers) {
52
- if (HEADER_BREAK.test(key) || HEADER_BREAK.test(value)) {
53
- throw new TransportProtocolError({
54
- transport: 'nats',
55
- stage: 'headers',
56
- // Quoted through JSON so the break that caused this cannot break the message reporting it.
57
- detail: `header ${JSON.stringify(key)} carries a CR or LF, which would inject a command`,
58
- fix: "strip the breaks first: headers.set(name, value.replace(/[\\r\\n]+/g, ' '))",
59
- });
60
- }
61
- block += `${key}: ${value}${CRLF}`;
62
- }
63
- return encoder.encode(`${block}${CRLF}`);
64
- };
65
-
66
- /** `PUB` when there are no headers, `HPUB` when there are — never both shapes for one call. */
67
- export function pubMessage(args: {
68
- readonly subject: string;
69
- readonly payload?: Uint8Array | undefined;
70
- readonly replyTo?: string | undefined;
71
- readonly headers?: NatsHeaders | undefined;
72
- }): Uint8Array {
73
- const payload = args.payload ?? new Uint8Array(0);
74
- const replyPart = args.replyTo !== undefined ? ` ${args.replyTo}` : '';
75
- const crlfBytes = encoder.encode(CRLF);
76
- if (args.headers === undefined || args.headers.size === 0) {
77
- const control = `PUB ${args.subject}${replyPart} ${payload.length}${CRLF}`;
78
- return concatBytes(encoder.encode(control), payload, crlfBytes);
79
- }
80
- const headerBlock = encodeHeaderBlock(args.headers);
81
- const total = headerBlock.length + payload.length;
82
- const control = `HPUB ${args.subject}${replyPart} ${headerBlock.length} ${total}${CRLF}`;
83
- return concatBytes(encoder.encode(control), headerBlock, payload, crlfBytes);
84
- }
85
-
86
- export function subMessage(subject: string, sid: string, queue?: string): Uint8Array {
87
- const queuePart = queue !== undefined ? ` ${queue}` : '';
88
- return encoder.encode(`SUB ${subject}${queuePart} ${sid}${CRLF}`);
89
- }
90
-
91
- export function unsubMessage(sid: string, max?: number): Uint8Array {
92
- const maxPart = max !== undefined ? ` ${max}` : '';
93
- return encoder.encode(`UNSUB ${sid}${maxPart}${CRLF}`);
94
- }
95
-
96
- export const PING_MESSAGE: Uint8Array = encoder.encode(`PING${CRLF}`);
97
- export const PONG_MESSAGE: Uint8Array = encoder.encode(`PONG${CRLF}`);