@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.
- 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/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Three tiers, one ladder, one protocol. Climbing a rung is a config change, never
|
|
|
8
8
|
|---|---|---|
|
|
9
9
|
| **1 — channels** | `publish`/`subscribe` on typed topics, presence, cursors, typing indicators | ~0. One filtered `send` per subscribed socket — no DB, no replication slot. **At most once**: a frame backpressure drops is counted, never replayed |
|
|
10
10
|
| **2 — live queries** | the list updates when someone else edits; your own click feels instant | one change feed + a matcher per query id + a bounded change window |
|
|
11
|
-
| **3 — local-first** | writes that survive being offline | a durable
|
|
11
|
+
| **3 — local-first** | writes that survive being offline | a durable outbox, client-side migrations, a conflict story per mutator (plan 101 slice 12) |
|
|
12
12
|
|
|
13
13
|
Tier 2 covers ~90% of "make it realtime". Tier 3 buys exactly one extra property — offline writes — and charges a client database for it. Do not buy it by accident.
|
|
14
14
|
|
|
@@ -25,35 +25,34 @@ export const liveFeed = query({
|
|
|
25
25
|
|
|
26
26
|
// mutator (action + optimistic local twin)
|
|
27
27
|
export const likePost = mutator({
|
|
28
|
-
// Convergent, not incremental: `local` replays on every
|
|
28
|
+
// Convergent, not incremental: `local` replays on every server update, so applying it N times has to
|
|
29
29
|
// equal applying it once — `likedByMe` is what makes the second application a no-op.
|
|
30
30
|
local(tx, { postId }) {
|
|
31
31
|
tx.posts.update(postId, (p) =>
|
|
32
32
|
p.likedByMe ? {} : { likedByMe: true, likeCount: p.likeCount + 1 });
|
|
33
33
|
},
|
|
34
34
|
async server(ctx, { postId }) { return ctx.posts.like(postId); },
|
|
35
|
-
conflict: 'server-wins', // | 'last-write-wins' | custom(
|
|
35
|
+
conflict: 'server-wins', // | 'last-write-wins' | { kind: 'custom', merge(local, server) }
|
|
36
36
|
});
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
mutation drain are frames in the same discriminated union (`src/sync-protocol.ts`), so the client's
|
|
43
|
-
frame handler is unchanged between rungs.
|
|
39
|
+
A write is always HTTP: `useMutation(likePost)` runs `local` into the page store's optimistic
|
|
40
|
+
overlay, then POSTs the mutator's action with an idempotency key. The socket is **read-only** —
|
|
41
|
+
subscriptions go up, snapshots, patches and presence come down (protocol 3, 21.0.0).
|
|
44
42
|
|
|
45
|
-
`local` must be pure — no I/O, no `Date.now()`, no `Math.random()
|
|
43
|
+
`local` must be pure and convergent — no I/O, no `Date.now()`, no `Math.random()`, and applying it
|
|
44
|
+
over its own result changes nothing — because the overlay REPLAYS it over every server update.
|
|
46
45
|
|
|
47
46
|
## Two entries, and which one an island may import
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
wire and the reconnect vocabulary. `@ultimat3/realtime/server` is the bus, the Postgres replication
|
|
48
|
+
`@ultimat3/realtime` is the **client** half — the hooks, the page's record store, the offline outbox,
|
|
49
|
+
the wire and the reconnect vocabulary. `@ultimat3/realtime/server` is the bus, the Postgres replication
|
|
51
50
|
path and the sync node. A name lives in exactly one of them; the `Entry` column below says which.
|
|
52
51
|
|
|
53
52
|
The split is not cosmetic. `nats` `require()`s `stream/web`, so one barrel carrying `openNatsClient`
|
|
54
|
-
beside
|
|
53
|
+
beside the client hooks made the browser island this package promises **unbuildable** —
|
|
55
54
|
`Browser build cannot require() Node.js builtin: "stream/web"`. `packages/cli/src/realtime-browser-barrel.test.ts`
|
|
56
|
-
bundles
|
|
55
|
+
bundles a client-only entry for `target: 'browser'` and fails the build if either
|
|
57
56
|
half reaches the other; `barrel-split.test.ts` fails if one name is exported from both.
|
|
58
57
|
|
|
59
58
|
Migrating from 7.x: an import of a **server** name changes its specifier and nothing else.
|
|
@@ -63,8 +62,8 @@ Migrating from 7.x: an import of a **server** name changes its specifier and not
|
|
|
63
62
|
+ import { ChannelHub, createSyncNode, LiveQueryRegistry } from '@ultimat3/realtime/server';
|
|
64
63
|
```
|
|
65
64
|
|
|
66
|
-
Client names —
|
|
67
|
-
|
|
65
|
+
Client names — the hooks, `RecordStore`, `OfflineQueue`, `encode`/`decode`, every `X_*` error
|
|
66
|
+
class — stay on `.`.
|
|
68
67
|
|
|
69
68
|
## Public API
|
|
70
69
|
|
|
@@ -76,68 +75,79 @@ Client names — `useLive`, `liveHookFor`, `LiveClient`, `OfflineQueue`, `Rebase
|
|
|
76
75
|
| fanout | `./server` | `Transport`, `InProcessTransport`, `NatsTransport`, `selectTransport`, `subjectMatches` |
|
|
77
76
|
| the bus, behind `NatsTransport` | `./server` | the port — `NatsClient`, `NatsMessage`, `NatsSubscription`, `NatsConnect`, `NatsTarget`, `parseNatsUrl` — plus `openNatsClient` (the `nats` adapter), `NatsKvSet`, `ensureKvBucket`, `kvGet`/`kvLast`/`kvWrite`, `assertBucket`, `encodeToken`/`decodeToken`, and `FakeNatsBroker`/`fakeNatsConnect` for tests |
|
|
78
77
|
| reconnect | both | `LiveCursor`, `resumeFrom`, `shouldResnapshot`, `defaultReconnectBudget`, `backoffDelay`, `Scheduler`, `timeoutScheduler` on `.`; `RingChangeBuffer`, `drainPlan`, `AcceptBudget`, `reconnectFrame` on `./server` — the node's half of the reconnect is the node's |
|
|
79
|
-
| the
|
|
80
|
-
|
|
|
81
|
-
| wire | `.` | `PROTOCOL_VERSION
|
|
82
|
-
|
|
|
78
|
+
| the page's record store | `.` | `RecordStore` — one record per `type:key`, synced truth plus the optimistic overlay — `recordKey`, `LocalTx`, `RowWindows`, `applyPatches`/`orderAfterPatches` |
|
|
79
|
+
| the outbox | `.` | `OfflineQueue`, `MemoryQueueStore` — replayed over HTTP from plan 101 slice 12. The conflict vocabulary is `ConflictPolicy` from `@ultimat3/core`; realtime declares none |
|
|
80
|
+
| wire | `.` | `PROTOCOL_VERSION` (3), `encode`, `decode`, `Frame` |
|
|
81
|
+
| the node | `./server` | `createSyncNode` / `listenSyncNode` (`sync` role) |
|
|
83
82
|
| a socket's identity | `./server` | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
|
|
84
|
-
| hooks | `.` | `
|
|
85
|
-
|
|
|
86
|
-
|
|
|
83
|
+
| hooks | `.` | `useQuery`, `useRecord`, `useMutation`, `useMutationQueue`, `useConnection`, `useChannel`, `usePresence`, `hasPageSocket`, `installRealtime` |
|
|
84
|
+
| channels | `.` | `channel`, `topic`, `readPresence`, the channel frame types |
|
|
85
|
+
| offline | `.` | `pageOutbox`, `recordPersister`, `persistedTypes`, `openLocalStore`, `pageLocalStore`, `MemoryLocalStore` |
|
|
86
|
+
| the socket's worker | `./sync-worker` | the SharedWorker entry — no exports |
|
|
87
87
|
|
|
88
|
-
## The
|
|
88
|
+
## The hooks
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
have been registered for, so every hook falls back to `serverRenderLiveClient()`: `useLive` answers
|
|
96
|
-
`state() === 'loading'` with no rows, `useConnection()` reports online, both queue counts are `0`,
|
|
97
|
-
and `mutate` / `drain` refuse with `X_LIVE_SERVER_RENDER`. The page renders its own loading branch
|
|
98
|
-
and a hydrating island takes over. `hasLiveClient()` still answers `false` there, which is what a
|
|
99
|
-
component with a static fallback is asking. `useLive` in a page BODY is not made live by this — a
|
|
100
|
-
page component never runs in a browser; put the live half in an `island()`.
|
|
90
|
+
One record store and one socket per **page**, on `globalThis`: every island is its own bundle, so a
|
|
91
|
+
module-level singleton would be one per island. The island bootstrap `x build` prepends installs
|
|
92
|
+
realtime for its bundle — `installRealtime({ signal: createSignal, sync: { url, buildId } })` — and
|
|
93
|
+
an island never constructs a client or a socket. The socket opens on the first live hook; an island
|
|
94
|
+
that only reads records or writes ships none of it.
|
|
101
95
|
|
|
102
96
|
```ts
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
97
|
+
import {
|
|
98
|
+
type ChannelRef,
|
|
99
|
+
type MutatorLike,
|
|
100
|
+
useChannel,
|
|
101
|
+
useConnection,
|
|
102
|
+
useMutation,
|
|
103
|
+
useMutationQueue,
|
|
104
|
+
usePresence,
|
|
105
|
+
useQuery,
|
|
106
|
+
useRecord,
|
|
107
|
+
} from '@ultimat3/realtime';
|
|
108
|
+
|
|
109
|
+
declare const orgId: string;
|
|
110
|
+
declare const postId: string;
|
|
111
|
+
declare const LIKE_POST: MutatorLike; // name + local twin + conflict — never the mutator VALUE
|
|
112
|
+
declare const orgFeed: ChannelRef<'orgId'>; // a `channel('org-feed', { params: ['orgId'], … })`
|
|
113
|
+
declare function onEvent(event: Readonly<Record<string, unknown>>): void;
|
|
114
|
+
|
|
115
|
+
const feed = useQuery({ name: 'liveFeed', live: true }, { orgId }); // AsyncState<readonly Row[]>
|
|
116
|
+
const posts = useQuery({ name: 'listPosts', entity: 'posts' }, {}); // one HTTP read, rows as records
|
|
117
|
+
const post = useRecord('posts', postId); // AsyncState<Row | undefined>
|
|
118
|
+
const like = useMutation(LIKE_POST); // await like(input); like.pending
|
|
119
|
+
const connection = useConnection(); // .offline .online .reconnectAt .updateAvailable
|
|
120
|
+
const writes = useMutationQueue(); // .pending .failed
|
|
121
|
+
const feedChannel = useChannel(orgFeed, { orgId }, { onEvent }); // records → the store; events → onEvent
|
|
122
|
+
const room = usePresence(orgFeed, { orgId }); // the channel's roster
|
|
109
123
|
```
|
|
110
124
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
| `pending` / `failed` are read off the queue, through an invalidation signal refreshed on each `mutate` and `drain` | the count is never a second copy of the queue, and `OfflineQueue` holds arrays, not signals |
|
|
119
|
-
|
|
120
|
-
Tier 2 has no queue, so `pending` is `0` there — stated, not guessed.
|
|
121
|
-
|
|
122
|
-
### The typed one: `liveHookFor`
|
|
125
|
+
**One socket per origin and principal**: the page's socket lives in a `SharedWorker`
|
|
126
|
+
(`@ultimat3/realtime/sync-worker`, bundled by `x build`) shared by every tab; with no worker the
|
|
127
|
+
same engine runs in-page. Writes that find no network go to the page's outbox — overlay kept — and
|
|
128
|
+
replay over HTTP, under their original idempotency keys, when the socket comes back. The outbox is
|
|
129
|
+
the page boot's (`@ultimat3/realtime/boot`): an island only reads it off the page. On a page the
|
|
130
|
+
CLI renders no boot for (no scope tag, so nothing on it persists) such a write is refused like any
|
|
131
|
+
other — rejected, overlay taken back — never held in memory a reload would silently lose.
|
|
123
132
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
through — the query's `input` in, its row type out. It is not a second subscribe path: it *is*
|
|
127
|
-
`useLive`, with the name and the types already bound.
|
|
133
|
+
`<AsyncRegion state={feed()} …/>` takes the answer as-is: `AsyncState` is `@ultimat3/core`'s, the
|
|
134
|
+
same type `@ultimat3/ui` renders.
|
|
128
135
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
`
|
|
139
|
-
|
|
140
|
-
|
|
136
|
+
| Rule | Why |
|
|
137
|
+
|---|---|
|
|
138
|
+
| A query ref is `{ name, live?, entity? }`, never the query VALUE | importing a `query()` drags its read path into the island (698,801 B measured) |
|
|
139
|
+
| Lists hold keys; rows are the store's | a record updated by any answer or frame re-renders every list showing it, with no refetch |
|
|
140
|
+
| Every write is HTTP through core's `clientTransport` | one write path: the action's authz, idempotency and contract; the socket carries none |
|
|
141
|
+
| The answer's records are adopted before the overlay goes | a convergent twin never flickers back to the pre-write value |
|
|
142
|
+
| **No `solid-js` import.** Each bundle installs its own `SignalFactory` | every island carries its own solid-js; a signal from another bundle is invisible to its effects |
|
|
143
|
+
| Every member is a **getter**, every result an **accessor** | a value snapshotted at hook time never re-renders |
|
|
144
|
+
| `input` is read **once** | nothing here re-runs it; a changed input is a new `useQuery` |
|
|
145
|
+
| The caller owns `release()` / `using` | this layer does not know what a mount is |
|
|
146
|
+
|
|
147
|
+
**A server render** (no install, no DOM) answers `pending`, reports online and creates no page
|
|
148
|
+
state; a `useMutation` call refuses with `X_LIVE_SERVER_RENDER`. With a DOM and no install, every
|
|
149
|
+
hook is `X_REALTIME_UNINSTALLED`. `hasPageSocket()` is the guard a component with a static fallback
|
|
150
|
+
asks.
|
|
141
151
|
|
|
142
152
|
## Who a socket is
|
|
143
153
|
|
|
@@ -147,16 +157,25 @@ resolves is what every policy downstream decides against — the topic guard, `a
|
|
|
147
157
|
the per-tenant subscription cap.
|
|
148
158
|
|
|
149
159
|
```ts
|
|
150
|
-
|
|
160
|
+
import type { Actor } from '@ultimat3/core';
|
|
161
|
+
import type { SyncGrant, SyncNodeOptions } from '@ultimat3/realtime/server';
|
|
162
|
+
|
|
163
|
+
declare function sessionFrom(
|
|
164
|
+
request: Request,
|
|
165
|
+
): Promise<{ actor: Actor; expiresAt: number; token: string } | null>;
|
|
166
|
+
declare function renew(token: string): Promise<SyncGrant | null>;
|
|
167
|
+
|
|
168
|
+
// The one option this section is about; `createSyncNode({ …, authenticate })` takes it.
|
|
169
|
+
const options: Pick<SyncNodeOptions, 'authenticate'> = {
|
|
151
170
|
// From @ultimat3/auth, or anywhere else: `sync` imports no authenticator, exactly as it owns no
|
|
152
|
-
//
|
|
171
|
+
// business logic. `refresh` is yours too, so the framework retains no credential of its own.
|
|
153
172
|
authenticate: async (request) => {
|
|
154
173
|
const session = await sessionFrom(request);
|
|
155
174
|
return session === null
|
|
156
175
|
? null
|
|
157
176
|
: { actor: session.actor, expiresAt: session.expiresAt, refresh: () => renew(session.token) };
|
|
158
177
|
},
|
|
159
|
-
}
|
|
178
|
+
};
|
|
160
179
|
```
|
|
161
180
|
|
|
162
181
|
| The answer | What the node does |
|
|
@@ -226,37 +245,22 @@ authenticated socket is the cheapest foothold there is.
|
|
|
226
245
|
`input` reaches `canonicalJson`, which recurses, so an unbounded one is a stack overflow in the
|
|
227
246
|
process rather than a slow query.
|
|
228
247
|
|
|
229
|
-
## One
|
|
230
|
-
|
|
231
|
-
Two components subscribing to two live queries that both return post #7 hold **one** row, not two
|
|
232
|
-
copies of it. That is the client's whole store: a `LiveClient` owns one `IdentityMap`, every live
|
|
233
|
-
window is an ordered list of ids over it, and the tier-3 local store's tables are membership over
|
|
234
|
-
the same map. A write through any of them is the same row for all of them.
|
|
235
|
-
|
|
236
|
-
```ts
|
|
237
|
-
const feed = useLive(liveFeed, () => ({ orgId })); // holds p1, p2, p7
|
|
238
|
-
const pinned = useLive(livePinned, () => ({ orgId })); // holds p7
|
|
239
|
-
|
|
240
|
-
await like({ postId: 'p7' }); // one optimistic write...
|
|
241
|
-
feed()[2] === pinned()[0]; // ...and both views are looking at it
|
|
242
|
-
```
|
|
248
|
+
## One record per `type:key`, per page
|
|
243
249
|
|
|
244
|
-
|
|
245
|
-
|
|
250
|
+
Two islands showing post #7 hold **one** record, not two copies. The page's `RecordStore` is the
|
|
251
|
+
whole client store: every live window, every `useQuery` list and every `useRecord` is a projection
|
|
252
|
+
over it, and an HTTP answer's records, a socket patch and an optimistic write all land in it.
|
|
246
253
|
|
|
247
254
|
| Rule | Why |
|
|
248
255
|
|---|---|
|
|
249
|
-
| Identity is `
|
|
250
|
-
| The
|
|
251
|
-
|
|
|
252
|
-
|
|
|
253
|
-
| A write **merges** columns
|
|
254
|
-
| A row is dropped
|
|
255
|
-
|
|
|
256
|
-
|
|
257
|
-
`entity` on a `snapshot` frame is **additive**: an older node omits it and the client falls back to
|
|
258
|
-
the private scope, a newer node sends it and an older client ignores it. Both skews are safe in
|
|
259
|
-
both directions, which is why it carries no `PROTOCOL_VERSION` bump.
|
|
256
|
+
| Identity is `type:key` — the entity's NAME and the key the SERVER computed | two entities may spell one key the same way, and the browser has no entity schema to derive a key from |
|
|
257
|
+
| The live path names the type server-side (`recordTypeForTable`) | a changefeed speaks tables; the store speaks entities |
|
|
258
|
+
| Two layers: synced truth and the optimistic overlay | a refused write drops its overlay and shows exactly what the server said — never a stale before-image |
|
|
259
|
+
| The overlay is REPLAYED over every server update | two pending writes on one row land in order on top of someone else's change |
|
|
260
|
+
| A value is **replaced, never mutated**; a write **merges** columns | a mutated row is a render that never happens; a narrower projection must not blank a wider one |
|
|
261
|
+
| A structurally bad row is `X_RECORD_REJECTED`, dropped and reported | never partially merged — a keyless row would overwrite another record |
|
|
262
|
+
| The last holder leaving evicts the record | an infinite scroll must not retain every row it ever saw |
|
|
263
|
+
| A principal change clears the store | nothing of the previous principal survives in memory |
|
|
260
264
|
|
|
261
265
|
## Reconnect is the hard part
|
|
262
266
|
|
|
@@ -292,14 +296,18 @@ subscribing to a topic *is* joining the room. `reconnectAt` is what a component
|
|
|
292
296
|
waits; `close()` cancels it, and `connect()` starts over. The timer comes from an injected
|
|
293
297
|
`Scheduler`, so a test fires it by hand instead of sleeping.
|
|
294
298
|
|
|
299
|
+
A browser's curve is `browserBackoff` — the same core curve, `equal` jitter, capped at
|
|
300
|
+
`BROWSER_RECONNECT_MAX_MS` (4s) where the server-side `defaultBackoff` caps at 30s: a node that
|
|
301
|
+
comes back is reached within seconds, and so is the `update-available` it carries. The socket
|
|
302
|
+
engine (one per origin, in the `SharedWorker`) keeps none of it across pages: a page arriving while
|
|
303
|
+
the node is down dials at once on a fresh curve, and the last page leaving forgets the target, so
|
|
304
|
+
the next build's page never dials with the old build id.
|
|
305
|
+
|
|
295
306
|
### Liveness: `heartbeatMs`
|
|
296
307
|
|
|
297
308
|
A half-open socket — the TCP connection is dead and no `close` ever fires — is invisible to the
|
|
298
|
-
browser. The client is the only thing that can end one
|
|
299
|
-
|
|
300
|
-
```ts
|
|
301
|
-
new LiveClient({ signal, connect, buildId, heartbeatMs: 15_000 }); // 0 disables the pass
|
|
302
|
-
```
|
|
309
|
+
browser. The client is the only thing that can end one, and the page socket beats at the default
|
|
310
|
+
below (`heartbeatMs` on the internal `LiveClient`; `0` disables the pass).
|
|
303
311
|
|
|
304
312
|
| Property | Behaviour |
|
|
305
313
|
|---|---|
|
|
@@ -353,16 +361,9 @@ wire twice by a reconnect that raced an ack.
|
|
|
353
361
|
froze at the last snapshot and `shouldResnapshot`'s lag check answered "re-snapshot" for every
|
|
354
362
|
client connected longer than `maxLagMs` — the delta resume the retained window exists for, dead
|
|
355
363
|
exactly during the deploy storm it was built for.
|
|
356
|
-
- **
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
itself stays exactly as the optimistic twin left it: an accepted write does not flicker.
|
|
360
|
-
- **A refused mutation is rolled back, not retried.** An `ack` carrying an error undoes that
|
|
361
|
-
mutation's optimistic write *and* every write made after it — newest first — then replays the
|
|
362
|
-
others without it, which is sound only because `local` is pure. The refused intent is dropped from
|
|
363
|
-
the rebase log rather than retried: a denial is a decision about that intent, and replaying it
|
|
364
|
-
would put the write the server refused back on the screen. Idempotent for a key the log does not
|
|
365
|
-
hold, because a denial can arrive twice and tier 2 records nothing to undo.
|
|
364
|
+
- **The socket carries no writes (protocol 3, 21.0.0).** A write is HTTP; an `ack` is only ever a
|
|
365
|
+
refusal, naming the subscription it refused (that window renders `failed`) or the socket for a
|
|
366
|
+
frame the node could not read — including a `mutate` from a client one major behind.
|
|
366
367
|
- **Nothing on the client detects drift, and nothing ever did.** `verifyDigest()` claimed to and had
|
|
367
368
|
no caller (deleted 2026-08-23); the `digest` it read went with it (2026-08-24), along with the
|
|
368
369
|
`count` beside it. What detects drift is the server's `desynced` mark and the re-snapshot it
|
|
@@ -388,10 +389,10 @@ wire twice by a reconnect that raced an ack.
|
|
|
388
389
|
cannot mark a subscriber desynced — which is to say it cannot do any of the three things above.
|
|
389
390
|
`WsLike.subscribe`/`unsubscribe` stay **declared and unused**: the interface is structural and a
|
|
390
391
|
tracked app implements it, so deleting the members breaks that app's typecheck.
|
|
391
|
-
- **Inbound frames are ordered per
|
|
392
|
+
- **Inbound frames are ordered per subscription, never per socket.** A
|
|
392
393
|
global per-socket lane puts every frame behind the slowest one, and the slowest one is a
|
|
393
394
|
subscribe's snapshot read — the round trip every reconnecting client pays in a restart storm.
|
|
394
|
-
`
|
|
395
|
+
`subscribe` is one lane per sid, or per topic name; `hello` and
|
|
395
396
|
the server-authored kinds are unlaned. A lane exists only while work is queued on it, because a
|
|
396
397
|
lane keyed by a client-chosen sid that outlived its work is an unbounded map one socket can grow.
|
|
397
398
|
- **`qid` is `@ultimat3/query`'s `queryHash(name, input)`** — `<name>:<first 16 hex of
|
|
@@ -528,16 +529,9 @@ wire twice by a reconnect that raced an ack.
|
|
|
528
529
|
running half: one per change delivered off a relation that is not FULL, so the decisions it
|
|
529
530
|
actually cost are countable rather than silent. A hard refusal at `x verify` time is the
|
|
530
531
|
follow-up.
|
|
531
|
-
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
subpath `exports` never declared). `MemoryLocalStore`, beside it on `.`, implements the full
|
|
535
|
-
journal/rollback/replay semantics today and is what the refusal's `fix:` names. It holds
|
|
536
|
-
membership and the journal; the row values are the client's one `IdentityMap`, which is what a
|
|
537
|
-
browser store has to inherit rather than re-implement.
|
|
538
|
-
- The identity map is **per client**, in memory, and it is not a query cache: it answers "what is
|
|
539
|
-
row X now", never "have I run this query before". Nothing evicts by time or size — a row lives
|
|
540
|
-
exactly as long as a window or a table holds it.
|
|
532
|
+
- The record store is **per page**, in memory, and it is not a query cache: it answers "what is
|
|
533
|
+
record X now", never "have I run this query before". Nothing evicts by time or size — a record
|
|
534
|
+
lives as long as something holds it. Persisting it (IndexedDB) is plan 101 slice 12.
|
|
541
535
|
|
|
542
536
|
## Errors
|
|
543
537
|
|
|
@@ -545,7 +539,8 @@ wire twice by a reconnect that raced an ack.
|
|
|
545
539
|
`X_PROTOCOL_VERSION` · `X_CURSOR_STALE` ·
|
|
546
540
|
`X_REBASE_CONFLICT` · `X_TRANSPORT_UNAVAILABLE` · `X_TRANSPORT_PROTOCOL` ·
|
|
547
541
|
`X_REPLICATION_FAILED` · `X_REPLICATION_PROTOCOL` · `X_REPLICATOR_SLOT_HELD` ·
|
|
548
|
-
`
|
|
542
|
+
`X_REALTIME_UNINSTALLED` · `X_SYNC_UNCONFIGURED` · `X_RECORD_REJECTED` ·
|
|
543
|
+
`X_LIVE_SERVER_RENDER` · `X_LIVE_QUERY_UNKNOWN` ·
|
|
549
544
|
`X_LIVE_REPLICA_IDENTITY` ·
|
|
550
545
|
`X_SOCKET_UNAUTHENTICATED` · `X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED` ·
|
|
551
546
|
`X_TIMEOUT`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/realtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "21.0.0",
|
|
4
4
|
"description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
},
|
|
19
19
|
"exports": {
|
|
20
20
|
".": "./src/index.ts",
|
|
21
|
-
"./
|
|
21
|
+
"./boot": "./src/boot.ts",
|
|
22
|
+
"./server": "./src/server.ts",
|
|
23
|
+
"./sync-worker": "./src/sync-worker.ts"
|
|
22
24
|
},
|
|
23
25
|
"files": [
|
|
24
26
|
"src",
|
|
@@ -36,8 +38,9 @@
|
|
|
36
38
|
"test": "bun test"
|
|
37
39
|
},
|
|
38
40
|
"dependencies": {
|
|
39
|
-
"@ultimat3/core": "
|
|
40
|
-
"@ultimat3/
|
|
41
|
+
"@ultimat3/core": "21.0.0",
|
|
42
|
+
"@ultimat3/entity": "21.0.0",
|
|
43
|
+
"@ultimat3/query": "21.0.0",
|
|
41
44
|
"nats": "2.29.3"
|
|
42
45
|
}
|
|
43
46
|
}
|
package/src/apply-patches.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// app can reuse to apply the same patches to its own store.
|
|
4
4
|
//
|
|
5
5
|
// Order and values are folded separately: a live window keeps its ORDER here and its VALUES in the
|
|
6
|
-
//
|
|
6
|
+
// record store, so `applyPatches` is the array form of the same fold rather than a second one.
|
|
7
7
|
|
|
8
8
|
import type { JsonObject, Row, RowPatch } from './json';
|
|
9
9
|
|
package/src/boot.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// `@ultimat3/realtime/boot` — the page's boot, ONE classic script per document (plan 101), built
|
|
2
|
+
// and served by the CLI the way the sync worker is. It restores the principal's persisted records
|
|
3
|
+
// and opens the outbox, so a reload replays what the previous load queued even on a page whose
|
|
4
|
+
// islands only read. Here and not in `page-store.ts`, so no island bundle carries it.
|
|
5
|
+
|
|
6
|
+
import type { ClientScope } from '@ultimat3/core/page';
|
|
7
|
+
import { pageClient } from '@ultimat3/core/page';
|
|
8
|
+
import { pageLocalStore, scopeKey } from './local-store-idb';
|
|
9
|
+
import { pageOutbox } from './page-outbox';
|
|
10
|
+
import { BOOT_KEY, BOOT_RELEASE_KEY, type BootHost, pageRealtime } from './page-store';
|
|
11
|
+
import { persistedTypes, type RecordPersister, recordPersister } from './record-persister';
|
|
12
|
+
import type { RecordStore } from './record-store';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The types the document marks persisted, read back off disk, then the page's outbox opened. None
|
|
16
|
+
* marked is no record disk.
|
|
17
|
+
*/
|
|
18
|
+
async function restoreFromDisk(store: RecordStore, principal: Principal): Promise<void> {
|
|
19
|
+
const types = persistedTypes();
|
|
20
|
+
const keep = scopeKey(principal);
|
|
21
|
+
let persister: RecordPersister | undefined;
|
|
22
|
+
try {
|
|
23
|
+
// A sign-out by full navigation (a form post and a redirect) never calls `rescope()`, so the
|
|
24
|
+
// previous principal's rows and queued writes would outlive it on disk. The boot is the one
|
|
25
|
+
// moment every page load passes: everything not THIS principal's goes, before anything is
|
|
26
|
+
// restored. An unscoped page (rendered for nobody) wipes nothing — it knows no principal.
|
|
27
|
+
// Trade-off: two principals in two tabs of one browser — the newer boot wipes the other's
|
|
28
|
+
// disk; that tab keeps its records in memory and re-persists them on its next write.
|
|
29
|
+
if (keep !== undefined) await (await pageLocalStore()).wipeOthers(keep);
|
|
30
|
+
if (types.size > 0) {
|
|
31
|
+
persister = recordPersister({ store, local: await pageLocalStore(), types });
|
|
32
|
+
await persister.restore();
|
|
33
|
+
}
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// A disk the browser refused (quota, a private window) costs the offline copy, never the page.
|
|
36
|
+
console.error(error);
|
|
37
|
+
}
|
|
38
|
+
// The outbox opens with the page, not with the first write or live hook: a reload that holds
|
|
39
|
+
// neither must still replay what the previous load queued (on open, `online`, the SW's drain).
|
|
40
|
+
// After the restore, so a replay settles overlays over the records it restored.
|
|
41
|
+
// A queued write flushes the persisted rows first, so both are on disk before a reload can come.
|
|
42
|
+
const kept = persister;
|
|
43
|
+
pageOutbox({ beforeEnqueue: kept === undefined ? undefined : () => kept.flush() });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Principal = ClientScope['principal'];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Once per page, whoever calls first; the promise is what `pageRealtime().booted` answers. The
|
|
50
|
+
* scope is the page's own — a parameter only so a test can boot an unscoped page (an OBJECT, so an
|
|
51
|
+
* explicit `undefined` principal is not mistaken for "use the default").
|
|
52
|
+
*/
|
|
53
|
+
export function bootPage(
|
|
54
|
+
scope: { readonly principal: Principal } = pageClient().scope,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const host = globalThis as BootHost;
|
|
57
|
+
const started = host[BOOT_KEY];
|
|
58
|
+
const waiting = host[BOOT_RELEASE_KEY];
|
|
59
|
+
// Already booting — unless what sits there is an island's placeholder, which this boot claims.
|
|
60
|
+
if (started !== undefined && waiting === undefined) return started;
|
|
61
|
+
Reflect.deleteProperty(host, BOOT_RELEASE_KEY);
|
|
62
|
+
const booted = restoreFromDisk(pageRealtime().store, scope.principal);
|
|
63
|
+
if (started !== undefined && waiting !== undefined) {
|
|
64
|
+
void booted.then(waiting);
|
|
65
|
+
return started;
|
|
66
|
+
}
|
|
67
|
+
Object.defineProperty(host, BOOT_KEY, { value: booted, configurable: true });
|
|
68
|
+
return booted;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// As a page script it runs itself; imported by a test (no DOM) it waits to be called.
|
|
72
|
+
if (typeof document !== 'undefined') void bootPage();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The ONE `new WebSocket` in the framework. The page opens one socket, through this adapter; an
|
|
2
|
+
// island never dials, and neither does an app. Four handlers and a send — the reconnect, the
|
|
3
|
+
// backoff and the heartbeat all stay in `LiveClient`, which is why this is the whole adapter.
|
|
4
|
+
|
|
5
|
+
import type { ClientSocket } from './client-contract';
|
|
6
|
+
import type { SyncTarget } from './page-store';
|
|
7
|
+
|
|
8
|
+
export function browserSocket(url: string): ClientSocket {
|
|
9
|
+
const socket = new WebSocket(url);
|
|
10
|
+
return {
|
|
11
|
+
send: (data: string): void => {
|
|
12
|
+
socket.send(data);
|
|
13
|
+
},
|
|
14
|
+
close: (code?: number, reason?: string): void => {
|
|
15
|
+
socket.close(code, reason);
|
|
16
|
+
},
|
|
17
|
+
onOpen: (handler: () => void): void => {
|
|
18
|
+
socket.onopen = (): void => {
|
|
19
|
+
handler();
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
onMessage: (handler: (data: string) => void): void => {
|
|
23
|
+
socket.onmessage = (event: MessageEvent): void => {
|
|
24
|
+
handler(String(event.data));
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
onClose: (handler: (code: number) => void): void => {
|
|
28
|
+
socket.onclose = (event: CloseEvent): void => {
|
|
29
|
+
handler(event.code);
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
get bufferedAmount(): number {
|
|
33
|
+
return socket.bufferedAmount;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `?build=` rides the dial, so a stale tab is told to reload on the socket it opens. */
|
|
39
|
+
export function dialUrl(target: SyncTarget): string {
|
|
40
|
+
const joiner = target.url.includes('?') ? '&' : '?';
|
|
41
|
+
return `${target.url}${joiner}build=${encodeURIComponent(target.buildId)}`;
|
|
42
|
+
}
|
package/src/changefeed.ts
CHANGED
|
@@ -26,6 +26,13 @@ export interface ChangeEvent<R extends Row = Row> {
|
|
|
26
26
|
readonly orgId: string | null;
|
|
27
27
|
/** Commit time, epoch ms. */
|
|
28
28
|
readonly at: number;
|
|
29
|
+
/**
|
|
30
|
+
* The write that made this change: `writeDigest` of the idempotency key its request carried
|
|
31
|
+
* (`@ultimat3/core`). Absent for a change no keyed request made. Read off the WAL message the
|
|
32
|
+
* Postgres driver writes first in the transaction, or off the request scope in-process; a
|
|
33
|
+
* channel stamps it on the `records` frame so the writing page recognises its own echo.
|
|
34
|
+
*/
|
|
35
|
+
readonly write?: string;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export interface ChangeFeedStartOptions {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A declared channel's policy, asked on subscribe: the params are the input and the row is what
|
|
2
|
+
// the channel's own loader answered. Through `@ultimat3/query`'s `guard`, the package's one authz
|
|
3
|
+
// seam — the same call `policy-gate.ts` makes for a live query.
|
|
4
|
+
|
|
5
|
+
import type { Actor, Ctx } from '@ultimat3/core';
|
|
6
|
+
import { guard, QueryDeniedError } from '@ultimat3/query';
|
|
7
|
+
import type { Channel } from './channel-decl';
|
|
8
|
+
import { TopicForbiddenError } from './errors';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A denial is `X_TOPIC_FORBIDDEN`. A loader or a rule that RAISED is not a denial and leaves as it
|
|
12
|
+
* came, so the caller can tell an outage from a decision — `onActorChange` keeps the topic on one.
|
|
13
|
+
*/
|
|
14
|
+
export async function authorizeChannel(
|
|
15
|
+
channel: Channel,
|
|
16
|
+
ctx: Ctx,
|
|
17
|
+
actor: Actor | null,
|
|
18
|
+
topic: string,
|
|
19
|
+
params: Readonly<Record<string, string>>,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
if (channel.policy === undefined) return;
|
|
22
|
+
const row = channel.row === undefined ? null : await channel.row({ params, ctx });
|
|
23
|
+
try {
|
|
24
|
+
guard(channel.policy, { actor, input: params, row, ctx, query: channel.name }, 'live');
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (!(error instanceof QueryDeniedError)) throw error;
|
|
27
|
+
throw new TopicForbiddenError({
|
|
28
|
+
topic,
|
|
29
|
+
actorId: actor === null ? null : actor.id,
|
|
30
|
+
reason: `channel "${channel.name}" policy denied the subscribe`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// One topic's transport bridge into this node — the shape `ChannelHub` refcounts, and its safe
|
|
2
|
+
// close.
|
|
3
|
+
|
|
4
|
+
import type { TransportSubscription } from './fanout';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One topic's fanout into this node. `sub` is the transport subscription as a PROMISE, published
|
|
8
|
+
* into the table before it is awaited: looked up before the await and written after it, two sockets
|
|
9
|
+
* reaching one topic at once opened two transport subscriptions — the second replacing the first in
|
|
10
|
+
* the table, and the first then unreachable by `#release`, by a socket dying, by `close()` or by
|
|
11
|
+
* anything else, delivering every message on that topic a second time for the life of the process.
|
|
12
|
+
*
|
|
13
|
+
* `null` means the slot is taken and nothing is open yet: the node cap is decided before the guard
|
|
14
|
+
* runs, so the reservation has to exist before there is anything to reserve it with.
|
|
15
|
+
*/
|
|
16
|
+
export interface Bridge {
|
|
17
|
+
sub: Promise<TransportSubscription> | null;
|
|
18
|
+
refs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A bridge released while its subscription is still opening still has to be closed — the transport
|
|
23
|
+
* hands the handle back after the caller has gone, and dropping the promise would leave a live
|
|
24
|
+
* subscription this node can no longer name. An open that failed has nothing to unsubscribe and its
|
|
25
|
+
* rejection was already answered to the subscriber that caused it.
|
|
26
|
+
*/
|
|
27
|
+
export function unsubscribeWhenOpen(bridge: Bridge): void {
|
|
28
|
+
void bridge.sub?.then(
|
|
29
|
+
(sub) => {
|
|
30
|
+
sub.unsubscribe();
|
|
31
|
+
},
|
|
32
|
+
() => undefined,
|
|
33
|
+
);
|
|
34
|
+
}
|