@ultimat3/realtime 7.0.0 → 9.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 CHANGED
@@ -13,6 +13,35 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
13
13
 
14
14
  ## Rules
15
15
 
16
+ - **Two entries, and a name lives in exactly ONE of them (2026-08-22, BREAKING).** `.` is the
17
+ client half — `hooks`, `client*`, `identity-map`, `live-rows`, `apply-patches`, `offline-queue`,
18
+ `rebase`, `local-store`, `sync-protocol`, `json`, `cursor`, `errors`, and the client's half of
19
+ `thundering-herd`. `./server` (`src/server.ts`) is everything that touches `nats`, Postgres, the
20
+ sync node, the channel hub, the live-query registry or the fanout. The reason is measured, not
21
+ aesthetic: `nats` `require()`s `stream/web`, so one barrel carrying `openNatsClient` beside
22
+ `useLive` failed `bun build --target=browser` with
23
+ `Browser build cannot require() Node.js builtin: "stream/web"` — the island `wiki/Realtime.md`
24
+ promises could not be built at all. Two build errors hold it: `packages/cli/src/realtime-browser-barrel.test.ts`
25
+ bundles a `useLive`-only entry for the browser, and `barrel-split.test.ts` refuses a name
26
+ exported from both (values through the namespace objects, types off the source text, because a
27
+ type-only export leaves no runtime entry). `errors.ts` is deliberately whole on `.`: every code
28
+ reaches the wire through `toWireError`, so a client must be able to name any of them, and the
29
+ module is already in the client graph via `sync-protocol`.
30
+ - **`sideEffects` is the ARRAY `["./src/errors.ts"]`, never `false` and never absent.** Absent was
31
+ what made the failure above unrecoverable — with no field a bundler must assume every module has
32
+ effects, so nothing was tree-shaken and `nats` came along with `useLive`. Measured, not guessed:
33
+ `bun run side-effects --explain --json` prints what the tree actually does at import time.
34
+ `false` would drop `registerErrorCodes()` and every `REALTIME_ERROR_TITLES` entry with it.
35
+ **The array alone would have fixed the build** and is not why the split exists: tree-shaking is
36
+ a bundler's discretion, `export * from` or a namespace import defeats it, and "the client entry
37
+ cannot reach the bus" is a contract rather than an optimisation.
38
+ - **`@ultimat3/realtime/server` needs its own `paths` entry in `tsconfig.base.json`**, beside
39
+ `@ultimat3/admin/dev`'s. `@ultimat3/*` maps `realtime/server` to `packages/realtime/server/src`,
40
+ which does not exist, and the root program has no `node_modules/@ultimat3` symlink to fall back
41
+ through — so `scripts/**` (which the root `tsc -b` compiles) reports `TS2307` without it. The
42
+ workspace packages resolve through their own `node_modules` and never needed it, which is what
43
+ makes the failure look local to `scripts/`.
44
+
16
45
  - Policy is evaluated **once per subscriber**, never once per query. `live-query.test.ts` proves it
17
46
  for a hand-written definition and `live-definition.test.ts` proves it for a real declared
18
47
  `query({ live: true })` — the second one matters, because a rule that only holds for test fakes
@@ -640,6 +669,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
640
669
 
641
670
  | File | Owns |
642
671
  |---|---|
672
+ | `index.ts` | the `.` barrel: the client half, and the only thing a browser island may import |
673
+ | `server.ts` | the `./server` barrel: the bus, the WAL path, the node. Disjoint from `index.ts` by test |
643
674
  | `sync-protocol.ts` | the wire: 10 frame kinds, `encode`/`decode`, `PROTOCOL_VERSION` |
644
675
  | `channel.ts` / `presence.ts` / `socket.ts` | tier 1 |
645
676
  | `live-query.ts` / `live-definition.ts` / `changefeed.ts` / `changefeed-env.ts` / `replicator.ts` / `pg-advisory-lock.ts` / `fanout.ts` / `transport-env.ts` / `matcher-bridge.ts` | tier 2 |
package/README.md CHANGED
@@ -44,23 +44,45 @@ frame handler is unchanged between rungs.
44
44
 
45
45
  `local` must be pure — no I/O, no `Date.now()`, no `Math.random()` — because rebase replays it.
46
46
 
47
+ ## Two entries, and which one an island may import
48
+
49
+ `As of 2026-08`, `@ultimat3/realtime` is the **client** half — the hooks, the identity map, the offline queue, the
50
+ wire and the reconnect vocabulary. `@ultimat3/realtime/server` is the bus, the Postgres replication
51
+ path and the sync node. A name lives in exactly one of them; the `Entry` column below says which.
52
+
53
+ The split is not cosmetic. `nats` `require()`s `stream/web`, so one barrel carrying `openNatsClient`
54
+ beside `useLive` made the browser island this package promises **unbuildable** —
55
+ `Browser build cannot require() Node.js builtin: "stream/web"`. `packages/cli/src/realtime-browser-barrel.test.ts`
56
+ bundles an entry importing only `useLive` for `target: 'browser'` and fails the build if either
57
+ half reaches the other; `barrel-split.test.ts` fails if one name is exported from both.
58
+
59
+ Migrating from 7.x: an import of a **server** name changes its specifier and nothing else.
60
+
61
+ ```
62
+ - import { ChannelHub, createSyncNode, LiveQueryRegistry } from '@ultimat3/realtime';
63
+ + import { ChannelHub, createSyncNode, LiveQueryRegistry } from '@ultimat3/realtime/server';
64
+ ```
65
+
66
+ Client names — `useLive`, `liveHookFor`, `LiveClient`, `OfflineQueue`, `RebaseLog`, `IdentityMap`,
67
+ `encode`/`decode`, every `X_*` error class — are unchanged.
68
+
47
69
  ## Public API
48
70
 
49
- | Concern | Export |
50
- |---|---|
51
- | tier 1 | `topic`, `ChannelHub`, `PresenceRegistry`, `SyncSocket`, `SocketRegistry` |
52
- | tier 2 | `LiveQueryRegistry`, `InMemoryChangeFeed`, `PgLogicalReplicationFeed`, `selectChangeFeed`, `createReplicator`, `PgAdvisoryLock`, `matcherFor` |
53
- | replication | `parsePgUrl`, `bunPgStream`, `PgOutputDecoder`, `entityRow`, `changeLsn`, `commitPositionOf` |
54
- | fanout | `Transport`, `InProcessTransport`, `NatsTransport`, `selectTransport`, `subjectMatches` |
55
- | the bus, behind `NatsTransport` | the port — `NatsClient`, `NatsMessage`, `NatsSubscription`, `NatsConnect`, `NatsTarget`, `parseNatsUrl` — plus `openNatsClient` (the `nats` adapter), `NatsKvSet`, `ensureKvBucket`, `kvGet`/`kvLast`/`kvWrite`, `assertBucket`, `encodeToken`/`decodeToken`, and `FakeNatsBroker`/`fakeNatsConnect` for tests |
56
- | reconnect | `LiveCursor`, `resumeFrom`, `shouldResnapshot`, `defaultReconnectBudget`, `RingChangeBuffer`, `backoffDelay`, `Scheduler`, `timeoutScheduler`, `drainPlan`, `AcceptBudget` |
57
- | the client store | `IdentityMap` — one row value per `(entity, id)` — plus `RowWindows`, `rowKey`, `privateScope`, `applyPatches`/`orderAfterPatches` |
58
- | tier 3 | `MemoryLocalStore`, `createOpfsLocalStore`, `OfflineQueue`, `RebaseLog`, `reconcile`, `custom` |
59
- | wire | `PROTOCOL_VERSION`, `encode`, `decode`, `Frame` |
60
- | halves | `LiveClient` (client), `createSyncNode` / `listenSyncNode` (`sync` role) |
61
- | a socket's identity | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
62
- | hooks | `setLiveClient`, `useLive`, `useConnection`, `useMutation`, `useMutationQueue` |
63
- | the typed projection | `liveHookFor` — one query bound to one named hook |
71
+ | Concern | Entry | Export |
72
+ |---|---|---|
73
+ | tier 1 | `./server` | `topic`, `ChannelHub`, `PresenceRegistry`, `SyncSocket`, `SocketRegistry` |
74
+ | tier 2 | `./server` | `LiveQueryRegistry`, `InMemoryChangeFeed`, `PgLogicalReplicationFeed`, `selectChangeFeed`, `createReplicator`, `PgAdvisoryLock`, `matcherFor` |
75
+ | replication | `./server` | `parsePgUrl`, `bunPgStream`, `PgOutputDecoder`, `entityRow`, `changeLsn`, `commitPositionOf` |
76
+ | fanout | `./server` | `Transport`, `InProcessTransport`, `NatsTransport`, `selectTransport`, `subjectMatches` |
77
+ | the bus, behind `NatsTransport` | `./server` | the port — `NatsClient`, `NatsMessage`, `NatsSubscription`, `NatsConnect`, `NatsTarget`, `parseNatsUrl` — plus `openNatsClient` (the `nats` adapter), `NatsKvSet`, `ensureKvBucket`, `kvGet`/`kvLast`/`kvWrite`, `assertBucket`, `encodeToken`/`decodeToken`, and `FakeNatsBroker`/`fakeNatsConnect` for tests |
78
+ | reconnect | both | `LiveCursor`, `resumeFrom`, `shouldResnapshot`, `defaultReconnectBudget`, `backoffDelay`, `Scheduler`, `timeoutScheduler` on `.`; `RingChangeBuffer`, `drainPlan`, `AcceptBudget`, `reconnectFrame` on `./server` — the node's half of the reconnect is the node's |
79
+ | the client store | `.` | `IdentityMap` — one row value per `(entity, id)` — plus `RowWindows`, `rowKey`, `privateScope`, `applyPatches`/`orderAfterPatches` |
80
+ | tier 3 | `.` | `MemoryLocalStore`, `createOpfsLocalStore`, `OfflineQueue`, `RebaseLog`, `reconcile`, `custom` |
81
+ | wire | `.` | `PROTOCOL_VERSION`, `encode`, `decode`, `Frame` |
82
+ | halves | both | `LiveClient` on `.`; `createSyncNode` / `listenSyncNode` (`sync` role) on `./server` |
83
+ | a socket's identity | `./server` | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
84
+ | hooks | `.` | `setLiveClient`, `useLive`, `useConnection`, `useMutation`, `useMutationQueue` |
85
+ | the typed projection | `.` | `liveHookFor` — one query bound to one named hook |
64
86
 
65
87
  ## The four hooks
66
88
 
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "7.0.0",
3
+ "version": "9.0.0",
4
4
  "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
+ "sideEffects": [
8
+ "./src/errors.ts"
9
+ ],
7
10
  "repository": {
8
11
  "type": "git",
9
12
  "url": "git+https://github.com/developerz-ai/ultimate.git",
@@ -14,7 +17,8 @@
14
17
  "provenance": true
15
18
  },
16
19
  "exports": {
17
- ".": "./src/index.ts"
20
+ ".": "./src/index.ts",
21
+ "./server": "./src/server.ts"
18
22
  },
19
23
  "files": [
20
24
  "src",
@@ -32,8 +36,8 @@
32
36
  "test": "bun test"
33
37
  },
34
38
  "dependencies": {
35
- "@ultimat3/core": "7.0.0",
36
- "@ultimat3/query": "7.0.0",
39
+ "@ultimat3/core": "9.0.0",
40
+ "@ultimat3/query": "9.0.0",
37
41
  "nats": "2.29.3"
38
42
  }
39
43
  }
package/src/index.ts CHANGED
@@ -1,49 +1,10 @@
1
- // Public API. Explicit, tier by tier: channels, live queries, local-first sync, plus the wire and
2
- // the server/client halves that carry all three.
1
+ // The CLIENT half of the public API everything a browser island may bundle. Explicit, tier by
2
+ // tier: the wire, the hooks, the identity map, the offline queue and the reconnect vocabulary.
3
+ // Nothing here reaches `nats`, a Postgres socket or the sync node; those are `./server`, and
4
+ // `packages/cli/src/realtime-browser-barrel.test.ts` is the build error that keeps them apart.
3
5
 
4
6
  // ---- the client's one stateless piece, reusable against an app's own store ----------------------
5
7
  export { applyPatches, orderAfterPatches } from './apply-patches';
6
- export {
7
- type ChangeBufferOptions,
8
- DEFAULT_MAX_BUFFER_BYTES,
9
- DEFAULT_MAX_BUFFER_BYTES_PER_QUERY,
10
- RingChangeBuffer,
11
- } from './change-buffer';
12
- // ---- tier 2: live queries ----------------------------------------------------------------------
13
- export {
14
- type ChangeEvent,
15
- type ChangeFeed,
16
- type ChangeFeedStartOptions,
17
- type ChangeOp,
18
- formatLsn,
19
- InMemoryChangeFeed,
20
- type InMemoryChangeFeedOptions,
21
- PgLogicalReplicationFeed,
22
- type PgLogicalReplicationOptions,
23
- parseLsn,
24
- } from './changefeed';
25
- export {
26
- type ChangeFeedSelection,
27
- DEFAULT_REPLICATION_PUBLICATION,
28
- DEFAULT_REPLICATION_SLOT,
29
- REPLICATION_ENV_KEYS,
30
- type ReplicationEnvironment,
31
- replicatorLockKey,
32
- type SelectChangeFeedOptions,
33
- selectChangeFeed,
34
- } from './changefeed-env';
35
- // ---- tier 1: channels + presence ---------------------------------------------------------------
36
- export {
37
- ChannelHub,
38
- type ChannelHubOptions,
39
- channelFrame,
40
- DEFAULT_MAX_TOPICS_PER_NODE,
41
- type Topic,
42
- type TopicGuard,
43
- type TopicGuardArgs,
44
- type TopicGuardResult,
45
- topic,
46
- } from './channel';
47
8
  // ---- server + client halves -------------------------------------------------------------------
48
9
  export {
49
10
  type ClientSocket,
@@ -75,7 +36,7 @@ export {
75
36
  shouldResnapshot,
76
37
  verifyDigest,
77
38
  } from './cursor';
78
- // ---- errors ----------------------------------------------------------------------------------
39
+ // ---- errors: one vocabulary for both halves, because every code reaches the wire ---------------
79
40
  export {
80
41
  CursorStaleError,
81
42
  FrameRateLimitError,
@@ -99,16 +60,6 @@ export {
99
60
  TransportProtocolError,
100
61
  TransportUnavailableError,
101
62
  } from './errors';
102
- export {
103
- InProcessTransport,
104
- type InProcessTransportOptions,
105
- subjectMatches,
106
- type Transport,
107
- type TransportHandler,
108
- type TransportSet,
109
- type TransportSetEntry,
110
- type TransportSubscription,
111
- } from './fanout';
112
63
  // ---- the client hooks --------------------------------------------------------------------------
113
64
  export {
114
65
  type ConflictLike,
@@ -147,17 +98,6 @@ export {
147
98
  type RowOp,
148
99
  type RowPatch,
149
100
  } from './json';
150
- export type {
151
- LiveQueryDefinition,
152
- LiveSubscription,
153
- SnapshotResult,
154
- } from './live-contract';
155
- export { type LiveDefinitionOptions, liveQueryDefinition } from './live-definition';
156
- export {
157
- DEFAULT_MAX_ENTRIES,
158
- LiveQueryRegistry,
159
- type LiveQueryRegistryOptions,
160
- } from './live-query';
161
101
  export { type Registration, RowWindows } from './live-rows';
162
102
  // ---- tier 3: local-first ------------------------------------------------------------------------
163
103
  export {
@@ -169,51 +109,6 @@ export {
169
109
  type OpfsLocalStoreOptions,
170
110
  type TableMap,
171
111
  } from './local-store';
172
- export {
173
- applyToWindow,
174
- type BridgeResult,
175
- bridgeChange,
176
- canAffect,
177
- type IncrementalMatcher,
178
- matcherFor,
179
- NO_CHANGE,
180
- normalizePatch,
181
- patchFromChange,
182
- type SubscriptionShape,
183
- toBridgeResult,
184
- } from './matcher-bridge';
185
- // ---- the production bus -------------------------------------------------------------------------
186
- export {
187
- DEFAULT_NATS_PORT,
188
- DEFAULT_REQUEST_TIMEOUT_MS,
189
- type NatsClient,
190
- type NatsClientOptions,
191
- type NatsConnect,
192
- type NatsHeaders,
193
- type NatsMessage,
194
- type NatsMessageHandler,
195
- type NatsRequestManyOptions,
196
- type NatsRequestOptions,
197
- type NatsSubscription,
198
- type NatsTarget,
199
- parseNatsUrl,
200
- } from './nats-client';
201
- export { FakeNatsBroker, type FakeNatsOptions, fakeNatsConnect } from './nats-fake';
202
- export {
203
- assertBucket,
204
- assertServerVersion,
205
- ensureKvBucket,
206
- type JsError,
207
- type KvRecord,
208
- kvGet,
209
- kvLast,
210
- kvStream,
211
- kvSubject,
212
- kvWrite,
213
- } from './nats-jetstream';
214
- export { decodeToken, encodeToken, NatsKvSet, type NatsKvSetOptions } from './nats-kv';
215
- export { openNatsClient } from './nats-lib-client';
216
- export { NatsTransport, type NatsTransportOptions } from './nats-transport';
217
112
  export {
218
113
  type DrainReport,
219
114
  MemoryQueueStore,
@@ -225,47 +120,12 @@ export {
225
120
  type QueueState,
226
121
  type QueueStore,
227
122
  } from './offline-queue';
228
- export { PgAdvisoryLock, type PgAdvisoryLockOptions } from './pg-advisory-lock';
229
- // ---- the postgres replication path ------------------------------------------------------------
230
- export { camel, entityRow } from './pg-entity-row';
231
- export {
232
- changeLsn,
233
- commitPositionOf,
234
- type ReplicationStreamStats,
235
- } from './pg-replication';
236
- export { bunPgStream, type PgTarget, parsePgUrl, type SslMode } from './pg-socket';
237
- export type { PgStream } from './pg-wire';
238
- export {
239
- type PgColumn,
240
- PgOutputDecoder,
241
- type PgOutputMessage,
242
- type PgRelation,
243
- } from './pgoutput';
244
- export { authorizeWithPolicy, type GateOptions, visibleWithPolicy } from './policy-gate';
245
- export {
246
- DEFAULT_MAX_PRESENCE_MEMBERS,
247
- PRESENCE_KEY_PREFIX,
248
- PRESENCE_SWEEP_PREFIX,
249
- type PresenceInput,
250
- type PresenceOptions,
251
- PresenceRegistry,
252
- type PresenceRoster,
253
- presenceFrame,
254
- } from './presence';
255
123
  /** The typed projection: one query bound to one named hook, `useLiveFeed({ orgId })`. */
256
124
  export {
257
125
  type LiveQueryHook,
258
126
  type LiveQuerySource,
259
127
  liveHookFor,
260
128
  } from './query-hook';
261
- export {
262
- createEntry,
263
- fillWindow,
264
- orgIdOf,
265
- type PendingRead,
266
- type QueryEntry,
267
- refillWindowInLane,
268
- } from './query-window';
269
129
  export {
270
130
  type ConflictStrategy,
271
131
  type CustomMerge,
@@ -280,72 +140,6 @@ export {
280
140
  type ServerAck,
281
141
  strategyName,
282
142
  } from './rebase';
283
- export {
284
- type AdvisoryLock,
285
- CHANGE_SUBJECT_PREFIX,
286
- type ChangeEnvelope,
287
- changeSubject,
288
- createReplicator,
289
- InMemoryAdvisoryLock,
290
- normalize,
291
- parseChange,
292
- parseEnvelope,
293
- type Replicator,
294
- type ReplicatorOptions,
295
- type ReplicatorStats,
296
- SeqGapDetector,
297
- } from './replicator';
298
- export {
299
- actorIdOf,
300
- CLOSE,
301
- DEFAULT_FRAME_BURST,
302
- DEFAULT_IDLE_TIMEOUT_MS,
303
- DEFAULT_MAX_BUFFERED_BYTES,
304
- DEFAULT_MAX_FRAMES_PER_SECOND,
305
- idleSweepPeriodMs,
306
- SocketRegistry,
307
- type SocketRegistryOptions,
308
- SyncSocket,
309
- type SyncSocketOptions,
310
- type WsLike,
311
- } from './socket';
312
- export type {
313
- GateFailed,
314
- GateStage,
315
- RowDenied,
316
- Subscriber,
317
- SubscriberGateOptions,
318
- } from './subscriber-gate';
319
- export {
320
- GrantBook,
321
- type GrantSweepDeps,
322
- type GrantSweepResult,
323
- type SyncAuthenticator,
324
- type SyncGrant,
325
- sweepGrants,
326
- } from './sync-auth';
327
- export {
328
- createFrameRouter,
329
- type FrameRouter,
330
- type FrameRouterOptions,
331
- type MutationHandler,
332
- } from './sync-frames';
333
- export {
334
- type ListenOptions,
335
- listenSyncNode,
336
- type SyncListener,
337
- } from './sync-listen';
338
- export {
339
- createSyncNode,
340
- DEFAULT_MAX_CONNECTIONS,
341
- DEFAULT_MAX_FRAME_BYTES,
342
- DEFAULT_REAUTH_INTERVAL_MS,
343
- type SyncNode,
344
- type SyncNodeOptions,
345
- type SyncWs,
346
- type UpgradeTarget,
347
- type WsData,
348
- } from './sync-node';
349
143
  // ---- the wire -------------------------------------------------------------------------------------
350
144
  export {
351
145
  type AckFrame,
@@ -371,29 +165,14 @@ export {
371
165
  type UpdateAvailableFrame,
372
166
  type WireError,
373
167
  } from './sync-protocol';
168
+ // ---- the client's own reconnect: the backoff it computes and the timer it arms -----------------
374
169
  export {
375
- AcceptBudget,
376
- type AcceptBudgetOptions,
377
170
  type BackoffPolicy,
378
171
  backoffDelay,
379
- type DrainedSocket,
380
- type DrainPlanEntry,
381
- type DrainPlanOptions,
382
172
  defaultBackoff,
383
- drainPlan,
384
173
  type JitterMode,
385
174
  type ReconnectReason,
386
175
  type Rng,
387
- reconnectFrame,
388
176
  type Scheduler,
389
177
  timeoutScheduler,
390
178
  } from './thundering-herd';
391
- export {
392
- DEFAULT_PRESENCE_BUCKET,
393
- DEFAULT_PRESENCE_TTL_MS,
394
- type SelectTransportOptions,
395
- selectTransport,
396
- TRANSPORT_ENV_KEYS,
397
- type TransportEnvironment,
398
- type TransportSelection,
399
- } from './transport-env';
@@ -217,6 +217,23 @@ export class OfflineQueue {
217
217
  await this.#persist();
218
218
  }
219
219
 
220
+ /**
221
+ * Re-asked at the top of every iteration, because `#sendable()` is a SNAPSHOT and the loop awaits
222
+ * a network `send` inside it. An `ack` landing mid-pass removes its entry from the queue, a
223
+ * `fail` marks it terminal and a `clear` drops all of them — and the pass, holding the snapshot,
224
+ * re-sent an intent the server had already settled AND counted it in `DrainReport.sent`, so the
225
+ * number a UI renders as "synced" was larger than the frames that left the tab.
226
+ *
227
+ * Deliberately NOT an `#epoch` bump in `ack`/`fail`: an ack arriving during a drain is the
228
+ * ordinary case — the server answers frame 1 while the pass is on frame 2 — and invalidating the
229
+ * pass would stop every drain against a server that answers promptly. The epoch means "the
230
+ * connection this pass was draining into is gone", which is one mutation's settlement away from
231
+ * nothing at all.
232
+ */
233
+ #stillSendable(mutation: QueuedMutation): boolean {
234
+ return mutation.status === 'pending' && this.#mutations.includes(mutation);
235
+ }
236
+
220
237
  /** Never sent on this connection. `inflight` is excluded: it is already on a socket. */
221
238
  #sendable(): readonly QueuedMutation[] {
222
239
  return this.#mutations
@@ -246,6 +263,9 @@ export class OfflineQueue {
246
263
  stoppedAt: mutation.key,
247
264
  };
248
265
  }
266
+ // Settled by the server, or dropped by `clear()`, since the snapshot was taken. Skipped
267
+ // rather than stopping the pass: nothing behind it moved, so the order invariant holds.
268
+ if (!this.#stillSendable(mutation)) continue;
249
269
  mutation.status = 'inflight';
250
270
  mutation.attempts += 1;
251
271
  try {
package/src/pgoutput.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { renderThrowable } from '@ultimat3/core';
1
2
  // Decodes pgoutput logical-replication messages (protocol version 1, Postgres >= 12) into typed
2
3
  // PgOutputMessage values, and the postgres text-format values inside each tuple into JsonValue.
3
4
  // Pure byte decoding: no sockets, no I/O. A decoder instance owns the per-connection relation
@@ -92,7 +93,7 @@ function decodeValue(typeOid: number, text: string): JsonValue {
92
93
  } catch (cause) {
93
94
  throw new ReplicationProtocolError({
94
95
  stage: 'value',
95
- detail: `type oid ${typeOid} carried invalid json: ${String(cause)}`,
96
+ detail: `type oid ${typeOid} carried invalid json: ${renderThrowable(cause)}`,
96
97
  });
97
98
  }
98
99
  return parsed as JsonValue;
package/src/server.ts ADDED
@@ -0,0 +1,234 @@
1
+ // The SERVER half of the public API: the bus, the Postgres replication path, the sync node and the
2
+ // live-query registry it fans out through. Split from `index.ts` because `nats` require()s
3
+ // `stream/web` and the WAL decoder is a Postgres client — one barrel carrying both made the browser
4
+ // island `useLive` promises unbuildable. Every name here has exactly one home; the shared
5
+ // vocabulary (the wire, the errors, `Row`, the backoff) stays on `@ultimat3/realtime`.
6
+
7
+ // ---- the retained change window one node fans out from ------------------------------------------
8
+ export {
9
+ type ChangeBufferOptions,
10
+ DEFAULT_MAX_BUFFER_BYTES,
11
+ DEFAULT_MAX_BUFFER_BYTES_PER_QUERY,
12
+ RingChangeBuffer,
13
+ } from './change-buffer';
14
+ // ---- tier 2: live queries ----------------------------------------------------------------------
15
+ export {
16
+ type ChangeEvent,
17
+ type ChangeFeed,
18
+ type ChangeFeedStartOptions,
19
+ type ChangeOp,
20
+ formatLsn,
21
+ InMemoryChangeFeed,
22
+ type InMemoryChangeFeedOptions,
23
+ PgLogicalReplicationFeed,
24
+ type PgLogicalReplicationOptions,
25
+ parseLsn,
26
+ } from './changefeed';
27
+ export {
28
+ type ChangeFeedSelection,
29
+ DEFAULT_REPLICATION_PUBLICATION,
30
+ DEFAULT_REPLICATION_SLOT,
31
+ REPLICATION_ENV_KEYS,
32
+ type ReplicationEnvironment,
33
+ replicatorLockKey,
34
+ type SelectChangeFeedOptions,
35
+ selectChangeFeed,
36
+ } from './changefeed-env';
37
+ // ---- tier 1: channels + presence ---------------------------------------------------------------
38
+ export {
39
+ ChannelHub,
40
+ type ChannelHubOptions,
41
+ channelFrame,
42
+ DEFAULT_MAX_TOPICS_PER_NODE,
43
+ type Topic,
44
+ type TopicGuard,
45
+ type TopicGuardArgs,
46
+ type TopicGuardResult,
47
+ topic,
48
+ } from './channel';
49
+ export {
50
+ InProcessTransport,
51
+ type InProcessTransportOptions,
52
+ subjectMatches,
53
+ type Transport,
54
+ type TransportHandler,
55
+ type TransportSet,
56
+ type TransportSetEntry,
57
+ type TransportSubscription,
58
+ } from './fanout';
59
+ export type {
60
+ LiveQueryDefinition,
61
+ LiveSubscription,
62
+ SnapshotResult,
63
+ } from './live-contract';
64
+ export { type LiveDefinitionOptions, liveQueryDefinition } from './live-definition';
65
+ export {
66
+ DEFAULT_MAX_ENTRIES,
67
+ LiveQueryRegistry,
68
+ type LiveQueryRegistryOptions,
69
+ } from './live-query';
70
+ export {
71
+ applyToWindow,
72
+ type BridgeResult,
73
+ bridgeChange,
74
+ canAffect,
75
+ type IncrementalMatcher,
76
+ matcherFor,
77
+ NO_CHANGE,
78
+ normalizePatch,
79
+ patchFromChange,
80
+ type SubscriptionShape,
81
+ toBridgeResult,
82
+ } from './matcher-bridge';
83
+ // ---- the production bus -------------------------------------------------------------------------
84
+ export {
85
+ DEFAULT_NATS_PORT,
86
+ DEFAULT_REQUEST_TIMEOUT_MS,
87
+ type NatsClient,
88
+ type NatsClientOptions,
89
+ type NatsConnect,
90
+ type NatsHeaders,
91
+ type NatsMessage,
92
+ type NatsMessageHandler,
93
+ type NatsRequestManyOptions,
94
+ type NatsRequestOptions,
95
+ type NatsSubscription,
96
+ type NatsTarget,
97
+ parseNatsUrl,
98
+ } from './nats-client';
99
+ export { FakeNatsBroker, type FakeNatsOptions, fakeNatsConnect } from './nats-fake';
100
+ export {
101
+ assertBucket,
102
+ assertServerVersion,
103
+ ensureKvBucket,
104
+ type JsError,
105
+ type KvRecord,
106
+ kvGet,
107
+ kvLast,
108
+ kvStream,
109
+ kvSubject,
110
+ kvWrite,
111
+ } from './nats-jetstream';
112
+ export { decodeToken, encodeToken, NatsKvSet, type NatsKvSetOptions } from './nats-kv';
113
+ export { openNatsClient } from './nats-lib-client';
114
+ export { NatsTransport, type NatsTransportOptions } from './nats-transport';
115
+ export { PgAdvisoryLock, type PgAdvisoryLockOptions } from './pg-advisory-lock';
116
+ // ---- the postgres replication path ------------------------------------------------------------
117
+ export { camel, entityRow } from './pg-entity-row';
118
+ export {
119
+ changeLsn,
120
+ commitPositionOf,
121
+ type ReplicationStreamStats,
122
+ } from './pg-replication';
123
+ export { bunPgStream, type PgTarget, parsePgUrl, type SslMode } from './pg-socket';
124
+ export type { PgStream } from './pg-wire';
125
+ export {
126
+ type PgColumn,
127
+ PgOutputDecoder,
128
+ type PgOutputMessage,
129
+ type PgRelation,
130
+ } from './pgoutput';
131
+ export { authorizeWithPolicy, type GateOptions, visibleWithPolicy } from './policy-gate';
132
+ export {
133
+ DEFAULT_MAX_PRESENCE_MEMBERS,
134
+ PRESENCE_KEY_PREFIX,
135
+ PRESENCE_SWEEP_PREFIX,
136
+ type PresenceInput,
137
+ type PresenceOptions,
138
+ PresenceRegistry,
139
+ type PresenceRoster,
140
+ presenceFrame,
141
+ } from './presence';
142
+ export {
143
+ createEntry,
144
+ fillWindow,
145
+ orgIdOf,
146
+ type PendingRead,
147
+ type QueryEntry,
148
+ refillWindowInLane,
149
+ } from './query-window';
150
+ export {
151
+ type AdvisoryLock,
152
+ CHANGE_SUBJECT_PREFIX,
153
+ type ChangeEnvelope,
154
+ changeSubject,
155
+ createReplicator,
156
+ InMemoryAdvisoryLock,
157
+ normalize,
158
+ parseChange,
159
+ parseEnvelope,
160
+ type Replicator,
161
+ type ReplicatorOptions,
162
+ type ReplicatorStats,
163
+ SeqGapDetector,
164
+ } from './replicator';
165
+ export {
166
+ actorIdOf,
167
+ CLOSE,
168
+ DEFAULT_FRAME_BURST,
169
+ DEFAULT_IDLE_TIMEOUT_MS,
170
+ DEFAULT_MAX_BUFFERED_BYTES,
171
+ DEFAULT_MAX_FRAMES_PER_SECOND,
172
+ idleSweepPeriodMs,
173
+ SocketRegistry,
174
+ type SocketRegistryOptions,
175
+ SyncSocket,
176
+ type SyncSocketOptions,
177
+ type WsLike,
178
+ } from './socket';
179
+ export type {
180
+ GateFailed,
181
+ GateStage,
182
+ RowDenied,
183
+ Subscriber,
184
+ SubscriberGateOptions,
185
+ } from './subscriber-gate';
186
+ export {
187
+ GrantBook,
188
+ type GrantSweepDeps,
189
+ type GrantSweepResult,
190
+ type SyncAuthenticator,
191
+ type SyncGrant,
192
+ sweepGrants,
193
+ } from './sync-auth';
194
+ export {
195
+ createFrameRouter,
196
+ type FrameRouter,
197
+ type FrameRouterOptions,
198
+ type MutationHandler,
199
+ } from './sync-frames';
200
+ export {
201
+ type ListenOptions,
202
+ listenSyncNode,
203
+ type SyncListener,
204
+ } from './sync-listen';
205
+ export {
206
+ createSyncNode,
207
+ DEFAULT_MAX_CONNECTIONS,
208
+ DEFAULT_MAX_FRAME_BYTES,
209
+ DEFAULT_REAUTH_INTERVAL_MS,
210
+ type SyncNode,
211
+ type SyncNodeOptions,
212
+ type SyncWs,
213
+ type UpgradeTarget,
214
+ type WsData,
215
+ } from './sync-node';
216
+ // ---- admission and drain: the node's half of the reconnect vocabulary ---------------------------
217
+ export {
218
+ AcceptBudget,
219
+ type AcceptBudgetOptions,
220
+ type DrainedSocket,
221
+ type DrainPlanEntry,
222
+ type DrainPlanOptions,
223
+ drainPlan,
224
+ reconnectFrame,
225
+ } from './thundering-herd';
226
+ export {
227
+ DEFAULT_PRESENCE_BUCKET,
228
+ DEFAULT_PRESENCE_TTL_MS,
229
+ type SelectTransportOptions,
230
+ selectTransport,
231
+ TRANSPORT_ENV_KEYS,
232
+ type TransportEnvironment,
233
+ type TransportSelection,
234
+ } from './transport-env';