@ultimat3/realtime 9.0.0 → 10.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
@@ -35,6 +35,15 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
35
35
  **The array alone would have fixed the build** and is not why the split exists: tree-shaking is
36
36
  a bundler's discretion, `export * from` or a namespace import defeats it, and "the client entry
37
37
  cannot reach the bus" is a contract rather than an optimisation.
38
+ - **Two entries means a specifier naming a third does not resolve, and a `fix:` is pasted.**
39
+ `local-store.ts`'s `X_NOT_IMPLEMENTED` told the caller to import `createOpfsLocalStore` from
40
+ `@ultimat3/realtime/browser` — a subpath `exports` has never declared — so the one instruction
41
+ the refusal carried ended in a module-resolution failure, in the package whose own rules cite
42
+ axiom 4. Its alternative, `persist: false` on the query, was the same defect twice: `query()`
43
+ does not accept `persist` either. `fix-specifier.test.ts` is the build error — every
44
+ `@ultimat3/realtime/<subpath>` written in shipped source must be a key of `exports`, comments
45
+ included, because a comment naming a subpath that does not exist is the next fix line's source.
46
+ It cannot see WHICH names a fix promises, so the OPFS one is pinned by name beside it.
38
47
  - **`@ultimat3/realtime/server` needs its own `paths` entry in `tsconfig.base.json`**, beside
39
48
  `@ultimat3/admin/dev`'s. `@ultimat3/*` maps `realtime/server` to `packages/realtime/server/src`,
40
49
  which does not exist, and the root program has no `node_modules/@ultimat3` symlink to fall back
@@ -259,6 +268,21 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
259
268
  whose non-key columns happen to be NULL sends the bytes a FULL one does, so counting absent keys
260
269
  would undercount exactly the rows a policy is most likely to misjudge. A hard refusal in the
261
270
  `x verify` step is the follow-up and lives in `@ultimat3/cli`.
271
+ - **The replication session pins its own output formats, `As of 2026-08-23`.** Postgres sends every
272
+ WAL value as TEXT and `pg-values.ts` reads a `timestamptz` by matching postgres' ISO spelling,
273
+ keeping the raw text when it does not match — deliberately, because a wrong instant is worse than
274
+ a string. That makes the SERVER's `DateStyle` load-bearing: `SQL`, `German` or `Postgres` sends
275
+ every timestamp down the fallback, the shared window holds `Date`s while the patch holds text,
276
+ `compareValues` falls to string comparison, and one edit to one column jumps its row to the top
277
+ of every `orderBy('createdAt','desc')` feed for every subscriber — the exact defect the decode
278
+ exists to close, re-opened by a GUC. `pg-connection.ts` therefore sends
279
+ `options: '-c datestyle=ISO -c intervalstyle=postgres -c extra_float_digits=3'` in the startup
280
+ packet, byte for byte what postgres' own logical-replication client sends
281
+ (`libpqwalreceiver.c`) — which is why a walsender accepts it. On **every** session this class
282
+ opens, not only the replicating one: one session shape is one thing to reason about, and the
283
+ advisory-lock connection is the same class. A server that refuses one answers `ErrorResponse` at
284
+ startup, so the replicator fails to boot with the server's own words rather than mis-sorting a
285
+ feed behind a warning nobody reads. `pg-connection.test.ts` pins the packet.
262
286
  - A change lsn is `<16 hex commit position><8 hex row position in that transaction>`. Never order by
263
287
  either half alone: the commit lsn repeats within a transaction, and per-record WAL positions are
264
288
  not monotonic across transactions. Never make it depend on wall time, the entity list or a process
@@ -716,10 +740,15 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
716
740
  ## Commands
717
741
 
718
742
  ```
719
- bun test # from packages/realtime
743
+ bun test packages/realtime/src # from the REPO ROOT, never from packages/realtime
720
744
  bun run typecheck
721
745
  ```
722
746
 
747
+ **The root is not a preference.** `bunfig.toml`'s preload installs `@ultimat3/testing`'s matchers
748
+ and Bun reads `bunfig.toml` from the cwd, so `bun test` inside this directory loads none and six
749
+ tests fail on a missing matcher — this package's suite reading red for the shell it was run in.
750
+ CI's `package` job spawns `bun test packages/<pkg>` with `cwd` at the root for the same reason.
751
+
723
752
  Changing a frame shape means adding a fixture to `sync-protocol.test.ts` — the round-trip test
724
753
  fails if a kind has no fixture — and bumping `PROTOCOL_VERSION` **when the change makes an old
725
754
  frame unreadable in either direction**. An *additive optional* field (`snapshot.entity`, 2026-08)
package/README.md CHANGED
@@ -256,7 +256,7 @@ that outlasts the deploy. The design confronts it with exactly two paths and no
256
256
  | **snapshot** | out of window, past `maxLagMs`, or past `reconnectBudget` | one bounded indexed query |
257
257
 
258
258
  A `LiveCursor` is `lsn` + result-set `digest` + last-seen `ids` + `count`. The digest is
259
- order-sensitive, so a re-sort is detected; the ids let a delta be re-filtered per subscriber, because
259
+ order-sensitive and server-side (see `cursor.ts#digestOf` for why a client cannot reproduce one); the ids let a delta be re-filtered per subscriber, because
260
260
  the retained window stores **pre-policy** patches. `resumeFrom()` picks the path,
261
261
  `shouldResnapshot()` explains it, and the budget is a cost model in patch-equivalents
262
262
  (`snapshotCost: 250` = "replaying 250 patches costs a snapshot") so the expensive path is *chosen*,
@@ -348,7 +348,9 @@ wire twice by a reconnect that raced an ack.
348
348
  would put the write the server refused back on the screen. Idempotent for a key the log does not
349
349
  hold, because a denial can arrive twice and tier 2 records nothing to undo.
350
350
  - **A delta resume leaves the digest unverified** (`DIGEST_UNVERIFIED`). Only a snapshot re-establishes
351
- it. `verifyDigest()` is how a client detects drift and asks for a fresh one.
351
+ it. The digest is the SERVER's own nothing on the client reproduces it, and `verifyDigest()`,
352
+ which claimed otherwise and had no caller, is deleted (2026-08-23). What detects drift on the
353
+ client is the server's `desynced` mark and the re-snapshot it triggers.
352
354
  - **Backpressure drops patch frames.** That is safe *only* because a re-snapshot is cheap: the drop
353
355
  is recorded on the socket (`desynced`) and the next delivery re-snapshots rather than diverging.
354
356
  - **A dropped CHANNEL frame is not safe, and is not repaired.** A topic has no cursor, no mark and
@@ -504,10 +506,13 @@ wire twice by a reconnect that raced an ack.
504
506
  running half: one per change delivered off a relation that is not FULL, so the decisions it
505
507
  actually cost are countable rather than silent. A hard refusal at `x verify` time is the
506
508
  follow-up.
507
- - Tier 3's OPFS SQLite store is browser-only and throws until the browser entry ships; `MemoryLocalStore`
508
- implements the full journal/rollback/replay semantics today. It holds membership and the journal;
509
- the row values are the client's one `IdentityMap`, which is what a browser store has to inherit
510
- rather than re-implement.
509
+ - Tier 3's OPFS SQLite store is browser-only, is **not built**, and throws `X_NOT_IMPLEMENTED` on
510
+ call. `createOpfsLocalStore` is exported from `.` and stays there when it ships — there is no
511
+ third entry to wait for, and the refusal used to name one (`@ultimat3/realtime/browser`, a
512
+ subpath `exports` never declared). `MemoryLocalStore`, beside it on `.`, implements the full
513
+ journal/rollback/replay semantics today and is what the refusal's `fix:` names. It holds
514
+ membership and the journal; the row values are the client's one `IdentityMap`, which is what a
515
+ browser store has to inherit rather than re-implement.
511
516
  - The identity map is **per client**, in memory, and it is not a query cache: it answers "what is
512
517
  row X now", never "have I run this query before". Nothing evicts by time or size — a row lives
513
518
  exactly as long as a window or a table holds it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "9.0.0",
3
+ "version": "10.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",
@@ -36,8 +36,8 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/core": "9.0.0",
40
- "@ultimat3/query": "9.0.0",
39
+ "@ultimat3/core": "10.0.0",
40
+ "@ultimat3/query": "10.0.0",
41
41
  "nats": "2.29.3"
42
42
  }
43
43
  }
@@ -51,6 +51,12 @@ function patchBytes(patch: RowPatch): number {
51
51
 
52
52
  export class RingChangeBuffer implements ResumeSource {
53
53
  readonly #rings = new Map<string, Ring>();
54
+ /**
55
+ * Query hashes whose ring was dropped, so the ring the NEXT change builds knows it does not
56
+ * carry the history before it. A `Set` of ids and not a `Map` of lsns: what the next ring is
57
+ * complete from is its own first patch, which only that patch can name.
58
+ */
59
+ readonly #forgotten = new Set<string>();
54
60
  readonly #capacity: number;
55
61
  readonly #maxQueries: number;
56
62
  readonly #maxBytesPerQuery: number;
@@ -71,7 +77,17 @@ export class RingChangeBuffer implements ResumeSource {
71
77
 
72
78
  append(qid: string, patch: RowPatch): void {
73
79
  const existing = this.#rings.get(qid);
74
- const ring: Ring = existing ?? { patches: [], bytes: 0, evictedThrough: null };
80
+ // A ring RE-CREATED after a `forget` is complete only from this patch onward: everything before
81
+ // it went with the ring, and — on the `unsubscribe` path — the entry went too, so the changes
82
+ // in between were never appended at all. `evictedThrough` at its own first lsn is what makes
83
+ // `since` refuse a cursor from before that, which it could not do while the field came back
84
+ // `null` and every earlier cursor read as in-window on a ring that had held none of it.
85
+ const reborn = this.#forgotten.delete(qid);
86
+ const ring: Ring = existing ?? {
87
+ patches: [],
88
+ bytes: 0,
89
+ evictedThrough: reborn ? patch.lsn : null,
90
+ };
75
91
  ring.patches.push(patch);
76
92
  const cost = patchBytes(patch);
77
93
  ring.bytes += cost;
@@ -124,9 +140,29 @@ export class RingChangeBuffer implements ResumeSource {
124
140
  */
125
141
  forget(qid: string): void {
126
142
  const ring = this.#rings.get(qid);
127
- if (!ring) return;
128
- this.#bytes -= ring.bytes;
129
- this.#rings.delete(qid);
143
+ if (ring !== undefined) {
144
+ this.#bytes -= ring.bytes;
145
+ this.#rings.delete(qid);
146
+ }
147
+ // The qid is remembered, the patches are not — and unconditionally, because the ring being
148
+ // absent is not the history being intact. Both callers lose history here and neither could say
149
+ // so: `LiveQueryRegistry.unsubscribe` drops the ENTRY, so every change until the next
150
+ // subscriber is never appended at all, and the LRU fires on a query that still has LIVE
151
+ // subscribers. Either way the next `append` was building a ring that reported itself complete
152
+ // from the beginning of time, so a client reconnecting inside `maxLagMs` folded a partial patch
153
+ // list onto a stale window with `shouldResnapshot` answering `in-window` and nothing marked
154
+ // desynced — permanently divergent on a healthy socket. What the tombstone costs in exchange is
155
+ // a resume that could have been a delta taking the snapshot path; that is one bounded read, and
156
+ // it is the direction this package errs in everywhere else.
157
+ this.#forgotten.add(qid);
158
+ // Bounded like everything else here: insertion-ordered, so the oldest tombstone goes first.
159
+ // Losing one costs a resume that could have been a delta; keeping them unbounded costs memory
160
+ // a client-chosen input mints at will.
161
+ while (this.#forgotten.size > this.#maxQueries) {
162
+ const oldest = this.#forgotten.values().next();
163
+ if (oldest.done === true) break;
164
+ this.#forgotten.delete(oldest.value);
165
+ }
130
166
  }
131
167
 
132
168
  get queryCount(): number {
package/src/cursor.ts CHANGED
@@ -105,17 +105,23 @@ export function makeCursor(
105
105
  };
106
106
  }
107
107
 
108
- /** FNV-1a over `id:row` pairs in result order — order-sensitive, so a re-sort is detected. */
108
+ /**
109
+ * FNV-1a over `id:row` pairs in result order — order-sensitive, so a re-sort is detected.
110
+ *
111
+ * **Server-side only, and it is not reproducible by a client.** `verifyDigest()` used to sit here
112
+ * and was DELETED (2026-08-23): it was documented as "how a client detects drift", had no caller
113
+ * outside its own test, and could not have had one. Three reasons, any one of them fatal.
114
+ * `canonicalJson` tags a `Date` as `Date(<epoch>)` while the client holds the ISO string
115
+ * `JSON.stringify` sent it. A delta-resumed cursor carries `DIGEST_UNVERIFIED`, so the check
116
+ * answered `false` for every cursor a delta produced — which is the only state drift can be
117
+ * detected in. And `identity-map.ts` MERGES columns across queries on purpose, so a client's row
118
+ * for one id is legitimately a superset of the row any single snapshot sent: an app with two reads
119
+ * over one entity would have reported permanent drift.
120
+ */
109
121
  export function digestOf(rows: readonly Row[]): string {
110
122
  return fnv1a(rows.map((row) => `${row.id}:${canonicalJson(row)}`).join(';'));
111
123
  }
112
124
 
113
- /** Client-side drift check: a mismatch after delta resumes is a request for a fresh snapshot. */
114
- export function verifyDigest(cursor: LiveCursor, rows: readonly Row[]): boolean {
115
- if (cursor.digest === DIGEST_UNVERIFIED) return false;
116
- return cursor.digest === digestOf(rows);
117
- }
118
-
119
125
  export function shouldResnapshot(
120
126
  cursor: LiveCursor,
121
127
  available: readonly RowPatch[] | null,
package/src/detach.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Work nobody is waiting on: a presence leave from a synchronous close, a sweep on a timer, a
2
+ // fanout off the change bus. Split out of `sync-node.ts` at the 500-line ceiling, and it is the
3
+ // natural seam — the function closes over nothing the node holds.
4
+
5
+ import { logger, renderThrowable, reportError } from '@ultimat3/core';
6
+
7
+ /**
8
+ * It reaches the bus or a policy, so it can fail; failing must not take a socket or the process
9
+ * with it, and must not be silent either, or "the room still shows someone who left" and "that
10
+ * change reached nobody" have nothing to read. `operation` stays low cardinality so the monitor
11
+ * can group on it; the topic or entity goes in `at`.
12
+ *
13
+ * `renderThrowable` and never `String(error)`: this is the one frame whose whole job is not to
14
+ * throw, and `String()` on a null-prototype throwable raises inside it — the detach's own `catch`,
15
+ * with nothing above it to answer. `channel.ts` already imports it for the same reason.
16
+ */
17
+ export function detach(work: Promise<unknown>, operation: string, at?: string): void {
18
+ void work.catch((error: unknown) => {
19
+ logger.error(`${operation} failed`, {
20
+ ...(at === undefined ? {} : { at }),
21
+ error: renderThrowable(error),
22
+ });
23
+ // Nobody is awaiting this, so the log is the only trace it leaves — and a log is not a signal
24
+ // anyone is paged on. The bus is this node's dependency, never the client's.
25
+ reportError(error, { source: 'realtime', scope: { operation } });
26
+ });
27
+ }
package/src/errors.ts CHANGED
@@ -128,16 +128,20 @@ registerErrorCodes(
128
128
  ),
129
129
  );
130
130
 
131
- const DOCS_BASE = 'https://ultimate.dev/errors/';
132
-
133
- /** Base for every realtime error: fills `docs` from the code so no call site can forget it. */
131
+ /**
132
+ * Base for every realtime error. No `docs:` — `UltimateError` fills it from
133
+ * `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every
134
+ * code, never one per code, because `wiki/` is the framework's only public documentation surface
135
+ * and a code lives there in a TABLE ROW, which has no anchor. The
136
+ * `https://ultimate.dev/errors/<code>` links this class built until 9.x answered 404, host
137
+ * included, on every error it has ever thrown — including the ones `toWireError` puts on the wire.
138
+ */
134
139
  export class RealtimeError extends UltimateError {
135
140
  constructor(opts: { code: RealtimeErrorCode; cause: string; fix: string }) {
136
141
  super({
137
142
  code: opts.code,
138
143
  cause: opts.cause,
139
144
  fix: opts.fix,
140
- docs: `${DOCS_BASE}${opts.code}`,
141
145
  });
142
146
  }
143
147
  }
package/src/index.ts CHANGED
@@ -34,7 +34,6 @@ export {
34
34
  type ResumeSource,
35
35
  resumeFrom,
36
36
  shouldResnapshot,
37
- verifyDigest,
38
37
  } from './cursor';
39
38
  // ---- errors: one vocabulary for both halves, because every code reaches the wire ---------------
40
39
  export {
package/src/live-query.ts CHANGED
@@ -10,6 +10,7 @@ import { type Actor, type Clock, systemClock, uuid } from '@ultimat3/core';
10
10
  import { queryHash } from '@ultimat3/query';
11
11
  import type { ChangeEvent } from './changefeed';
12
12
  import {
13
+ advance,
13
14
  type LiveCursor,
14
15
  makeCursor,
15
16
  type ReconnectBudget,
@@ -199,10 +200,18 @@ export class LiveQueryRegistry {
199
200
  resumed.patches,
200
201
  new Set(cursor.ids),
201
202
  );
202
- const subscription = this.#attachUnlessGone(entry, args.socket, sid, resumed.cursor);
203
+ // Advanced over the FILTERED list, never over `resumed.cursor` — which `resumeFrom` built
204
+ // by advancing across the retained window, and that window is PRE-POLICY. Seated as it
205
+ // came back, this subscriber's `cursor.ids` gained the id of every row inserted for every
206
+ // OTHER actor while it was away; `subscriber-gate` then reads `held.has(patch.id)` off it,
207
+ // takes the "the subscriber holds this row" branch, and delivers a `delete` frame carrying
208
+ // another tenant's row id and the instant it went. The leak that branch closes, re-opened
209
+ // one layer up. `live-fanout.ts` advances over `allowed` for exactly this reason.
210
+ const seated = advance(cursor, patches, resumed.cursor.lsn, now);
211
+ const subscription = this.#attachUnlessGone(entry, args.socket, sid, seated);
203
212
  return {
204
213
  subscription,
205
- frame: { type: 'patch', v: PROTOCOL_VERSION, sid, patches, lsn: resumed.cursor.lsn },
214
+ frame: { type: 'patch', v: PROTOCOL_VERSION, sid, patches, lsn: seated.lsn },
206
215
  };
207
216
  }
208
217
  const subscription = this.#attachUnlessGone(entry, args.socket, sid, resumed.cursor);
@@ -222,10 +222,20 @@ export interface OpfsLocalStoreOptions {
222
222
  * long write never blocks the main thread. Browser-only, so it must not be reachable from a server
223
223
  * bundle — which is why it is a factory that throws rather than a class you can accidentally new
224
224
  * on the server.
225
+ *
226
+ * The refusal below is the whole of what tier 3's durable half ships today, so its two lines have
227
+ * to be true of THIS build. Both were false until 2026-08-23: the fix told the caller to import
228
+ * this factory from a `/browser` subpath, which `package.json`'s `exports` has never declared —
229
+ * two entries ship, `.` and `./server` — so pasting it ended in a module-resolution failure. Its
230
+ * alternative was `persist: false` on the query, which `query()` has never accepted either (a
231
+ * `TS2353` excess property). An instruction that cannot run is axiom 4 failing in the package that
232
+ * documents it, so the fix now names an export that exists on an entry that exists:
233
+ * `MemoryLocalStore`, on `.`, declared beside this factory. `fix-specifier.test.ts` is the
234
+ * mechanical half.
225
235
  */
226
236
  export function createOpfsLocalStore(options: OpfsLocalStoreOptions): LocalStore {
227
237
  throw new NotImplementedError({
228
- what: `OPFS SQLite local store (${options.file} v${options.schemaVersion})`,
229
- fix: "import { createOpfsLocalStore } from '@ultimat3/realtime/browser' (tier 3, v2); today use MemoryLocalStore or set persist: false on the query",
238
+ what: `the OPFS SQLite local store for ${options.file} (schema v${options.schemaVersion}), which is realtime tier 3's durable half,`,
239
+ fix: "replace createOpfsLocalStore(...) with new MemoryLocalStore(), imported from '@ultimat3/realtime' beside it: the same LocalStore contract — journalled writes, ordered rollback, one row value per (entity, id) held for the tab's lifetime rather than across a reload",
230
240
  });
231
241
  }
@@ -14,6 +14,7 @@
14
14
  // This header said "every failure leaves here as an `UltimateError`" until 2026-08, which a reader
15
15
  // took as a guarantee it never was.
16
16
 
17
+ import { renderThrowable } from '@ultimat3/core';
17
18
  import { connect, Events, headers, Match, type Msg, type MsgHdrs, type NatsConnection } from 'nats';
18
19
  import { TransportUnavailableError } from './errors';
19
20
  import {
@@ -57,8 +58,13 @@ const unavailable = (target: NatsTarget, reason: string): TransportUnavailableEr
57
58
  reason: `${target.host}:${target.port} — ${reason}`,
58
59
  });
59
60
 
60
- const describe = (error: unknown): string =>
61
- error instanceof Error ? error.message : String(error);
61
+ /**
62
+ * The caught value as text, for the `reason` that becomes an `X_TRANSPORT_UNAVAILABLE`. Core's
63
+ * total renderer and never `String(error)`: this text is built inside a `catch` block that has
64
+ * nothing left to answer with, and `String()` raises on a null-prototype throwable — which a
65
+ * library that is not this framework's may hand over.
66
+ */
67
+ const describe = (error: unknown): string => renderThrowable(error);
62
68
 
63
69
  class LibNatsClient implements NatsClient {
64
70
  readonly #connection: NatsConnection;
@@ -222,7 +222,9 @@ export class NatsTransport implements Transport {
222
222
  transport: this.name,
223
223
  subject,
224
224
  code: isUltimateError(error) ? error.code : undefined,
225
- error: error instanceof Error ? error.message : String(error),
225
+ // `renderThrowable`, never `String(error)`: this is a reporter, and a throwable that fights
226
+ // being read makes the report the thing that throws.
227
+ error: renderThrowable(error),
226
228
  });
227
229
  }
228
230
  }
@@ -0,0 +1,141 @@
1
+ // The Postgres array TEXT literal — `{a,b}`, `{"a,b",NULL}`, `{{1,2},{3,4}}` — and the element
2
+ // type behind an array oid. Split from `pg-values.ts` so that file stays the oid switch and this
3
+ // one stays the grammar; the element decoder arrives as a parameter, so neither imports the other.
4
+ //
5
+ // It exists because `@ultimat3/entity`'s repository hands an `arrayOf()` column back as a JS array
6
+ // (the driver parses the literal) while the WAL carries the literal itself. A live row and a
7
+ // repository row have to be the same object, so the literal is parsed here rather than shipped.
8
+
9
+ /**
10
+ * `text[]` -> `text`. The wire names only the ARRAY type, so the element type behind it is a
11
+ * table — and it is a closed one: `arrayOf()` refuses `jsonb`, `bytea`, `money` and a nested array
12
+ * at declaration, so every array a framework entity can produce has its element listed below.
13
+ *
14
+ * The two it refuses are listed anyway (`1001`, `3807`, `199`): an adopted table can hold them,
15
+ * and decoding an element correctly costs nothing next to leaving the whole literal as text.
16
+ * An oid with no row — a user-defined enum's array, whose oid is per-database — is left as text
17
+ * rather than guessed at, which is what `undefined` means to `pg-values.ts`.
18
+ */
19
+ const ELEMENT_OF = Object.freeze<Record<number, number>>({
20
+ 1000: 16, // bool[]
21
+ 1001: 17, // bytea[]
22
+ 1005: 21, // int2[]
23
+ 1007: 23, // int4[]
24
+ 1009: 25, // text[]
25
+ 1014: 1042, // bpchar[]
26
+ 1015: 1043, // varchar[]
27
+ 1016: 20, // int8[]
28
+ 1021: 700, // float4[]
29
+ 1022: 701, // float8[]
30
+ 1028: 26, // oid[]
31
+ 1115: 1114, // timestamp[]
32
+ 1182: 1082, // date[]
33
+ 1185: 1184, // timestamptz[]
34
+ 1231: 1700, // numeric[]
35
+ 199: 114, // json[]
36
+ 2951: 2950, // uuid[]
37
+ 3807: 3802, // jsonb[]
38
+ });
39
+
40
+ /** The element type oid behind an array type oid, or `undefined` when this table does not name it. */
41
+ export function arrayElementOid(typeOid: number): number | undefined {
42
+ return Object.hasOwn(ELEMENT_OF, typeOid) ? ELEMENT_OF[typeOid] : undefined;
43
+ }
44
+
45
+ /** Where the scan is, so every branch below advances one cursor rather than slicing the text. */
46
+ interface Scan {
47
+ readonly text: string;
48
+ at: number;
49
+ }
50
+
51
+ /**
52
+ * One array literal -> a nested JS array, or `null` when the text is not one this grammar
53
+ * describes. `null` is not an error: a dimension prefix (`[0:2]={…}`), a `DateStyle` this decoder
54
+ * does not read, or a corrupted literal all mean the same thing to the caller — keep the text it
55
+ * arrived as rather than deliver an array that is missing a member.
56
+ *
57
+ * `decode` is the ELEMENT decoder. It never sees a quoted element's quotes or its backslash
58
+ * escapes: an unquoted `NULL` is the null value and a quoted `"NULL"` is the four-character
59
+ * string, which is the one distinction the quoting exists to carry.
60
+ */
61
+ export function parsePgArray<T>(text: string, decode: (raw: string) => T): PgArray<T> | null {
62
+ const scan: Scan = { text, at: 0 };
63
+ const parsed = readArray(scan, decode);
64
+ // Trailing content means the literal was not what this grammar accepted, whatever it parsed.
65
+ return parsed === null || scan.at !== text.length ? null : parsed;
66
+ }
67
+
68
+ /** A member is a decoded value, the null member, or — for a multidimensional array — a row of them. */
69
+ export type PgArray<T> = (T | null | PgArray<T>)[];
70
+
71
+ function readArray<T>(scan: Scan, decode: (raw: string) => T): PgArray<T> | null {
72
+ if (scan.text[scan.at] !== '{') return null;
73
+ scan.at += 1;
74
+ const out: PgArray<T> = [];
75
+ if (scan.text[scan.at] === '}') {
76
+ scan.at += 1;
77
+ return out;
78
+ }
79
+ for (;;) {
80
+ const member = readMember(scan, decode);
81
+ if (member === FAILED) return null;
82
+ out.push(member);
83
+ const next = scan.text[scan.at];
84
+ scan.at += 1;
85
+ if (next === '}') return out;
86
+ if (next !== ',') return null;
87
+ }
88
+ }
89
+
90
+ /** A sentinel, because `null` is a legal member and `undefined` would be a second spelling of it. */
91
+ const FAILED = Symbol('pg-array-failed');
92
+
93
+ function readMember<T>(
94
+ scan: Scan,
95
+ decode: (raw: string) => T,
96
+ ): T | null | PgArray<T> | typeof FAILED {
97
+ const head = scan.text[scan.at];
98
+ if (head === '{') {
99
+ const nested = readArray(scan, decode);
100
+ return nested === null ? FAILED : nested;
101
+ }
102
+ if (head === '"') {
103
+ const quoted = readQuoted(scan);
104
+ return quoted === null ? FAILED : decode(quoted);
105
+ }
106
+ const start = scan.at;
107
+ while (scan.at < scan.text.length) {
108
+ const char = scan.text[scan.at];
109
+ if (char === ',' || char === '}') break;
110
+ // A brace or a quote inside a bare element is a literal this grammar does not describe.
111
+ if (char === '{' || char === '"') return FAILED;
112
+ scan.at += 1;
113
+ }
114
+ if (scan.at === scan.text.length) return FAILED;
115
+ const raw = scan.text.slice(start, scan.at);
116
+ // Unquoted and case-insensitive is the ONLY spelling of the null member; `"NULL"` is a string.
117
+ return raw.toUpperCase() === 'NULL' ? null : decode(raw);
118
+ }
119
+
120
+ /** The text between one pair of quotes, with `\\` and `\"` unescaped. `null` if it never closes. */
121
+ function readQuoted(scan: Scan): string | null {
122
+ scan.at += 1;
123
+ let out = '';
124
+ while (scan.at < scan.text.length) {
125
+ const char = scan.text[scan.at];
126
+ if (char === '"') {
127
+ scan.at += 1;
128
+ return out;
129
+ }
130
+ if (char === '\\') {
131
+ const escaped = scan.text[scan.at + 1];
132
+ if (escaped === undefined) return null;
133
+ out += escaped;
134
+ scan.at += 2;
135
+ continue;
136
+ }
137
+ out += char ?? '';
138
+ scan.at += 1;
139
+ }
140
+ return null;
141
+ }
@@ -80,8 +80,22 @@ export class PgConnection {
80
80
  user: options.user,
81
81
  database: options.database,
82
82
  application_name: options.applicationName ?? 'ultimate-replicator',
83
+ // The session's output formats, decided here because the decoder cannot ask. Postgres sends
84
+ // every WAL value as TEXT and `pg-values.ts` reads a `timestamptz` by matching postgres'
85
+ // ISO spelling — keeping the raw text when it does not match, on purpose, because a wrong
86
+ // instant is worse than a string. So `DateStyle = SQL | German | Postgres` on the SERVER
87
+ // silently re-opens the defect that decode exists to close: the shared window holds `Date`s,
88
+ // the patch holds text, and one edit to one column jumps its row to the top of every
89
+ // `orderBy('createdAt','desc')` feed for every subscriber.
90
+ //
91
+ // Byte for byte what postgres' OWN logical-replication client sends (`libpqwalreceiver.c`),
92
+ // which is why a walsender takes it: `intervalstyle` for the values this decoder keeps as
93
+ // text (a text form still has to be ONE text form), `extra_float_digits` so a float8 round
94
+ // trips exactly on servers whose default is not already 3. A server that refuses one
95
+ // answers `ErrorResponse` during startup, so the replicator fails to boot with the server's
96
+ // own words — never a warning that a feed then goes on mis-sorting behind.
97
+ options: '-c datestyle=ISO -c intervalstyle=postgres -c extra_float_digits=3',
83
98
  };
84
- // A walsender rejects most GUCs, so only the two it accepts are sent.
85
99
  if (options.replication !== undefined) parameters['replication'] = options.replication;
86
100
  // A handshake fails on ordinary conditions — no password, an ErrorResponse, an EOF — and on
87
101
  // every one of them the caller gets an exception instead of an object, so nothing is left
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { describeValue } from '@ultimat3/core';
9
9
  import { ReplicationProtocolError } from './errors';
10
- import type { JsonObject, JsonValue } from './json';
10
+ import type { PhysicalRow, PhysicalValue } from './pg-values';
11
11
 
12
12
  /** `org_id` -> `orgId`, `published_at` -> `publishedAt`. The inverse of `@ultimat3/entity`'s `snake()`. */
13
13
  export function camel(column: string): string {
@@ -24,7 +24,7 @@ interface FoldedMoney {
24
24
  readonly property: string;
25
25
  /** The physical columns the fold consumed, in declaration order — named together on a collision. */
26
26
  readonly columns: readonly string[];
27
- readonly value: JsonObject;
27
+ readonly value: PhysicalRow;
28
28
  }
29
29
 
30
30
  /**
@@ -73,7 +73,7 @@ function moneyMinor(column: string, value: number | string): number {
73
73
  * `/^\d+$/` and not `Number(value)`: `Number('')` is 0, and 0 means whole units — the one value
74
74
  * an empty column must never decode to. The same guard `parseScale` uses.
75
75
  */
76
- function moneyScale(column: string, value: JsonValue): number {
76
+ function moneyScale(column: string, value: PhysicalValue): number {
77
77
  const digits = typeof value === 'string' && /^\d+$/.test(value);
78
78
  const scale = typeof value === 'number' ? value : digits ? Number(value) : Number.NaN;
79
79
  if (Number.isSafeInteger(scale) && scale >= 0) return scale;
@@ -100,7 +100,7 @@ function moneyScale(column: string, value: JsonValue): number {
100
100
  * string that *is* one), and everything else is reported as shape. The scale column is matched by
101
101
  * name the same way and carries the same risk, so it renders through here too.
102
102
  */
103
- function shownNumber(value: JsonValue): string {
103
+ function shownNumber(value: PhysicalValue): string {
104
104
  if (typeof value === 'number') return `"${value}"`;
105
105
  if (typeof value !== 'string') return describeValue(value);
106
106
  const numeric = value.trim() !== '' && Number.isFinite(Number(value));
@@ -119,7 +119,7 @@ function shownNumber(value: JsonValue): string {
119
119
  * column of its own: the fold consumes it whenever the group folds.
120
120
  */
121
121
  function foldMoney(
122
- physical: Readonly<Record<string, JsonValue>>,
122
+ physical: Readonly<Record<string, PhysicalValue>>,
123
123
  name: string,
124
124
  ): FoldedMoney | null {
125
125
  const prefix = moneyPrefix(name);
@@ -171,8 +171,8 @@ function claim(taken: Map<string, string>, property: string, column: string): vo
171
171
  * property is camelCase, and money is one property over the columns `<p>_minor`/`<p>_currency`
172
172
  * and the nullable `<p>_scale`.
173
173
  */
174
- export function entityRow(physical: Readonly<Record<string, JsonValue>>): JsonObject {
175
- const row: JsonObject = {};
174
+ export function entityRow(physical: Readonly<Record<string, PhysicalValue>>): PhysicalRow {
175
+ const row: PhysicalRow = {};
176
176
  // Column order in is key order out; a folded money property lands wherever its earliest member
177
177
  // (whichever of the three the source happened to emit first) would otherwise have sat.
178
178
  const consumed = new Set<string>();
@@ -3,19 +3,30 @@
3
3
  // the framing and the pgoutput decode live next door; what is decided here is *ordering*, because
4
4
  // the lsn is the only authority the pipeline has.
5
5
 
6
- import { type Clock, logger, systemClock } from '@ultimat3/core';
6
+ import { type Clock, logger, renderThrowable, systemClock } from '@ultimat3/core';
7
7
  import type { ChangeEvent, ChangeOp, PgLogicalReplicationOptions } from './changefeed';
8
8
  import { ReplicationProtocolError } from './errors';
9
- import { isRow, type JsonObject, type Row } from './json';
9
+ import { isRow, type Row } from './json';
10
10
  import { ByteReader, ByteWriter, epochMsToPgTimestamp, printLsn } from './pg-bytes';
11
11
  import { PgConnection } from './pg-connection';
12
12
  import { entityRow } from './pg-entity-row';
13
13
  import { assertIdentifier, preflight } from './pg-preflight';
14
14
  import { bunPgStream, parsePgUrl } from './pg-socket';
15
+ import type { PhysicalRow } from './pg-values';
15
16
  import { PgOutputDecoder, type PgOutputMessage, type PgRelation } from './pgoutput';
16
17
 
17
18
  const DEFAULT_STATUS_INTERVAL_MS = 10_000;
18
19
 
20
+ /**
21
+ * Consecutive standby-status writes that may fail before the stream is declared dead.
22
+ *
23
+ * Three, at the default 10s interval, is 30s — inside postgres' own 60s `wal_sender_timeout`, so
24
+ * the replicator gives up at roughly the same moment the server would. It is a constant and not an
25
+ * option because it is a fraction of `statusIntervalMs`, and a second knob is a second number that
26
+ * can disagree with the one it is a fraction of.
27
+ */
28
+ const MAX_CONFIRM_FAILURES = 3;
29
+
19
30
  /** `r` — the standby status update, the only frontend message a walsender listens for. */
20
31
  const STANDBY_STATUS = 0x72;
21
32
 
@@ -33,6 +44,13 @@ export interface ReplicationStreamStats {
33
44
  * be alerted on. Inserts never count: there is no `before` to be partial.
34
45
  */
35
46
  readonly partialBefore: number;
47
+ /**
48
+ * Standby-status writes that have failed in a row without one landing in between. It is the half
49
+ * of a broken stream the delivery count cannot show: the read side goes on delivering while
50
+ * `confirmed_flush_lsn` stops advancing, so WAL accumulates on the primary with nothing else
51
+ * reporting it. Reset by the first confirm that lands.
52
+ */
53
+ readonly confirmFailures: number;
36
54
  /**
37
55
  * Why the pump stopped, or `null` while it is live. The read loop cannot throw into a caller —
38
56
  * nothing awaits it — so this is the one place `/readyz` and a test can see that it died at all.
@@ -92,6 +110,7 @@ export class PgReplicationStream {
92
110
  #skipped = 0;
93
111
  #replayed = 0;
94
112
  #partialBefore = 0;
113
+ #confirmFailures = 0;
95
114
  #failure: string | null = null;
96
115
 
97
116
  constructor(options: PgLogicalReplicationOptions) {
@@ -115,6 +134,7 @@ export class PgReplicationStream {
115
134
  skipped: this.#skipped,
116
135
  replayed: this.#replayed,
117
136
  partialBefore: this.#partialBefore,
137
+ confirmFailures: this.#confirmFailures,
118
138
  failure: this.#failure,
119
139
  };
120
140
  }
@@ -163,8 +183,9 @@ export class PgReplicationStream {
163
183
  // A restart that kept the last death in `stats()` reports a live stream as failed, and the
164
184
  // supervisor that reads it never sees the replicator come back.
165
185
  this.#failure = null;
186
+ this.#confirmFailures = 0;
166
187
  this.#timer = setInterval(() => {
167
- void this.#confirm();
188
+ void this.#confirmOnTimer();
168
189
  }, this.#options.statusIntervalMs ?? DEFAULT_STATUS_INTERVAL_MS);
169
190
  // A pending timer must not be what keeps `x dev` alive after the app is done with it.
170
191
  this.#timer.unref?.();
@@ -276,7 +297,7 @@ export class PgReplicationStream {
276
297
  }
277
298
  }
278
299
  } catch (failure) {
279
- await this.#die(failure instanceof Error ? failure.message : String(failure));
300
+ await this.#die(renderThrowable(failure));
280
301
  }
281
302
  }
282
303
 
@@ -312,8 +333,8 @@ export class PgReplicationStream {
312
333
  async #deliver(
313
334
  op: ChangeOp,
314
335
  relation: PgRelation,
315
- oldTuple: JsonObject | null,
316
- newTuple: JsonObject | null,
336
+ oldTuple: PhysicalRow | null,
337
+ newTuple: PhysicalRow | null,
317
338
  handlers: ReplicationStreamHandlers,
318
339
  ): Promise<void> {
319
340
  const transaction = this.#transaction;
@@ -359,9 +380,47 @@ export class PgReplicationStream {
359
380
  this.#delivered += 1;
360
381
  }
361
382
 
383
+ /**
384
+ * The TIMER's confirm, and the one call that may not reject. `void this.#confirm()` handed the
385
+ * rejection to nobody: `#confirm` awaits `#writing`, which rejects the moment the socket is gone,
386
+ * and no package in this repo installs an `unhandledRejection` handler, so Bun ends the process —
387
+ * an uncoded `TypeError` reaching the operator with no code and no `fix:`, and exit code 1 on an
388
+ * otherwise clean shutdown.
389
+ *
390
+ * Worse than the crash was the silence before it: `stats().failure` stayed `null`, so `/readyz`
391
+ * reported the replicator live while `confirmed_flush_lsn` stopped advancing and WAL piled up on
392
+ * the primary. A run of failures is now a death, the same way `#drain`'s catch is — the four
393
+ * things `#die` owns go together or not at all.
394
+ */
395
+ async #confirmOnTimer(): Promise<void> {
396
+ try {
397
+ await this.#confirm();
398
+ // Consecutive, not cumulative: one confirm that lands means the walsender is being told
399
+ // where we are, and a lifetime count would eventually kill a healthy stream.
400
+ this.#confirmFailures = 0;
401
+ } catch (failure) {
402
+ this.#confirmFailures += 1;
403
+ logger.warn('replication confirm failed', {
404
+ slot: this.#options.slot,
405
+ consecutive: this.#confirmFailures,
406
+ error: renderThrowable(failure),
407
+ });
408
+ const consecutive = this.#confirmFailures;
409
+ if (consecutive >= MAX_CONFIRM_FAILURES) {
410
+ await this.#die(
411
+ `${consecutive} standby status updates failed in a row — ` +
412
+ `the slot is not being confirmed: ${renderThrowable(failure)}`,
413
+ );
414
+ }
415
+ }
416
+ }
417
+
362
418
  /**
363
419
  * Tell the walsender how far we got. Serialized behind one chain because the timer and the read
364
420
  * loop both reach it, and two interleaved writes would frame one another's bytes.
421
+ *
422
+ * It still RAISES: `stop()` and the keepalive reply both await it and both need the answer. Only
423
+ * the timer, which awaits nothing, goes through `#confirmOnTimer` above.
365
424
  */
366
425
  async #confirm(connection: PgConnection | null = this.#connection): Promise<void> {
367
426
  if (connection === null || !connection.inCopyBoth) return;
@@ -375,16 +434,21 @@ export class PgReplicationStream {
375
434
  .int64(at)
376
435
  .uint8(0)
377
436
  .finish();
378
- this.#writing = this.#writing.then(
379
- () => connection.sendCopyData(payload),
380
- () => undefined,
381
- );
382
- await this.#writing;
437
+ // The chain SERIALIZES writes; it does not decide them. Putting the rejection handler on the
438
+ // chain itself meant the confirm after a failed one resolved without writing anything —
439
+ // `.then(send, () => undefined)` runs the second handler and hands back its `undefined`, so
440
+ // every other standby status update after the first failure was a no-op that reported success.
441
+ // A run of failures could then never be seen, because the run never got past one.
442
+ const attempt = this.#writing.then(() => connection.sendCopyData(payload));
443
+ // What the NEXT caller queues behind is this attempt settled either way; what THIS caller
444
+ // awaits is the attempt itself, because it is the only one entitled to its outcome.
445
+ this.#writing = attempt.catch(() => undefined);
446
+ await attempt;
383
447
  }
384
448
  }
385
449
 
386
450
  /** A physical tuple becomes the row the matcher's predicates are written against, or nothing. */
387
- function toRow(relation: PgRelation, physical: JsonObject | null): Row | null {
451
+ function toRow(relation: PgRelation, physical: PhysicalRow | null): Row | null {
388
452
  if (physical === null) return null;
389
453
  const row = entityRow(physical);
390
454
  // A bigserial id decodes as a number inside `Number.isSafeInteger` range and as text outside it,
@@ -0,0 +1,166 @@
1
+ // One physical Postgres value, off the WAL as text, becomes the value a ROW holds — the same one
2
+ // `@ultimat3/entity`'s repository produces for that column. Lifted out of `pgoutput.ts` so that
3
+ // file stays message framing and this one stays the type catalogue.
4
+ //
5
+ // The rule it enforces is `CLAUDE.md`'s: **a live row must equal a repository row.** The WAL is
6
+ // text and a repository row is not, so `timestamp()` is a `Date` on both sides, `arrayOf()` is a
7
+ // JS array on both sides and `bytes()` is a `Uint8Array` on both sides. Left as postgres' own
8
+ // text, `compareValues(new Date(…), '2026-08-09 12:00:00+00')` fell to `String(left) < String(right)`
9
+ // — `"1786…"` against `"2026-…"` — so one edit to one column moved every row of an
10
+ // `orderBy('createdAt','desc')` feed to the top for every subscriber, and `post.tags.map(…)` threw
11
+ // in the component the first patch reached.
12
+
13
+ import { renderThrowable } from '@ultimat3/core';
14
+ import { ReplicationProtocolError } from './errors';
15
+ import type { JsonValue } from './json';
16
+ import { arrayElementOid, parsePgArray } from './pg-array';
17
+
18
+ /**
19
+ * What a row value can be between the WAL and the wire: JSON, plus the two JS shapes a repository
20
+ * row already carries. `Date` and `Uint8Array` are not `JsonValue` and are not meant to be — they
21
+ * are what `JSON.stringify` turns into the string a SNAPSHOT frame carries, which is precisely the
22
+ * format a patch frame has to converge on.
23
+ */
24
+ export type PhysicalValue =
25
+ | JsonValue
26
+ | Date
27
+ | Uint8Array
28
+ | PhysicalValue[]
29
+ | { [key: string]: PhysicalValue };
30
+
31
+ export type PhysicalRow = { [key: string]: PhysicalValue };
32
+
33
+ /**
34
+ * `2026-08-09 12:00:00.123456+00` -> the ISO-8601 form `new Date` is specified to accept.
35
+ *
36
+ * Postgres writes a space between the date and the clock, an offset that may be `+00`, `+0530` or
37
+ * `+05:30`, and as many fractional digits as the column's precision. `Date` holds milliseconds, so
38
+ * the fraction is TRUNCATED to three — which is what the driver does on the repository side, so
39
+ * both readers of one column land on the same instant.
40
+ */
41
+ const TIMESTAMP =
42
+ /^(\d{4,6})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}(?::?\d{2})?)?$/;
43
+
44
+ /**
45
+ * `undefined` for a text this decoder does not describe — `infinity`, a BC date, a non-ISO
46
+ * `DateStyle` — and the caller keeps the text it arrived as. Silence beats a wrong instant: the
47
+ * value still crosses, it simply does not claim to be a `Date`.
48
+ *
49
+ * The `DateStyle` half is closed at the SESSION and not here: `pg-connection.ts` pins
50
+ * `datestyle=ISO` in the startup packet, so a server configured `SQL`, `German` or `Postgres`
51
+ * cannot quietly send this branch every timestamp it decodes. Nothing in this file may depend on
52
+ * that — a text it cannot read still keeps its text — but a reader wondering why the ISO
53
+ * assumption is safe should look there rather than rediscover it.
54
+ *
55
+ * A `timestamp without time zone` (oid 1114) carries no offset and is read as UTC. This framework's
56
+ * `timestamp()` is always `timestamptz`, so the only way to reach that branch is an adopted table —
57
+ * and UTC is the one reading with no ambient zone in it.
58
+ */
59
+ function toInstant(text: string): Date | undefined {
60
+ const parts = TIMESTAMP.exec(text);
61
+ if (parts === null) return undefined;
62
+ const [, year, month, day, hour, minute, second, fraction, zone] = parts;
63
+ const millis = fraction === undefined ? '000' : `${fraction.slice(1)}000`.slice(0, 3);
64
+ const parsed = new Date(
65
+ `${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}${offsetOf(zone)}`,
66
+ );
67
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
68
+ }
69
+
70
+ /** `+05` and `+0530` are offsets postgres writes and `Date` does not read; `±HH:MM` is both. */
71
+ function offsetOf(zone: string | undefined): string {
72
+ if (zone === undefined || zone === 'Z') return 'Z';
73
+ if (zone.length === 3) return `${zone}:00`;
74
+ if (zone.length === 5) return `${zone.slice(0, 3)}:${zone.slice(3)}`;
75
+ return zone;
76
+ }
77
+
78
+ const HEX = /^[0-9a-fA-F]*$/;
79
+
80
+ /**
81
+ * `\x0102` -> `Uint8Array([1, 2])`, the value `bytes()` parses to on the repository side.
82
+ *
83
+ * `undefined` for anything else, including the pre-9.0 `escape` output format: that one is
84
+ * ambiguous without knowing the server's `bytea_output`, and a wrong byte string is worse than the
85
+ * text. Nothing this framework creates sets it.
86
+ */
87
+ function toBytes(text: string): Uint8Array | undefined {
88
+ if (!text.startsWith('\\x')) return undefined;
89
+ const hex = text.slice(2);
90
+ if (hex.length % 2 !== 0 || !HEX.test(hex)) return undefined;
91
+ const out = new Uint8Array(hex.length / 2);
92
+ for (let i = 0; i < out.length; i += 1) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Postgres sends every value as text (we never negotiate binary). Decoding depends on the
98
+ * column's type oid — the wire gives us nothing else to go on, so this switch is the one place
99
+ * that type catalogue is encoded.
100
+ */
101
+ export function decodeValue(typeOid: number, text: string): PhysicalValue {
102
+ switch (typeOid) {
103
+ case 16: // bool
104
+ return text === 't';
105
+
106
+ case 20: {
107
+ // int8: only safe as a number if it round-trips exactly; otherwise keep the digits —
108
+ // a rounded bigint is a worse lie than a string that still parses correctly downstream.
109
+ const asNumber = Number(text);
110
+ return Number.isSafeInteger(asNumber) ? asNumber : text;
111
+ }
112
+
113
+ case 21: // int2
114
+ case 23: // int4
115
+ case 26: // oid
116
+ return Number(text);
117
+
118
+ case 700: // float4
119
+ case 701: // float8
120
+ // JSON has no literal for these three, so the text form survives the round trip instead of
121
+ // silently becoming a number `JSON.stringify` would otherwise turn into `null`.
122
+ if (text === 'NaN' || text === 'Infinity' || text === '-Infinity') return text;
123
+ return Number(text);
124
+
125
+ case 1700: // numeric — exactness beats convenience; money is never a float here.
126
+ return text;
127
+
128
+ case 114: // json
129
+ case 3802: {
130
+ // jsonb
131
+ let parsed: unknown;
132
+ try {
133
+ parsed = JSON.parse(text);
134
+ } catch (cause) {
135
+ throw new ReplicationProtocolError({
136
+ stage: 'value',
137
+ detail: `type oid ${typeOid} carried invalid json: ${renderThrowable(cause)}`,
138
+ });
139
+ }
140
+ return parsed as JsonValue;
141
+ }
142
+
143
+ case 1114: // timestamp
144
+ case 1184: // timestamptz
145
+ // A `Date`, because `timestamp()` reads back as one through the repository and the two have
146
+ // to be one value: `JSON.stringify` gives the wire the same ISO string either way.
147
+ return toInstant(text) ?? text;
148
+
149
+ case 1082: // date
150
+ // Already the value: `date()` parses to `@ultimat3/time`'s `PlainDate`, which IS the
151
+ // `YYYY-MM-DD` string postgres wrote. Converting it to a `Date` would be the 100x-style
152
+ // reinterpretation the calendar/instant split exists to prevent.
153
+ return text;
154
+
155
+ case 17: // bytea
156
+ return toBytes(text) ?? text;
157
+
158
+ default: {
159
+ // An array type's element is the only thing left that changes the answer, and only when this
160
+ // decoder knows which element type it is — see `pg-array.ts` for what an unknown oid costs.
161
+ const element = arrayElementOid(typeOid);
162
+ if (element === undefined) return text; // text, varchar, uuid, enum, and everything else.
163
+ return parsePgArray(text, (raw) => decodeValue(element, raw)) ?? text;
164
+ }
165
+ }
166
+ }
package/src/pgoutput.ts CHANGED
@@ -1,12 +1,13 @@
1
- import { renderThrowable } from '@ultimat3/core';
2
1
  // Decodes pgoutput logical-replication messages (protocol version 1, Postgres >= 12) into typed
3
- // PgOutputMessage values, and the postgres text-format values inside each tuple into JsonValue.
4
- // Pure byte decoding: no sockets, no I/O. A decoder instance owns the per-connection relation
5
- // cache that later Insert/Update/Delete/Truncate messages reference by oid.
2
+ // PgOutputMessage values. Pure byte decoding: no sockets, no I/O. A decoder instance owns the
3
+ // per-connection relation cache that later Insert/Update/Delete/Truncate messages reference by oid.
4
+ //
5
+ // What a tuple's TEXT means is `pg-values.ts`'s: this file frames messages, that one owns the type
6
+ // catalogue that turns postgres' text into the value a repository row holds.
6
7
 
7
8
  import { ReplicationProtocolError } from './errors';
8
- import type { JsonObject, JsonValue } from './json';
9
9
  import { ByteReader, pgTimestampToEpochMs } from './pg-bytes';
10
+ import { decodeValue, type PhysicalRow } from './pg-values';
10
11
 
11
12
  export interface PgColumn {
12
13
  /** part of the replica identity key — set by the `flags & 1` bit. */
@@ -40,85 +41,25 @@ export type PgOutputMessage =
40
41
  readonly commitAt: number;
41
42
  }
42
43
  | { readonly kind: 'relation'; readonly relation: PgRelation }
43
- | { readonly kind: 'insert'; readonly relation: PgRelation; readonly after: JsonObject }
44
+ | { readonly kind: 'insert'; readonly relation: PgRelation; readonly after: PhysicalRow }
44
45
  | {
45
46
  readonly kind: 'update';
46
47
  readonly relation: PgRelation;
47
- readonly before: JsonObject | null;
48
- readonly after: JsonObject;
48
+ readonly before: PhysicalRow | null;
49
+ readonly after: PhysicalRow;
49
50
  }
50
- | { readonly kind: 'delete'; readonly relation: PgRelation; readonly before: JsonObject }
51
+ | { readonly kind: 'delete'; readonly relation: PgRelation; readonly before: PhysicalRow }
51
52
  | { readonly kind: 'truncate'; readonly relations: readonly PgRelation[] }
52
53
  /** origin / type / logical message — decoded far enough to be skipped safely. */
53
54
  | { readonly kind: 'other'; readonly tag: string };
54
55
 
55
- /**
56
- * Postgres sends every value as text (we never negotiate binary). Decoding depends on the
57
- * column's type oid — the wire gives us nothing else to go on, so this switch is the one place
58
- * that type catalogue is encoded.
59
- */
60
- function decodeValue(typeOid: number, text: string): JsonValue {
61
- switch (typeOid) {
62
- case 16: // bool
63
- return text === 't';
64
-
65
- case 20: {
66
- // int8: only safe as a number if it round-trips exactly; otherwise keep the digits —
67
- // a rounded bigint is a worse lie than a string that still parses correctly downstream.
68
- const asNumber = Number(text);
69
- return Number.isSafeInteger(asNumber) ? asNumber : text;
70
- }
71
-
72
- case 21: // int2
73
- case 23: // int4
74
- case 26: // oid
75
- return Number(text);
76
-
77
- case 700: // float4
78
- case 701: // float8
79
- // JSON has no literal for these three, so the text form survives the round trip instead of
80
- // silently becoming a number `JSON.stringify` would otherwise turn into `null`.
81
- if (text === 'NaN' || text === 'Infinity' || text === '-Infinity') return text;
82
- return Number(text);
83
-
84
- case 1700: // numeric — exactness beats convenience; money is never a float here.
85
- return text;
86
-
87
- case 114: // json
88
- case 3802: {
89
- // jsonb
90
- let parsed: unknown;
91
- try {
92
- parsed = JSON.parse(text);
93
- } catch (cause) {
94
- throw new ReplicationProtocolError({
95
- stage: 'value',
96
- detail: `type oid ${typeOid} carried invalid json: ${renderThrowable(cause)}`,
97
- });
98
- }
99
- return parsed as JsonValue;
100
- }
101
-
102
- case 1082: // date
103
- case 1114: // timestamp
104
- case 1184: // timestamptz
105
- return text; // an ISO-ish string; never a `Date` — the row must stay JSON.
106
-
107
- case 17: // bytea — the `\x...` text form, as-is.
108
- return text;
109
-
110
- default: // text, varchar, uuid, enum, and everything else not called out above.
111
- return text;
112
- }
113
- }
114
-
115
56
  /**
116
57
  * Int16 ncolumns + that many columns. Every tuple kind (insert's new row, update/delete's old
117
58
  * row, a `'K'` key-only row) shares this decoder: postgres always sends one byte per column,
118
59
  * `'u'` standing in for the columns a key-only tuple leaves out — so the column count always
119
60
  * matches the relation, and only the per-column byte tells us whether a value is actually there.
120
61
  */
121
- function decodeTupleData(reader: ByteReader, relation: PgRelation): JsonObject {
62
+ function decodeTupleData(reader: ByteReader, relation: PgRelation): PhysicalRow {
122
63
  const count = reader.int16();
123
64
  if (count !== relation.columns.length) {
124
65
  throw new ReplicationProtocolError({
@@ -129,7 +70,9 @@ function decodeTupleData(reader: ByteReader, relation: PgRelation): JsonObject {
129
70
  });
130
71
  }
131
72
 
132
- const row: JsonObject = {};
73
+ // Null-prototype: `column.name` is off the WIRE, so a column literally named `__proto__` set
74
+ // the prototype of every row this decoder built. `Object.create(null)` has no prototype to set.
75
+ const row: PhysicalRow = Object.create(null) as PhysicalRow;
133
76
  for (const column of relation.columns) {
134
77
  const kind = reader.tag();
135
78
  if (kind === 'n') {
@@ -256,7 +199,7 @@ export class PgOutputDecoder {
256
199
  #decodeUpdate(reader: ByteReader): PgOutputMessage {
257
200
  const relation = this.#relationOrThrow(reader.int32());
258
201
  let marker = reader.tag();
259
- let before: JsonObject | null = null;
202
+ let before: PhysicalRow | null = null;
260
203
  if (marker === 'K' || marker === 'O') {
261
204
  before = decodeTupleData(reader, relation);
262
205
  marker = reader.tag();
package/src/server.ts CHANGED
@@ -121,6 +121,9 @@ export {
121
121
  type ReplicationStreamStats,
122
122
  } from './pg-replication';
123
123
  export { bunPgStream, type PgTarget, parsePgUrl, type SslMode } from './pg-socket';
124
+ // The value domain a WAL tuple lands in. Public because it is `PgOutputMessage`'s and
125
+ // `entityRow`'s: a caller naming either type has to be able to name what is inside one.
126
+ export { decodeValue, type PhysicalRow, type PhysicalValue } from './pg-values';
124
127
  export type { PgStream } from './pg-wire';
125
128
  export {
126
129
  type PgColumn,
package/src/socket.ts CHANGED
@@ -185,7 +185,21 @@ export class SyncSocket {
185
185
  }
186
186
  return false;
187
187
  }
188
- this.#ws.send(encode(frame));
188
+ // `WsLike.send` is declared `: number` for this line and no other: Bun answers `0` for a
189
+ // message it DROPPED — the socket closed between the buffered-amount check above and this
190
+ // write — and `-1` under backpressure. Discarded, a dropped frame read as delivered, so
191
+ // `live-fanout` advanced the subscriber's cursor past a patch that never left and
192
+ // `sync-frames`' desync mark was never taken: permanently stale on a healthy socket, which is
193
+ // the exact outcome every other `socket.send` on this node reads its answer to prevent.
194
+ if (this.#ws.send(encode(frame)) <= 0) {
195
+ this.droppedFrames += 1;
196
+ // The same ceiling backpressure takes: a socket the runtime keeps refusing is one to close,
197
+ // and the two are one failure — the write went nowhere either way.
198
+ if (this.droppedFrames > this.#maxDroppedFrames) {
199
+ this.close(CLOSE.overloaded, 'backpressure');
200
+ }
201
+ return false;
202
+ }
189
203
  this.sentFrames += 1;
190
204
  return true;
191
205
  }
package/src/sync-node.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { type Clock, logger, markReady, reportError, systemClock, uuid } from '@ultimat3/core';
8
8
  import type { ChannelHub, Topic } from './channel';
9
+ import { detach } from './detach';
9
10
  import { isClientFault } from './errors';
10
11
  import type { Transport, TransportSubscription } from './fanout';
11
12
  import type { LiveQueryRegistry } from './live-query';
@@ -162,25 +163,6 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
162
163
  let reauthing: ReturnType<typeof setInterval> | null = null;
163
164
  let idling: ReturnType<typeof setInterval> | null = null;
164
165
 
165
- /**
166
- * Work nobody is waiting on — a presence leave from a synchronous close, a sweep on a timer, a
167
- * fanout off the change bus. It reaches the bus or a policy, so it can fail; failing must not take
168
- * a socket or the process with it, and must not be silent either, or "the room still shows someone
169
- * who left" and "that change reached nobody" have nothing to read. `operation` stays low
170
- * cardinality so the monitor can group on it; the topic or entity goes in `at`.
171
- */
172
- const detach = (work: Promise<unknown>, operation: string, at?: string): void => {
173
- void work.catch((error: unknown) => {
174
- logger.error(`${operation} failed`, {
175
- ...(at === undefined ? {} : { at }),
176
- error: error instanceof Error ? error.message : String(error),
177
- });
178
- // Nobody is awaiting this, so the log is the only trace it leaves — and a log is not a
179
- // signal anyone is paged on. The bus is this node's dependency, never the client's.
180
- reportError(error, { source: 'realtime', scope: { operation } });
181
- });
182
- };
183
-
184
166
  /**
185
167
  * Everything `start()` acquired that is not a socket: the change subscription and the presence
186
168
  * sweep. Both `drain()` and `stop()` run it, because a `drain()` is terminal on its own — it