@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.
@@ -3,19 +3,30 @@
3
3
  // the framing and the pgoutput decode live next door; what is decided here is *ordering*, because
4
4
  // the lsn is the only authority the pipeline has.
5
5
 
6
- import { type Clock, logger, systemClock } from '@ultimat3/core';
6
+ import { type Clock, logger, renderThrowable, systemClock } from '@ultimat3/core';
7
7
  import type { ChangeEvent, ChangeOp, PgLogicalReplicationOptions } from './changefeed';
8
8
  import { ReplicationProtocolError } from './errors';
9
- import { isRow, type JsonObject, type Row } from './json';
9
+ import { isRow, type Row } from './json';
10
10
  import { ByteReader, ByteWriter, epochMsToPgTimestamp, printLsn } from './pg-bytes';
11
11
  import { PgConnection } from './pg-connection';
12
12
  import { entityRow } from './pg-entity-row';
13
13
  import { assertIdentifier, preflight } from './pg-preflight';
14
14
  import { bunPgStream, parsePgUrl } from './pg-socket';
15
+ import type { PhysicalRow } from './pg-values';
15
16
  import { PgOutputDecoder, type PgOutputMessage, type PgRelation } from './pgoutput';
16
17
 
17
18
  const DEFAULT_STATUS_INTERVAL_MS = 10_000;
18
19
 
20
+ /**
21
+ * Consecutive standby-status writes that may fail before the stream is declared dead.
22
+ *
23
+ * Three, at the default 10s interval, is 30s — inside postgres' own 60s `wal_sender_timeout`, so
24
+ * the replicator gives up at roughly the same moment the server would. It is a constant and not an
25
+ * option because it is a fraction of `statusIntervalMs`, and a second knob is a second number that
26
+ * can disagree with the one it is a fraction of.
27
+ */
28
+ const MAX_CONFIRM_FAILURES = 3;
29
+
19
30
  /** `r` — the standby status update, the only frontend message a walsender listens for. */
20
31
  const STANDBY_STATUS = 0x72;
21
32
 
@@ -33,6 +44,13 @@ export interface ReplicationStreamStats {
33
44
  * be alerted on. Inserts never count: there is no `before` to be partial.
34
45
  */
35
46
  readonly partialBefore: number;
47
+ /**
48
+ * Standby-status writes that have failed in a row without one landing in between. It is the half
49
+ * of a broken stream the delivery count cannot show: the read side goes on delivering while
50
+ * `confirmed_flush_lsn` stops advancing, so WAL accumulates on the primary with nothing else
51
+ * reporting it. Reset by the first confirm that lands.
52
+ */
53
+ readonly confirmFailures: number;
36
54
  /**
37
55
  * Why the pump stopped, or `null` while it is live. The read loop cannot throw into a caller —
38
56
  * nothing awaits it — so this is the one place `/readyz` and a test can see that it died at all.
@@ -92,6 +110,7 @@ export class PgReplicationStream {
92
110
  #skipped = 0;
93
111
  #replayed = 0;
94
112
  #partialBefore = 0;
113
+ #confirmFailures = 0;
95
114
  #failure: string | null = null;
96
115
 
97
116
  constructor(options: PgLogicalReplicationOptions) {
@@ -115,6 +134,7 @@ export class PgReplicationStream {
115
134
  skipped: this.#skipped,
116
135
  replayed: this.#replayed,
117
136
  partialBefore: this.#partialBefore,
137
+ confirmFailures: this.#confirmFailures,
118
138
  failure: this.#failure,
119
139
  };
120
140
  }
@@ -163,8 +183,9 @@ export class PgReplicationStream {
163
183
  // A restart that kept the last death in `stats()` reports a live stream as failed, and the
164
184
  // supervisor that reads it never sees the replicator come back.
165
185
  this.#failure = null;
186
+ this.#confirmFailures = 0;
166
187
  this.#timer = setInterval(() => {
167
- void this.#confirm();
188
+ void this.#confirmOnTimer();
168
189
  }, this.#options.statusIntervalMs ?? DEFAULT_STATUS_INTERVAL_MS);
169
190
  // A pending timer must not be what keeps `x dev` alive after the app is done with it.
170
191
  this.#timer.unref?.();
@@ -276,7 +297,7 @@ export class PgReplicationStream {
276
297
  }
277
298
  }
278
299
  } catch (failure) {
279
- await this.#die(failure instanceof Error ? failure.message : String(failure));
300
+ await this.#die(renderThrowable(failure));
280
301
  }
281
302
  }
282
303
 
@@ -312,8 +333,8 @@ export class PgReplicationStream {
312
333
  async #deliver(
313
334
  op: ChangeOp,
314
335
  relation: PgRelation,
315
- oldTuple: JsonObject | null,
316
- newTuple: JsonObject | null,
336
+ oldTuple: PhysicalRow | null,
337
+ newTuple: PhysicalRow | null,
317
338
  handlers: ReplicationStreamHandlers,
318
339
  ): Promise<void> {
319
340
  const transaction = this.#transaction;
@@ -359,9 +380,47 @@ export class PgReplicationStream {
359
380
  this.#delivered += 1;
360
381
  }
361
382
 
383
+ /**
384
+ * The TIMER's confirm, and the one call that may not reject. `void this.#confirm()` handed the
385
+ * rejection to nobody: `#confirm` awaits `#writing`, which rejects the moment the socket is gone,
386
+ * and no package in this repo installs an `unhandledRejection` handler, so Bun ends the process —
387
+ * an uncoded `TypeError` reaching the operator with no code and no `fix:`, and exit code 1 on an
388
+ * otherwise clean shutdown.
389
+ *
390
+ * Worse than the crash was the silence before it: `stats().failure` stayed `null`, so `/readyz`
391
+ * reported the replicator live while `confirmed_flush_lsn` stopped advancing and WAL piled up on
392
+ * the primary. A run of failures is now a death, the same way `#drain`'s catch is — the four
393
+ * things `#die` owns go together or not at all.
394
+ */
395
+ async #confirmOnTimer(): Promise<void> {
396
+ try {
397
+ await this.#confirm();
398
+ // Consecutive, not cumulative: one confirm that lands means the walsender is being told
399
+ // where we are, and a lifetime count would eventually kill a healthy stream.
400
+ this.#confirmFailures = 0;
401
+ } catch (failure) {
402
+ this.#confirmFailures += 1;
403
+ logger.warn('replication confirm failed', {
404
+ slot: this.#options.slot,
405
+ consecutive: this.#confirmFailures,
406
+ error: renderThrowable(failure),
407
+ });
408
+ const consecutive = this.#confirmFailures;
409
+ if (consecutive >= MAX_CONFIRM_FAILURES) {
410
+ await this.#die(
411
+ `${consecutive} standby status updates failed in a row — ` +
412
+ `the slot is not being confirmed: ${renderThrowable(failure)}`,
413
+ );
414
+ }
415
+ }
416
+ }
417
+
362
418
  /**
363
419
  * Tell the walsender how far we got. Serialized behind one chain because the timer and the read
364
420
  * loop both reach it, and two interleaved writes would frame one another's bytes.
421
+ *
422
+ * It still RAISES: `stop()` and the keepalive reply both await it and both need the answer. Only
423
+ * the timer, which awaits nothing, goes through `#confirmOnTimer` above.
365
424
  */
366
425
  async #confirm(connection: PgConnection | null = this.#connection): Promise<void> {
367
426
  if (connection === null || !connection.inCopyBoth) return;
@@ -375,16 +434,21 @@ export class PgReplicationStream {
375
434
  .int64(at)
376
435
  .uint8(0)
377
436
  .finish();
378
- this.#writing = this.#writing.then(
379
- () => connection.sendCopyData(payload),
380
- () => undefined,
381
- );
382
- await this.#writing;
437
+ // The chain SERIALIZES writes; it does not decide them. Putting the rejection handler on the
438
+ // chain itself meant the confirm after a failed one resolved without writing anything —
439
+ // `.then(send, () => undefined)` runs the second handler and hands back its `undefined`, so
440
+ // every other standby status update after the first failure was a no-op that reported success.
441
+ // A run of failures could then never be seen, because the run never got past one.
442
+ const attempt = this.#writing.then(() => connection.sendCopyData(payload));
443
+ // What the NEXT caller queues behind is this attempt settled either way; what THIS caller
444
+ // awaits is the attempt itself, because it is the only one entitled to its outcome.
445
+ this.#writing = attempt.catch(() => undefined);
446
+ await attempt;
383
447
  }
384
448
  }
385
449
 
386
450
  /** A physical tuple becomes the row the matcher's predicates are written against, or nothing. */
387
- function toRow(relation: PgRelation, physical: JsonObject | null): Row | null {
451
+ function toRow(relation: PgRelation, physical: PhysicalRow | null): Row | null {
388
452
  if (physical === null) return null;
389
453
  const row = entityRow(physical);
390
454
  // A bigserial id decodes as a number inside `Number.isSafeInteger` range and as text outside it,
@@ -0,0 +1,166 @@
1
+ // One physical Postgres value, off the WAL as text, becomes the value a ROW holds — the same one
2
+ // `@ultimat3/entity`'s repository produces for that column. Lifted out of `pgoutput.ts` so that
3
+ // file stays message framing and this one stays the type catalogue.
4
+ //
5
+ // The rule it enforces is `CLAUDE.md`'s: **a live row must equal a repository row.** The WAL is
6
+ // text and a repository row is not, so `timestamp()` is a `Date` on both sides, `arrayOf()` is a
7
+ // JS array on both sides and `bytes()` is a `Uint8Array` on both sides. Left as postgres' own
8
+ // text, `compareValues(new Date(…), '2026-08-09 12:00:00+00')` fell to `String(left) < String(right)`
9
+ // — `"1786…"` against `"2026-…"` — so one edit to one column moved every row of an
10
+ // `orderBy('createdAt','desc')` feed to the top for every subscriber, and `post.tags.map(…)` threw
11
+ // in the component the first patch reached.
12
+
13
+ import { renderThrowable } from '@ultimat3/core';
14
+ import { ReplicationProtocolError } from './errors';
15
+ import type { JsonValue } from './json';
16
+ import { arrayElementOid, parsePgArray } from './pg-array';
17
+
18
+ /**
19
+ * What a row value can be between the WAL and the wire: JSON, plus the two JS shapes a repository
20
+ * row already carries. `Date` and `Uint8Array` are not `JsonValue` and are not meant to be — they
21
+ * are what `JSON.stringify` turns into the string a SNAPSHOT frame carries, which is precisely the
22
+ * format a patch frame has to converge on.
23
+ */
24
+ export type PhysicalValue =
25
+ | JsonValue
26
+ | Date
27
+ | Uint8Array
28
+ | PhysicalValue[]
29
+ | { [key: string]: PhysicalValue };
30
+
31
+ export type PhysicalRow = { [key: string]: PhysicalValue };
32
+
33
+ /**
34
+ * `2026-08-09 12:00:00.123456+00` -> the ISO-8601 form `new Date` is specified to accept.
35
+ *
36
+ * Postgres writes a space between the date and the clock, an offset that may be `+00`, `+0530` or
37
+ * `+05:30`, and as many fractional digits as the column's precision. `Date` holds milliseconds, so
38
+ * the fraction is TRUNCATED to three — which is what the driver does on the repository side, so
39
+ * both readers of one column land on the same instant.
40
+ */
41
+ const TIMESTAMP =
42
+ /^(\d{4,6})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}(?::?\d{2})?)?$/;
43
+
44
+ /**
45
+ * `undefined` for a text this decoder does not describe — `infinity`, a BC date, a non-ISO
46
+ * `DateStyle` — and the caller keeps the text it arrived as. Silence beats a wrong instant: the
47
+ * value still crosses, it simply does not claim to be a `Date`.
48
+ *
49
+ * The `DateStyle` half is closed at the SESSION and not here: `pg-connection.ts` pins
50
+ * `datestyle=ISO` in the startup packet, so a server configured `SQL`, `German` or `Postgres`
51
+ * cannot quietly send this branch every timestamp it decodes. Nothing in this file may depend on
52
+ * that — a text it cannot read still keeps its text — but a reader wondering why the ISO
53
+ * assumption is safe should look there rather than rediscover it.
54
+ *
55
+ * A `timestamp without time zone` (oid 1114) carries no offset and is read as UTC. This framework's
56
+ * `timestamp()` is always `timestamptz`, so the only way to reach that branch is an adopted table —
57
+ * and UTC is the one reading with no ambient zone in it.
58
+ */
59
+ function toInstant(text: string): Date | undefined {
60
+ const parts = TIMESTAMP.exec(text);
61
+ if (parts === null) return undefined;
62
+ const [, year, month, day, hour, minute, second, fraction, zone] = parts;
63
+ const millis = fraction === undefined ? '000' : `${fraction.slice(1)}000`.slice(0, 3);
64
+ const parsed = new Date(
65
+ `${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}${offsetOf(zone)}`,
66
+ );
67
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
68
+ }
69
+
70
+ /** `+05` and `+0530` are offsets postgres writes and `Date` does not read; `±HH:MM` is both. */
71
+ function offsetOf(zone: string | undefined): string {
72
+ if (zone === undefined || zone === 'Z') return 'Z';
73
+ if (zone.length === 3) return `${zone}:00`;
74
+ if (zone.length === 5) return `${zone.slice(0, 3)}:${zone.slice(3)}`;
75
+ return zone;
76
+ }
77
+
78
+ const HEX = /^[0-9a-fA-F]*$/;
79
+
80
+ /**
81
+ * `\x0102` -> `Uint8Array([1, 2])`, the value `bytes()` parses to on the repository side.
82
+ *
83
+ * `undefined` for anything else, including the pre-9.0 `escape` output format: that one is
84
+ * ambiguous without knowing the server's `bytea_output`, and a wrong byte string is worse than the
85
+ * text. Nothing this framework creates sets it.
86
+ */
87
+ function toBytes(text: string): Uint8Array | undefined {
88
+ if (!text.startsWith('\\x')) return undefined;
89
+ const hex = text.slice(2);
90
+ if (hex.length % 2 !== 0 || !HEX.test(hex)) return undefined;
91
+ const out = new Uint8Array(hex.length / 2);
92
+ for (let i = 0; i < out.length; i += 1) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Postgres sends every value as text (we never negotiate binary). Decoding depends on the
98
+ * column's type oid — the wire gives us nothing else to go on, so this switch is the one place
99
+ * that type catalogue is encoded.
100
+ */
101
+ export function decodeValue(typeOid: number, text: string): PhysicalValue {
102
+ switch (typeOid) {
103
+ case 16: // bool
104
+ return text === 't';
105
+
106
+ case 20: {
107
+ // int8: only safe as a number if it round-trips exactly; otherwise keep the digits —
108
+ // a rounded bigint is a worse lie than a string that still parses correctly downstream.
109
+ const asNumber = Number(text);
110
+ return Number.isSafeInteger(asNumber) ? asNumber : text;
111
+ }
112
+
113
+ case 21: // int2
114
+ case 23: // int4
115
+ case 26: // oid
116
+ return Number(text);
117
+
118
+ case 700: // float4
119
+ case 701: // float8
120
+ // JSON has no literal for these three, so the text form survives the round trip instead of
121
+ // silently becoming a number `JSON.stringify` would otherwise turn into `null`.
122
+ if (text === 'NaN' || text === 'Infinity' || text === '-Infinity') return text;
123
+ return Number(text);
124
+
125
+ case 1700: // numeric — exactness beats convenience; money is never a float here.
126
+ return text;
127
+
128
+ case 114: // json
129
+ case 3802: {
130
+ // jsonb
131
+ let parsed: unknown;
132
+ try {
133
+ parsed = JSON.parse(text);
134
+ } catch (cause) {
135
+ throw new ReplicationProtocolError({
136
+ stage: 'value',
137
+ detail: `type oid ${typeOid} carried invalid json: ${renderThrowable(cause)}`,
138
+ });
139
+ }
140
+ return parsed as JsonValue;
141
+ }
142
+
143
+ case 1114: // timestamp
144
+ case 1184: // timestamptz
145
+ // A `Date`, because `timestamp()` reads back as one through the repository and the two have
146
+ // to be one value: `JSON.stringify` gives the wire the same ISO string either way.
147
+ return toInstant(text) ?? text;
148
+
149
+ case 1082: // date
150
+ // Already the value: `date()` parses to `@ultimat3/time`'s `PlainDate`, which IS the
151
+ // `YYYY-MM-DD` string postgres wrote. Converting it to a `Date` would be the 100x-style
152
+ // reinterpretation the calendar/instant split exists to prevent.
153
+ return text;
154
+
155
+ case 17: // bytea
156
+ return toBytes(text) ?? text;
157
+
158
+ default: {
159
+ // An array type's element is the only thing left that changes the answer, and only when this
160
+ // decoder knows which element type it is — see `pg-array.ts` for what an unknown oid costs.
161
+ const element = arrayElementOid(typeOid);
162
+ if (element === undefined) return text; // text, varchar, uuid, enum, and everything else.
163
+ return parsePgArray(text, (raw) => decodeValue(element, raw)) ?? text;
164
+ }
165
+ }
166
+ }
package/src/pgoutput.ts CHANGED
@@ -1,12 +1,13 @@
1
- import { renderThrowable } from '@ultimat3/core';
2
1
  // Decodes pgoutput logical-replication messages (protocol version 1, Postgres >= 12) into typed
3
- // PgOutputMessage values, and the postgres text-format values inside each tuple into JsonValue.
4
- // Pure byte decoding: no sockets, no I/O. A decoder instance owns the per-connection relation
5
- // cache that later Insert/Update/Delete/Truncate messages reference by oid.
2
+ // PgOutputMessage values. Pure byte decoding: no sockets, no I/O. A decoder instance owns the
3
+ // per-connection relation cache that later Insert/Update/Delete/Truncate messages reference by oid.
4
+ //
5
+ // What a tuple's TEXT means is `pg-values.ts`'s: this file frames messages, that one owns the type
6
+ // catalogue that turns postgres' text into the value a repository row holds.
6
7
 
7
8
  import { ReplicationProtocolError } from './errors';
8
- import type { JsonObject, JsonValue } from './json';
9
9
  import { ByteReader, pgTimestampToEpochMs } from './pg-bytes';
10
+ import { decodeValue, type PhysicalRow } from './pg-values';
10
11
 
11
12
  export interface PgColumn {
12
13
  /** part of the replica identity key — set by the `flags & 1` bit. */
@@ -40,85 +41,25 @@ export type PgOutputMessage =
40
41
  readonly commitAt: number;
41
42
  }
42
43
  | { readonly kind: 'relation'; readonly relation: PgRelation }
43
- | { readonly kind: 'insert'; readonly relation: PgRelation; readonly after: JsonObject }
44
+ | { readonly kind: 'insert'; readonly relation: PgRelation; readonly after: PhysicalRow }
44
45
  | {
45
46
  readonly kind: 'update';
46
47
  readonly relation: PgRelation;
47
- readonly before: JsonObject | null;
48
- readonly after: JsonObject;
48
+ readonly before: PhysicalRow | null;
49
+ readonly after: PhysicalRow;
49
50
  }
50
- | { readonly kind: 'delete'; readonly relation: PgRelation; readonly before: JsonObject }
51
+ | { readonly kind: 'delete'; readonly relation: PgRelation; readonly before: PhysicalRow }
51
52
  | { readonly kind: 'truncate'; readonly relations: readonly PgRelation[] }
52
53
  /** origin / type / logical message — decoded far enough to be skipped safely. */
53
54
  | { readonly kind: 'other'; readonly tag: string };
54
55
 
55
- /**
56
- * Postgres sends every value as text (we never negotiate binary). Decoding depends on the
57
- * column's type oid — the wire gives us nothing else to go on, so this switch is the one place
58
- * that type catalogue is encoded.
59
- */
60
- function decodeValue(typeOid: number, text: string): JsonValue {
61
- switch (typeOid) {
62
- case 16: // bool
63
- return text === 't';
64
-
65
- case 20: {
66
- // int8: only safe as a number if it round-trips exactly; otherwise keep the digits —
67
- // a rounded bigint is a worse lie than a string that still parses correctly downstream.
68
- const asNumber = Number(text);
69
- return Number.isSafeInteger(asNumber) ? asNumber : text;
70
- }
71
-
72
- case 21: // int2
73
- case 23: // int4
74
- case 26: // oid
75
- return Number(text);
76
-
77
- case 700: // float4
78
- case 701: // float8
79
- // JSON has no literal for these three, so the text form survives the round trip instead of
80
- // silently becoming a number `JSON.stringify` would otherwise turn into `null`.
81
- if (text === 'NaN' || text === 'Infinity' || text === '-Infinity') return text;
82
- return Number(text);
83
-
84
- case 1700: // numeric — exactness beats convenience; money is never a float here.
85
- return text;
86
-
87
- case 114: // json
88
- case 3802: {
89
- // jsonb
90
- let parsed: unknown;
91
- try {
92
- parsed = JSON.parse(text);
93
- } catch (cause) {
94
- throw new ReplicationProtocolError({
95
- stage: 'value',
96
- detail: `type oid ${typeOid} carried invalid json: ${renderThrowable(cause)}`,
97
- });
98
- }
99
- return parsed as JsonValue;
100
- }
101
-
102
- case 1082: // date
103
- case 1114: // timestamp
104
- case 1184: // timestamptz
105
- return text; // an ISO-ish string; never a `Date` — the row must stay JSON.
106
-
107
- case 17: // bytea — the `\x...` text form, as-is.
108
- return text;
109
-
110
- default: // text, varchar, uuid, enum, and everything else not called out above.
111
- return text;
112
- }
113
- }
114
-
115
56
  /**
116
57
  * Int16 ncolumns + that many columns. Every tuple kind (insert's new row, update/delete's old
117
58
  * row, a `'K'` key-only row) shares this decoder: postgres always sends one byte per column,
118
59
  * `'u'` standing in for the columns a key-only tuple leaves out — so the column count always
119
60
  * matches the relation, and only the per-column byte tells us whether a value is actually there.
120
61
  */
121
- function decodeTupleData(reader: ByteReader, relation: PgRelation): JsonObject {
62
+ function decodeTupleData(reader: ByteReader, relation: PgRelation): PhysicalRow {
122
63
  const count = reader.int16();
123
64
  if (count !== relation.columns.length) {
124
65
  throw new ReplicationProtocolError({
@@ -129,7 +70,9 @@ function decodeTupleData(reader: ByteReader, relation: PgRelation): JsonObject {
129
70
  });
130
71
  }
131
72
 
132
- const row: JsonObject = {};
73
+ // Null-prototype: `column.name` is off the WIRE, so a column literally named `__proto__` set
74
+ // the prototype of every row this decoder built. `Object.create(null)` has no prototype to set.
75
+ const row: PhysicalRow = Object.create(null) as PhysicalRow;
133
76
  for (const column of relation.columns) {
134
77
  const kind = reader.tag();
135
78
  if (kind === 'n') {
@@ -256,7 +199,7 @@ export class PgOutputDecoder {
256
199
  #decodeUpdate(reader: ByteReader): PgOutputMessage {
257
200
  const relation = this.#relationOrThrow(reader.int32());
258
201
  let marker = reader.tag();
259
- let before: JsonObject | null = null;
202
+ let before: PhysicalRow | null = null;
260
203
  if (marker === 'K' || marker === 'O') {
261
204
  before = decodeTupleData(reader, relation);
262
205
  marker = reader.tag();
@@ -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
+ }