@ultimat3/realtime 20.2.1 → 21.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 (89) hide show
  1. package/CLAUDE.md +186 -122
  2. package/README.md +121 -126
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +7 -0
  8. package/src/channel-authz.ts +33 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +144 -0
  11. package/src/channel-describe.ts +33 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +116 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +79 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +289 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +138 -195
  26. package/src/cursor.ts +2 -2
  27. package/src/errors.ts +34 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +113 -0
  30. package/src/idb-types.ts +41 -0
  31. package/src/index.ts +80 -74
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +10 -3
  35. package/src/live-fanout.ts +30 -4
  36. package/src/live-record-type.ts +19 -0
  37. package/src/live-rows.ts +70 -67
  38. package/src/local-store-idb.ts +250 -0
  39. package/src/offline-queue.ts +9 -18
  40. package/src/outbox-slot.ts +31 -0
  41. package/src/page-errors.ts +124 -0
  42. package/src/page-outbox.ts +242 -0
  43. package/src/page-socket.ts +108 -0
  44. package/src/page-store.ts +138 -0
  45. package/src/pg-replication.ts +9 -2
  46. package/src/pgoutput.ts +37 -2
  47. package/src/presence.ts +17 -9
  48. package/src/query-window.ts +3 -0
  49. package/src/reactivity.ts +70 -0
  50. package/src/realtime-error.ts +1 -1
  51. package/src/record-await.ts +102 -0
  52. package/src/record-key.ts +34 -0
  53. package/src/record-names.ts +45 -0
  54. package/src/record-persister.ts +156 -0
  55. package/src/record-store.ts +364 -0
  56. package/src/record-synced.ts +100 -0
  57. package/src/record-tx.ts +145 -0
  58. package/src/replicator.ts +7 -1
  59. package/src/server.ts +2 -8
  60. package/src/socket-engine.ts +332 -0
  61. package/src/socket-host.ts +126 -0
  62. package/src/socket-port.ts +55 -0
  63. package/src/socket-routes.ts +170 -0
  64. package/src/socket.ts +51 -12
  65. package/src/sync-auth.ts +2 -2
  66. package/src/sync-frames.ts +41 -114
  67. package/src/sync-meta.ts +42 -0
  68. package/src/sync-node-contract.ts +100 -0
  69. package/src/sync-node.ts +24 -107
  70. package/src/sync-protocol.ts +63 -212
  71. package/src/sync-worker.ts +12 -0
  72. package/src/thundering-herd.ts +19 -1
  73. package/src/type-pins.ts +30 -61
  74. package/src/use-channel.ts +88 -0
  75. package/src/use-connection.ts +59 -0
  76. package/src/use-mutation.ts +214 -0
  77. package/src/use-query.ts +255 -0
  78. package/src/use-record.ts +121 -0
  79. package/src/wire-channel.ts +116 -0
  80. package/src/wire-read.ts +86 -0
  81. package/src/wire-version.ts +44 -0
  82. package/src/client-mutations.ts +0 -114
  83. package/src/client-topics.ts +0 -54
  84. package/src/hooks.ts +0 -277
  85. package/src/identity-map.ts +0 -141
  86. package/src/local-store.ts +0 -241
  87. package/src/query-hook.ts +0 -56
  88. package/src/rebase.ts +0 -263
  89. package/src/server-render-client.ts +0 -96
@@ -1,14 +1,15 @@
1
- // The wire. One protocol for all three tiers: a channel subscribe, a live-query subscribe, and an
2
- // offline mutation drain are frames in the same union — so moving a route from tier 2 to tier 3
3
- // needs no new protocol, and THAT is the half this file enforces.
4
- // It is not a `persist: true` config flag: this header said so until 2026-09 and `query()` has
5
- // never accepted the key (`local-store.ts` records the same). Tier 3 is reached by passing a
6
- // `LocalStore` to the live client, which is a client-side wiring decision this file cannot see —
7
- // claiming to enforce it here is the declared-and-never-wired shape the repo keeps re-shipping.
8
-
9
- import { renderThrowable, stringField } from '@ultimat3/core';
10
- import { CURSOR_ID_LIMIT, type LiveCursor } from './cursor';
11
- import { ProtocolVersionError } from './errors';
1
+ // The wire, READ-ONLY for the client: channel and live-query subscriptions go up, snapshots,
2
+ // patches, presence and refusals come down. A client write is HTTP (`useMutation`), never a frame —
3
+ // so there is one write path, with the action's authz, idempotency store and contract behind it.
4
+
5
+ import { renderThrowable, stringField } from '@ultimat3/core/page';
6
+ import type {
7
+ ChannelEventsFrame,
8
+ ChannelRecordsFrame,
9
+ ChannelSubscribeTarget,
10
+ ReplayGapFrame,
11
+ } from './channel-wire';
12
+ import type { LiveCursor } from './cursor';
12
13
  import {
13
14
  isJsonObject,
14
15
  isRow,
@@ -17,41 +18,19 @@ import {
17
18
  type Row,
18
19
  type RowPatch,
19
20
  } from './json';
20
-
21
- /**
22
- * **2 since 2026-08-24**, when `cursor.digest` and `cursor.count` were deleted. The version guards
23
- * incompatibility, never novelty — an additive optional field (`snapshot.entity`) and a removed
24
- * field read through `list()` (`hello.resume`) both stayed at 1, because `decode` is a whitelist
25
- * and `list()` answers `[]` for an absent field. `cursor()` is the other kind of reader: it reads
26
- * through `str`/`num`, which THROW on an absent field, so a cursor without those two is a frame a
27
- * node or a client one deploy behind cannot read — in BOTH directions, since a cursor rides the
28
- * client's `subscribe` and the node's `snapshot`. That is exactly what this number refuses, with
29
- * one instruction instead of a per-frame "field \"digest\" must be a string".
30
- */
31
- export const PROTOCOL_VERSION = 2;
32
-
33
- /**
34
- * What one frame may contain. Hard ceilings a caller cannot widen — the shape
35
- * `packages/mcp/src/query-limits.ts` uses — because every one of them is read off a socket the
36
- * node has already paid for: an unbounded `cursor.ids` was consumed raw into a `Set`, and an
37
- * `input` of arbitrary depth reached `canonicalJson`, which recurses.
38
- *
39
- * Every number clears what this node itself produces, or the decoder refuses its own frames on
40
- * the next reconnect: `cursorIds` is `CURSOR_ID_LIMIT`, `patches` clears
41
- * `defaultReconnectBudget.maxPatches`.
42
- */
43
- export const FRAME_LIMITS = Object.freeze({
44
- cursorIds: CURSOR_ID_LIMIT,
45
- patches: 4_096,
46
- rows: 10_000,
47
- members: 4_096,
48
- /** Nesting one `input` may reach. 32 is far past any query's real argument shape. */
49
- inputDepth: 32,
50
- /** Values one `input` may hold in total, so a flat-but-enormous object is refused too. */
51
- inputNodes: 10_000,
52
- });
53
-
54
- export type ConflictStrategyName = 'server-wins' | 'last-write-wins' | 'custom';
21
+ import { ProtocolVersionError } from './page-errors';
22
+ import { channelTarget, events, records, replayGap } from './wire-channel';
23
+ import { bounded, fail, list, nullableStr, num, object, pick, str } from './wire-read';
24
+ import { FRAME_LIMITS, PROTOCOL_VERSION } from './wire-version';
25
+
26
+ export type {
27
+ ChannelEventsFrame,
28
+ ChannelRecordsFrame,
29
+ ChannelSubscribeTarget,
30
+ ReplayGapFrame,
31
+ } from './channel-wire';
32
+ /** The version and the ceilings live below this file so the readers can share them without a cycle. */
33
+ export { FRAME_LIMITS, PROTOCOL_VERSION } from './wire-version';
55
34
 
56
35
  export interface WireError {
57
36
  readonly code: string;
@@ -69,7 +48,8 @@ export interface PresenceMember {
69
48
  }
70
49
 
71
50
  export type SubscribeTarget =
72
- | { readonly kind: 'topic'; readonly topic: string }
51
+ /** A declared `channel()`: its NAME and params, never a topic string the client spelled. */
52
+ | ChannelSubscribeTarget
73
53
  | {
74
54
  /**
75
55
  * Client -> server, `qid` carries the *query name*; the server derives the real qid from
@@ -124,6 +104,12 @@ export interface SnapshotFrame {
124
104
  * skews are safe in both directions, which is why it carries no `PROTOCOL_VERSION` bump.
125
105
  */
126
106
  readonly entity?: string;
107
+ /**
108
+ * Each row's RECORD key, parallel to `rows`, sent only when some key is not its row's `id` (a
109
+ * composite primary key). The server renders it with the entity's projection; the browser never
110
+ * derives a key.
111
+ */
112
+ readonly keys?: readonly string[];
127
113
  }
128
114
 
129
115
  export interface PatchFrame {
@@ -134,51 +120,18 @@ export interface PatchFrame {
134
120
  readonly lsn: string;
135
121
  }
136
122
 
137
- export interface MutateFrame {
138
- readonly type: 'mutate';
139
- readonly v: number;
140
- /** Idempotency key. The server collapses repeats; the client never renumbers. */
141
- readonly key: string;
142
- readonly seq: number;
143
- readonly name: string;
144
- readonly input: JsonValue;
145
- }
146
-
147
123
  export interface AckFrame {
148
124
  readonly type: 'ack';
149
125
  readonly v: number;
150
- /** Mutation key or subscription id being acknowledged. */
126
+ /**
127
+ * What a refusal refers to: the sid of a subscription the node refused, or the socket id for a
128
+ * frame it could not read at all. The socket carries no writes, so an ack is never a receipt.
129
+ */
151
130
  readonly ref: string;
152
131
  readonly lsn: string | null;
153
132
  readonly error: WireError | null;
154
133
  }
155
134
 
156
- export interface RebaseFrame {
157
- readonly type: 'rebase';
158
- readonly v: number;
159
- readonly key: string;
160
- readonly entity: string;
161
- readonly strategy: ConflictStrategyName;
162
- /** Server truth for the row the mutation touched; `null` when the server deleted it. */
163
- readonly row: Row | null;
164
- }
165
-
166
- export interface PresenceFrame {
167
- readonly type: 'presence';
168
- readonly v: number;
169
- readonly topic: string;
170
- readonly op: 'join' | 'leave' | 'update' | 'sync';
171
- readonly members: readonly PresenceMember[];
172
- /**
173
- * Members in the whole set behind a `sync` frame, which is capped: a 5,000-avatar row is not a UI
174
- * anyone renders, and the count is what lets a client say "and 4,744 others" without holding
175
- * them. Optional and **additive**, exactly like `snapshot.entity`: an old node omits it and a new
176
- * one reads its absence as "this frame is the whole set", so neither skew is unreadable and
177
- * `PROTOCOL_VERSION` does not move. Never set on a `join`/`leave`/`update` — those are deltas.
178
- */
179
- readonly total?: number;
180
- }
181
-
182
135
  export interface ReconnectFrame {
183
136
  readonly type: 'reconnect';
184
137
  readonly v: number;
@@ -198,12 +151,12 @@ export type Frame =
198
151
  | SubscribeFrame
199
152
  | SnapshotFrame
200
153
  | PatchFrame
201
- | MutateFrame
202
154
  | AckFrame
203
- | RebaseFrame
204
- | PresenceFrame
205
155
  | ReconnectFrame
206
- | UpdateAvailableFrame;
156
+ | UpdateAvailableFrame
157
+ | ChannelRecordsFrame
158
+ | ChannelEventsFrame
159
+ | ReplayGapFrame;
207
160
 
208
161
  export type FrameKind = Frame['type'];
209
162
 
@@ -212,12 +165,12 @@ export const FRAME_KINDS: readonly FrameKind[] = [
212
165
  'subscribe',
213
166
  'snapshot',
214
167
  'patch',
215
- 'mutate',
216
168
  'ack',
217
- 'rebase',
218
- 'presence',
219
169
  'reconnect',
220
170
  'update-available',
171
+ 'records',
172
+ 'events',
173
+ 'replay-gap',
221
174
  ];
222
175
 
223
176
  export function encode(frame: Frame): string {
@@ -265,7 +218,14 @@ export function decode(raw: string | Uint8Array): Frame {
265
218
  cursor: cursor(parsed['cursor']),
266
219
  } as const;
267
220
  const entity = nullableStr(parsed, 'entity');
268
- return entity === null ? base : { ...base, entity };
221
+ const scoped = entity === null ? base : { ...base, entity };
222
+ if (parsed['keys'] === undefined) return scoped;
223
+ const keys = list(parsed, 'keys', FRAME_LIMITS.rows).map((key) => {
224
+ if (typeof key !== 'string') throw fail('snapshot.keys must hold strings');
225
+ return key;
226
+ });
227
+ if (keys.length !== base.rows.length) throw fail('snapshot.keys must pair with rows');
228
+ return { ...scoped, keys };
269
229
  }
270
230
  case 'patch':
271
231
  return {
@@ -275,15 +235,6 @@ export function decode(raw: string | Uint8Array): Frame {
275
235
  patches: list(parsed, 'patches', FRAME_LIMITS.patches).map(patch),
276
236
  lsn: str(parsed, 'lsn'),
277
237
  };
278
- case 'mutate':
279
- return {
280
- type: 'mutate',
281
- v: PROTOCOL_VERSION,
282
- key: str(parsed, 'key'),
283
- seq: num(parsed, 'seq'),
284
- name: str(parsed, 'name'),
285
- input: bounded(parsed['input'] ?? null, 'input'),
286
- };
287
238
  case 'ack':
288
239
  return {
289
240
  type: 'ack',
@@ -292,25 +243,6 @@ export function decode(raw: string | Uint8Array): Frame {
292
243
  lsn: nullableStr(parsed, 'lsn'),
293
244
  error: wireError(parsed['error']),
294
245
  };
295
- case 'rebase':
296
- return {
297
- type: 'rebase',
298
- v: PROTOCOL_VERSION,
299
- key: str(parsed, 'key'),
300
- entity: str(parsed, 'entity'),
301
- strategy: pick(parsed, 'strategy', ['server-wins', 'last-write-wins', 'custom'] as const),
302
- row: parsed['row'] === null ? null : row(parsed['row']),
303
- };
304
- case 'presence': {
305
- const base = {
306
- type: 'presence',
307
- v: PROTOCOL_VERSION,
308
- topic: str(parsed, 'topic'),
309
- op: pick(parsed, 'op', ['join', 'leave', 'update', 'sync'] as const),
310
- members: list(parsed, 'members', FRAME_LIMITS.members).map(member),
311
- } as const;
312
- return parsed['total'] === undefined ? base : { ...base, total: num(parsed, 'total') };
313
- }
314
246
  case 'reconnect':
315
247
  return {
316
248
  type: 'reconnect',
@@ -320,6 +252,12 @@ export function decode(raw: string | Uint8Array): Frame {
320
252
  };
321
253
  case 'update-available':
322
254
  return { type: 'update-available', v: PROTOCOL_VERSION, buildId: str(parsed, 'buildId') };
255
+ case 'records':
256
+ return records(parsed);
257
+ case 'events':
258
+ return events(parsed);
259
+ case 'replay-gap':
260
+ return replayGap(parsed);
323
261
  default:
324
262
  throw fail(`unknown frame type ${JSON.stringify(kind)}`);
325
263
  }
@@ -340,80 +278,6 @@ export function toWireError(error: unknown): WireError {
340
278
  return docs === undefined ? { code, cause, fix } : { code, cause, fix, docs };
341
279
  }
342
280
 
343
- function fail(detail: string): ProtocolVersionError {
344
- return new ProtocolVersionError({ got: detail, expected: PROTOCOL_VERSION, detail });
345
- }
346
-
347
- function str(obj: JsonObject, key: string): string {
348
- const value = obj[key];
349
- if (typeof value !== 'string') throw fail(`field "${key}" must be a string`);
350
- return value;
351
- }
352
-
353
- function nullableStr(obj: JsonObject, key: string): string | null {
354
- const value = obj[key];
355
- if (value === null || value === undefined) return null;
356
- if (typeof value !== 'string') throw fail(`field "${key}" must be a string or null`);
357
- return value;
358
- }
359
-
360
- function num(obj: JsonObject, key: string): number {
361
- const value = obj[key];
362
- if (typeof value !== 'number' || !Number.isFinite(value)) {
363
- throw fail(`field "${key}" must be a finite number`);
364
- }
365
- return value;
366
- }
367
-
368
- function pick<T extends string>(obj: JsonObject, key: string, allowed: readonly T[]): T {
369
- const value = str(obj, key);
370
- const found = allowed.find((candidate) => candidate === value);
371
- if (found === undefined) throw fail(`field "${key}" must be one of ${allowed.join('|')}`);
372
- return found;
373
- }
374
-
375
- /**
376
- * An array field, with the ceiling the caller had to choose. `max` is required rather than
377
- * defaulted: a new list field on a new frame is a new thing an authenticated socket can make
378
- * arbitrarily large, and a default would let one ship without anyone deciding its size.
379
- */
380
- function list(obj: JsonObject, key: string, max: number, label = key): JsonValue[] {
381
- const value = obj[key];
382
- if (value === undefined || value === null) return [];
383
- if (!Array.isArray(value)) throw fail(`field "${label}" must be an array`);
384
- if (value.length > max) {
385
- throw fail(`field "${label}" carries ${value.length} entries, over the limit of ${max}`);
386
- }
387
- return value;
388
- }
389
-
390
- /**
391
- * A client-supplied value, walked ITERATIVELY to its limits. Iteratively because the thing being
392
- * refused is a stack overflow: `queryHash` -> `canonicalJson` recurses over exactly this value, so a
393
- * depth check that recursed would be the same crash one frame earlier.
394
- */
395
- function bounded(value: JsonValue, label: string): JsonValue {
396
- const stack: { node: JsonValue; depth: number }[] = [{ node: value, depth: 1 }];
397
- let seen = 0;
398
- while (stack.length > 0) {
399
- // `pop` cannot answer undefined here — the loop guard is the length — and the check is what
400
- // makes that readable to the compiler without a cast.
401
- const next = stack.pop();
402
- if (next === undefined) break;
403
- seen += 1;
404
- if (seen > FRAME_LIMITS.inputNodes) {
405
- throw fail(`field "${label}" holds more than ${FRAME_LIMITS.inputNodes} values`);
406
- }
407
- if (next.depth > FRAME_LIMITS.inputDepth) {
408
- throw fail(`field "${label}" is nested deeper than ${FRAME_LIMITS.inputDepth}`);
409
- }
410
- if (next.node === null || typeof next.node !== 'object') continue;
411
- const children = Array.isArray(next.node) ? next.node : Object.values(next.node);
412
- for (const child of children) stack.push({ node: child, depth: next.depth + 1 });
413
- }
414
- return value;
415
- }
416
-
417
281
  function row(value: unknown): Row {
418
282
  if (!isRow(value)) throw fail('row must be an object with a string "id"');
419
283
  return value;
@@ -440,23 +304,15 @@ function patch(value: unknown): RowPatch {
440
304
  row: value['row'] === null || value['row'] === undefined ? null : object(value['row']),
441
305
  lsn: str(value, 'lsn'),
442
306
  };
443
- return value['index'] === undefined ? base : { ...base, index: num(value, 'index') };
444
- }
445
-
446
- function member(value: unknown): PresenceMember {
447
- if (!isJsonObject(value)) throw fail('presence member must be an object');
448
- return {
449
- id: str(value, 'id'),
450
- actorId: nullableStr(value, 'actorId'),
451
- meta: object(value['meta'] ?? {}),
452
- updatedAt: num(value, 'updatedAt'),
453
- };
307
+ const indexed = value['index'] === undefined ? base : { ...base, index: num(value, 'index') };
308
+ const key = nullableStr(value, 'key');
309
+ return key === null ? indexed : { ...indexed, key };
454
310
  }
455
311
 
456
312
  function target(value: unknown): SubscribeTarget {
457
313
  if (!isJsonObject(value)) throw fail('subscribe.target must be an object');
458
- const kind = pick(value, 'kind', ['topic', 'query'] as const);
459
- if (kind === 'topic') return { kind, topic: str(value, 'topic') };
314
+ const kind = pick(value, 'kind', ['query', 'channel'] as const);
315
+ if (kind === 'channel') return channelTarget(value);
460
316
  return {
461
317
  kind,
462
318
  qid: str(value, 'qid'),
@@ -466,11 +322,6 @@ function target(value: unknown): SubscribeTarget {
466
322
  };
467
323
  }
468
324
 
469
- function object(value: unknown): JsonObject {
470
- if (!isJsonObject(value)) throw fail('expected a JSON object');
471
- return value;
472
- }
473
-
474
325
  function wireError(value: unknown): WireError | null {
475
326
  if (value === null || value === undefined) return null;
476
327
  if (!isJsonObject(value)) throw fail('ack.error must be an object or null');
@@ -0,0 +1,12 @@
1
+ // The SharedWorker entry (plan 101, slice 11): every tab of this origin and principal connects a
2
+ // port, and the one engine behind them holds the one socket. `x build` bundles this file as a
3
+ // classic script at `/_x/sync-worker/<hash>.js`. No other code lives here.
4
+
5
+ import { browserSocket, dialUrl } from './browser-socket';
6
+ import { messagePort, SocketEngine } from './socket-engine';
7
+
8
+ const engine = new SocketEngine({ dial: (target) => browserSocket(dialUrl(target)) });
9
+
10
+ (globalThis as { onconnect?: (event: MessageEvent) => void }).onconnect = (event) => {
11
+ for (const port of event.ports) engine.attach(messagePort(port));
12
+ };
@@ -13,7 +13,7 @@ import {
13
13
  type JitterMode,
14
14
  type Random,
15
15
  systemClock,
16
- } from '@ultimat3/core';
16
+ } from '@ultimat3/core/page';
17
17
  import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
18
18
 
19
19
  /** Injected so tests are deterministic and `local` mutators stay replayable. */
@@ -40,6 +40,24 @@ export const defaultBackoff: BackoffPolicy = {
40
40
  jitter: 'full',
41
41
  };
42
42
 
43
+ /** The longest a browser waits between two dials, whatever the attempt. */
44
+ export const BROWSER_RECONNECT_MAX_MS = 4_000;
45
+
46
+ /**
47
+ * A browser's redial: the same curve, capped at seconds rather than `defaultBackoff`'s thirty.
48
+ * Measured after a deploy — six dials in ~3s, then a full-jitter roll under a 30s cap left the
49
+ * node that came back (and the `update-available` it had for the tab) unreached for 27s. `equal`
50
+ * jitter, because at the cap it keeps a floor: a SIGKILLed node's herd redials inside a 2-4s
51
+ * window rather than at once, and the `AcceptBudget` sheds the excess before any query runs. A
52
+ * PLANNED restart never reaches this: the drain's `reconnect` frame assigns each socket its slot.
53
+ */
54
+ export const browserBackoff: BackoffPolicy = {
55
+ baseMs: 500,
56
+ maxMs: BROWSER_RECONNECT_MAX_MS,
57
+ factor: 2,
58
+ jitter: 'equal',
59
+ };
60
+
43
61
  /**
44
62
  * Attempt is 0-BASED. Result is always in `[0, maxMs]`.
45
63
  *
package/src/type-pins.ts CHANGED
@@ -1,83 +1,52 @@
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.
1
+ // Compile-time pins for the hook surface. Source, not a `.test.ts`, on purpose: `tsconfig.json`
2
+ // excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a type-level assertion
3
+ // written there can never fail. This module emits nothing anybody imports — a regression is a
4
+ // build error, the only enforcement that counts (axiom 3).
8
5
 
9
- import type { Query } from '@ultimat3/query';
10
- import type { LiveClient, LiveClientLike, LiveHandle, Unsubscribe } from './client';
11
- import type { LiveRows } from './hooks';
12
- import type { LiveQueryHook, LiveQuerySource } from './query-hook';
6
+ import type { AsyncState, Row } from '@ultimat3/core';
7
+ import type { LiveHandle, Unsubscribe } from './client-contract';
8
+ import type { QueryAccessor, QueryRef } from './use-query';
9
+ import type { RecordAccessor } from './use-record';
13
10
 
14
11
  /** Fails to compile when `T` is anything but `true`. The whole mechanism. */
15
12
  type Assert<T extends true> = T;
16
13
 
17
14
  type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
18
15
 
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
16
  interface FeedRow {
27
17
  readonly id: string;
28
18
  readonly title: string;
29
19
  }
30
20
 
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
21
  /**
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`.
22
+ * `useQuery` answers core's `AsyncState` over the caller's own row type — the value `@ultimat3/ui`
23
+ * takes as `state`, with no adapter between them (plan 101, decision 10).
46
24
  */
47
- export type _WrongInputKeyIsRefused = Assert<
48
- [{ readonly orgIdd: string }] extends [Parameters<FeedHook>[0]] ? false : true
25
+ export type _QueryAnswersAsyncState = Assert<
26
+ Equals<ReturnType<QueryAccessor<FeedRow>>, AsyncState<readonly FeedRow[]>>
49
27
  >;
50
28
 
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
29
+ /** …and `useRecord` the same vocabulary, with `undefined` for a record the server removed. */
30
+ export type _RecordAnswersAsyncState = Assert<
31
+ Equals<ReturnType<RecordAccessor<FeedRow>>, AsyncState<FeedRow | undefined>>
59
32
  >;
60
33
 
61
34
  /**
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`.
35
+ * A query ref carries no server field: a `Query` VALUE in an island drags its whole read path into
36
+ * the bundle, so the ref is a name and two declared facts. A `sql` key is refused.
65
37
  */
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>;
38
+ export type _QueryRefHasNoServerHalf = Assert<
39
+ [{ readonly name: string; readonly sql: () => unknown }] extends [Required<QueryRef>]
40
+ ? false
41
+ : true
42
+ >;
73
43
 
74
- /**
75
- * The hook seam takes `LiveClientLike`, not `LiveClient` — a structural shape, so the server
76
- * render's client can satisfy it without dragging the connection lifecycle into every island that
77
- * calls `useLive` (measured: 8,368 B → 26,571 B). This is what keeps the two in step: a member
78
- * `hooks.ts` needs and `LiveClient` stops providing fails HERE, at the build, rather than at the
79
- * one app that registered a real client.
80
- */
81
- export type _LiveClientSatisfiesTheHookSeam = Assert<
82
- [LiveClient] extends [LiveClientLike] ? true : false
44
+ /** Every handle a caller gets back stays `Disposable`, so `using` releases it on scope exit. */
45
+ export type _LiveHandleIsDisposable = Assert<[LiveHandle] extends [Disposable] ? true : false>;
46
+ export type _QueryAccessorIsDisposable = Assert<
47
+ [QueryAccessor] extends [Disposable] ? true : false
83
48
  >;
49
+ export type _RecordAccessorIsDisposable = Assert<
50
+ [RecordAccessor<Row>] extends [Disposable] ? true : false
51
+ >;
52
+ export type _UnsubscribeIsDisposable = Assert<[Unsubscribe] extends [Disposable] ? true : false>;
@@ -0,0 +1,88 @@
1
+ // A declared channel, held by a component. Every holder of one topic on the page shares ONE
2
+ // membership on the page socket; its `records` land in the store (read them with `useRecord` /
3
+ // `useQuery`), and only `events` and presence reach the handlers given here.
4
+
5
+ import type { ChannelHandlers, ChannelRef, ChannelState } from './client-channels';
6
+ import { pageSocket } from './page-socket';
7
+ import { isServerRender, signalFor } from './reactivity';
8
+ import type { PresenceMember } from './sync-protocol';
9
+
10
+ /** The membership's state, callable, plus its release (Solid: `onCleanup`). */
11
+ export type ChannelAccessor = (() => ChannelState) & {
12
+ release(): void;
13
+ [Symbol.dispose](): void;
14
+ };
15
+
16
+ const nothing = (): void => undefined;
17
+
18
+ /**
19
+ * Takes the `channel()` DECLARATION (any `ChannelRef` — a declaration is one), never a topic
20
+ * string: the declaration is the only thing that may spell a topic (`bun run channel-literals`
21
+ * holds it), so both halves spell it one way.
22
+ */
23
+ export function useChannel<K extends string>(
24
+ declared: ChannelRef<K>,
25
+ params: Readonly<Record<K, string>>,
26
+ handlers?: ChannelHandlers,
27
+ ): ChannelAccessor {
28
+ const signal = signalFor('useChannel');
29
+ if (isServerRender()) {
30
+ return Object.assign((): ChannelState => 'joining', {
31
+ release: nothing,
32
+ [Symbol.dispose]: nothing,
33
+ });
34
+ }
35
+ const membership = pageSocket('useChannel').holdChannel(declared, params, handlers);
36
+ const [version, setVersion] = signal(0);
37
+ const off = membership.onChange(() => setVersion(version() + 1));
38
+ const read = (): ChannelState => {
39
+ version();
40
+ return membership.state();
41
+ };
42
+ const release = (): void => {
43
+ off();
44
+ membership.release();
45
+ };
46
+ return Object.assign(read, { release, [Symbol.dispose]: release });
47
+ }
48
+
49
+ /** Who is in a channel's room, as the node's presence set says — one entry per member id. */
50
+ export type PresenceAccessor = (() => readonly PresenceMember[]) & {
51
+ release(): void;
52
+ [Symbol.dispose](): void;
53
+ };
54
+
55
+ /**
56
+ * The roster of a channel declared with `events: true`: a `sync` replaces it, `join` / `update`
57
+ * upsert a member, `leave` removes one. It is a membership of the channel like any other — the
58
+ * same one `useChannel` holds, shared on the page — so holding both costs one subscribe.
59
+ */
60
+ export function usePresence<K extends string>(
61
+ declared: ChannelRef<K>,
62
+ params: Readonly<Record<K, string>>,
63
+ ): PresenceAccessor {
64
+ const signal = signalFor('usePresence');
65
+ if (isServerRender()) {
66
+ return Object.assign((): readonly PresenceMember[] => [], {
67
+ release: nothing,
68
+ [Symbol.dispose]: nothing,
69
+ });
70
+ }
71
+ const [version, setVersion] = signal(0);
72
+ let members = new Map<string, PresenceMember>();
73
+ const membership = pageSocket('usePresence').holdChannel(declared, params, {
74
+ onPresence: (event) => {
75
+ if (event.presence === 'sync') members = new Map();
76
+ for (const member of event.members) {
77
+ if (event.presence === 'leave') members.delete(member.id);
78
+ else members.set(member.id, member);
79
+ }
80
+ setVersion(version() + 1);
81
+ },
82
+ });
83
+ const read = (): readonly PresenceMember[] => {
84
+ version();
85
+ return [...members.values()];
86
+ };
87
+ return Object.assign(read, { release: membership.release, [Symbol.dispose]: membership.release });
88
+ }