@ultimat3/realtime 30.9.0 → 31.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 +72 -82
- package/README.md +36 -40
- package/package.json +8 -6
- package/src/adapter.ts +14 -0
- package/src/apply-patches.ts +8 -5
- package/src/boot.ts +15 -2
- package/src/changefeed.ts +11 -4
- package/src/channel-decl.ts +29 -1
- package/src/channel-publish.ts +17 -19
- package/src/channel-ref.ts +22 -2
- package/src/client-channels.ts +11 -5
- package/src/client-contract.ts +2 -2
- package/src/cursor.ts +5 -5
- package/src/device-agent.ts +2 -2
- package/src/device-errors.ts +1 -1
- package/src/device-socket.ts +1 -1
- package/src/error-titles-server.ts +40 -0
- package/src/error-titles.ts +12 -21
- package/src/errors.ts +43 -10
- package/src/idb-types.ts +6 -0
- package/src/index.ts +5 -27
- package/src/json.ts +7 -3
- package/src/live-contract.ts +4 -4
- package/src/live-definition.ts +9 -6
- package/src/live-deliver.ts +40 -2
- package/src/live-entries.ts +73 -0
- package/src/live-fanout.ts +25 -9
- package/src/live-patch-encode.ts +30 -0
- package/src/live-query-options.ts +11 -0
- package/src/live-query.ts +26 -11
- package/src/live-replicator.ts +24 -21
- package/src/local-store-idb.ts +66 -6
- package/src/matcher-bridge.ts +15 -15
- package/src/nats-transport.ts +5 -5
- package/src/offline-queue-store.ts +69 -0
- package/src/offline-queue.ts +91 -91
- package/src/page-errors.ts +15 -0
- package/src/page-outbox.ts +18 -3
- package/src/pg-connection.ts +9 -1
- package/src/pg-preflight.ts +21 -5
- package/src/pg-replication-dial.ts +95 -0
- package/src/pg-replication-liveness.ts +84 -0
- package/src/pg-replication-position.ts +42 -0
- package/src/pg-replication-row.ts +6 -6
- package/src/pg-replication-stats.ts +28 -0
- package/src/pg-replication.ts +106 -125
- package/src/pg-slot-release.ts +40 -0
- package/src/policy-gate.ts +2 -2
- package/src/query-window.ts +4 -4
- package/src/record-publisher.ts +2 -2
- package/src/record-store.ts +7 -5
- package/src/replication-errors.ts +40 -2
- package/src/replication-resync.ts +38 -0
- package/src/replicator-deadline.ts +33 -0
- package/src/replicator-reconnect.ts +85 -0
- package/src/replicator-resync.ts +30 -0
- package/src/replicator-row.ts +5 -5
- package/src/replicator-subject.ts +28 -0
- package/src/replicator.ts +79 -69
- package/src/server.ts +8 -6
- package/src/socket-engine.ts +2 -2
- package/src/subscriber-gate.ts +14 -10
- package/src/sync-bus-handlers.ts +20 -4
- package/src/sync-protocol.ts +3 -3
- package/src/thundering-herd.ts +17 -24
- package/src/transport-env.ts +20 -48
- package/src/window-lock.ts +15 -3
package/CLAUDE.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# @ultimat3/realtime — agent notes
|
|
2
2
|
|
|
3
3
|
Tier 3 package. Channels, live queries, local-first sync. One protocol for all three.
|
|
4
|
+
A rule marked `†` has its full text in [`docs/history/realtime-rules.md`](../../docs/history/realtime-rules.md).
|
|
4
5
|
|
|
5
6
|
## Boundary
|
|
6
7
|
|
|
@@ -20,13 +21,17 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
20
21
|
the hub, the registry or the fanout (`nats` requires `stream/web`, which a browser build cannot
|
|
21
22
|
link). `packages/cli/src/realtime-browser-barrel.test.ts` bundles a `useLive`-only entry;
|
|
22
23
|
`barrel-split.test.ts` refuses a name exported from both. `./boot` and `./sync-worker` are the page
|
|
23
|
-
boot and the SharedWorker entry.
|
|
24
|
-
- **`error-titles.ts` holds the codes
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
`errors.ts`
|
|
28
|
-
|
|
29
|
-
`page-errors.ts`
|
|
24
|
+
boot and the SharedWorker entry; `./adapter` the bus driver's port plus the boot's installer.
|
|
25
|
+
- **`error-titles.ts` holds the PAGE codes and titles** (every code a browser raises or receives on
|
|
26
|
+
the wire), **`error-titles-server.ts` the server-only ones** (replication, topology, producer
|
|
27
|
+
conflict — never on the wire); `errors.ts` composes `REALTIME_OWNED_ERROR_CODES` /
|
|
28
|
+
`REALTIME_ERROR_TITLES` from both. `page-errors.ts` the browser refusals, `realtime-error.ts` the
|
|
29
|
+
base class (TDZ), `replication-errors.ts` Postgres; all re-exported from `./errors`. A hook's chunk
|
|
30
|
+
never loads `errors.ts` (`page-errors-bundle.test.ts`). A new code: `bun run new-error-code …
|
|
31
|
+
--browser | --server-only`, exactly one (`scripts/lib/error-code-realm.ts`).
|
|
32
|
+
- **`sideEffects` is `["./src/error-titles.ts", "./src/error-titles-server.ts"]`**; the server one is
|
|
33
|
+
imported bare by `errors.ts` and `server.ts`, never a page module (`error-titles.test.ts`: no
|
|
34
|
+
server title in an island chunk); never `false` or absent.
|
|
30
35
|
- **Every `@ultimat3/realtime/<subpath>` written in shipped source must be a key of `exports`**,
|
|
31
36
|
comments included — `fix-specifier.test.ts`.
|
|
32
37
|
- A browser file imports core from `@ultimat3/core/page` (`packages/core/src/page-bundle.test.ts`).
|
|
@@ -57,37 +62,31 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
57
62
|
`reauthorize` calls `book.retenant(socket)`.
|
|
58
63
|
- **A cap is a RESERVATION taken before the first await**: `SubscriptionBook.reserve(socket, sid)`
|
|
59
64
|
decides the sid claim, `maxPerSocket`, `maxPerTenant` and `maxPerActor` in one synchronous step;
|
|
60
|
-
`ChannelHub.subscribe` does the same for its caps. Released in a `finally`,
|
|
65
|
+
`ChannelHub.subscribe` does the same for its caps. Released in a `finally`, idempotent.
|
|
61
66
|
- **Readiness AND the connection cap are functions on `UpgradeDeps`**, re-asked after
|
|
62
|
-
`authenticate`
|
|
63
|
-
|
|
67
|
+
`authenticate` and right before `server.upgrade`: the recheck sheds with the same 503 +
|
|
68
|
+
`retry-after-ms`, no second `tryAccept()`.
|
|
64
69
|
|
|
65
70
|
## Live queries
|
|
66
71
|
|
|
67
|
-
- Policy is evaluated **once per subscriber**, never once per query (`live-query.test.ts`,
|
|
68
|
-
`live-definition.test.ts`
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
registry is never enumerated over the wire.
|
|
72
|
+
- Policy is evaluated **once per subscriber**, never once per query (`live-query.test.ts`,
|
|
73
|
+
`live-definition.test.ts`); every policy call in `live-query.ts` takes a `Subscriber`.
|
|
74
|
+
- **An unregistered name is `X_LIVE_QUERY_UNKNOWN`** (`x queries list --json`); the registry is
|
|
75
|
+
never enumerated over the wire.
|
|
72
76
|
- **One build per `(query, input)`**: `target.live()` returns the descriptor and runs the read
|
|
73
|
-
(`LiveQuery.execute`),
|
|
77
|
+
(`LiveQuery.execute`), unmemoised.
|
|
74
78
|
- `liveQueryDefinition` caches per query id the compiled source, shape, matcher and shared window —
|
|
75
|
-
never a decision
|
|
79
|
+
never a decision: the shared half is `enforce: false`, `authorize` runs per socket.
|
|
76
80
|
- **The row policy sees the WHOLE row from the shared window.** A patch whose row the window does not
|
|
77
81
|
hold is WITHHELD (not denied, not a failure). A delta resume onto an unread entry fills it first
|
|
78
82
|
(`entry.lsn === ''`).
|
|
79
|
-
- **A denial is a decision; everything else is a failure
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`
|
|
85
|
-
|
|
86
|
-
enters every lane before awaiting any; no fanout takes a second lane; a lane that fails desyncs its
|
|
87
|
-
own subscribers; lanes chain on a settled shadow of each task.
|
|
88
|
-
- **Two reads of one entry are ordered by a READ GENERATION**, never by an lsn (`QueryEntry.lsn` may
|
|
89
|
-
be `''`): `entry.generation` / `entry.applied` is an identity check. The lsn guard stays for the
|
|
90
|
-
other question — a read that resolved behind a change the fanout already folded.
|
|
83
|
+
- **A denial is a decision; everything else is a failure** (`isPolicyDenial`): a denied re-check
|
|
84
|
+
refuses the subscription, a failed one keeps it and counts `gateFailures`. †
|
|
85
|
+
- **One serial lane per query id (`WindowLock`) is the only thing that orders a fanout**; a lane at
|
|
86
|
+
`maxLaneDepth` (1,024) sheds: window staled, `changesShed` counted. †
|
|
87
|
+
- **Two reads of one entry are ordered by a READ GENERATION**, never an lsn (`QueryEntry.lsn` may be
|
|
88
|
+
`''`): `entry.generation` / `entry.applied`. The lsn guard answers the other question — a read
|
|
89
|
+
that resolved behind a change the fanout already folded.
|
|
91
90
|
- **The definition's read is once per entry**: a cold subscriber joins the in-flight read (a share,
|
|
92
91
|
cleared on settle), and the result lands in the lane and never backwards.
|
|
93
92
|
- **The shared read carries a deadline and frees the SLOT**: `startRead` races it against
|
|
@@ -96,51 +95,44 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
96
95
|
a non-positive or non-finite value takes the default (`query-window.test.ts`).
|
|
97
96
|
- The retained change window stores **pre-policy** patches; resume re-filters them per subscriber,
|
|
98
97
|
outside the lane, against the live window.
|
|
99
|
-
- **`desynced` is a mark with a reader**: the next delivery
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
(`sync-drain.test.ts`): Bun's `drain` (`registry.repair`, which waits out a read in flight), a read
|
|
103
|
-
that lands, and the node's sweep (`registry.repairMarked`, on the idle interval), which also re-reads
|
|
104
|
-
a stale window that owes one, once a pass. Never out of a window stale or with a read not settled.
|
|
98
|
+
- **`desynced` is a mark with a reader**: the next delivery re-snapshots out of the shared window,
|
|
99
|
+
then clears it; `result.refill` is checked BEFORE the mark. A quiet query's three other readers
|
|
100
|
+
(drain, a landed read, `repairMarked`): `docs/history/realtime.md` (2026-11-09).
|
|
105
101
|
- **A change the window already holds is refused** (`change.lsn <= entry.lsn`, counted as
|
|
106
102
|
`staleChanges`). **A gap is detected**: the replicator stamps `producer` + `seq`; a skipped
|
|
107
103
|
sequence — or a NEW producer after a known one — stales every window; `refillWindowInLane` replaces it.
|
|
108
104
|
- **A bulk write stales only its entity's readers**: windows + channel topics, `invalidate(entity)`
|
|
109
|
-
on both from `onBulk` (channels: not a `recordPublisher` table). **A stale window's re-read
|
|
110
|
-
guard, even on a non-match (`live-replicator-bulk`,
|
|
111
|
-
-
|
|
112
|
-
|
|
113
|
-
(`live-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
- **A `sid` is CLIENT data: a subscription is keyed by `(socket, sid)`**
|
|
117
|
-
|
|
118
|
-
`X_SUBSCRIPTION_ID_TAKEN`.
|
|
105
|
+
on both from `onBulk` (channels: not a `recordPublisher` table). **A stale window's re-read
|
|
106
|
+
re-snapshots EVERY subscriber**, before the lsn guard, even on a non-match (`live-replicator-bulk`,
|
|
107
|
+
`live-fanout` tests).
|
|
108
|
+
- **`startLiveReplicator` SUBSCRIBES to `entity.row`/`entity.bulk`**, never a slot: `stop()`
|
|
109
|
+
releases its own; two both hear a write (`live-replicator.test.ts`). Its handlers never throw.
|
|
110
|
+
- **A WINDOW is per TENANT** (`windowId(queryHash, tenant)`, `live-tenant.ts`); the read runs as a
|
|
111
|
+
service actor carrying ONLY that org, never the subscriber. †
|
|
112
|
+
- **A `sid` is CLIENT data: a subscription is keyed by `(socket, sid)`** (`subscription-book.ts`);
|
|
113
|
+
reusing one the same socket holds is `X_SUBSCRIPTION_ID_TAKEN`.
|
|
119
114
|
- **`rateLimit:` is spent once per NEW subscription** (`live-spend.ts`).
|
|
120
115
|
- Truth is the server. A client is never the merge authority.
|
|
121
116
|
|
|
122
117
|
## The node, the bus and presence
|
|
123
118
|
|
|
124
119
|
- `sync` is stateless: no sticky sessions, nothing on a socket survives a restart.
|
|
125
|
-
- **`selectTransport(env,
|
|
126
|
-
and presence TTL come back)
|
|
127
|
-
`
|
|
120
|
+
- **`selectTransport(env, { use })` is the one place env becomes the DEFAULT bus**: `NATS_URL` set
|
|
121
|
+
is NATS, unset in-process (bucket and presence TTL come back); any other bus is `runtime.ts`'s
|
|
122
|
+
`transport`, installed with `installChannelTransport` from `./adapter` — never a setter. `use`: `'sockets'`/`'feed'` await a 15 s dial, then
|
|
128
123
|
`X_TRANSPORT_UNAVAILABLE`; `'publish'` dials in background. Never `waitOnFirstConnect`
|
|
129
124
|
(retries forever). A publish with no live connection is refused.
|
|
130
|
-
- **`nats` is imported only by `nats-lib-client.ts`**; its reconnect re-subscribes.
|
|
131
|
-
|
|
125
|
+
- **`nats` is imported only by `nats-lib-client.ts`**; its reconnect re-subscribes. GIVEN UP
|
|
126
|
+
(`onClosed`), `NatsTransport` re-dials on backoff and re-binds.
|
|
132
127
|
- **Nothing leaves `NatsTransport` uncoded** — `#translating` wraps `publish`/`subscribe` refusals
|
|
133
128
|
as `X_TRANSPORT_UNAVAILABLE`. `#ensure` reuses a mid-reconnect client on purpose;
|
|
134
129
|
`Transport.onReconnect(listener)` (required) announces each recovery.
|
|
135
130
|
- **A bucket name is checked before interpolation** (`assertBucket`, `X_TRANSPORT_PROTOCOL`); a
|
|
136
|
-
presence key or member id is base64url-encoded (`encodeToken`)
|
|
131
|
+
presence key or member id is base64url-encoded (`encodeToken`).
|
|
137
132
|
- **A socket closed during its presence write takes the write back** (`joinPresence`, asked after the
|
|
138
133
|
awaits; `sync-frames.test.ts`).
|
|
139
|
-
-
|
|
140
|
-
|
|
141
|
-
leave. Expiry is silent, so the node sweeps on an interval, and **one node per topic sweeps** (lowest
|
|
142
|
-
id in the lease's keyed set; the lease carries the roster followers record). `roster()` caps a frame at `maxMembers` (256) with `total`;
|
|
143
|
-
`list()` is never capped. `total` is set on `sync` frames only.
|
|
134
|
+
- **An upgrade from another origin is `403 X_SOCKET_ORIGIN_REFUSED`** before `authenticate`
|
|
135
|
+
(`sync-origin.ts`); `allowedOrigins` is the WHOLE list. Detail: `docs/history/realtime-agent-notes.md`.
|
|
144
136
|
- **A socket the node evicts ITSELF goes through `evict(socket, code, reason)`** (the full
|
|
145
137
|
`teardown`), never `sockets.remove`. **`drain()` waits for the presence leaves it started** —
|
|
146
138
|
`evictInChunks` (`drain-evictions.ts`, `DRAIN_EVICT_CHUNK`, `allSettled`).
|
|
@@ -153,12 +145,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
153
145
|
- **A hub that closed opens nothing**: `close()` sets `#closed` before the walk; `#open` closes a
|
|
154
146
|
late subscription and RAISES `X_TRANSPORT_UNAVAILABLE`. `#release` takes the reserved bridge, never
|
|
155
147
|
a name.
|
|
156
|
-
-
|
|
157
|
-
|
|
158
|
-
`Host`-derived origin (and its `https` spelling when reached over plain http). The refusal names
|
|
159
|
-
the asker and the list. `x dev` keeps the reached-on origin (`admitReachedOrigin`) and adds
|
|
160
|
-
its web role's beside a declared list; a container adds nothing. `AcceptBudget` is reserved before
|
|
161
|
-
`authenticate`, `refund()`ed on every exit that takes no socket.
|
|
148
|
+
- Presence lives in `transport.shared`; the sync node is `PresenceRegistry`'s only caller, and
|
|
149
|
+
**one node per topic sweeps**. Detail: `docs/history/realtime-agent-notes.md`.
|
|
162
150
|
- **A socket's actor comes from `syncNode({ authenticate })` only**, run before
|
|
163
151
|
`server.upgrade`. `null` = 401 `X_SOCKET_UNAUTHENTICATED`; a throw = 503
|
|
164
152
|
`X_SOCKET_AUTH_UNAVAILABLE`. Absent = anonymous, and `start()` warns. The actor lives only in the
|
|
@@ -195,14 +183,18 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
195
183
|
- Exactly one `replicator` per DB, by a session-level advisory lock. **`start()` and
|
|
196
184
|
`PostgresAdvisoryLock.tryAcquire` are MEMOISED**; `stop()`/`release()` await the in-flight one. A start
|
|
197
185
|
that FAILS hands the lock back; `running` is set once the feed pumps.
|
|
198
|
-
- **The pump has one way out, `#die`**: failure
|
|
199
|
-
|
|
200
|
-
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
186
|
+
- **The pump has one way out, `#die`**: failure, timer, connection, then `onEnd`. **A walsender
|
|
187
|
+
that stops answering is dead** — 3 intervals read-silent, or a confirm unsettled
|
|
188
|
+
(`pg-replication-liveness.ts`): destroyed, no goodbye. A run is fenced by its connection.
|
|
189
|
+
- **A stream that ENDS, or a LOST lock, is restarted** from `lastLsn` on `retryDelayMs`; a lost
|
|
190
|
+
connection (`connectionLost`) redials at once, then ≤ 2 s (`replicator-reconnect.ts`); feed then
|
|
191
|
+
lock ABANDONED; `running` = pumping and no refused change owed (history, 2026-11-09).
|
|
192
|
+
- **A slot is asked its `wal_status`**: `lost` is `X_REPLICATION_SLOT_INVALIDATED`; one created under
|
|
193
|
+
a resume is `X_REPLICATION_SLOT_RECREATED`, drops `from`, and owes `x.change.-resync` (a node
|
|
194
|
+
answers it as a reconnect) till published.
|
|
195
|
+
- **A slot still held is waited for before START_REPLICATION** (`pg-slot-release.ts`): a walsender
|
|
196
|
+
frees it AFTER the goodbye, so preflight reads `active_pid` and polls on the server's clock, ~5 s
|
|
197
|
+
bound, then lets 55006 refuse (`pg-slot-release.test.ts`, the held-slot live test).
|
|
206
198
|
- **A row crosses the bus through its entity's columns** (`replicator-row.ts`): `parseEnvelope`
|
|
207
199
|
revives by `entityForTable` + `$parse`, bytes as base64 — by declared kind, never by shape.
|
|
208
200
|
- **Cleartext/md5 auth is answered only under `sslmode` `require`, `verify-*` or `disable`**.
|
|
@@ -210,7 +202,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
210
202
|
entity table, or `ALTER … ADD TABLE` the missing; never a DROP, never `FOR ALL TABLES`.
|
|
211
203
|
- **TLS is libpq's `sslmode`** (`pg-tls.ts`): only `verify-*` verify; the runtime never rejects,
|
|
212
204
|
`judgeHandshake` decides; the raw socket is DEAF after `upgradeTLS`.
|
|
213
|
-
- **Every session pins `datestyle=ISO`, `intervalstyle=postgres`, `extra_float_digits=3`, `TimeZone=UTC
|
|
205
|
+
- **Every session pins `datestyle=ISO`, `intervalstyle=postgres`, `extra_float_digits=3`, `TimeZone=UTC`**,
|
|
206
|
+
and asks for TCP keepalives (10 s/5 s/3): a lock holder lost without a FIN frees it in ~25 s.
|
|
214
207
|
- A change lsn is `<16 hex commit position><8 hex row position>`; never order by one half, a wall
|
|
215
208
|
time or a counter.
|
|
216
209
|
- Slot, publication and entity names match `[a-z_][a-z0-9_]*` before interpolation — a security
|
|
@@ -234,10 +227,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
234
227
|
- **The page runtime (`page-runtime.ts`) is installed once per page, never in an island**;
|
|
235
228
|
hooks call `installedPage(hook).services`. One socket per page (`page-socket.ts`);
|
|
236
229
|
`browser-socket.ts` holds the only `new WebSocket`. `rescope` clears the store, redials.
|
|
237
|
-
- **One socket per ORIGIN and principal, in a SharedWorker
|
|
238
|
-
|
|
239
|
-
routed; a port silent for `REAP_AFTER_BEATS` is reaped; each tab resubscribes from its OWN cursors.
|
|
240
|
-
No `SharedWorker` → the in-page host runs the same engine over a `MessageChannel`.
|
|
230
|
+
- **One socket per ORIGIN and principal, in a SharedWorker** (`socket-engine.ts`); no `SharedWorker`
|
|
231
|
+
→ the same engine in-page. †
|
|
241
232
|
- **The socket is READ-ONLY (protocol 3)**. A write is `useMutation`: the twin into the overlay, then
|
|
242
233
|
the stamped pin (`renderedActionPin`) or `POST actionPath(name)`, via `clientTransport`, keyed;
|
|
243
234
|
the answer's records are adopted inside the transport call, before `settle`. An `ack` is only a refusal.
|
|
@@ -265,10 +256,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
265
256
|
|
|
266
257
|
## The client connection
|
|
267
258
|
|
|
268
|
-
- **The client owns its reconnect**:
|
|
269
|
-
|
|
270
|
-
`reconnect` frame's slot wins), and speaks only for its own socket; `close()` cancels. A dial that
|
|
271
|
-
throws in the timer is reported through `onError` (default `console.error`) and arms the next.
|
|
259
|
+
- **The client owns its reconnect**: ONE timer through the injected `Scheduler`; `close()`
|
|
260
|
+
cancels. Detail: `docs/history/realtime-agent-notes.md`.
|
|
272
261
|
- **`connect()` closes the socket it replaces**; `onOpen`, `onMessage` and `onClose` all carry the
|
|
273
262
|
identity guard.
|
|
274
263
|
- **A refusal `ack` is revived as a branded `UltimateError`** (`refusalError`, `client-frames.ts`).
|
|
@@ -278,8 +267,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
278
267
|
`client-heartbeat.test.ts`; `0` disables): a `hello` plus one subscribe per topic. Two silent
|
|
279
268
|
windows after an UNANSWERED beat close with `4000`. **The node names the beat: `hello.heartbeatMs`** — `Heartbeat.follow`
|
|
280
269
|
per socket; the engine reaps ports on the longer of its own and the node's.
|
|
281
|
-
- **There is ONE `backoffDelay`, `@ultimat3/core`'s, counted from 1** — `policyDelay()`
|
|
282
|
-
`
|
|
270
|
+
- **There is ONE `backoffDelay`, `@ultimat3/core`'s, counted from 1** — `policyDelay()` passes a
|
|
271
|
+
`ReconnectBackoff` (core's `RetryPolicy` minus `attempts`/`timeBudgetMs`) to it; `bun run flight-copies` refuses a second curve.
|
|
283
272
|
- Never a bare `Error`. Never `any`. Never `Date.now()` — take a `Clock` (`monotonic()` for durations).
|
|
284
273
|
|
|
285
274
|
## The page boot
|
|
@@ -290,6 +279,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
290
279
|
`Symbol.for('ultimate.page-boot')`. No boot: islands load its chunk.
|
|
291
280
|
- **An island never carries the outbox**: `boot.ts` builds it; `useMutation`, `useOutbox`, the page socket
|
|
292
281
|
read it through `outbox-slot.ts` after `page.booted`. No boot ⇒ a refused write is rejected, not queued.
|
|
282
|
+
- **The outbox's ceiling is `realtime.maxQueued`**, read by `boot.ts` off `<meta name="ultimate-outbox-max">`.
|
|
293
283
|
|
|
294
284
|
## Map
|
|
295
285
|
|
|
@@ -303,7 +293,7 @@ side: `client*.ts`, `socket-*.ts`, `sync-worker.ts`, `record-*.ts`, `page-*.ts`,
|
|
|
303
293
|
## Commands
|
|
304
294
|
|
|
305
295
|
```
|
|
306
|
-
bun test packages/realtime/
|
|
296
|
+
bun test ./packages/realtime/ # from the REPO ROOT; the ./ keeps examples/dummy out
|
|
307
297
|
bun run typecheck
|
|
308
298
|
```
|
|
309
299
|
|
package/README.md
CHANGED
|
@@ -74,15 +74,16 @@ class — stay on `.`.
|
|
|
74
74
|
|
|
75
75
|
| Concern | Entry | Export |
|
|
76
76
|
|---|---|---|
|
|
77
|
-
| tier 1 | `./server` | `ChannelHub`, `PresenceRegistry`, `SyncSocket`, `SocketRegistry`; `publishChannelEvent` — a channel event from a job or an action
|
|
77
|
+
| tier 1 | `./server` | `ChannelHub`, `PresenceRegistry`, `SyncSocket`, `SocketRegistry`; `publishChannelEvent` — a channel event from a job or an action, on the bus the boot installed |
|
|
78
|
+
| a bus driver | `./adapter` | `Transport` and its parts (`TransportHandler`, `TransportSet`, `TransportSetEntry`, `TransportSubscription`), the errors a driver throws (`TransportUnavailableError`, also on `.`; `TransportProtocolError`, only on this entry), and `installChannelTransport` — the boot's installer and `@ultimat3/testing`'s seam, on no other entry. The offline stores (`LocalStore` / `openLocalStore` / `pageLocalStore` / `memoryLocalStore()`, `QueueStore` / `memoryQueueStore()`, `RecordStoreOptions`, `SignalFactory`) are on `.`: no `runtime.ts` field takes one. An app hands its bus to `transport` in `apps/<app>/runtime.ts`, never to the installer |
|
|
78
79
|
| tier 2 | `./server` | `LiveQueryRegistry`, `memoryChangeFeed()`, `postgresChangeFeed()`, `selectChangeFeed`, `changeFeedReplicator`, `postgresAdvisoryLock()`, `matcherFor` |
|
|
79
80
|
| replication | `./server` | `parsePgUrl`, `bunPgStream`, `entityRow`, `changeLsn`, `commitPositionOf` |
|
|
80
|
-
| the in-process change source | `./server` | `startLiveReplicator` — a repository's own writes as `ChangeEvent`s, for the embedded database `x dev` runs on (PGlite has no walsender). `ChangeEvent.table` is the entity's TABLE on it as on the WAL decoder and `recordPublisher`, so an `entity(name, { table })` reaches channels and live windows (25.0.0). Moved from `@ultimat3/testing`, which no longer re-exports it |
|
|
81
|
+
| the in-process change source | `./server` | `startLiveReplicator` — a repository's own writes (core's `entity.row` / `entity.bulk` bus events, subscribed) as `ChangeEvent`s, for the embedded database `x dev` runs on (PGlite has no walsender). `ChangeEvent.table` is the entity's TABLE on it as on the WAL decoder and `recordPublisher`, so an `entity(name, { table })` reaches channels and live windows (25.0.0). Moved from `@ultimat3/testing`, which no longer re-exports it |
|
|
81
82
|
| fanout | `./server` | `Transport`, `InProcessTransport`, `NatsTransport`, `selectTransport`, `subjectMatches` |
|
|
82
83
|
| 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 |
|
|
83
|
-
| reconnect | both | `LiveCursor`, `resumeFrom`, `shouldResnapshot`, `
|
|
84
|
-
| the page's record store | `.` | `RecordStore` — one record per `type:key`, synced truth plus the optimistic overlay — `recordKey`, `LocalTx`, `
|
|
85
|
-
| the outbox | `.` | `OfflineQueue
|
|
84
|
+
| reconnect | both | `LiveCursor`, `resumeFrom`, `shouldResnapshot`, `Scheduler` on `.`; `RingChangeBuffer`, `drainPlan`, `AcceptBudget`, `reconnectFrame` on `./server` — the node's half of the reconnect is the node's |
|
|
85
|
+
| the page's record store | `.` | `RecordStore` — one record per `type:key`, synced truth plus the optimistic overlay — `recordKey`, `LocalTx`, `applyPatches`/`orderAfterPatches` |
|
|
86
|
+
| the outbox | `.` | `OfflineQueue` (its store is `./adapter`'s `QueueStore`) — replayed over HTTP from plan 101 slice 12. The conflict vocabulary is `ConflictPolicy` from `@ultimat3/core`; realtime declares none |
|
|
86
87
|
| wire | `.` | `PROTOCOL_VERSION` (3), `encode`, `decode`, `Frame` |
|
|
87
88
|
| the node | `./server` | `syncNode` / `listenSyncNode` (`sync` role) |
|
|
88
89
|
| a socket's identity | `./server` | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
|
|
@@ -90,7 +91,7 @@ class — stay on `.`.
|
|
|
90
91
|
| channel records, no replicator | `./server` | `recordPublisher` — an app's committed rows as channel `records`, sequenced per producer ([below](#channel-records-with-no-replicator)) |
|
|
91
92
|
| channels | `.` | `channel`, `channelRef`, `ChannelHandle`, `topic`, `readPresence`, the channel frame types |
|
|
92
93
|
| first paint | `.` | `holdFirstPaint`, `FIRST_PAINT_HOLD_MS` (1 s) — what the island bootstrap awaits before `mount`, for an island that reads the store or the outbox: the boot's restore and the open outbox, capped (#506). An island taking only `useChannel` / `usePresence` / `useConnection` never awaits it and is never hidden, nor is one taking `useQuery` / `useRecord` in an app that declares no `persist: true` entity |
|
|
93
|
-
| offline | `.` | `pageOutbox`, `
|
|
94
|
+
| offline | `.` | `pageOutbox`, `localOutbox`, `listenForDrain`; the durable store is `./adapter`'s |
|
|
94
95
|
| the socket's worker | `./sync-worker` | the SharedWorker entry — no exports |
|
|
95
96
|
|
|
96
97
|
## `channelRef` — a channel an island can hold
|
|
@@ -120,7 +121,9 @@ The server half, in its own file, names the entity and the policy on the same re
|
|
|
120
121
|
`ref.topic(params)` is the one spelling of the topic both halves use (`org-posts.<orgId>`), and
|
|
121
122
|
`bun run channel-literals` refuses a hand-built topic anywhere else. A bad name or a repeated param
|
|
122
123
|
is `X_CHANNEL_DECLARATION_INVALID` at declaration. `catchUp` is read on every access, so a query
|
|
123
|
-
whose name `
|
|
124
|
+
whose name `defineApi({ queries })` stamps at boot is picked up. A `catchUp` given as the query itself
|
|
125
|
+
must carry the channel's own `policy` object — it is served with the channel params as input — or
|
|
126
|
+
`channel()` refuses it (`X_CHANNEL_CATCHUP_POLICY_MISMATCH`); a name-only ref is not checked. The reference app's
|
|
124
127
|
[`app/posts/channel-ref.ts`](../../examples/dummy/apps/web/app/posts/channel-ref.ts) is the idiom.
|
|
125
128
|
|
|
126
129
|
### What `useChannel()` reports
|
|
@@ -143,7 +146,8 @@ join and it reads `joining` for as long as it is held.
|
|
|
143
146
|
|
|
144
147
|
The `ChannelHub` lives in the `sync` role. Everything else publishes an event with
|
|
145
148
|
`publishChannelEvent` (`./server`), on the bus the boot installed for the process — every role's
|
|
146
|
-
`startServices`
|
|
149
|
+
`startServices` installs the process's transport (`installChannelTransport`, `./adapter`): `runtime.ts`'s `transport`, else
|
|
150
|
+
the one `selectTransport` chose.
|
|
147
151
|
|
|
148
152
|
```ts
|
|
149
153
|
import { allow } from '@ultimat3/policy';
|
|
@@ -165,10 +169,10 @@ export const announce = (orgId: string, id: string): Promise<void> =>
|
|
|
165
169
|
|
|
166
170
|
| Fact | Rule |
|
|
167
171
|
|---|---|
|
|
168
|
-
| who delivers | every `sync` node bridged to that topic, to its members' `onEvent`. Under `x dev` the node is in this process and shares the bus; across processes the bus is NATS (`
|
|
172
|
+
| who delivers | every `sync` node bridged to that topic, to its members' `onEvent`. Under `x dev` the node is in this process and shares the bus; across processes the bus is NATS (`NATS_URL` set) or the app's own (`transport` in `apps/<app>/runtime.ts`) |
|
|
169
173
|
| after COMMIT | nothing here can see a transaction: an event sent from inside one is on every page before a rollback |
|
|
170
174
|
| at-most-once | never stored, never replayed — a member that was offline does not get it. Send "re-read", never state. A bus that refuses the send rejects (`X_TRANSPORT_UNAVAILABLE`); catch it where an event is not worth failing the write for |
|
|
171
|
-
| nothing booted | a unit test or a bare script publishes onto a heap bus nobody hears, and does not throw. A test that wants delivery
|
|
175
|
+
| nothing booted | a unit test or a bare script publishes onto a heap bus nobody hears, and does not throw. A test that wants delivery swaps the bus through `@ultimat3/testing`'s override seam, which installs the bus its `ChannelHub` holds and releases it after |
|
|
172
176
|
| `realtime.enabled: false` | still safe to call: the bus exists, no node subscribes |
|
|
173
177
|
| refusals | `X_CHANNEL_DECLARATION_INVALID` on a channel declared without `events: true`; `X_TOPIC_FORBIDDEN` for a param that is no topic segment |
|
|
174
178
|
| no replicator | a node that serves no `live: true` query and no `records` channel reads no change, boots `live=none` on a real database, and is never `X_REALTIME_TOPOLOGY` |
|
|
@@ -181,12 +185,12 @@ Without one, `recordPublisher` (`./server`) is how an app puts its own **committ
|
|
|
181
185
|
bus; every node turns them into `records` frames exactly as it does a replicated change (#682).
|
|
182
186
|
|
|
183
187
|
```ts
|
|
184
|
-
import type { ChannelEntity,
|
|
188
|
+
import type { ChannelEntity, LiveRow } from '@ultimat3/realtime';
|
|
185
189
|
import { recordPublisher, type Transport } from '@ultimat3/realtime/server';
|
|
186
190
|
|
|
187
191
|
declare const transport: Transport; // the process's bus — the one `selectTransport` chose
|
|
188
192
|
declare const runs: ChannelEntity; // an `entity()` a `channel(…, { records: [runs] })` lists
|
|
189
|
-
declare const finished: readonly
|
|
193
|
+
declare const finished: readonly LiveRow[];
|
|
190
194
|
|
|
191
195
|
// Once per process, at boot, naming every entity it will publish.
|
|
192
196
|
const publisher = recordPublisher({ transport, entities: [runs] });
|
|
@@ -403,6 +407,7 @@ wire.
|
|
|
403
407
|
| sockets per actor; an anonymous network (IPv4 exact, IPv6 /64) gets 8 × (`ANONYMOUS_SOCKET_MULTIPLIER`) — one NAT is many people | 16 (`DEFAULT_MAX_SOCKETS_PER_ACTOR`); 128 per anonymous network | `syncNode({ maxSocketsPerActor })`; the boot passes `realtime.maxSocketsPerActor` | `429 X_SOCKET_LIMIT` |
|
|
404
408
|
| live subscriptions per tenant | unset | `new LiveQueryRegistry({ maxPerTenant, tenantOf })` — **both**, or it arms nothing | `X_SUBSCRIPTION_LIMIT` |
|
|
405
409
|
| distinct `(query, input)` pairs per node | 10,000 | `new LiveQueryRegistry({ maxEntries })` | `X_SUBSCRIPTION_LIMIT` |
|
|
410
|
+
| changes queued in one live window's lane | 1,024 (`DEFAULT_MAX_LANE_DEPTH`) | `new LiveQueryRegistry({ maxLaneDepth })` | nothing refused: the change is shed for that window, which is staled and re-read by the next queued change; counted as `registry.changesShed` |
|
|
406
411
|
| how long one entry's SHARED snapshot read may hold its slot | 30s | `new LiveQueryRegistry({ readDeadlineMs })` | `X_TIMEOUT`, to that read's caller AND every subscriber joined to it |
|
|
407
412
|
| channel topics per socket | 64 | `new ChannelHub({ maxTopicsPerSocket })` | `X_SUBSCRIPTION_LIMIT` |
|
|
408
413
|
| distinct channel topics per node | 10,000 | `new ChannelHub({ maxTopicsPerNode })` | `X_SUBSCRIPTION_LIMIT` |
|
|
@@ -513,7 +518,7 @@ verdict only. A proxy that adds none of them is indistinguishable from a direct
|
|
|
513
518
|
header-less proxy must not route the health paths.
|
|
514
519
|
|
|
515
520
|
The client dials itself back. A closed socket arms one timer — the node's delay when a `reconnect`
|
|
516
|
-
frame assigned one, otherwise `@ultimat3/core`'s `backoffDelay()` on the client's `
|
|
521
|
+
frame assigned one, otherwise `@ultimat3/core`'s `backoffDelay()` on the client's `backoff` (core's `RetryPolicy` fields `base`/`max`/`factor`/`curve`/`jitter`) — and that timer calls `connect()`, which re-subscribes
|
|
517
522
|
every registration **and re-announces every topic**. Topic membership is state on the node's socket
|
|
518
523
|
and `hello` carries none of it, so without that half a channel goes silent from the first reconnect
|
|
519
524
|
onwards while its handler is still installed — and its presence membership is swept, because
|
|
@@ -579,6 +584,14 @@ wire twice by a reconnect that raced an ack.
|
|
|
579
584
|
(`X_LOCAL_STORE_UNAVAILABLE`, default `console.warn`); the outbox then queues in memory for that
|
|
580
585
|
principal.
|
|
581
586
|
|
|
587
|
+
`maxQueued` — the outbox's ceiling, on `pageOutbox({ maxQueued })` (the page's outbox: the first
|
|
588
|
+
call in a tab builds it) or `localOutbox({ maxQueued })`, and carried to every queue the outbox
|
|
589
|
+
opens, the memory fallback included. A whole number ≥ 1 (`X_INVARIANT` where the outbox is built);
|
|
590
|
+
absent, no ceiling. An app sets it as `realtime: { maxQueued }` in `app.config.ts`; the page boot reads it off the document (`<meta name="ultimate-outbox-max">`, core's `CLIENT_OUTBOX_MAX_META`). It counts writes still to send (a refused one kept for the UI is not); past it
|
|
591
|
+
a NEW write is refused, `X_OFFLINE_QUEUE_FULL`, to the mutation that
|
|
592
|
+
made it — a key already queued collapses as always, and nothing queued is ever dropped to make room.
|
|
593
|
+
`OfflineQueue.open(store, { maxQueued })` is the same rule one level down.
|
|
594
|
+
|
|
582
595
|
### Limits, stated plainly
|
|
583
596
|
|
|
584
597
|
- **The change window is per node, and a `qid` window can only be.** A client that reconnects to a
|
|
@@ -812,26 +825,24 @@ principal.
|
|
|
812
825
|
synchronous): the sockets are closed outright with no goodbye written, the feed first so it is
|
|
813
826
|
silent before the lock is given up. `stop()` asks politely and gives each goodbye
|
|
814
827
|
`STOP_DEADLINE_MS` (5 s) before abandoning it. The restart delay resets only once a change has
|
|
815
|
-
been published, so a change the bus refuses every time backs off to `
|
|
828
|
+
been published, so a change the bus refuses every time backs off to `max` instead of
|
|
816
829
|
redialling at the base; `stats().failure` names its entity and lsn, and `running` stays `false`
|
|
817
830
|
until it goes out.
|
|
818
831
|
- **`postgresAdvisoryLock()` is the production `AdvisoryLock`** — `SELECT
|
|
819
832
|
pg_try_advisory_lock(hashtext('x:replicator:<slot>'))` on its own session. Session-scoped, so a
|
|
820
833
|
crashed replicator releases it automatically: no lease renewal, no fencing token, no split brain.
|
|
821
834
|
`memoryAdvisoryLock()` remains the single-process default for `x dev` and tests.
|
|
822
|
-
- **`selectTransport(env
|
|
823
|
-
|
|
824
|
-
`
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
and `'memory'` with `NATS_URL` (or the named variable) set — an operator who set one expected
|
|
830
|
-
fanout across nodes. Until 22.0.0 `NATS_URL` alone decided and both keys were read by nothing.
|
|
835
|
+
- **`selectTransport(env)` decides the default transport a boot fans out on** — since 31.0.0 the
|
|
836
|
+
ENVIRONMENT decides it: `NATS_URL` set (not blank) → a `NatsTransport` on the KV bucket
|
|
837
|
+
`NATS_KV_BUCKET` names (default `x_presence`, so two apps on one cluster do not share one
|
|
838
|
+
presence namespace), validated here rather than on first connect; unset → `InProcessTransport`,
|
|
839
|
+
`mode: 'embedded'`. Any other bus is `transport` in `apps/<app>/runtime.ts`, which the boot takes
|
|
840
|
+
instead. `app.config.ts`'s `realtime.transport`/`urlEnv` (22.0.0–30.x) are gone. It returns
|
|
841
|
+
`{ transport, mode, detail, bucket, presenceTtlMs, connect }`.
|
|
831
842
|
`presenceTtlMs` comes back with it because the bucket's whole-stream age limit was derived from
|
|
832
843
|
it — a `PresenceRegistry` given a different number would report members leaving that never left.
|
|
833
844
|
Selection is pure; `connect()` is the dial. **What the process does with the bus decides whether
|
|
834
|
-
that dial is waited for** — `selectTransport(env,
|
|
845
|
+
that dial is waited for** — `selectTransport(env, { use })`, `BusUse`:
|
|
835
846
|
`'sockets'` (a `sync` node; the default) awaits it and the presence bucket, retried on backoff for
|
|
836
847
|
`connectWithinMs` (default `BUS_CONNECT_WAIT_MS`, 15 s) and then `X_TRANSPORT_UNAVAILABLE`;
|
|
837
848
|
`'feed'` (the replicator) awaits it and asks JetStream for nothing; `'publish'` (`web`, `worker`,
|
|
@@ -963,24 +974,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
963
974
|
|
|
964
975
|
| Class | Code | Declared in |
|
|
965
976
|
|---|---|---|
|
|
966
|
-
| `CursorStaleError` (extends `RealtimeError`) | `X_CURSOR_STALE` | `src/page-errors.ts` |
|
|
967
|
-
| `FrameRateLimitError` (extends `RealtimeError`) | `X_FRAME_RATE_LIMIT` | `src/errors.ts` |
|
|
968
|
-
| `LiveQueryUnknownError` (extends `RealtimeError`) | `X_LIVE_QUERY_UNKNOWN` | `src/errors.ts` |
|
|
969
|
-
| `LiveRowUnidentifiedError` (extends `RealtimeError`) | `X_LIVE_ROW_UNIDENTIFIED` | `src/errors.ts` |
|
|
970
977
|
| `OfflineQueueAbandonedError` (extends `RealtimeError`) | `X_OFFLINE_QUEUE_ABANDONED` | `src/page-errors.ts` |
|
|
971
|
-
| `
|
|
972
|
-
| `RealtimeError` | any `RealtimeErrorCode` — `REALTIME_ERROR_CODES`; the base of every other class here, thrown directly for a code none of them covers | `src/realtime-error.ts` |
|
|
973
|
-
| `RealtimeUninstalledError` (extends `RealtimeError`) | `X_REALTIME_UNINSTALLED` | `src/page-errors.ts` |
|
|
974
|
-
| `RebaseConflictError` (extends `RealtimeError`) | `X_REBASE_CONFLICT` | `src/page-errors.ts` |
|
|
975
|
-
| `RecordRejectedError` (extends `RealtimeError`) | `X_RECORD_REJECTED` | `src/page-errors.ts` |
|
|
976
|
-
| `ReplicaIdentityError` (extends `RealtimeError`) | `X_LIVE_REPLICA_IDENTITY` | `src/replication-errors.ts` |
|
|
977
|
-
| `ReplicationFailedError` (extends `RealtimeError`) | `X_REPLICATION_FAILED` | `src/replication-errors.ts` |
|
|
978
|
-
| `ReplicationProtocolError` (extends `RealtimeError`) | `X_REPLICATION_PROTOCOL` | `src/replication-errors.ts` |
|
|
978
|
+
| `RealtimeError` | any `RealtimeErrorCode`; the base of every other class here, thrown directly for a code none of them covers | `src/realtime-error.ts` |
|
|
979
979
|
| `ReplicatorSlotHeldError` (extends `RealtimeError`) | `X_REPLICATOR_SLOT_HELD` | `src/replication-errors.ts` |
|
|
980
|
-
| `ServerRenderLiveError` (extends `RealtimeError`) | `X_LIVE_SERVER_RENDER` | `src/page-errors.ts` |
|
|
981
980
|
| `SubscriptionLimitError` (extends `RealtimeError`) | `X_SUBSCRIPTION_LIMIT` | `src/errors.ts` |
|
|
982
|
-
| `SyncUnconfiguredError` (extends `RealtimeError`) | `X_SYNC_UNCONFIGURED` | `src/page-errors.ts` |
|
|
983
981
|
| `TopicForbiddenError` (extends `RealtimeError`) | `X_TOPIC_FORBIDDEN` | `src/errors.ts` |
|
|
984
|
-
| `TransportProtocolError` (extends `RealtimeError`) | `X_TRANSPORT_PROTOCOL` | `src/errors.ts` |
|
|
985
982
|
| `TransportUnavailableError` (extends `RealtimeError`) | `X_TRANSPORT_UNAVAILABLE` | `src/errors.ts` |
|
|
986
|
-
| `WindowReadTimeoutError` (extends `RealtimeError`) | `X_TIMEOUT` | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/realtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "31.0.0",
|
|
4
4
|
"description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"type": "module",
|
|
17
17
|
"sideEffects": [
|
|
18
|
-
"./src/error-titles.ts"
|
|
18
|
+
"./src/error-titles.ts",
|
|
19
|
+
"./src/error-titles-server.ts"
|
|
19
20
|
],
|
|
20
21
|
"repository": {
|
|
21
22
|
"type": "git",
|
|
@@ -28,6 +29,7 @@
|
|
|
28
29
|
},
|
|
29
30
|
"exports": {
|
|
30
31
|
".": "./src/index.ts",
|
|
32
|
+
"./adapter": "./src/adapter.ts",
|
|
31
33
|
"./boot": "./src/boot.ts",
|
|
32
34
|
"./page-runtime": "./src/page-runtime.ts",
|
|
33
35
|
"./page-runtime-wait": "./src/page-runtime-wait.ts",
|
|
@@ -50,10 +52,10 @@
|
|
|
50
52
|
"test": "bun test"
|
|
51
53
|
},
|
|
52
54
|
"dependencies": {
|
|
53
|
-
"@ultimat3/core": "
|
|
54
|
-
"@ultimat3/entity": "
|
|
55
|
-
"@ultimat3/policy": "
|
|
56
|
-
"@ultimat3/query": "
|
|
55
|
+
"@ultimat3/core": "31.0.0",
|
|
56
|
+
"@ultimat3/entity": "31.0.0",
|
|
57
|
+
"@ultimat3/policy": "31.0.0",
|
|
58
|
+
"@ultimat3/query": "31.0.0",
|
|
57
59
|
"nats": "2.29.3"
|
|
58
60
|
}
|
|
59
61
|
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Single responsibility: the `@ultimat3/realtime/adapter` entry — what a bus driver implements and
|
|
2
|
+
// throws, and the installer the boot (and `@ultimat3/testing`'s override seam) puts it in with. An
|
|
3
|
+
// app never imports this: it hands its `Transport` to `apps/<app>/runtime.ts`'s `transport`.
|
|
4
|
+
// Never re-exported from `.` or `./server` (`barrel-split.test.ts`).
|
|
5
|
+
|
|
6
|
+
export { installChannelTransport } from './channel-publish';
|
|
7
|
+
export { TransportProtocolError, TransportUnavailableError } from './errors';
|
|
8
|
+
export type {
|
|
9
|
+
Transport,
|
|
10
|
+
TransportHandler,
|
|
11
|
+
TransportSet,
|
|
12
|
+
TransportSetEntry,
|
|
13
|
+
TransportSubscription,
|
|
14
|
+
} from './fanout';
|
package/src/apply-patches.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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
|
-
import type { JsonObject,
|
|
8
|
+
import type { JsonObject, LiveRow, RowPatch } from './json';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Membership and order after a patch list, ids only. `index` places a row the set does not hold
|
|
@@ -36,13 +36,16 @@ export function orderAfterPatches(
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/** Minimal in-place patch application: the shape a Solid store update maps onto directly. */
|
|
39
|
-
export function applyPatches(
|
|
40
|
-
|
|
39
|
+
export function applyPatches(
|
|
40
|
+
rows: readonly LiveRow[],
|
|
41
|
+
patches: readonly RowPatch[],
|
|
42
|
+
): readonly LiveRow[] {
|
|
43
|
+
const values = new Map<string, LiveRow>(rows.map((row) => [row.id, row]));
|
|
41
44
|
for (const patch of patches) {
|
|
42
45
|
if (patch.op === 'delete' || patch.row === null) continue;
|
|
43
46
|
values.set(patch.id, mergeRow(values.get(patch.id), patch.id, patch.row));
|
|
44
47
|
}
|
|
45
|
-
const out:
|
|
48
|
+
const out: LiveRow[] = [];
|
|
46
49
|
for (const id of orderAfterPatches(
|
|
47
50
|
rows.map((row) => row.id),
|
|
48
51
|
patches,
|
|
@@ -54,7 +57,7 @@ export function applyPatches(rows: readonly Row[], patches: readonly RowPatch[])
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/** The one merge rule: changed columns over the current value, and `id` is never overwritten. */
|
|
57
|
-
function mergeRow(current:
|
|
60
|
+
function mergeRow(current: LiveRow | undefined, id: string, columns: JsonObject): LiveRow {
|
|
58
61
|
const next: JsonObject = { ...(current ?? {}), ...columns };
|
|
59
62
|
return { ...next, id };
|
|
60
63
|
}
|
package/src/boot.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// and opens the outbox, so a reload replays what the last load queued even if islands only read.
|
|
5
5
|
|
|
6
6
|
import type { ClientScope } from '@ultimat3/core/page';
|
|
7
|
-
import { pageClient } from '@ultimat3/core/page';
|
|
7
|
+
import { CLIENT_OUTBOX_MAX_META, pageClient } from '@ultimat3/core/page';
|
|
8
8
|
import { pageLocalStore, scopeKey } from './local-store-idb';
|
|
9
9
|
import { pageOutbox } from './page-outbox';
|
|
10
10
|
import { installPageRuntime } from './page-runtime';
|
|
@@ -18,6 +18,7 @@ import type { RecordStore } from './record-store';
|
|
|
18
18
|
*/
|
|
19
19
|
async function restoreFromDisk(store: RecordStore, principal: () => Principal): Promise<void> {
|
|
20
20
|
const types = persistedTypes();
|
|
21
|
+
const maxQueued = outboxCeiling();
|
|
21
22
|
let persister: RecordPersister | undefined;
|
|
22
23
|
try {
|
|
23
24
|
// A sign-out by full navigation (a form post and a redirect) never calls `rescope()`, so the
|
|
@@ -45,7 +46,19 @@ async function restoreFromDisk(store: RecordStore, principal: () => Principal):
|
|
|
45
46
|
// After the restore, so a replay settles overlays over the records it restored.
|
|
46
47
|
// A queued write flushes the persisted rows first, so both are on disk before a reload can come.
|
|
47
48
|
const kept = persister;
|
|
48
|
-
pageOutbox({ beforeEnqueue: kept === undefined ? undefined : () => kept.flush() });
|
|
49
|
+
pageOutbox({ beforeEnqueue: kept === undefined ? undefined : () => kept.flush(), maxQueued });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `realtime.maxQueued` as the server rendered it (`<meta name="ultimate-outbox-max">`), already
|
|
54
|
+
* screened by `defineConfig`; `localOutbox` screens it again where it builds, so a malformed meta
|
|
55
|
+
* is `X_INVARIANT`, never a ceiling no count reaches. Absent: no ceiling.
|
|
56
|
+
*/
|
|
57
|
+
function outboxCeiling(): number | undefined {
|
|
58
|
+
const doc: { querySelector?: (selector: string) => { content?: unknown } | null } | undefined =
|
|
59
|
+
Reflect.get(globalThis, 'document');
|
|
60
|
+
const content = doc?.querySelector?.(`meta[name="${CLIENT_OUTBOX_MAX_META}"]`)?.content;
|
|
61
|
+
return typeof content === 'string' ? Number(content) : undefined;
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
type Principal = ClientScope['principal'];
|