@ultimat3/realtime 20.2.0 → 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
package/CLAUDE.md CHANGED
@@ -6,17 +6,17 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
6
6
 
7
7
  | May import | Must not |
8
8
  |---|---|
9
- | `@ultimat3/core`, `@ultimat3/query` | anything tier 4+ (`render`, `pwa`, `mcp`, `ui`, `cli`) |
9
+ | `@ultimat3/core`, `@ultimat3/query`, `@ultimat3/entity` (`/record` only: `recordTypeForTable`, server-side) | anything tier 4+ (`render`, `pwa`, `mcp`, `ui`, `cli`) |
10
10
  | `@ultimat3/policy` **only via** `@ultimat3/query`'s `guard` | a second authz path of any kind |
11
- | — | `solid-js` (the client takes an injected signal factory) |
11
+ | — | `solid-js` (each island bundle installs its own signal factory: `installRealtime`) |
12
12
  | `nats` (the one external dependency, pinned exact) — from `nats-lib-client.ts`, and no other file | `nats` from anywhere else: a second importer is the failure this row exists to prevent. Every other file is written against the port in `nats-client.ts` |
13
13
 
14
14
  ## Rules
15
15
 
16
16
  - **Two entries, and a name lives in exactly ONE of them (2026-08-22, BREAKING).** `.` is the
17
- client half — `hooks`, `client*`, `identity-map`, `live-rows`, `apply-patches`, `offline-queue`,
18
- `rebase`, `local-store`, `sync-protocol`, `json`, `cursor`, `errors`, and the client's half of
19
- `thundering-herd`. `./server` (`src/server.ts`) is everything that touches `nats`, Postgres, the
17
+ client half — `use-*` hooks, `page-*`, `reactivity`, `record-*`, `client*`, `browser-socket`,
18
+ `live-rows`, `apply-patches`, `offline-queue`, `sync-protocol`, `json`, `cursor`, `errors`, and
19
+ the client's half of `thundering-herd`. `./server` (`src/server.ts`) is everything that touches `nats`, Postgres, the
20
20
  sync node, the channel hub, the live-query registry or the fanout. The reason is measured, not
21
21
  aesthetic: `nats` `require()`s `stream/web`, so one barrel carrying `openNatsClient` beside
22
22
  `useLive` failed `bun build --target=browser` with
@@ -51,7 +51,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
51
51
  does not accept `persist` either. `fix-specifier.test.ts` is the build error — every
52
52
  `@ultimat3/realtime/<subpath>` written in shipped source must be a key of `exports`, comments
53
53
  included, because a comment naming a subpath that does not exist is the next fix line's source.
54
- It cannot see WHICH names a fix promises, so the OPFS one is pinned by name beside it.
54
+ The OPFS refusal it was written for is deleted with `local-store.ts` (21.0.0).
55
55
  - **`@ultimat3/realtime/server` needs its own `paths` entry in `tsconfig.base.json`**, beside
56
56
  `@ultimat3/admin/dev`'s. `@ultimat3/*` maps `realtime/server` to `packages/realtime/server/src`,
57
57
  which does not exist, and the root program has no `node_modules/@ultimat3` symlink to fall back
@@ -394,87 +394,81 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
394
394
  the library refuses a malformed subject itself, and a second spelling of that rule here is a second
395
395
  place it can drift. A presence key or member id is user data, so it is base64url-encoded
396
396
  (`encodeToken`) rather than validated — no name is refused for its spelling.
397
- - **One row value per `(entity, id)` per client, and `identity-map.ts` is the only place one lives.**
398
- A live window is an ordered list of ids over that map and a local-store table is membership over
399
- it — neither holds a row of its own, because two components holding two copies of post #7 is the
400
- bug the map exists to make unrepresentable. A `LiveClient` takes the map off its store when tier 3
401
- is configured (`options.store.identity`) and builds one otherwise: a second map here would be that
402
- same duplication, one level up.
403
- - **The scope is `(entity, id)`, never `id` alone, and the entity comes from the server.** The
404
- compiled shape's root entity (`live.shape.entity`) is the one name the live path, `ChangeEvent`,
405
- a mutator's `tx.<table>` and `rebase`'s `ack.entity` all already agree on; a browser cannot derive
406
- it, because the shape is compiled out of `sql`. It rides on the `snapshot` frame, and a
407
- subscription that is told no entity keeps its rows under `?query:<name>` — private, colliding with
408
- nothing. Wrong sharing merges two entities into one row; no sharing only costs a stale view.
409
- - **`snapshot.entity` is additive, and that is why `PROTOCOL_VERSION` did NOT move.** The bump rule
410
- exists for a shape change that makes an old frame unreadable. This one is readable both ways — an
411
- old node omits the field and the client falls back to the private scope, an old client drops it in
412
- `decode` — so bumping would refuse every in-flight client during a rolling deploy in exchange for
413
- nothing. An *incompatible* frame change still bumps, and every kind still needs a fixture.
414
- - **A value is replaced, never mutated, and a write merges columns rather than replacing the row.**
415
- A mutated row is a render that never happens — the projections hand rows to a signal, which
416
- compares by reference. And two queries may project different columns of one row, so a snapshot
417
- from the narrower one must not blank what the wider one is rendering. Only a `delete` removes.
418
- - **A row lives exactly as long as something holds it.** Every projection retains its ids and
419
- releases them when it lets go (`RowWindows` on a re-snapshot, a patch, a close; a table on delete
420
- and rollback). The last release drops the value — without it an infinite scroll retains every row
421
- it ever saw. It is what lets a rollback of an optimistic insert leave a row a live window still
422
- holds: the table's membership goes, the row does not.
423
- - `local(tx, input)` is pure: no I/O, no `Date.now()`, no `Math.random()`. Rebase replays it.
424
- - One registered `LiveClient` per app (`setLiveClient`), and every hook reads it through that seam —
425
- no hook takes a client argument, and an unregistered one is `X_LIVE_CLIENT_MISSING`, never a
426
- lazily-constructed default.
427
- - **A DOM is the whole of the question, and it decides what "no client" MEANS** (2026-08-23, issue
428
- #271). Deliberately the same rule, the same probe and the same words as `@ultimat3/ui`'s
429
- `solid()`: with a DOM, a hook that finds no registration is a real bug — the app entry forgot
430
- `setLiveClient` and every live query on the page is dead — so it stays `X_LIVE_CLIENT_MISSING`.
431
- Without one there is no socket a client could have been registered *for*; that is a **server
432
- render**, and it gets `serverRenderLiveClient()`. Before it, a page whose whole body read a live
433
- query could not server-render at all: `useConnection()` threw and the route answered 500, and the
434
- existing `hasLiveClient()` guard could not help — it only serves a component that already has a
435
- static fallback written. `hasLiveClient()` still answers **false** on the server, on purpose,
436
- because that is exactly what such a component is asking.
437
- - **The server client serves the first render and opens no socket, so it holds nothing per
438
- request.** One instance per process, and that is only safe because `useLive` on it registers
439
- nothing: a client that kept a registration per call would grow by one entry per request forever
440
- and pin a row window with each. `state()` is **`loading`**, never `offline` and never `live` — the
441
- rows arrive over a socket this render does not have, so the page's own loading fallback is what
442
- the document carries. `offline` would be read as a settled answer (`state() !== 'loading'` is the
443
- gate a page writes), so an empty result set would render "you have no posts" for a feed that has
444
- some. `connected` is `true` for the mirror-image reason: `useConnection().offline` is a banner
445
- about this visitor's connectivity, and the request being served is the proof it is up. Everything
446
- that can only mean "talk to the socket" — `mutate`, `drain` — refuses with
447
- `X_LIVE_SERVER_RENDER`, because a dropped mutation looks exactly like one that happened.
448
- - **The hook seam takes `LiveClientLike`, not the `LiveClient` class, and that is a measurement.**
449
- A value import of the class from `hooks.ts` put the whole connection lifecycle — heartbeat, topic
450
- book, mutation sender, wire protocol, backoff — into every island that calls `useLive`: a
451
- `useLive`-only browser chunk went **8,368 B → 26,571 B**. Against the structural shape it is
452
- 9,356 B, and the ~1 kB is the server client and its refusal. `type-pins.ts`
453
- (`_LiveClientSatisfiesTheHookSeam`) is what keeps the two in step.
454
- - **A server render that renders is not a live page.** A page component never runs in a browser —
455
- only an `island()` module does — so `useLive` in a page body server-renders its loading branch and
456
- nothing replaces it unless that route ships an island that registers a client. The server client
457
- removes the 500; it does not make a page live, and it must never be described as if it did.
458
- `examples/dummy`'s `/feed` is exactly that state and its own header says so.
397
+ - **One record store per PAGE, and `record-store.ts` is the only place a record lives (21.0.0).**
398
+ Keyed `type:key` — the entity's NAME and the key the SERVER computed with the entity's own
399
+ projection (`live-record-type.ts`, via `recordProjectionForTable`): an envelope arrives keyed, a
400
+ live snapshot carries `keys` and a patch `key` whenever the record key is not the row's `id`, and a
401
+ non-live `useQuery` window is the key order of the answer's `records[type]`. The browser never
402
+ derives a key; an answer with no envelope holds its own rows, not records. Two layers: SYNCED is server truth, the OVERLAY is every pending optimistic write,
403
+ REPLAYED over synced truth whenever it moves — never a before-image restored over newer truth,
404
+ which is what the old journal did. A live window, a `useQuery` list and a `useRecord` are all
405
+ projections over it; none holds a row of its own. It is core's `RecordSink`, installed on
406
+ `pageClient().store`, so an HTTP answer adopts into it without this package in the call.
407
+ - **Page state lives on `globalThis[Symbol.for('ultimate.realtime')]` (`page-store.ts`), never in
408
+ module scope.** Every island is its own bundle with its own copy of this package, so a module
409
+ singleton is one store and one socket PER ISLAND — the bug the plan-101 major ended.
410
+ `page-client.test.ts` builds this package twice with `Bun.build` and proves one store and one
411
+ `new WebSocket` between the copies. Never `instanceof` across copies: the class is whichever
412
+ bundle's copy made it.
413
+ - **The signal factory is per BUNDLE, the store per PAGE.** Each island carries its own solid-js, so
414
+ a signal must come from the bundle whose effects read it: `installRealtime({ signal, sync })`
415
+ (`reactivity.ts`, module scope on purpose) is what the island bootstrap calls. No install and a
416
+ DOM is `X_REALTIME_UNINSTALLED`; no install and no DOM is a server render — every hook answers
417
+ `pending`/online and creates NO page state (on a server that would be one store for every
418
+ request). `useRecord` and `useQuery` return `AsyncState` accessors the caller releases.
419
+ - **One socket per page, built by the first live hook (`page-socket.ts`), and `browser-socket.ts`
420
+ holds the framework's only `new WebSocket`.** A form-only or `useRecord`-only island never
421
+ imports that module, so it ships none of the lifecycle — measured, a `useRecord`-only chunk
422
+ carries no `WebSocket`. `import()` would buy nothing: islands build with `splitting: false`, and
423
+ Bun inlines a dynamic import there. `hasPageSocket()` lives in `page-store.ts` so asking costs no
424
+ socket bytes either. A principal change (`rescope`) clears the store and redials.
425
+ - **The socket is READ-ONLY (protocol 3, 21.0.0).** `mutate`/`rebase` frames and
426
+ `createSyncNode({ onMutate })` are deleted; an old client's `mutate` decodes to
427
+ `X_PROTOCOL_VERSION`. A write is `useMutation`: the twin into the overlay, then `POST
428
+ actionPath(name)` through core's `clientTransport` with an idempotency key; the answer's records
429
+ are adopted INSIDE the transport call, before `settle` drops the overlay, so a convergent twin
430
+ never flickers. A refusal drops the overlay. An `ack` is now only ever a refusal: `ref` is a
431
+ subscription's sid (that window renders `failed`) or the socket (reported through `onError`).
432
+ - **`LiveClient` holds no signal.** It is shared by every bundle on the page, and a signal belongs to
433
+ one bundle — so status is plain reads plus `onStatus`, and a live window is `LiveHandle` reads
434
+ plus `onChange`. Hooks wrap both in the calling bundle's own signal.
435
+ - **An answer that did not carry a row the write touched does not end its overlay.** `useMutation`
436
+ settles against the answer's own records (`onEnvelope`); a touched row it did not carry (an
437
+ action that returns a view, not the entity) keeps its overlay until the server's next row for it
438
+ — a frame, another answer — bounded by `DEFAULT_AWAIT_SERVER_MS` (10 s), after which the synced
439
+ layer stands. Without it, a like showed `+1`, fell back, then rose again on the frame.
440
+ - **A `records` frame names the write that produced it, and the writing page settles on it
441
+ (21.0.0, additive to protocol 3).** The node fans a commit out before it answers, so the write's
442
+ own frame routinely beats its HTTP answer, and merged under the still-pending twin it painted
443
+ the write twice: measured in `examples/dummy`'s `offline-like` e2e, `2,2 → 3,3 → 2,2` on the
444
+ outbox replay. No client-side rule can tell its own echo from somebody else's change without the
445
+ frame saying which write it is, and deferring frames for touched rows was rejected: with no
446
+ version it reorders an older frame over a newer answer. `ChannelRecordsFrame.write` is
447
+ `writeDigest(idempotencyKey)` (`@ultimat3/core`), never the key, because a frame goes to every
448
+ member. `RecordStore.push` digests every overlay key it takes (`record-names.ts`), before the
449
+ request leaves. `client-channels.ts` calls `settleWrite` inside the frame's own batch, so the
450
+ merge and the settle are one notification. A digest this page does not hold changes nothing.
451
+ Server side, the name comes off `ChangeEvent.write`: `channel-logs.ts` stamps it on the ring
452
+ entry, so a `since` replay names it too. `pg-replication.ts` reads it off the transaction's
453
+ opening `pg_logical_emit_message` (prefix `WRITE_ORIGIN_WAL_PREFIX`, START_REPLICATION asks
454
+ `messages 'true'`), and `@ultimat3/testing`'s in-process replicator reads it off the row
455
+ observer. A frame `write` that is not a digest is a protocol error (`wire-channel.ts`); on the
456
+ bus it is dropped, never trusted (`parseEnvelope`). `write-echo.test.ts` covers the useMutation and
457
+ outbox paths, another writer, an unknown digest, a custom policy, and a partial echo.
458
+ - **An overlay a server answer partly confirmed stops painting on those rows.** `settle` with rows
459
+ missing used to keep the WHOLE twin, so the rows the answer or echo did carry showed the write
460
+ twice until the rest arrived. The carried rows now go into `OverlayEntry.confirmed`, and
461
+ `replayOverlays` leaves them to synced truth. Only the rows still waiting show the twin.
462
+ - `local(tx, input)` is pure and CONVERGENT: no I/O, no `Date.now()`, no `Math.random()`, and
463
+ applying it over its own result changes nothing. The overlay replays it on every server update.
459
464
  - Anything a component reads is a **getter or an accessor**, never a value snapshotted at hook time:
460
- a plain field cannot re-render. `MutatorLike.local` is declared with method syntax so an
461
- `@ultimat3/action` `Mutator` assigns with no cast — a function-typed property would not.
462
- - `useLive`'s thunk input is read once, at subscribe time. There is no reactive runtime here to
463
- re-run it, and pretending otherwise would be a silently stale subscription.
464
- - Every subscription handle client code gets back — `LiveHandle` (`useLive`'s return, and
465
- `LiveRows` one layer up through the hook), `Unsubscribe` (`client.subscribe(topic, …)`'s return)
466
- — is `Disposable`. `[Symbol.dispose]` is the exact same function reference as `unsubscribe`,
467
- never a second implementation that could drift from it, so `using sub = client.useLive(...)` and
468
- `sub.unsubscribe()` are one teardown path either way. Pinned in `type-pins.ts`
469
- (`_LiveHandleIsDisposable`, `_LiveRowsIsDisposable`, `_UnsubscribeIsDisposable`) so a refactor
470
- that drops the member fails the build, not a call site months later.
471
- - `liveHookFor(query)` is the typed projection the wiki promises as `useLiveFeed({ orgId })`. It
472
- **binds** `useLive` — it never re-implements a subscribe path, because two of those is two places
473
- a subscription can be opened wrong. It names `Query`'s shape structurally (`LiveQuerySource`)
474
- rather than importing `@ultimat3/query` as a value: a hook is browser code, and a value import
475
- would pull the server's read path into the bundle.
476
- - The query's name is read **per call**, never captured at bind time. `export const useLiveFeed =
477
- liveHookFor(liveFeed)` runs at import; `registerQueries()` stamps the name later, at boot.
465
+ a plain field cannot re-render. `MutatorLike.local` is declared with method syntax so a twin
466
+ typed over its own `tx` assigns with no cast.
467
+ - `useQuery`'s input is read once, at call time. There is no reactive runtime here to re-run it.
468
+ - Every subscription handle client code gets back — `LiveHandle` (`subscribeLive`), the
469
+ `useQuery`/`useRecord` accessors, `Unsubscribe` (`client.subscribe(topic, …)`) — is `Disposable`,
470
+ and `[Symbol.dispose]` is the exact same function reference as the release, never a second
471
+ teardown path. Pinned in `type-pins.ts`.
478
472
  - Type claims about the hook go in `type-pins.ts`, never in a `.test.ts` — `tsconfig.json` excludes
479
473
  test files, so `tsc -b` never reads one and an assertion written there can never fail.
480
474
  - **`backoffDelay` is `@ultimat3/core`'s, and `attempt + 1` is the whole of the seam** (`As of
@@ -567,7 +561,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
567
561
  spread and nothing re-sends it, so `drain()` returns `DrainedSocket[]` with `notified` per socket
568
562
  and logs `sync.drain_frames_dropped`.
569
563
  - **A socket's actor comes from `createSyncNode({ authenticate })` and from nowhere else.** The node
570
- imports no authenticator — the app supplies one, exactly as it supplies `onMutate` — and it runs
564
+ imports no authenticator — the app supplies one — and it runs
571
565
  on the upgrade *before* `server.upgrade`, so a refused credential never costs a websocket.
572
566
  `null` is a **decision** (401, `X_SOCKET_UNAUTHENTICATED`, a client fault that pages nobody); a
573
567
  throw is a **failure** (503, `X_SOCKET_AUTH_UNAVAILABLE`, reported) — the same rule the row gate
@@ -634,13 +628,12 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
634
628
  every topic on every re-authenticated socket on the node, silently, with the client never told to
635
629
  resubscribe. The initial `subscribe` is deliberately NOT split: there is no subscription to keep,
636
630
  so a raising guard refuses that subscribe and the client hears about it.
637
- - **The `rebase` frame goes out BEFORE its `ack`, and an `ack` refers to what failed.** The ack is
638
- the receipt and the receipt retires the client's journal row and rebase-log entry, so a rebase
639
- landing after it has no entry to read `conflict` off — every merge silently becomes `server-wins`
640
- — and no sequence to decide which later optimistic writes to replay. Two frames on one socket:
641
- the order is the only coordination there is. `ackRefOf` answers the mutation key for a `mutate`
642
- and the sid for a `subscribe`; the socket id is only for a frame that could not be decoded, since
643
- `queue.fail(ref)` looks up by idempotency key and a socket id names a key no queue holds.
631
+ - **One conflict vocabulary, and it is not declared here.** A mutator's `conflict` is
632
+ `ConflictPolicy` from `@ultimat3/core`, over ROWS, and `RecordStore.settle` hands it to core's
633
+ `resolveConflict` with the overlay's row and the adopted server row — in that order. Until 21.0.0
634
+ there were two spellings bridged by a filter in `hooks.ts` that dropped one and handed the other
635
+ a `{ local, base, server }` bag, so no merge an app declared ever decided a row. A server delete,
636
+ or a row the client never held, is not a conflict: the server's answer stands, no merge runs.
644
637
  - **Inbound frames run in a lane, and the lane is NEVER the socket.** `sync-node.message` dispatches
645
638
  every frame as `void (async () => routeFrame(…))()`, so nothing upstream orders them. A global
646
639
  per-socket lane would put every frame behind the slowest one, and the slowest one is a subscribe's
@@ -663,16 +656,30 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
663
656
  `WsLike.subscribe`/`unsubscribe` stay declared and unused — a tracked app implements the
664
657
  interface structurally, so removing the members is that app's typecheck failure — and the
665
658
  declaration says so, because a member that looks live is one someone will call.
666
- - **A dropped channel frame is counted in three places and repaired in none.** The series
667
- `channel_frames_dropped_total` (no attributes — a topic is client-chosen, so a per-topic label is
668
- unbounded series one socket can mint), the log `channel.frames_dropped` with `{ topic, dropped,
669
- total }`, and `SocketRegistry.droppedChannelFrames` for a test or a bench that cannot scrape.
670
- Node-wide because a socket past `maxDroppedFrames` is closed and removed — a per-socket count
671
- leaves exactly when loss is worst — and distinct from `SyncSocket.droppedFrames`, which counts
672
- every frame kind and dies with its socket. Repair needs a per-topic sequence on the wire: a
673
- channel's lsn is the publishing hub's own per-node counter, so a client cannot tell a gap from a
674
- message that came via another node. Declared in `socket.ts`, not core's `runtime-metrics.ts`:
675
- that file is the series every process emits, this one exists only where channels do.
659
+ - **A dropped `records` frame is counted AND repaired (plan 101, slice 10).** Every declared
660
+ channel carries a per-node `seq` and `epoch`; a frame backpressure refuses marks that socket
661
+ gapped on that channel, and the node sends `replay-gap` the moment the socket drains (the
662
+ websocket `drain` handler → `gapRepairs.repairAll`), counted as `channel_replay_gaps_total` beside
663
+ `channel_frames_dropped_total`. The client re-runs the channel's catch-up query, holds every frame
664
+ that arrives meanwhile, and applies those over the read. The server owns the gap verdict: a
665
+ numeric hole on its own is never one (a row this socket may not see is skipped for it). A resume
666
+ sends `since` = the highest seq with no hole below it; duplicates are dropped, a replay that
667
+ fills a hole is applied (`client-channels.ts`).
668
+ - **A channel is a DECLARATION, never a topic string.** `channel(name, { params, policy, catchUp,
669
+ records?, events? })` spells the topic; a subscribe names the declaration and its params, and a
670
+ name no declaration carries is `X_TOPIC_FORBIDDEN`. Presence rides the channel's `events` frames
671
+ (`readPresence`) — only a channel declared `events: true` has a room. `useChannel` / `usePresence`
672
+ hold one membership per topic per page, however many components ask.
673
+ - **One socket per ORIGIN and principal, in a SharedWorker (plan 101, slice 11).** `socket-engine.ts`
674
+ holds the real socket and talks to every tab only over a `MessagePort`; to each tab's `LiveClient`
675
+ its port IS a socket (`socket-host.ts`'s virtual socket). Channel wants are reference-counted
676
+ across ports and every server frame is ROUTED to the ports that want it; live-query sids carry the
677
+ port. A tab's own beat is the engine's ping: a port silent for `REAP_AFTER_BEATS` is a closed tab
678
+ and is reaped. When the real socket drops, every tab's virtual socket closes and each tab
679
+ resubscribes from its OWN cursors — the engine keeps nothing that could go stale. No
680
+ `SharedWorker`, a constructor that throws, or no built worker: the in-page host runs the same
681
+ engine over a `MessageChannel`, one socket per tab. The worker is named by principal, so two
682
+ principals never share a socket.
676
683
  - **A qid is `@ultimat3/query`'s `queryHash(name, input)`, and this package derives none of its
677
684
  own — `As of 2026-08`.** `qidOf` was the same two lines over a local copy of the canonical form
678
685
  (`stableDigest(canonicalJson(input))`), and `canonicalJson`/`stableDigest` were this package's
@@ -805,14 +812,6 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
805
812
  `maxBufferedBytes` and `maxDroppedFrames` were until 2026-08. Forwarded the same way
806
813
  `maxFramesPerSecond`/`frameBurst` already are (`...(x === undefined ? {} : { x })`, so an unset
807
814
  option keeps `SyncSocket`'s own default rather than overwriting it with `undefined`).
808
- - **One socket's buffer has one number on the server and a separate one in the browser.**
809
- `DEFAULT_MAX_BUFFERED_BYTES` (`socket.ts`) is both `SyncSocket`'s send-side ceiling and the
810
- `backpressureLimit` `sync-node.ts` hands Bun — two spellings of one buffer on one side, and the
811
- runtime's limit set lower means our check never fires and a frame is dropped with nothing marked
812
- desynced. `client-mutations.ts`'s `MAX_BUFFERED_BYTES` is deliberately *not* imported from it:
813
- that is browser code and `socket.ts` is the node's registry, its metrics and its close codes.
814
- `sync-limits.test.ts` pins the server pair through behaviour, not by comparing two constants that
815
- are now one declaration — an equality between them is a test that cannot fail.
816
815
  - **A `SubscriptionLimitError` names the knob, never the default.** `knob` defaults to
817
816
  `maxPerSocket`/`maxPerTenant`, which are `LiveQueryRegistry`'s — so the channel hub's per-socket
818
817
  *topic* cap, thrown without one, told an operator to move a number in a different constructor
@@ -879,6 +878,60 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
879
878
  "equality satisfied by both sides failing open together" failure the row-parity test names. The
880
879
  rule governs what this package **throws**, never what a test hands it.
881
880
 
881
+ ## Browser bytes, per hook (`As of 2026-09-22`)
882
+
883
+ `bun build --target=browser --minify --metafile`, one entry importing one hook from the barrel.
884
+ Re-measure before quoting.
885
+
886
+ | Hook | Before the page boot moved | After the page boot moved | After the light core entry (current) |
887
+ |---|---|---|---|
888
+ | `useRecord` | 34,838 | 19,404 | **13,226** |
889
+ | `useMutation` | 37,073 | 35,480 | **29,907** |
890
+ | `useQuery` | 64,183 | 62,596 | **56,647** |
891
+ | `useChannel` | 62,155 | 60,566 | **54,621** |
892
+ | `@ultimat3/realtime/boot` (once per page, cached immutable) | — | 34,884 | not re-measured |
893
+
894
+ The last column (`As of 2026-09-22`, `bun build --target=browser --minify`, one entry per hook)
895
+ is after every browser-reachable file here moved its core imports to **`@ultimat3/core/page`** and
896
+ `@ultimat3/query/client` stopped reaching `@ultimat3/query`'s `errors.ts`: no hook loads a titles
897
+ table any more — not core's (`core-error-codes.ts`), not schema's, not query's. A browser file here
898
+ imports core from `@ultimat3/core/page`; `packages/core/src/page-bundle.test.ts` and
899
+ `packages/query/src/client-bundle.test.ts` are the guards.
900
+
901
+ `examples/dummy`'s islands, built by `x build`'s own `buildIslands`: `likes-badge` 48,598 → 32,882,
902
+ `feed` 95,983 → 94,174, `like` 84,351 → 82,503; `settings` and `contact-sales` reach no realtime
903
+ and are unchanged.
904
+
905
+ - **The disk boot is ONE page script, never island code.** `boot.ts` (`./boot`) restores the
906
+ principal's persisted records and opens the outbox; the CLI builds it like the sync worker
907
+ (`/_x/page-boot/<hash>.js`, immutable) and renders one `<script defer>` on a document that carries
908
+ the scope tag AND emitted an island reaching realtime. The boot first wipes every stored scope
909
+ but the current principal's (rows and outbox) — a sign-out by full navigation never calls
910
+ `rescope()` — then restores; an unscoped page wipes nothing. Two principals in two tabs: the newer
911
+ boot wipes the other's disk, which keeps its records in memory and re-persists on its next write.
912
+ `page-store.ts` imports none of it: `booted` reads the boot's promise off
913
+ `globalThis[Symbol.for('ultimate.page-boot')]` per access, so an island that ran first still waits.
914
+ A lazy `import()` could not have done this: islands build with `splitting: false`, and Bun inlines
915
+ a dynamic import, so bytes leave a bundle only by leaving its import graph.
916
+ - Everything else on these paths buys function: the in-page `socket-engine` is the no-SharedWorker
917
+ fallback, `client-channels` rides the ONE page client every bundle shares, the decoders are one
918
+ copy. Core's error-registry chain (~8.4 kB with the schema and query titles) WAS core's to cut,
919
+ and is cut: see the last column above.
920
+ - **An island never carries the outbox.** `boot.ts` is the one module that builds it (IndexedDB,
921
+ the queue, the drain listeners); `useMutation` and the page socket read it off the page through
922
+ `outbox-slot.ts`, after `page.booted`. No boot ⇒ no outbox ⇒ a write the network refused is
923
+ rejected, not queued in memory. Measured the same day, same method, before → after:
924
+ `useMutation` 30,071 → 21,725, `useQuery` 56,824 → 47,696, `useChannel` 54,798 → 45,585;
925
+ `examples/dummy` via `buildIslands`: `feed` 101,261 → 92,973, `like` 86,325 → 78,050.
926
+ - **A browser path never loads realtime's code table.** The refusals a browser can reach live in
927
+ `page-errors.ts`; `errors.ts` re-exports them and keeps the table and its one
928
+ `registerErrorCodes()`. Measured the same day, same method: `useRecord` 21,629 → 18,600,
929
+ `useMutation` 38,225 → 35,194, `useQuery` 65,964 → 62,935 (`useChannel` 60,903 after).
930
+ `page-errors-bundle.test.ts` fails if `errors.ts` comes back into `useRecord` or `useMutation`.
931
+ In a browser that loaded no table a code titles itself from its name; `code`, `cause` and `fix`
932
+ are unchanged. The ~7.6 kB of core's `UltimateError` chain that was left under `useRecord` is now
933
+ the class and the lookup alone (~2.4 kB): core's titles table is an anchor only its barrel loads.
934
+
882
935
  ## Map
883
936
 
884
937
  | File | Owns |
@@ -896,10 +949,25 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
896
949
  | `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 |
897
950
  | `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 |
898
951
  | `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 |
899
- | `identity-map.ts` | the client's single source of truth: one row value per `(scope, id)`, its holds and its batched change notification |
900
- | `live-rows.ts` | one subscription's window over that map — its scope, its order, its retain/release, and `Registration` itself |
901
- | `local-store.ts` / `offline-queue.ts` / `rebase.ts` | tier 3 |
902
- | `client.ts` / `sync-node.ts` | the two halves — connection lifecycle, subscriptions, mutations |
952
+ | `page-errors.ts` | the refusals a browser can reach — the store, the hooks, the wire check, the local store — without the code table |
953
+ | `record-store.ts` | the page's one record store: the optimistic overlay over synced truth, batched notification, and `settle` under the conflict policy — from an answer, or from the write's own `records` echo (`settleWrite`) |
954
+ | `record-names.ts` | which pending overlay a frame's `write` digest names: every pushed key digested before its request leaves |
955
+ | `record-key.ts` / `record-synced.ts` / `record-await.ts` | a record's `type:key` name and `carriedBy`; the synced layer (merge, holds, provisional disk rows); the overlays waiting on server truth and what a write heard while in flight |
956
+ | `record-tx.ts` | the overlay replay and the `tx` a mutator's `local` half writes through |
957
+ | `page-store.ts` | the page state on `globalThis` — the store, the sync target, the write counts — and `hasPageSocket` |
958
+ | `outbox-slot.ts` | the outbox as an island reaches it: the page slot, its handle type and `OutboxEntry` — read, never built |
959
+ | `boot.ts` | `./boot`: the page's ONE boot script — the disk restore and the outbox open, never in an island |
960
+ | `page-socket.ts` / `browser-socket.ts` | the page's one socket, built by the first live hook, and the framework's one `new WebSocket` |
961
+ | `reactivity.ts` | `installRealtime`: this bundle's signal factory, and the server-render test |
962
+ | `use-record.ts` / `use-query.ts` / `use-mutation.ts` / `use-connection.ts` / `use-channel.ts` | the hooks — the only surface an island calls |
963
+ | `client-channels.ts` | the client's declared channels: one membership per topic, the seq/epoch cursor, the catch-up read and the frames held during it |
964
+ | `socket-engine.ts` / `socket-host.ts` / `sync-worker.ts` | the one socket per origin: the port-driven engine (dial, beat, redial, reap), the tab's host choice and virtual socket, the SharedWorker entry (`./sync-worker`) |
965
+ | `socket-port.ts` / `socket-routes.ts` | the engine ⇄ tab port messages; the multiplexing — one membership per topic, every frame routed only to the ports that want it |
966
+ | `sync-meta.ts` | the sync target and worker URL read off the document's `<meta>` tags (core's names) |
967
+ | `live-record-type.ts` | the live path's record type and record key per table, from the entity's own projection |
968
+ | `live-rows.ts` | one live subscription's window over the store — its record type, its order, its retain/release, and `Registration` itself |
969
+ | `offline-queue.ts` | the durable outbox queue; `page-outbox.ts` opens one per principal and replays it in order over HTTP |
970
+ | `client.ts` / `sync-node.ts` | the two halves — connection lifecycle and subscriptions; the socket carries no writes |
903
971
  | `sync-auth.ts` | what a socket's identity IS (`SyncGrant`), the book that holds one per socket, and the pass that re-decides an expired one |
904
972
  | `sync-frames.ts` | what a RECEIVED frame does to server state — the node's inbound surface, and the mirror of `client-frames.ts` |
905
973
  | `sync-upgrade.ts` | the node's HTTP surface: `/healthz`, `/readyz`, load shedding, and the authenticated upgrade — `WsData` and `UpgradeTarget` are declared with the decision that builds them |
@@ -908,18 +976,14 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
908
976
  | `query-window.ts` | the shared pre-policy window per query id: built once, read once for N subscribers, and replaced when it is known to be wrong |
909
977
  | `client-frames.ts` | what a RECEIVED frame does to client state, and `ClientFrameTarget` — the only inbound surface the client exposes. The mirror of `sync-frames.ts` |
910
978
  | `client-harness-fixture.ts` | the injected socket + scheduler + harness both client suites drive. Excluded from the tarball |
911
- | `hooks-fixture.ts` | the same, for the two hook suites (`hooks.test.ts`, `hooks-identity.test.ts`). Excluded from the tarball |
979
+ | `hooks-fixture.ts` | the page harness the hook and frame suites drive: a fake socket, a page reset, a fetch double. Excluded from the tarball |
912
980
  | `subscription-book.ts` | who holds which subscription, keyed by `(socket, sid)`, and the per-socket/per-tenant caps answered from it |
913
981
  | `apply-patches.ts` | folding a patch list onto a row list (`applyPatches`) or onto ids alone (`orderAfterPatches`, what a window uses) — the client's one stateless piece, and one fold, not two |
914
- | `hooks.ts` | the ambient client seam + the four component hooks — the only file an app imports |
915
- | `query-hook.ts` | the typed projection: one declared query bound to one named hook |
916
- | `type-pins.ts` | compile-time assertions `tsc` checks — the hook's input type, its row type, the `Query` seam |
982
+ | `type-pins.ts` | compile-time assertions `tsc` checks — the hooks answer `AsyncState`, a query ref has no server field, every handle is `Disposable` |
917
983
  | `window-lock.ts` | one FIFO lane per query id — the only thing that orders a fanout |
918
984
  | `frame-lanes.ts` | the order one socket's INBOUND frames are applied in, and the lane key each kind belongs to. `WindowLock` again, keyed differently — and it bounds no cap |
919
985
  | `live-fanout.ts` | what one change does inside one entry's lane: match, fold, one policy pass per subscriber, and the re-snapshot that repairs a desynced one |
920
- | `client-mutations.ts` | the outbound mutation path — the optimistic twin, the rebase entry, the queue entry, and the sender the drain hands each frame to |
921
986
  | `client-heartbeat.ts` | when to beat and when to give up. A policy, which is why it is not in `client.ts`'s connection lifecycle |
922
- | `client-topics.ts` | the client's channel book, and the one membership frame its two callers (`subscribe`, the reconnect replay) must never spell differently |
923
987
  | `client-contract.ts` | the client's injected shapes — `ClientSocket`, `LiveClientOptions`, `LiveHandle` — declared apart from the class that consumes them |
924
988
  | `policy-gate.ts` | the only authz seam |
925
989
  | `subscriber-gate.ts` | the per-subscriber pass of a definition's row policy, and its two counters — `rowsDenied` and `gateFailures`. Evaluates no policy of its own |