@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.
- package/CLAUDE.md +186 -122
- package/README.md +121 -126
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +7 -0
- package/src/channel-authz.ts +33 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +144 -0
- package/src/channel-describe.ts +33 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +116 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +79 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +289 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +138 -195
- package/src/cursor.ts +2 -2
- package/src/errors.ts +34 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +113 -0
- package/src/idb-types.ts +41 -0
- package/src/index.ts +80 -74
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +10 -3
- package/src/live-fanout.ts +30 -4
- package/src/live-record-type.ts +19 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +250 -0
- package/src/offline-queue.ts +9 -18
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +242 -0
- package/src/page-socket.ts +108 -0
- package/src/page-store.ts +138 -0
- package/src/pg-replication.ts +9 -2
- package/src/pgoutput.ts +37 -2
- package/src/presence.ts +17 -9
- package/src/query-window.ts +3 -0
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +7 -1
- package/src/server.ts +2 -8
- package/src/socket-engine.ts +332 -0
- package/src/socket-host.ts +126 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +51 -12
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +24 -107
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +19 -1
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +214 -0
- package/src/use-query.ts +255 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- 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` (
|
|
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
|
|
18
|
-
`
|
|
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
|
-
|
|
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
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
`
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
461
|
-
|
|
462
|
-
- `
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
`
|
|
466
|
-
|
|
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
|
|
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
|
-
- **
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
|
667
|
-
`
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
| `
|
|
900
|
-
| `
|
|
901
|
-
| `
|
|
902
|
-
| `
|
|
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
|
|
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
|
-
| `
|
|
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 |
|