@ultimat3/realtime 9.0.0 → 11.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 +96 -7
- package/README.md +33 -9
- package/package.json +3 -3
- package/src/change-buffer.ts +40 -4
- package/src/client-contract.ts +23 -0
- package/src/client.ts +1 -0
- package/src/cursor.ts +13 -7
- package/src/detach.ts +27 -0
- package/src/drain-evictions.ts +31 -0
- package/src/errors.ts +39 -92
- package/src/hooks.ts +38 -5
- package/src/index.ts +4 -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/realtime-error.ts +28 -0
- package/src/replication-errors.ts +87 -0
- package/src/server-render-client.ts +96 -0
- package/src/server.ts +3 -0
- package/src/socket.ts +15 -1
- package/src/sync-node.ts +24 -26
- package/src/sync-upgrade.ts +15 -6
- package/src/type-pins.ts +12 -1
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();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The class every realtime error extends, and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// Apart from `errors.ts` so a concern-specific error module can extend it WITHOUT importing the
|
|
4
|
+
// code table — `errors.ts` re-exports both, so `RealtimeError` and every subclass stay importable
|
|
5
|
+
// from where they always were. Its own file rather than a re-export inside `errors.ts`, because
|
|
6
|
+
// that would be a cycle: `extends` runs at module evaluation, imports hoist above it, and the base
|
|
7
|
+
// would be in its temporal dead zone by the time the subclass module was evaluated.
|
|
8
|
+
|
|
9
|
+
import { UltimateError } from '@ultimat3/core';
|
|
10
|
+
import type { RealtimeErrorCode } from './errors';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Base for every realtime error. No `docs:` — `UltimateError` fills it from
|
|
14
|
+
* `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every
|
|
15
|
+
* code, never one per code, because `wiki/` is the framework's only public documentation surface
|
|
16
|
+
* and a code lives there in a TABLE ROW, which has no anchor. The
|
|
17
|
+
* `https://ultimate.dev/errors/<code>` links this class built until 9.x answered 404, host
|
|
18
|
+
* included, on every error it has ever thrown — including the ones `toWireError` puts on the wire.
|
|
19
|
+
*/
|
|
20
|
+
export class RealtimeError extends UltimateError {
|
|
21
|
+
constructor(opts: { code: RealtimeErrorCode; cause: string; fix: string }) {
|
|
22
|
+
super({
|
|
23
|
+
code: opts.code,
|
|
24
|
+
cause: opts.cause,
|
|
25
|
+
fix: opts.fix,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The four refusals the Postgres replication half raises: the wire, the connection, the slot, and
|
|
2
|
+
// the replica identity it warns about.
|
|
3
|
+
//
|
|
4
|
+
// Split out of `errors.ts` on the one seam this package already draws — these are the only codes
|
|
5
|
+
// no browser can reach, thrown by `pg-*.ts` and the replicator and by nothing on the client half.
|
|
6
|
+
// The codes themselves stay in `errors.ts`, whose `registerErrorCodes()` is what
|
|
7
|
+
// `package.json`'s `sideEffects` names; this module runs nothing at import.
|
|
8
|
+
|
|
9
|
+
import { RealtimeError } from './realtime-error';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The bytes on the replication socket are not the bytes the protocol allows: a truncated message,
|
|
13
|
+
* an unknown pgoutput tag, an auth method we do not speak. Always a version or configuration
|
|
14
|
+
* mismatch rather than a transient fault, so retrying the same connection cannot help.
|
|
15
|
+
*/
|
|
16
|
+
export class ReplicationProtocolError extends RealtimeError {
|
|
17
|
+
constructor(args: { stage: string; detail: string; fix?: string }) {
|
|
18
|
+
super({
|
|
19
|
+
code: 'X_REPLICATION_PROTOCOL',
|
|
20
|
+
cause: `postgres replication ${args.stage}: ${args.detail}`,
|
|
21
|
+
fix:
|
|
22
|
+
args.fix ??
|
|
23
|
+
'x doctor db — the server must be postgres >= 14 with a pgoutput publication and wal_level=logical',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The replication connection itself failed — refused credentials, a slot another process holds,
|
|
30
|
+
* an `ErrorResponse` from the server. The server's own message is passed through verbatim
|
|
31
|
+
* because it names the object that has to change.
|
|
32
|
+
*/
|
|
33
|
+
export class ReplicationFailedError extends RealtimeError {
|
|
34
|
+
constructor(args: { stage: string; detail: string; fix: string }) {
|
|
35
|
+
super({
|
|
36
|
+
code: 'X_REPLICATION_FAILED',
|
|
37
|
+
cause: `postgres replication ${args.stage} failed: ${args.detail}`,
|
|
38
|
+
fix: args.fix,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A second replicator found the advisory lock held. Distinct from `X_REPLICATION_FAILED` because
|
|
45
|
+
* nothing is wrong with this process: the database already has its one replicator, and a second
|
|
46
|
+
* one that started anyway would publish every change twice. Terminal for a container whose whole
|
|
47
|
+
* job is that role — the scheduler is the thing that has to change, not the connection.
|
|
48
|
+
*/
|
|
49
|
+
export class ReplicatorSlotHeldError extends RealtimeError {
|
|
50
|
+
constructor(args: { key: string; holder?: string | undefined }) {
|
|
51
|
+
super({
|
|
52
|
+
code: 'X_REPLICATOR_SLOT_HELD',
|
|
53
|
+
cause:
|
|
54
|
+
`advisory lock ${args.key} is held${args.holder === undefined ? '' : ` by ${args.holder}`}` +
|
|
55
|
+
' — one database has exactly one replicator',
|
|
56
|
+
fix: 'scale the replicator to 1 per database: kubectl scale deploy/replicator --replicas=1',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A table in the entity list replicates with a replica identity other than FULL, so its `delete`
|
|
63
|
+
* (and any key-changing `update`) carries the KEY COLUMNS ONLY. `toRow` accepts that tuple —
|
|
64
|
+
* it only requires a text `id` — so the live matcher decides "did this row leave the result set"
|
|
65
|
+
* from a one-column row, and a row policy written against `!row.private` reads `undefined`.
|
|
66
|
+
*
|
|
67
|
+
* **Raised at preflight and LOGGED, never thrown.** Every app running today on the default
|
|
68
|
+
* identity would stop booting, and the replicator refusing to start is a worse outcome than the
|
|
69
|
+
* partial rows it is warning about. The runtime half is `ReplicationStreamStats.partialBefore`,
|
|
70
|
+
* which counts the changes this actually affects. Refusing it at `x verify` time is the follow-up.
|
|
71
|
+
*
|
|
72
|
+
* The tables are named because the fix is per table, and they are the entity list's own names —
|
|
73
|
+
* every one has already passed `assertIdentifier`, so the `fix:` is SQL that can be pasted.
|
|
74
|
+
*/
|
|
75
|
+
export class ReplicaIdentityError extends RealtimeError {
|
|
76
|
+
constructor(args: { tables: readonly string[] }) {
|
|
77
|
+
super({
|
|
78
|
+
code: 'X_LIVE_REPLICA_IDENTITY',
|
|
79
|
+
cause:
|
|
80
|
+
`${args.tables.join(', ')} replicate with a replica identity other than FULL, so a ` +
|
|
81
|
+
'delete carries the key columns only and a live query decides visibility from a partial row',
|
|
82
|
+
fix:
|
|
83
|
+
`${args.tables.map((table) => `ALTER TABLE ${table} REPLICA IDENTITY FULL;`).join(' ')}` +
|
|
84
|
+
' -- rows already written to the WAL keep the identity they were written with',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// What a live client IS on the server: one that serves the first render and opens no socket.
|
|
2
|
+
//
|
|
3
|
+
// The rule it exists for is `@ultimat3/ui`'s, one package over — no runtime and no DOM is a SERVER
|
|
4
|
+
// RENDER, and a server render gets an honest account of itself rather than a throw. A page whose
|
|
5
|
+
// whole body reads a live query could not server-render at all before this: `useConnection()` threw
|
|
6
|
+
// `X_LIVE_CLIENT_MISSING` and the route answered 500 (issue #271).
|
|
7
|
+
//
|
|
8
|
+
// It implements `LiveClientLike` and imports NO connection lifecycle — no `LiveClient`, no
|
|
9
|
+
// heartbeat, no wire protocol. Measured: reaching the class from here costs every island that
|
|
10
|
+
// calls `useLive` 18 kB it can never run.
|
|
11
|
+
|
|
12
|
+
import type { LiveClientLike, LiveHandle, LiveQueryRef, SignalFactory } from './client-contract';
|
|
13
|
+
import { ServerRenderLiveError } from './errors';
|
|
14
|
+
import type { JsonValue, Row } from './json';
|
|
15
|
+
import type { LiveState } from './live-rows';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A signal that never changes, because nothing on the server can change it: one render, one pass,
|
|
19
|
+
* no reactive runtime. The setter is kept rather than dropped so a caller that writes through it
|
|
20
|
+
* reads its own write back — a signal that swallowed writes would be a different lie.
|
|
21
|
+
*/
|
|
22
|
+
const inertSignal: SignalFactory = <T>(initial: T): [() => T, (next: T) => void] => {
|
|
23
|
+
let held = initial;
|
|
24
|
+
return [
|
|
25
|
+
(): T => held,
|
|
26
|
+
(next: T): void => {
|
|
27
|
+
held = next;
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Frozen, so a handle a page holds cannot be turned into a result set by writing to it. */
|
|
33
|
+
const NO_ROWS: readonly Row[] = Object.freeze([]);
|
|
34
|
+
|
|
35
|
+
/** Nothing was subscribed, so nothing is released — and a teardown never fails a render. */
|
|
36
|
+
const releaseNothing = (): void => undefined;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The handle a server render gets for a live query: `loading`, never `offline` and never `live`.
|
|
40
|
+
*
|
|
41
|
+
* That is the one honest state — the rows arrive over a socket this render does not have, so the
|
|
42
|
+
* page's own loading fallback is what the document carries until hydration replaces it. `offline`
|
|
43
|
+
* would be read as a SETTLED answer (`state() !== 'loading'` is the gate `examples/dummy`'s feed
|
|
44
|
+
* uses), so an empty result set would render "you have no posts" for a feed that has some.
|
|
45
|
+
*/
|
|
46
|
+
function serverRenderHandle<R extends Row>(): LiveHandle<R> {
|
|
47
|
+
return {
|
|
48
|
+
rows: () => NO_ROWS as readonly R[],
|
|
49
|
+
state: (): LiveState => 'loading',
|
|
50
|
+
cursor: () => null,
|
|
51
|
+
unsubscribe: releaseNothing,
|
|
52
|
+
[Symbol.dispose]: releaseNothing,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every member that can only mean "talk to the socket" refuses; every member a render READS
|
|
58
|
+
* answers what a server render actually is.
|
|
59
|
+
*
|
|
60
|
+
* `connected: true` is not a lie about the socket — `useConnection().offline` is a banner about
|
|
61
|
+
* THIS visitor's connectivity, and the request being served is the proof it is up. Answering
|
|
62
|
+
* `false` would server-render "you are offline" into every document, for a reader who is not, and
|
|
63
|
+
* then remove it on hydrate.
|
|
64
|
+
*
|
|
65
|
+
* It registers nothing, which is what makes ONE instance per process safe under concurrent
|
|
66
|
+
* renders: a client that kept a registration per `useLive` would grow by one entry per request,
|
|
67
|
+
* forever, and hold a row window with each.
|
|
68
|
+
*/
|
|
69
|
+
function build(): LiveClientLike {
|
|
70
|
+
return {
|
|
71
|
+
signal: inertSignal,
|
|
72
|
+
queue: undefined,
|
|
73
|
+
connected: true,
|
|
74
|
+
reconnectAt: () => null,
|
|
75
|
+
appUpdateAvailable: () => null,
|
|
76
|
+
useLive: <R extends Row>(_query: LiveQueryRef, _input: JsonValue): LiveHandle<R> =>
|
|
77
|
+
serverRenderHandle<R>(),
|
|
78
|
+
mutate: (): Promise<void> => {
|
|
79
|
+
throw new ServerRenderLiveError({ operation: 'mutate()' });
|
|
80
|
+
},
|
|
81
|
+
drain: (): Promise<void> => {
|
|
82
|
+
throw new ServerRenderLiveError({ operation: 'drain()' });
|
|
83
|
+
},
|
|
84
|
+
// A listener is accepted and never called: nothing on the server can change a queue that does
|
|
85
|
+
// not exist. Refusing here would break `setLiveClient`, which registers one unconditionally.
|
|
86
|
+
onQueueChange: () => releaseNothing,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let held: LiveClientLike | null = null;
|
|
91
|
+
|
|
92
|
+
/** ONE per process, built on first use. It holds nothing per request — see `build` above. */
|
|
93
|
+
export function serverRenderLiveClient(): LiveClientLike {
|
|
94
|
+
held ??= build();
|
|
95
|
+
return held;
|
|
96
|
+
}
|