@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 +30 -1
- package/README.md +11 -6
- package/package.json +3 -3
- package/src/change-buffer.ts +40 -4
- package/src/cursor.ts +13 -7
- package/src/detach.ts +27 -0
- package/src/errors.ts +8 -4
- package/src/index.ts +0 -1
- package/src/live-query.ts +11 -2
- package/src/local-store.ts +12 -2
- package/src/nats-lib-client.ts +8 -2
- package/src/nats-transport.ts +3 -1
- package/src/pg-array.ts +141 -0
- package/src/pg-connection.ts +15 -1
- package/src/pg-entity-row.ts +7 -7
- package/src/pg-replication.ts +76 -12
- package/src/pg-values.ts +166 -0
- package/src/pgoutput.ts +15 -72
- package/src/server.ts +3 -0
- package/src/socket.ts +15 -1
- package/src/sync-node.ts +1 -19
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
|
|
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
|
|
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.
|
|
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
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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": "
|
|
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": "
|
|
40
|
-
"@ultimat3/query": "
|
|
39
|
+
"@ultimat3/core": "10.0.0",
|
|
40
|
+
"@ultimat3/query": "10.0.0",
|
|
41
41
|
"nats": "2.29.3"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/change-buffer.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
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
|
-
|
|
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:
|
|
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);
|
package/src/local-store.ts
CHANGED
|
@@ -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
|
|
229
|
-
fix: "
|
|
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
|
}
|
package/src/nats-lib-client.ts
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
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;
|
package/src/nats-transport.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/pg-array.ts
ADDED
|
@@ -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
|
+
}
|
package/src/pg-connection.ts
CHANGED
|
@@ -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
|
package/src/pg-entity-row.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { describeValue } from '@ultimat3/core';
|
|
9
9
|
import { ReplicationProtocolError } from './errors';
|
|
10
|
-
import type {
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
|
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,
|
|
175
|
-
const row:
|
|
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>();
|
package/src/pg-replication.ts
CHANGED
|
@@ -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
|
|
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.#
|
|
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(
|
|
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:
|
|
316
|
-
newTuple:
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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:
|
|
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,
|
package/src/pg-values.ts
ADDED
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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:
|
|
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:
|
|
48
|
-
readonly after:
|
|
48
|
+
readonly before: PhysicalRow | null;
|
|
49
|
+
readonly after: PhysicalRow;
|
|
49
50
|
}
|
|
50
|
-
| { readonly kind: 'delete'; readonly relation: PgRelation; readonly before:
|
|
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):
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|