@ultimat3/realtime 1.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +641 -0
- package/README.md +336 -19
- package/package.json +6 -3
- package/src/apply-patches.ts +60 -0
- package/src/change-buffer.ts +77 -11
- package/src/channel.ts +202 -20
- package/src/client-contract.ts +81 -0
- package/src/client-frames.ts +175 -0
- package/src/client-heartbeat.ts +77 -0
- package/src/client-mutations.ts +114 -0
- package/src/client-topics.ts +54 -0
- package/src/client.ts +307 -273
- package/src/cursor.ts +7 -1
- package/src/errors.ts +193 -4
- package/src/frame-lanes.ts +58 -0
- package/src/hooks.ts +19 -5
- package/src/identity-map.ts +141 -0
- package/src/index.ts +99 -28
- package/src/json.ts +38 -1
- package/src/live-contract.ts +67 -0
- package/src/live-definition.ts +16 -11
- package/src/live-fanout.ts +150 -0
- package/src/live-query.ts +215 -268
- package/src/live-rows.ts +143 -0
- package/src/local-store.ts +86 -43
- package/src/nats-client.ts +132 -0
- package/src/nats-fake.ts +389 -344
- package/src/nats-jetstream.ts +21 -20
- package/src/nats-kv.ts +7 -7
- package/src/nats-lib-client.ts +210 -0
- package/src/nats-transport.ts +109 -138
- package/src/offline-queue.ts +146 -30
- package/src/pg-entity-row.ts +99 -31
- package/src/pg-replication.ts +84 -27
- package/src/pg-socket.ts +4 -1
- package/src/policy-gate.ts +13 -5
- package/src/presence.ts +76 -6
- package/src/query-hook.ts +56 -0
- package/src/query-window.ts +187 -0
- package/src/rebase.ts +68 -8
- package/src/replicator.ts +84 -11
- package/src/socket.ts +225 -34
- package/src/subscriber-gate.ts +209 -0
- package/src/subscription-book.ts +237 -0
- package/src/sync-auth.ts +124 -0
- package/src/sync-frames.ts +185 -0
- package/src/sync-listen.ts +73 -0
- package/src/sync-node.ts +324 -248
- package/src/sync-protocol.ts +115 -24
- package/src/sync-upgrade.ts +124 -0
- package/src/thundering-herd.ts +21 -0
- package/src/transport-env.ts +3 -3
- package/src/type-pins.ts +72 -0
- package/src/window-lock.ts +21 -0
- package/src/nats-commands.ts +0 -97
- package/src/nats-connection-fixture.ts +0 -105
- package/src/nats-connection.ts +0 -464
- package/src/nats-protocol.ts +0 -222
- package/src/nats-socket.ts +0 -236
- package/src/pg-connection-fixture.ts +0 -215
- package/src/pg-replication-fixture.ts +0 -261
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Three tiers, one ladder, one protocol. Climbing a rung is a config change, never
|
|
|
6
6
|
|
|
7
7
|
| Tier | What it gives you | What it costs you |
|
|
8
8
|
|---|---|---|
|
|
9
|
-
| **1 — channels** | `publish`/`subscribe` on typed topics, presence, cursors, typing indicators | ~0.
|
|
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
11
|
| **3 — local-first** | writes that survive being offline | a durable local store, a rebase log, client-side migrations, a conflict story per mutator |
|
|
12
12
|
|
|
@@ -52,12 +52,15 @@ frame handler is unchanged between rungs.
|
|
|
52
52
|
| tier 2 | `LiveQueryRegistry`, `InMemoryChangeFeed`, `PgLogicalReplicationFeed`, `selectChangeFeed`, `createReplicator`, `PgAdvisoryLock`, `matcherFor` |
|
|
53
53
|
| replication | `parsePgUrl`, `bunPgStream`, `PgOutputDecoder`, `entityRow`, `changeLsn`, `commitPositionOf` |
|
|
54
54
|
| fanout | `Transport`, `InProcessTransport`, `NatsTransport`, `selectTransport`, `subjectMatches` |
|
|
55
|
-
| the bus
|
|
56
|
-
| reconnect | `LiveCursor`, `resumeFrom`, `shouldResnapshot`, `defaultReconnectBudget`, `RingChangeBuffer`, `backoffDelay`, `drainPlan`, `AcceptBudget` |
|
|
55
|
+
| the bus, behind `NatsTransport` | 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 |
|
|
56
|
+
| reconnect | `LiveCursor`, `resumeFrom`, `shouldResnapshot`, `defaultReconnectBudget`, `RingChangeBuffer`, `backoffDelay`, `Scheduler`, `timeoutScheduler`, `drainPlan`, `AcceptBudget` |
|
|
57
|
+
| the client store | `IdentityMap` — one row value per `(entity, id)` — plus `RowWindows`, `rowKey`, `privateScope`, `applyPatches`/`orderAfterPatches` |
|
|
57
58
|
| tier 3 | `MemoryLocalStore`, `createOpfsLocalStore`, `OfflineQueue`, `RebaseLog`, `reconcile`, `custom` |
|
|
58
59
|
| wire | `PROTOCOL_VERSION`, `encode`, `decode`, `Frame` |
|
|
59
60
|
| halves | `LiveClient` (client), `createSyncNode` / `listenSyncNode` (`sync` role) |
|
|
61
|
+
| a socket's identity | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
|
|
60
62
|
| hooks | `setLiveClient`, `useLive`, `useConnection`, `useMutation`, `useMutationQueue` |
|
|
63
|
+
| the typed projection | `liveHookFor` — one query bound to one named hook |
|
|
61
64
|
|
|
62
65
|
## The four hooks
|
|
63
66
|
|
|
@@ -79,15 +82,146 @@ const queue = useMutationQueue(); // .pending .fai
|
|
|
79
82
|
| Every member is a **getter**, every result set an **accessor** | a value snapshotted at hook time never re-renders |
|
|
80
83
|
| A thunk `input` is read **once**, at subscribe time | nothing here re-runs it; changing input is a new subscription |
|
|
81
84
|
| The caller owns `unsubscribe` | this layer does not know what a mount is |
|
|
85
|
+
| Every subscription handle (`useLive`'s return, `client.subscribe(topic, …)`'s return) is `Disposable` | `using feed = useLive(liveFeed, () => input)` unsubscribes on scope exit — the same call as `unsubscribe()`, never a second teardown path |
|
|
82
86
|
| `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 |
|
|
83
87
|
|
|
84
88
|
Tier 2 has no queue, so `pending` is `0` there — stated, not guessed.
|
|
85
89
|
|
|
90
|
+
### The typed one: `liveHookFor`
|
|
91
|
+
|
|
92
|
+
`useLive(query, input)` takes any object carrying a `name`, so it cannot type either side.
|
|
93
|
+
`liveHookFor` binds one declared `query({ live: true })` to one named hook and carries both types
|
|
94
|
+
through — the query's `input` in, its row type out. It is not a second subscribe path: it *is*
|
|
95
|
+
`useLive`, with the name and the types already bound.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
export const useLiveFeed = liveHookFor(liveFeed); // app/feed/hooks.ts — one line, no codegen
|
|
99
|
+
|
|
100
|
+
const feed = useLiveFeed({ orgId: actor.orgId }); // feed()[0].title typechecks
|
|
101
|
+
useLiveFeed({ orgIdd: actor.orgId }); // does not compile
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The query's name is read **per call**, never at bind time: `registerQueries()` stamps it at boot,
|
|
105
|
+
after a module-level binding has already run. Binding a query with no `live: true` is
|
|
106
|
+
`X_QUERY_NOT_SUBSCRIBABLE`, thrown where the binding is written — a read that never patches has no
|
|
107
|
+
subscription to hold, and the non-live read from a component is `query.client({ baseUrl })`.
|
|
108
|
+
`type-pins.ts` fails the build if the hook ever widens either type.
|
|
109
|
+
|
|
110
|
+
## Who a socket is
|
|
111
|
+
|
|
112
|
+
`createSyncNode({ authenticate })` is the one place a websocket gets an identity. It runs on the
|
|
113
|
+
upgrade **before** `server.upgrade`, so a refused credential never costs a socket, and the actor it
|
|
114
|
+
resolves is what every policy downstream decides against — the topic guard, `authorize`, `visible`,
|
|
115
|
+
the per-tenant subscription cap.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
createSyncNode({
|
|
119
|
+
// From @ultimat3/auth, or anywhere else: `sync` imports no authenticator, exactly as it owns no
|
|
120
|
+
// mutation logic. `refresh` is yours too, so the framework retains no credential of its own.
|
|
121
|
+
authenticate: async (request) => {
|
|
122
|
+
const session = await sessionFrom(request);
|
|
123
|
+
return session === null
|
|
124
|
+
? null
|
|
125
|
+
: { actor: session.actor, expiresAt: session.expiresAt, refresh: () => renew(session.token) };
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
| The answer | What the node does |
|
|
131
|
+
|---|---|
|
|
132
|
+
| a `SyncGrant` | upgrades, and the socket carries `grant.actor` |
|
|
133
|
+
| `null` | **401** `X_SOCKET_UNAUTHENTICATED` — a decision, and the client's own condition |
|
|
134
|
+
| a throw | **503** `X_SOCKET_AUTH_UNAVAILABLE` — a failure, reported, and the client is told to retry |
|
|
135
|
+
| the option is absent | upgrades **anonymous**, and `start()` warns: every policy on that node is being asked about `null` |
|
|
136
|
+
|
|
137
|
+
`expiresAt` is the half a long-lived socket needs: the node re-decides an expired grant on an
|
|
138
|
+
interval (`reauthenticateIntervalMs`, 30s), calling `refresh` and then `hub.onActorChange` +
|
|
139
|
+
`registry.reauthorize` — so a revoked role drops the topics and subscriptions it no longer covers,
|
|
140
|
+
and survivors are re-snapshotted under the new authority. No `refresh`, and an expired grant closes
|
|
141
|
+
the socket with `1008`; the client re-dials with a fresh credential. A `refresh` that *raises* keeps
|
|
142
|
+
the socket and retries next pass — a token service timing out is not a revocation.
|
|
143
|
+
|
|
144
|
+
**Without `authenticate` this node is single-tenant.** Every actor is `null`, so
|
|
145
|
+
`hub.guard('org.*.feed', ({ actor }) => actor?.orgId === …)` denies everyone and the only guard that
|
|
146
|
+
lets anything through is one that reads no actor at all.
|
|
147
|
+
|
|
86
148
|
Authz goes through `@ultimat3/query`'s `guard`, which is the only contact with `@ultimat3/policy`.
|
|
87
149
|
One authz system, never two: `policy` is evaluated **once per subscriber**, never once per query.
|
|
88
150
|
Two actors on one live query get two different result sets, and a row that leaves an actor's policy
|
|
89
151
|
is delivered to them as a `delete` — never as silence.
|
|
90
152
|
|
|
153
|
+
## What one socket may cost
|
|
154
|
+
|
|
155
|
+
Every ceiling on this node, and the option that moves it. Each one is a default, not a policy: an
|
|
156
|
+
app narrows or widens it where the object is constructed, and none of them can be raised from the
|
|
157
|
+
wire.
|
|
158
|
+
|
|
159
|
+
| Ceiling | Default | Option | Refused with |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| concurrent sockets on this node | 250,000 | `createSyncNode({ maxConnections })` | `503` + `retry-after-ms`, the same shed as the accept budget |
|
|
162
|
+
| inbound bytes per frame | 256 KiB | `createSyncNode({ maxFrameBytes })` | the socket, by `Bun.serve`'s `maxPayloadLength` |
|
|
163
|
+
| inbound frames per socket | 64/s, burst 256 | `createSyncNode({ maxFramesPerSecond, frameBurst })` | `X_FRAME_RATE_LIMIT` |
|
|
164
|
+
| live subscriptions per socket | 128 | `new LiveQueryRegistry({ maxPerSocket })` | `X_SUBSCRIPTION_LIMIT` |
|
|
165
|
+
| live subscriptions per tenant | unset | `new LiveQueryRegistry({ maxPerTenant, tenantOf })` — **both**, or it arms nothing | `X_SUBSCRIPTION_LIMIT` |
|
|
166
|
+
| distinct `(query, input)` pairs per node | 10,000 | `new LiveQueryRegistry({ maxEntries })` | `X_SUBSCRIPTION_LIMIT` |
|
|
167
|
+
| channel topics per socket | 64 | `new ChannelHub({ maxTopicsPerSocket })` | `X_SUBSCRIPTION_LIMIT` |
|
|
168
|
+
| distinct channel topics per node | 10,000 | `new ChannelHub({ maxTopicsPerNode })` | `X_SUBSCRIPTION_LIMIT` |
|
|
169
|
+
| outbound bytes buffered on one socket | 1 MiB | `createSyncNode({ maxBufferedBytes })` | the frame is dropped and `send` answers `false` |
|
|
170
|
+
| dropped frames before that socket is closed | 32 | `createSyncNode({ maxDroppedFrames })` | close `1013` (`overloaded`), reason `backpressure` |
|
|
171
|
+
| time one socket may route no frame | 120s | `createSyncNode({ idleTimeoutMs })` | close `4001` (`idle`), reason `idle timeout` |
|
|
172
|
+
| retained patch bytes per node | 64 MiB | `new RingChangeBuffer({ maxBytes, maxBytesPerQuery })` | eviction, then a re-snapshot on resume |
|
|
173
|
+
| array lengths and `input` nesting in a frame | `FRAME_LIMITS` | none — a hard ceiling | `X_PROTOCOL_VERSION` |
|
|
174
|
+
|
|
175
|
+
**Every one of those is taken as a reservation, not checked.** A subscribe holds nothing until three
|
|
176
|
+
awaits later, so `SubscriptionBook.reserve(socket, sid)` and `ChannelHub`'s bridge reservation decide
|
|
177
|
+
the sid claim and all four subscription caps **synchronously, before the first `await`**, against a
|
|
178
|
+
count that already includes the subscribes still in flight. One WebSocket write carrying N subscribe
|
|
179
|
+
frames used to pass every cap N times — the ordinary case, no attacker required. The slot is given
|
|
180
|
+
back in a `finally`, and releasing twice is a no-op.
|
|
181
|
+
|
|
182
|
+
The accept budget bounds the accept **rate**; `maxConnections` bounds the **count**, and they are
|
|
183
|
+
two different attacks — 500 accepts/s held open with one keepalive each is 1.8M sockets an hour.
|
|
184
|
+
The frame budget is per socket and checked at the top of the frame router, before anything a frame
|
|
185
|
+
can reach: a subscribe frame is a database read, a presence write and a fleet-wide publish, and one
|
|
186
|
+
authenticated socket is the cheapest foothold there is.
|
|
187
|
+
|
|
188
|
+
`FRAME_LIMITS` is the wire's own hard ceiling — array lengths (`cursor.ids`, `patches`, `rows`,
|
|
189
|
+
`members`) plus the depth and node count of a client-supplied `input`. It is not an option:
|
|
190
|
+
`input` reaches `canonicalJson`, which recurses, so an unbounded one is a stack overflow in the
|
|
191
|
+
process rather than a slow query.
|
|
192
|
+
|
|
193
|
+
## One row per `(entity, id)`
|
|
194
|
+
|
|
195
|
+
Two components subscribing to two live queries that both return post #7 hold **one** row, not two
|
|
196
|
+
copies of it. That is the client's whole store: a `LiveClient` owns one `IdentityMap`, every live
|
|
197
|
+
window is an ordered list of ids over it, and the tier-3 local store's tables are membership over
|
|
198
|
+
the same map. A write through any of them is the same row for all of them.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
const feed = useLive(liveFeed, () => ({ orgId })); // holds p1, p2, p7
|
|
202
|
+
const pinned = useLive(livePinned, () => ({ orgId })); // holds p7
|
|
203
|
+
|
|
204
|
+
await like({ postId: 'p7' }); // one optimistic write...
|
|
205
|
+
feed()[2] === pinned()[0]; // ...and both views are looking at it
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Nothing is declared to get this. There is no normalization schema, no cache key, no selector — an
|
|
209
|
+
app writes `useLive` and `useMutation` exactly as before.
|
|
210
|
+
|
|
211
|
+
| Rule | Why |
|
|
212
|
+
|---|---|
|
|
213
|
+
| Identity is `(entity, id)`, never `id` alone | two entities may spell one id the same way; `posts/7` and `users/7` are two rows |
|
|
214
|
+
| The entity comes **from the server**, on the `snapshot` frame | the shape is compiled server-side out of `sql`; a browser cannot derive it, and a scope an app declares by hand is a second place for it to be wrong |
|
|
215
|
+
| A subscription the server named no entity for keeps its rows in a scope private to itself | no sharing is a stale view; wrong sharing is two entities merged into one row |
|
|
216
|
+
| A value is **replaced, never mutated** — every write is a new object | a mutated row is a render that never happens |
|
|
217
|
+
| A write **merges** columns; it never drops one | two queries may project different columns of one row, and the narrower one must not blank what the wider one renders |
|
|
218
|
+
| A row is dropped when the last window and the last table let go of it | an infinite scroll must not retain every row it ever saw |
|
|
219
|
+
| A rebase rolls back through the same map | the optimistic write, the server's truth and the replay are one row's history, not a second copy's |
|
|
220
|
+
|
|
221
|
+
`entity` on a `snapshot` frame is **additive**: an older node omits it and the client falls back to
|
|
222
|
+
the private scope, a newer node sends it and an older client ignores it. Both skews are safe in
|
|
223
|
+
both directions, which is why it carries no `PROTOCOL_VERSION` bump.
|
|
224
|
+
|
|
91
225
|
## Reconnect is the hard part
|
|
92
226
|
|
|
93
227
|
A deploy drops N sockets at once and every one asks "what changed since X?". If that answer needs
|
|
@@ -111,16 +245,171 @@ sends a `reconnect` frame carrying that delay — clients redistribute instead o
|
|
|
111
245
|
`AcceptBudget` is the receiving node's token bucket, and a refusal always carries a retry delay,
|
|
112
246
|
because refusing without one just moves the herd next door.
|
|
113
247
|
|
|
248
|
+
The client dials itself back. A closed socket arms one timer — the node's delay when a `reconnect`
|
|
249
|
+
frame assigned one, otherwise `backoffDelay()` — and that timer calls `connect()`, which re-subscribes
|
|
250
|
+
every registration **and re-announces every topic**. Topic membership is state on the node's socket
|
|
251
|
+
and `hello` carries none of it, so without that half a channel goes silent from the first reconnect
|
|
252
|
+
onwards while its handler is still installed — and its presence membership is swept, because
|
|
253
|
+
subscribing to a topic *is* joining the room. `reconnectAt` is what a component renders while it
|
|
254
|
+
waits; `close()` cancels it, and `connect()` starts over. The timer comes from an injected
|
|
255
|
+
`Scheduler`, so a test fires it by hand instead of sleeping.
|
|
256
|
+
|
|
257
|
+
### Liveness: `heartbeatMs`
|
|
258
|
+
|
|
259
|
+
A half-open socket — the TCP connection is dead and no `close` ever fires — is invisible to the
|
|
260
|
+
browser. The client is the only thing that can end one.
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
new LiveClient({ signal, connect, buildId, heartbeatMs: 15_000 }); // 0 disables the pass
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
| Property | Behaviour |
|
|
267
|
+
|---|---|
|
|
268
|
+
| Default | `DEFAULT_HEARTBEAT_MS`, 15s. The same number as the server's `realtime.heartbeatMs`, restated rather than read: that is server config and this is browser code |
|
|
269
|
+
| One beat | a `hello` — which carries no cursors at all; `HelloFrame` has no resume list, so a beat and an opening frame are byte-identical — plus one subscribe frame per topic held |
|
|
270
|
+
| Why the topics | on the node, repeating the subscribe frame **is** the presence heartbeat; presence has no frame of its own in either direction |
|
|
271
|
+
| Not a deploy check | `update-available` answers a skew between the build id recorded at the upgrade and the node's own, and neither can change on an open socket — so every `hello` on one socket answers the same forever. A client hears about a deploy on the socket it opens against the **new** node |
|
|
272
|
+
| Silence | nothing received for **two** intervals ⇒ close `4000` (a private-use code, so it is distinguishable in a log) and arm the reconnect. Judged from the last frame of any kind, since the point is that bytes still cross |
|
|
273
|
+
| Not an interval | one armed tick, re-armed by itself, on the same injected `Scheduler` the reconnect uses — a client is either beating on a live socket or backing off toward a new one, never both |
|
|
274
|
+
|
|
275
|
+
`realtime.heartbeatMs` in `app.config.ts` is **read by nothing** `As of 2026-08`; this option is the
|
|
276
|
+
only knob that changes behaviour.
|
|
277
|
+
|
|
278
|
+
### A `send` that returned is not an acknowledgement
|
|
279
|
+
|
|
280
|
+
`WebSocket.send` on a CLOSING socket discards the frame and returns normally, so a drained mutation
|
|
281
|
+
is `inflight` — never `acked` — until the server settles it with an `ack`/`fail` frame, or a lost
|
|
282
|
+
connection returns it to `pending`. Only `pending` entries are sendable, so nothing is put on the
|
|
283
|
+
wire twice by a reconnect that raced an ack.
|
|
284
|
+
|
|
285
|
+
| Rule | Consequence |
|
|
286
|
+
|---|---|
|
|
287
|
+
| `drain()` is **one pass at a time**, chained rather than joined | two overlapping passes read the same entry as sendable and put one key on the wire twice; a later pass could also overtake the one in front of it, which is the ordering guarantee gone. A caller that enqueued mid-pass gets a pass *behind* it, not that pass's promise |
|
|
288
|
+
| A pass stops at the first refusal | continuing past a failure is how a sync engine reorders a user's intent |
|
|
289
|
+
| Backpressure **declines**, it does not fail | over `MAX_BUFFERED_BYTES` (1 MiB, the node's `backpressureLimit` at the other end of the same socket) the sender throws `X_TRANSPORT_UNAVAILABLE`, the mutation stays pending and the next drain resumes there. `ClientSocket.bufferedAmount` is optional; a socket that does not report it is treated as never backed up |
|
|
290
|
+
| Delivery is therefore at least once | every mutation carries an idempotency key — the `key` argument, or `<mutator>:<uuid>` — and the resend carries the same one |
|
|
291
|
+
| A lost connection **cancels the pass it interrupted** | the lane orders passes against each other, but a socket death is not a pass and cannot reach one parked inside `send`. `requeueInflight()` bumps a connection epoch; a pass whose epoch went stale returns and leaves the rest `pending`. Without it the parked pass resumed and marked everything behind it `inflight` for a dead socket — never re-sent (`inflight` is not sendable) and never acked |
|
|
292
|
+
| The store is handed a **snapshot**, never the live entries | `QueueStore.save` is a durable write and may await before it reads; given the array itself it persists a status that was never true when it was called |
|
|
293
|
+
|
|
114
294
|
### Limits, stated plainly
|
|
115
295
|
|
|
116
|
-
- **The change window is per node.** A client that reconnects to a
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
296
|
+
- **The change window is per node, and a `qid` window can only be.** A client that reconnects to a
|
|
297
|
+
*different* `sync` node has no ring there and takes the snapshot path. It is not a placement bug:
|
|
298
|
+
a patch is query-scoped, and the replicator is entity-scoped — it holds no compiled shape, no
|
|
299
|
+
matcher and no window, so it cannot produce one. What the snapshot path costs is one **shared**
|
|
300
|
+
read per (query, node), not one per client. A cross-node delta needs an *entity*-keyed window each
|
|
301
|
+
node fills from the change stream it already subscribes to, which is a `ResumeSource` shape change.
|
|
302
|
+
- **Fanout is at-most-once, and a gap is detected rather than assumed away.** The replicator stamps
|
|
303
|
+
every published change with `producer` + `seq`; a `sync` node that sees a skipped sequence
|
|
304
|
+
invalidates every window it holds and desyncs every subscriber, so the next change to each query
|
|
305
|
+
re-reads and re-snapshots. Both fields are optional on the bus, so a publisher that does not
|
|
306
|
+
sequence simply detects nothing. Durable replay (JetStream) is a separate decision — retention,
|
|
307
|
+
storage and replay window — and is deliberately not this mechanism.
|
|
308
|
+
- **`desynced` has a reader.** A subscriber recorded as diverged — a dropped patch, a gate that
|
|
309
|
+
failed, a window that lost its tail — is served a fresh snapshot out of the shared window on the
|
|
310
|
+
next delivery, and only then is the mark cleared. A snapshot the socket refuses leaves it
|
|
311
|
+
diverged, which is the state it is actually in.
|
|
312
|
+
- **The client's cursor advances on every patch, not only on a snapshot.** Left behind, `cursor.at`
|
|
313
|
+
froze at the last snapshot and `shouldResnapshot`'s lag check answered "re-snapshot" for every
|
|
314
|
+
client connected longer than `maxLagMs` — the delta resume the retained window exists for, dead
|
|
315
|
+
exactly during the deploy storm it was built for.
|
|
316
|
+
- **An accepted mutation is committed, not merely acknowledged.** The `ack` drops the journal row
|
|
317
|
+
and the rebase-log entry — there is nothing to roll back *to* any more, and a later reconcile
|
|
318
|
+
would otherwise replay a write the server already applied over rows that have moved on. The row
|
|
319
|
+
itself stays exactly as the optimistic twin left it: an accepted write does not flicker.
|
|
320
|
+
- **A refused mutation is rolled back, not retried.** An `ack` carrying an error undoes that
|
|
321
|
+
mutation's optimistic write *and* every write made after it — newest first — then replays the
|
|
322
|
+
others without it, which is sound only because `local` is pure. The refused intent is dropped from
|
|
323
|
+
the rebase log rather than retried: a denial is a decision about that intent, and replaying it
|
|
324
|
+
would put the write the server refused back on the screen. Idempotent for a key the log does not
|
|
325
|
+
hold, because a denial can arrive twice and tier 2 records nothing to undo.
|
|
120
326
|
- **A delta resume leaves the digest unverified** (`DIGEST_UNVERIFIED`). Only a snapshot re-establishes
|
|
121
327
|
it. `verifyDigest()` is how a client detects drift and asks for a fresh one.
|
|
122
328
|
- **Backpressure drops patch frames.** That is safe *only* because a re-snapshot is cheap: the drop
|
|
123
|
-
is recorded on the socket (`desynced`) and the next
|
|
329
|
+
is recorded on the socket (`desynced`) and the next delivery re-snapshots rather than diverging.
|
|
330
|
+
- **A dropped CHANNEL frame is not safe, and is not repaired.** A topic has no cursor, no mark and
|
|
331
|
+
no re-snapshot, so tier 1 is **at most once**. Every refusal is counted — the series
|
|
332
|
+
`channel_frames_dropped_total` (no labels: a topic is client-chosen, so a per-topic label is
|
|
333
|
+
unbounded series one socket can mint), the log line `channel.frames_dropped` at `warn` carrying
|
|
334
|
+
`{ topic, dropped, total }`, and `node.sockets.droppedChannelFrames` for a test or a benchmark
|
|
335
|
+
that cannot scrape. Node-wide and cumulative, because a socket past `maxDroppedFrames` is closed
|
|
336
|
+
and removed — a per-socket count leaves exactly when loss is worst. Distinct from
|
|
337
|
+
`SyncSocket.droppedFrames`, which counts every kind of frame one connection lost and dies with it.
|
|
338
|
+
Repair would need a per-topic sequence on the wire: a channel's `lsn` is the publishing hub's own
|
|
339
|
+
per-node counter, so a client cannot tell a gap from a message that arrived via another node.
|
|
340
|
+
**Anything that must arrive belongs on a live query.**
|
|
341
|
+
- **Bun's native WS pub/sub is not used.** `subscribeTopic` does not call `ws.subscribe` and the
|
|
342
|
+
websocket config declares no `publishToSelf`; every channel message is one filtered `send` per
|
|
343
|
+
socket through `SocketRegistry.deliver`, reading a per-topic index rather than walking the socket
|
|
344
|
+
table. A native publish cannot be refused per socket, cannot report the frame it dropped and
|
|
345
|
+
cannot mark a subscriber desynced — which is to say it cannot do any of the three things above.
|
|
346
|
+
`WsLike.subscribe`/`unsubscribe` stay **declared and unused**: the interface is structural and a
|
|
347
|
+
tracked app implements it, so deleting the members breaks that app's typecheck.
|
|
348
|
+
- **Inbound frames are ordered per `mutate`-socket and per subscription, never per socket.** A
|
|
349
|
+
global per-socket lane puts every frame behind the slowest one, and the slowest one is a
|
|
350
|
+
subscribe's snapshot read — the round trip every reconnecting client pays in a restart storm.
|
|
351
|
+
`mutate` is one lane per socket; `subscribe` is one lane per sid, or per topic name; `hello` and
|
|
352
|
+
the server-authored kinds are unlaned. A lane exists only while work is queued on it, because a
|
|
353
|
+
lane keyed by a client-chosen sid that outlived its work is an unbounded map one socket can grow.
|
|
354
|
+
- **`qid` is `<name>:<first 16 hex of SHA-256(canonicalJson(input))>`** — 64 bits `As of 2026-08`,
|
|
355
|
+
where it was a 32-bit FNV-1a. It is a *sharing* key: a hit is answered with the existing entry and
|
|
356
|
+
the seated window, both holding the first subscriber's input and rows, and input is client-chosen,
|
|
357
|
+
so a collision is one client served out of another's window. A rolling deploy across that change
|
|
358
|
+
costs one bounded snapshot per subscription — a cursor minted under the old format names a ring
|
|
359
|
+
entry the new node never held, so the resume falls back correctly rather than silently.
|
|
360
|
+
- **A topic guard that *fails* keeps the topic.** On the re-auth pass, only a denial
|
|
361
|
+
(`X_TOPIC_FORBIDDEN`, or a policy denial) unsubscribes; anything else increments `hub.guardFailures`
|
|
362
|
+
and logs `channel.guard_failed`. `catch { unsubscribe }` reported a store that timed out as a
|
|
363
|
+
revoked grant — every topic on every re-authenticated socket, silently, with the client never told
|
|
364
|
+
to resubscribe. The initial `subscribe` is deliberately not split that way: there is no
|
|
365
|
+
subscription to keep, so a guard that raises refuses that subscribe and the client hears about it.
|
|
366
|
+
- **An idle socket is swept, and the sweep is an APPLICATION budget, not Bun's.** Bun's own
|
|
367
|
+
`idleTimeout` is renewed by its ping/pong, so a client whose frame loop is wedged answers pings
|
|
368
|
+
and keeps its grant, its live subscriptions and its topic membership indefinitely. `start()`
|
|
369
|
+
arms one `.unref()`ed pass every `idleTimeoutMs / 4` (floored at a second, derived rather than
|
|
370
|
+
configured) and evicts anything past the budget the same way a close does — through the node's
|
|
371
|
+
`teardown`, never `SocketRegistry.remove`. `SocketRegistry.idle()` is a *query* for that reason:
|
|
372
|
+
the socket table is three of the five things a socket holds, and the other two are its live
|
|
373
|
+
subscriptions and its presence membership on the shared set. `sweepIdle` — which closed and
|
|
374
|
+
removed here, and had no caller at all — is gone. The budget is measured on `Clock.monotonic()`,
|
|
375
|
+
so `SyncSocket.lastSeenMonotonicMs` is a duration's start and not an instant: an NTP step forward
|
|
376
|
+
would otherwise evict every socket that is talking, and a step backward would spare every socket
|
|
377
|
+
that is dead. `openedAt` stays on the wall clock — it is a value a human reads.
|
|
378
|
+
- **A `sync` node shuts down in two phases.** The `accept` phase calls `stopAccepting()`: `/readyz`
|
|
379
|
+
answers 503 and a late upgrade is shed with `retry-after-ms`, while **every socket the node holds
|
|
380
|
+
keeps its patch stream**. The `close` phase is `drain()` then `stop()`. Registered with no phase it
|
|
381
|
+
all landed in `close`, and until that ran the node went on upgrading new websockets onto a process
|
|
382
|
+
that was going away. Both hooks are unregistered by the listener's `stop()`.
|
|
383
|
+
- **A full presence frame is capped** at `maxMembers` (256) and carries `total`, so a 5,000-person
|
|
384
|
+
room renders "and 4,744 others" instead of shipping 5,000 members to every joiner. The set itself
|
|
385
|
+
is never capped — the sweep differences it — and one node per topic runs that sweep, elected
|
|
386
|
+
through the shared store, rather than every node re-reading every room it has ever seen.
|
|
387
|
+
- **Deliveries are serialized per query id, not per node.** A change is fanned out inside that
|
|
388
|
+
query's own FIFO lane, so two changes off the bus cannot interleave: the window one of them
|
|
389
|
+
writes is the window every subscriber's gate reads, and patch frames leave in lsn order. Every
|
|
390
|
+
lane is entered before any is awaited, so one slow policy pass never sets the node's pace, and
|
|
391
|
+
across query ids there is no ordering and none is wanted — a qid pins both the query and its
|
|
392
|
+
input. A lane that fails costs one query id: its own subscribers are desynced and re-snapshotted
|
|
393
|
+
on the next flush, every other query id still sees the change, and the failure still reaches the
|
|
394
|
+
caller.
|
|
395
|
+
- **A cold subscribe reads once per query id.** Subscribers arriving during a read join it and each
|
|
396
|
+
runs its own policy pass over the result. A read that resolves behind a change already fanned out
|
|
397
|
+
is discarded rather than written back: the window only ever moves forwards. Two reads are ordered
|
|
398
|
+
by a monotonic **read generation** and never by lsn — a definition with no lsn provider answers
|
|
399
|
+
`''` for every read, and `'' >= ''` let the older of two concurrent reads land on top of the
|
|
400
|
+
newer one's gap repair, with `stale` already cleared and therefore nothing left to re-read.
|
|
401
|
+
- **A denial drops a row; a gate that could not decide does not.** A policy answer (`X_FORBIDDEN`,
|
|
402
|
+
`X_UNAUTHENTICATED`) is a decision and costs the row, counted as `rowsDenied`. Anything else a
|
|
403
|
+
gate throws — a rule whose lookup timed out, a predicate with a typo — is counted as
|
|
404
|
+
`gateFailures` and reported through `onGateFailed`, never as a denial: it raises out of
|
|
405
|
+
`subscribe`, desyncs exactly the one subscriber it happened to during a delivery, and leaves a
|
|
406
|
+
subscription standing at `reauthorize`. Reading a timeout as "you may no longer see this" is an
|
|
407
|
+
outage published as a permission change.
|
|
408
|
+
- **A patch is authorized against the whole row or it is not authorized.** An update patch carries
|
|
409
|
+
the changed columns only, so a rule reading a column the change did not touch would read
|
|
410
|
+
`undefined` and answer as if the row had said so. A patch whose row the shared window does not
|
|
411
|
+
hold is withheld — the window *is* the result set — and a subscriber holding that row gets the
|
|
412
|
+
one `delete` that says so. It counts as neither a denial nor a gate failure: nothing decided.
|
|
124
413
|
- **`PgLogicalReplicationFeed` decodes `pgoutput` off a real slot** — its own Postgres v3 client
|
|
125
414
|
(SCRAM-SHA-256, in-band TLS, CopyBoth), no driver dependency. It preflights `wal_level`, the
|
|
126
415
|
publication and the slot, creates the slot when there is none, and confirms the slot as it goes so
|
|
@@ -149,14 +438,19 @@ because refusing without one just moves the herd next door.
|
|
|
149
438
|
`presenceTtlMs` comes back with it because the bucket's whole-stream age limit was derived from
|
|
150
439
|
it — a `PresenceRegistry` given a different number would report members leaving that never left.
|
|
151
440
|
Selection is pure; `connect()` is the dial, so an unreachable bus fails at boot.
|
|
152
|
-
- **`NatsTransport`
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
441
|
+
- **`NatsTransport` runs on the official `nats` client** — `nats@2.29.3`, pinned exact, admitted at
|
|
442
|
+
this transport seam and nowhere else
|
|
443
|
+
([`docs/idea/18-build-vs-wrap.md`](../../docs/idea/18-build-vs-wrap.md)). The package reaches it
|
|
444
|
+
through one port, `NatsClient`, and exactly one file imports the library to implement it, so a
|
|
445
|
+
test injects a client rather than a socket. Fanout is core NATS. `shared` is a JetStream KV bucket the transport creates on
|
|
446
|
+
first connect, one key per presence member, expired by the **server's** per-message TTL so a node
|
|
447
|
+
that dies needs nobody to notice — that bucket and its direct reads stay the framework's, because
|
|
448
|
+
the library's own KV abstraction expresses neither a per-message TTL nor a batch direct get.
|
|
449
|
+
Reconnect and re-subscription are the library's: a lost connection is re-established underneath
|
|
450
|
+
the caller, which is what makes `sync` stateless, and the jitter that spreads a restart herd is
|
|
451
|
+
handed to it as its reconnect delay rather than re-implemented above it. The bucket needs
|
|
452
|
+
nats-server ≥ 2.11 (batch direct get, per-message TTL); an older one is `X_TRANSPORT_PROTOCOL` on
|
|
453
|
+
the first dial, never a retry loop, because no amount of reconnecting makes a server newer.
|
|
160
454
|
- **The lsn is `<commit position><row position in the transaction>`, 24 hex characters.** Neither
|
|
161
455
|
half works alone: every row of one transaction shares a commit lsn, and logical decoding emits
|
|
162
456
|
*transactions* in commit order, so per-record WAL positions are not monotonic across them. The
|
|
@@ -165,16 +459,39 @@ because refusing without one just moves the herd next door.
|
|
|
165
459
|
- **A live query needs `REPLICA IDENTITY FULL`.** Deciding whether a row *left* a result set needs
|
|
166
460
|
the old values; with the default identity a delete replicates only the key columns.
|
|
167
461
|
- Tier 3's OPFS SQLite store is browser-only and throws until the browser entry ships; `MemoryLocalStore`
|
|
168
|
-
implements the full journal/rollback/replay semantics today.
|
|
462
|
+
implements the full journal/rollback/replay semantics today. It holds membership and the journal;
|
|
463
|
+
the row values are the client's one `IdentityMap`, which is what a browser store has to inherit
|
|
464
|
+
rather than re-implement.
|
|
465
|
+
- The identity map is **per client**, in memory, and it is not a query cache: it answers "what is
|
|
466
|
+
row X now", never "have I run this query before". Nothing evicts by time or size — a row lives
|
|
467
|
+
exactly as long as a window or a table holds it.
|
|
169
468
|
|
|
170
469
|
## Errors
|
|
171
470
|
|
|
172
|
-
`X_TOPIC_FORBIDDEN` · `X_SUBSCRIPTION_LIMIT` · `
|
|
471
|
+
`X_TOPIC_FORBIDDEN` · `X_SUBSCRIPTION_LIMIT` · `X_SUBSCRIPTION_ID_TAKEN` ·
|
|
472
|
+
`X_PROTOCOL_VERSION` · `X_CURSOR_STALE` ·
|
|
173
473
|
`X_REBASE_CONFLICT` · `X_TRANSPORT_UNAVAILABLE` · `X_TRANSPORT_PROTOCOL` ·
|
|
174
474
|
`X_REPLICATION_FAILED` · `X_REPLICATION_PROTOCOL` · `X_REPLICATOR_SLOT_HELD` ·
|
|
175
|
-
`X_LIVE_CLIENT_MISSING` · `
|
|
475
|
+
`X_LIVE_CLIENT_MISSING` · `X_LIVE_QUERY_UNKNOWN` · `X_SOCKET_UNAUTHENTICATED` ·
|
|
476
|
+
`X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED`
|
|
176
477
|
|
|
177
478
|
Topics deny by default: a topic with no matching guard is forbidden. An authz hole is not a config
|
|
178
479
|
option someone forgot to set.
|
|
179
480
|
|
|
481
|
+
An upgrade `authenticate` refuses is `X_SOCKET_UNAUTHENTICATED` (401) and one it *could not decide*
|
|
482
|
+
is `X_SOCKET_AUTH_UNAVAILABLE` (503). Two codes, because the two have opposite instructions: the
|
|
483
|
+
first is the client's credential and pages nobody, the second is this node's dependency and pages
|
|
484
|
+
someone. Both are rendered as the error contract in the response body — there is no frame to carry
|
|
485
|
+
one, because the client never got a socket.
|
|
486
|
+
|
|
487
|
+
A `sid` belongs to the socket that chose it. A subscription is keyed by `(socket, sid)`, a drop
|
|
488
|
+
frame is scoped to the socket that sent it, and reusing a sid the same socket already holds is
|
|
489
|
+
`X_SUBSCRIPTION_ID_TAKEN` — one client can neither take over nor end another's live stream.
|
|
490
|
+
|
|
491
|
+
A `subscribe` frame naming a query this node never registered is `X_LIVE_QUERY_UNKNOWN`, not
|
|
492
|
+
`X_PROTOCOL_VERSION`: the frame parsed and the version matched, so "rebuild and redeploy the
|
|
493
|
+
client" is the one instruction that cannot help — a rebuilt client spells the name the same way.
|
|
494
|
+
The fix is `x queries list --json`, and the name the client sent is echoed back while the registry
|
|
495
|
+
never is.
|
|
496
|
+
|
|
180
497
|
`As of 2026-07`: tiers 1–2 target v1, tier 3 targets v2.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/realtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.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",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*-fixture.ts",
|
|
23
|
+
"CLAUDE.md",
|
|
22
24
|
"README.md",
|
|
23
25
|
"LICENSE"
|
|
24
26
|
],
|
|
@@ -30,7 +32,8 @@
|
|
|
30
32
|
"test": "bun test"
|
|
31
33
|
},
|
|
32
34
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
-
"@ultimat3/query": "
|
|
35
|
+
"@ultimat3/core": "3.0.0",
|
|
36
|
+
"@ultimat3/query": "3.0.0",
|
|
37
|
+
"nats": "2.29.3"
|
|
35
38
|
}
|
|
36
39
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// One job: fold a patch list onto a result set. Pure and outside `LiveClient` because it is the only
|
|
2
|
+
// piece of the client with no socket, no signal and no state — which is what makes it the piece an
|
|
3
|
+
// app can reuse to apply the same patches to its own store.
|
|
4
|
+
//
|
|
5
|
+
// Order and values are folded separately: a live window keeps its ORDER here and its VALUES in the
|
|
6
|
+
// identity map, so `applyPatches` is the array form of the same fold rather than a second one.
|
|
7
|
+
|
|
8
|
+
import type { JsonObject, Row, RowPatch } from './json';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Membership and order after a patch list, ids only. `index` places a row the set does not hold
|
|
12
|
+
* yet; a row it already holds keeps its position, because a patch reorders nothing it did not say
|
|
13
|
+
* to reorder.
|
|
14
|
+
*/
|
|
15
|
+
export function orderAfterPatches(
|
|
16
|
+
ids: readonly string[],
|
|
17
|
+
patches: readonly RowPatch[],
|
|
18
|
+
): readonly string[] {
|
|
19
|
+
let next = ids;
|
|
20
|
+
for (const patch of patches) {
|
|
21
|
+
if (patch.op === 'delete') {
|
|
22
|
+
next = next.filter((id) => id !== patch.id);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (patch.row === null) continue;
|
|
26
|
+
if (next.includes(patch.id)) continue;
|
|
27
|
+
if (patch.index !== undefined) {
|
|
28
|
+
const copy = [...next];
|
|
29
|
+
copy.splice(patch.index, 0, patch.id);
|
|
30
|
+
next = copy;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
next = [...next, patch.id];
|
|
34
|
+
}
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Minimal in-place patch application: the shape a Solid store update maps onto directly. */
|
|
39
|
+
export function applyPatches(rows: readonly Row[], patches: readonly RowPatch[]): readonly Row[] {
|
|
40
|
+
const values = new Map<string, Row>(rows.map((row) => [row.id, row]));
|
|
41
|
+
for (const patch of patches) {
|
|
42
|
+
if (patch.op === 'delete' || patch.row === null) continue;
|
|
43
|
+
values.set(patch.id, mergeRow(values.get(patch.id), patch.id, patch.row));
|
|
44
|
+
}
|
|
45
|
+
const out: Row[] = [];
|
|
46
|
+
for (const id of orderAfterPatches(
|
|
47
|
+
rows.map((row) => row.id),
|
|
48
|
+
patches,
|
|
49
|
+
)) {
|
|
50
|
+
const row = values.get(id);
|
|
51
|
+
if (row !== undefined) out.push(row);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The one merge rule: changed columns over the current value, and `id` is never overwritten. */
|
|
57
|
+
function mergeRow(current: Row | undefined, id: string, columns: JsonObject): Row {
|
|
58
|
+
const next: JsonObject = { ...(current ?? {}), ...columns };
|
|
59
|
+
return { ...next, id };
|
|
60
|
+
}
|