@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.
Files changed (67) hide show
  1. package/CLAUDE.md +72 -82
  2. package/README.md +36 -40
  3. package/package.json +8 -6
  4. package/src/adapter.ts +14 -0
  5. package/src/apply-patches.ts +8 -5
  6. package/src/boot.ts +15 -2
  7. package/src/changefeed.ts +11 -4
  8. package/src/channel-decl.ts +29 -1
  9. package/src/channel-publish.ts +17 -19
  10. package/src/channel-ref.ts +22 -2
  11. package/src/client-channels.ts +11 -5
  12. package/src/client-contract.ts +2 -2
  13. package/src/cursor.ts +5 -5
  14. package/src/device-agent.ts +2 -2
  15. package/src/device-errors.ts +1 -1
  16. package/src/device-socket.ts +1 -1
  17. package/src/error-titles-server.ts +40 -0
  18. package/src/error-titles.ts +12 -21
  19. package/src/errors.ts +43 -10
  20. package/src/idb-types.ts +6 -0
  21. package/src/index.ts +5 -27
  22. package/src/json.ts +7 -3
  23. package/src/live-contract.ts +4 -4
  24. package/src/live-definition.ts +9 -6
  25. package/src/live-deliver.ts +40 -2
  26. package/src/live-entries.ts +73 -0
  27. package/src/live-fanout.ts +25 -9
  28. package/src/live-patch-encode.ts +30 -0
  29. package/src/live-query-options.ts +11 -0
  30. package/src/live-query.ts +26 -11
  31. package/src/live-replicator.ts +24 -21
  32. package/src/local-store-idb.ts +66 -6
  33. package/src/matcher-bridge.ts +15 -15
  34. package/src/nats-transport.ts +5 -5
  35. package/src/offline-queue-store.ts +69 -0
  36. package/src/offline-queue.ts +91 -91
  37. package/src/page-errors.ts +15 -0
  38. package/src/page-outbox.ts +18 -3
  39. package/src/pg-connection.ts +9 -1
  40. package/src/pg-preflight.ts +21 -5
  41. package/src/pg-replication-dial.ts +95 -0
  42. package/src/pg-replication-liveness.ts +84 -0
  43. package/src/pg-replication-position.ts +42 -0
  44. package/src/pg-replication-row.ts +6 -6
  45. package/src/pg-replication-stats.ts +28 -0
  46. package/src/pg-replication.ts +106 -125
  47. package/src/pg-slot-release.ts +40 -0
  48. package/src/policy-gate.ts +2 -2
  49. package/src/query-window.ts +4 -4
  50. package/src/record-publisher.ts +2 -2
  51. package/src/record-store.ts +7 -5
  52. package/src/replication-errors.ts +40 -2
  53. package/src/replication-resync.ts +38 -0
  54. package/src/replicator-deadline.ts +33 -0
  55. package/src/replicator-reconnect.ts +85 -0
  56. package/src/replicator-resync.ts +30 -0
  57. package/src/replicator-row.ts +5 -5
  58. package/src/replicator-subject.ts +28 -0
  59. package/src/replicator.ts +79 -69
  60. package/src/server.ts +8 -6
  61. package/src/socket-engine.ts +2 -2
  62. package/src/subscriber-gate.ts +14 -10
  63. package/src/sync-bus-handlers.ts +20 -4
  64. package/src/sync-protocol.ts +3 -3
  65. package/src/thundering-herd.ts +17 -24
  66. package/src/transport-env.ts +20 -48
  67. 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, titles and `registerErrorCodes()`**; `errors.ts` the server
25
- refusals, `page-errors.ts` the browser ones, `realtime-error.ts` the base class (TDZ),
26
- `replication-errors.ts` Postgres. All re-exported from `./errors`. A hook's chunk never loads
27
- `errors.ts` (`page-errors-bundle.test.ts`).
28
- - **`sideEffects` is `["./src/error-titles.ts"]`**, imported bare by the barrel (`refusalError` and
29
- `page-errors.ts` title by code); never `false` or absent.
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`, idempotently.
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` (app code with an await) and right before `server.upgrade`. The recheck sheds with
63
- the same 503 + `retry-after-ms` and spends no second `tryAccept()`.
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`, and
68
- `live-definition.test.ts` for a real `query({ live: true })`). Every policy call in `live-query.ts`
69
- takes a `Subscriber`.
70
- - **A name nothing registered is `X_LIVE_QUERY_UNKNOWN`**, fix `x queries list --json`. The
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`), never memoised.
77
+ (`LiveQuery.execute`), unmemoised.
74
78
  - `liveQueryDefinition` caches per query id the compiled source, shape, matcher and shared window —
75
- never a decision. The shared half is built with `enforce: false` on purpose; `authorize` runs per socket.
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.** `visibleWithPolicy` matches
80
- `QueryDeniedError` and rethrows the rest; `subscriber-gate.ts` and `reauthorize` ask
81
- `isPolicyDenial(error)`. A failed snapshot raises out of `subscribe`; a failed delivery desyncs that
82
- one subscriber; a failed `reauthorize` keeps the subscription, a DENIED one is refused under its sid
83
- (`refuseSubscription`) — as is one whose org moved, and a failed re-seat. Failures count as
84
- `gateFailures` via `onGateFailed`, never `onRowDenied`.
85
- - **One serial lane per query id (`WindowLock`) is the only thing that orders a fanout.** `deliver`
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 serves a fresh snapshot out of the shared
100
- window and only then clears it. **`result.refill` is checked BEFORE the mark** — a guessed window
101
- never clears a mark (`live-fanout.test.ts`). **A quiet query has three more readers**
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 re-snapshots EVERY subscriber**, before the lsn
110
- guard, even on a non-match (`live-replicator-bulk`, `live-fanout` tests).
111
- - **A WINDOW is per TENANT**: its id is `windowId(queryHash(name, input), tenant)` (`live-tenant.ts`;
112
- tenant = the subscriber's `actor.orgId`). The read runs `readFor`
113
- (`live-definition.ts`): the node's ctx, a service actor carrying ONLY that org — never the
114
- subscriber. A cursor naming another window is a cold start; `reauthorize` re-seats a
115
- subscription whose actor changed org (`live-tenancy.test.ts`; `docs/history/realtime.md`).
116
- - **A `sid` is CLIENT data: a subscription is keyed by `(socket, sid)`**
117
- (`subscription-book.ts` is the only spelling). Reusing a sid the same socket holds is
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, realtime, { use })` is the one place config + env become a bus** (bucket
126
- and presence TTL come back). `'nats'` refuses (`X_CONFIG_INVALID`) an unset `urlEnv` variable;
127
- `'memory'` a set one. `use`: `'sockets'`/`'feed'` await a 15 s dial, then
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. When it GIVES
131
- UP (`onClosed`) `NatsTransport` re-dials on backoff and re-binds.
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`), never validated.
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
- - Presence lives in `transport.shared`. The sync node is `PresenceRegistry`'s only caller:
140
- subscribing to a topic IS joining, repeating the frame is the heartbeat, dropping or closing is the
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
- - **An upgrade from another origin is `403 X_SOCKET_ORIGIN_REFUSED`** before `authenticate`
157
- (`sync-origin.ts`): `allowedOrigins`, when declared, is the WHOLE list (`sec-fetch-site` unread); undeclared, the
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 recorded, timer stopped, connection closed, then
199
- `onEnd(reason)`. `start()` awaits the previous pump. **`stop()` releases everything first.**
200
- - **A stream that ENDS is restarted**: feed then lock ABANDONED (synchronous, never asked);
201
- `stop()` bounds both goodbyes by `STOP_DEADLINE_MS`; redial on `retryDelayMs` (injected
202
- `schedule`) from `lastLsn` until `stop()`. The delay resets only when a change is PUBLISHED. A
203
- rejected `publish` is one such end, and so is a LOST lock (`AdvisoryLock.onLost`), mid-dial
204
- included. `running` = pumping AND no refused change owed; `onChange` is fenced per run and
205
- REFUSES, never drops.
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**: `socket-engine.ts` holds it and talks
238
- to tabs over `MessagePort`s (`socket-host.ts`'s virtual socket); wants are ref-counted and frames
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**: a closed socket arms ONE timer through the injected `Scheduler`
269
- that calls `connect()`. `onClose` nulls `#socket`, schedules only when nothing is armed (a
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()` maps a
282
- `BackoffPolicy` onto it; `bun run flight-copies` refuses a second curve.
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/src # from the REPO ROOT
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 — with the boot's `setChannelTransport` and the test seam `resetChannelTransport` |
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`, `defaultReconnectBudget`, `Scheduler`, `timeoutScheduler` on `.`; `RingChangeBuffer`, `drainPlan`, `AcceptBudget`, `reconnectFrame` on `./server` — the node's half of the reconnect is the node's |
84
- | the page's record store | `.` | `RecordStore` — one record per `type:key`, synced truth plus the optimistic overlay — `recordKey`, `LocalTx`, `RowWindows`, `applyPatches`/`orderAfterPatches` |
85
- | the outbox | `.` | `OfflineQueue`, `memoryQueueStore()` — replayed over HTTP from plan 101 slice 12. The conflict vocabulary is `ConflictPolicy` from `@ultimat3/core`; realtime declares none |
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`, `recordPersister`, `persistedTypes`, `openLocalStore`, `pageLocalStore`, `memoryLocalStore()` |
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 `registerQueries()` stamps at boot is picked up. The reference app's
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` calls `setChannelTransport` with the transport `selectTransport` chose.
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 (`realtime.transport: '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 calls `setChannelTransport(bus)` with the bus its `ChannelHub` holds, and `resetChannelTransport()` after |
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, Row } from '@ultimat3/realtime';
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 Row[];
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 `BackoffPolicy` — and that timer calls `connect()`, which re-subscribes
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 `maxMs` instead of
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, realtime)` decides which transport a boot fans out on** — and since
823
- 22.0.0 the CONFIG decides it, not the environment: `realtime` is `app.config.ts`'s
824
- `{ transport, urlEnv }`. It returns `{ transport, mode, detail, bucket, presenceTtlMs, connect }`:
825
- `'memory'` → `InProcessTransport` and `mode: 'embedded'`; `'nats'` → a `NatsTransport` dialling
826
- the variable `urlEnv` names, on the KV bucket `NATS_KV_BUCKET` names (default `x_presence`, so
827
- two apps on one cluster do not share one presence namespace), validated here rather than on
828
- first connect. Both mismatches refuse with `X_CONFIG_INVALID`: `'nats'` with that variable unset,
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, realtime, { use })`, `BusUse`:
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
- | `ProtocolVersionError` (extends `RealtimeError`) | `X_PROTOCOL_VERSION` | `src/page-errors.ts` |
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": "30.9.0",
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": "30.9.0",
54
- "@ultimat3/entity": "30.9.0",
55
- "@ultimat3/policy": "30.9.0",
56
- "@ultimat3/query": "30.9.0",
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';
@@ -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, Row, RowPatch } from './json';
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(rows: readonly Row[], patches: readonly RowPatch[]): readonly Row[] {
40
- const values = new Map<string, Row>(rows.map((row) => [row.id, row]));
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: Row[] = [];
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: Row | undefined, id: string, columns: JsonObject): Row {
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'];