@ultimat3/realtime 23.0.0 → 24.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.
Files changed (68) hide show
  1. package/CLAUDE.md +75 -81
  2. package/README.md +107 -17
  3. package/package.json +4 -4
  4. package/src/advisory-lock.ts +57 -0
  5. package/src/boot.ts +12 -7
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +2 -2
  8. package/src/changefeed.ts +39 -1
  9. package/src/channel-authz.ts +43 -12
  10. package/src/channel-latch.ts +52 -0
  11. package/src/channel-logs.ts +34 -4
  12. package/src/channel-records.ts +33 -2
  13. package/src/channel-sids.ts +37 -0
  14. package/src/channel.ts +143 -44
  15. package/src/client-channels.ts +10 -1
  16. package/src/client-contract.ts +4 -1
  17. package/src/client-frames.ts +7 -1
  18. package/src/client-heartbeat.ts +39 -9
  19. package/src/client.ts +1 -0
  20. package/src/cursor.ts +11 -0
  21. package/src/errors.ts +70 -19
  22. package/src/fanout.ts +13 -0
  23. package/src/index.ts +1 -1
  24. package/src/live-definition.ts +22 -7
  25. package/src/live-fanout.ts +43 -7
  26. package/src/live-floor.ts +35 -0
  27. package/src/live-query-options.ts +38 -0
  28. package/src/live-query.ts +36 -37
  29. package/src/nats-client.ts +11 -2
  30. package/src/nats-transport.ts +22 -0
  31. package/src/offline-queue.ts +48 -5
  32. package/src/page-errors.ts +17 -0
  33. package/src/page-outbox.ts +121 -50
  34. package/src/pg-advisory-lock.ts +45 -1
  35. package/src/pg-auth.ts +9 -4
  36. package/src/pg-bytes.ts +4 -1
  37. package/src/pg-connection.ts +119 -21
  38. package/src/pg-entity-row.ts +16 -0
  39. package/src/pg-identity-tables.ts +25 -0
  40. package/src/pg-preflight.ts +50 -4
  41. package/src/pg-replication-row.ts +82 -0
  42. package/src/pg-replication.ts +44 -45
  43. package/src/pg-socket.ts +6 -4
  44. package/src/pg-tls.ts +18 -0
  45. package/src/pg-wire.ts +5 -3
  46. package/src/pgoutput.ts +19 -9
  47. package/src/presence.ts +85 -8
  48. package/src/query-window.ts +16 -4
  49. package/src/record-persister.ts +7 -2
  50. package/src/replication-errors.ts +8 -4
  51. package/src/replicator-envelope.ts +110 -0
  52. package/src/replicator-row.ts +75 -0
  53. package/src/replicator.ts +319 -186
  54. package/src/server.ts +10 -6
  55. package/src/socket-engine.ts +14 -2
  56. package/src/socket-idle.ts +3 -1
  57. package/src/socket-routes.ts +8 -3
  58. package/src/subscriber-gate.ts +11 -1
  59. package/src/sync-auth.ts +47 -2
  60. package/src/sync-frames.ts +73 -20
  61. package/src/sync-node-bounds.ts +15 -0
  62. package/src/sync-node-contract.ts +20 -2
  63. package/src/sync-node.ts +92 -17
  64. package/src/sync-origin.ts +26 -11
  65. package/src/sync-protocol.ts +13 -3
  66. package/src/sync-upgrade.ts +76 -8
  67. package/src/use-mutation.ts +33 -5
  68. package/src/wire-channel.ts +13 -4
@@ -131,9 +131,8 @@ export function createEntry(
131
131
  * had already been fanned out would rewind every later subscriber to rows the window has moved
132
132
  * past, so a stale read is discarded and its caller is served from the newer window instead.
133
133
  */
134
- export async function fillWindow(
135
- entry: QueryEntry,
136
- ): Promise<{ rows: readonly Row[]; lsn: string }> {
134
+ export async function fillWindow(entry: QueryEntry): Promise<FilledWindow> {
135
+ let landed: FilledWindow['landed'] = null;
137
136
  for (let attempt = 0; ; attempt += 1) {
138
137
  // Read before `startRead` clears it: a second caller arriving during the read joins it and is
139
138
  // not the one that forced it, which is what keeps one forced read from becoming N.
@@ -155,15 +154,28 @@ export async function fillWindow(
155
154
  const first = entry.applied === 0;
156
155
  if (isNewestRead(entry, pending) && (forced || first || result.lsn >= entry.lsn)) {
157
156
  applyRead(entry, pending, result);
157
+ // A forced read outranks a first one: what it replaced was known to be wrong.
158
+ landed = forced || landed === 'forced' ? 'forced' : first ? 'first' : landed;
158
159
  }
159
160
  // A change reached this window while its first read was in flight and could not be folded,
160
161
  // so what just landed may predate it: read once more before serving anyone a partial window.
161
162
  return first && entry.stale && attempt < COLD_REREADS;
162
163
  });
163
- if (!again) return { rows: entry.rows, lsn: entry.lsn };
164
+ if (!again) return { rows: entry.rows, lsn: entry.lsn, landed };
164
165
  }
165
166
  }
166
167
 
168
+ /**
169
+ * The window a caller is served from, and whether THIS call put a read into it. `landed` is what
170
+ * decides the retained ring's floor (`change-buffer.ts`): `null` is a window that was already
171
+ * filled — its patch history is still true, and flooring it would evict every other subscriber's.
172
+ */
173
+ export interface FilledWindow {
174
+ readonly rows: readonly Row[];
175
+ readonly lsn: string;
176
+ readonly landed: 'first' | 'forced' | null;
177
+ }
178
+
167
179
  /**
168
180
  * How many times a cold window re-reads because writes kept landing during its read. Bounded: a
169
181
  * table written faster than it can be read would otherwise never serve a subscriber, and after the
@@ -88,10 +88,15 @@ export function recordPersister(options: RecordPersisterOptions): RecordPersiste
88
88
  });
89
89
 
90
90
  const restore = async (): Promise<number> => {
91
- const scope = scopeKey(principal());
91
+ const owner = principal();
92
+ const scope = scopeKey(owner);
92
93
  if (scope === undefined) return 0;
94
+ const stored = await options.local.rows(scope);
95
+ // The read is async and a principal change can land inside it: its rescope has already cleared
96
+ // the store, so restoring now would hand the previous principal's rows to the next one.
97
+ if (principal() !== owner) return 0;
93
98
  let restored = 0;
94
- for (const [type, rows] of await options.local.rows(scope)) {
99
+ for (const [type, rows] of stored) {
95
100
  if (!options.types.has(type)) continue;
96
101
  options.store.restore(type, rows);
97
102
  restored += Object.keys(rows).length;
@@ -17,10 +17,14 @@ export class ReplicationProtocolError extends RealtimeError {
17
17
  constructor(args: { stage: string; detail: string; fix?: string }) {
18
18
  super({
19
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',
20
+ // The requirement rides the cause when the caller names no fix of its own: a `fix:` is one
21
+ // command, and `x doctor` has no `db` subcommand for the old line to have been one.
22
+ cause:
23
+ `postgres replication ${args.stage}: ${args.detail}` +
24
+ (args.fix === undefined
25
+ ? ' — the server must be postgres >= 14 with a pgoutput publication and wal_level=logical'
26
+ : ''),
27
+ fix: args.fix ?? 'x doctor --json',
24
28
  });
25
29
  }
26
30
  }
@@ -0,0 +1,110 @@
1
+ // The bus envelope, read side: one published change decoded back into a `ChangeEvent` (rows revived
2
+ // through their entity's columns), with the producer and sequence a lost message is visible in —
3
+ // and the detector a `sync` node runs over them. The publisher is `replicator.ts`.
4
+
5
+ import { isWriteDigest } from '@ultimat3/core';
6
+ import type { ChangeEvent } from './changefeed';
7
+ import { reviveBusRow } from './replicator-row';
8
+
9
+ /**
10
+ * What the bus actually carries: one change, plus who published it and where in that publisher's
11
+ * stream it sits.
12
+ *
13
+ * Fanout is core NATS — `publish`, no ack, at most once — so a consumer that reads only the change
14
+ * cannot tell "nothing happened" from "eleven changes went past while I was reconnecting". An lsn
15
+ * cannot answer it either: a WAL position is a byte offset, so a legitimate next change is an
16
+ * arbitrary jump forwards. A per-publisher counter is the one number a gap is visible in.
17
+ *
18
+ * Both fields are optional on the wire, so a node reading a publisher that predates them simply
19
+ * detects nothing — the same rule the `snapshot.entity` field follows, on a subject no client sees.
20
+ */
21
+ export interface ChangeEnvelope {
22
+ readonly change: ChangeEvent;
23
+ /** Monotonic within `producer`, from 1. `null` from a publisher that does not sequence. */
24
+ readonly seq: number | null;
25
+ /** Identifies one replicator *run*. A new one restarts `seq` — and follows a stream that died. */
26
+ readonly producer: string | null;
27
+ }
28
+
29
+ /** Sync-node side of the bus: decode a published change back into a `ChangeEvent`. */
30
+ export function parseChange(payload: string): ChangeEvent | null {
31
+ return parseEnvelope(payload)?.change ?? null;
32
+ }
33
+
34
+ /** The same decode, keeping the two fields a gap is visible in. `parseChange` is this, narrowed. */
35
+ export function parseEnvelope(payload: string): ChangeEnvelope | null {
36
+ try {
37
+ const parsed: unknown = JSON.parse(payload);
38
+ if (typeof parsed !== 'object' || parsed === null) return null;
39
+ const shape = parsed as Partial<ChangeEvent> & { seq?: unknown; producer?: unknown };
40
+ if (typeof shape.entity !== 'string' || typeof shape.lsn !== 'string') return null;
41
+ if (
42
+ shape.op !== 'insert' &&
43
+ shape.op !== 'update' &&
44
+ shape.op !== 'delete' &&
45
+ shape.op !== 'truncate'
46
+ ) {
47
+ return null;
48
+ }
49
+ return {
50
+ change: {
51
+ entity: shape.entity,
52
+ op: shape.op,
53
+ before: reviveBusRow(shape.entity, shape.before ?? null),
54
+ after: reviveBusRow(shape.entity, shape.after ?? null),
55
+ lsn: shape.lsn,
56
+ txid: typeof shape.txid === 'string' ? shape.txid : '',
57
+ orgId: typeof shape.orgId === 'string' ? shape.orgId : null,
58
+ at: typeof shape.at === 'number' ? shape.at : 0,
59
+ // Only the minted shape crosses: a frame decoder refuses anything else, so a malformed
60
+ // label here would cost every member the whole frame rather than one page its echo.
61
+ ...(isWriteDigest(shape.write) ? { write: shape.write } : {}),
62
+ ...(isNames(shape.omitted) ? { omitted: shape.omitted } : {}),
63
+ },
64
+ seq: typeof shape.seq === 'number' && Number.isFinite(shape.seq) ? shape.seq : null,
65
+ producer: typeof shape.producer === 'string' ? shape.producer : null,
66
+ };
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /** A non-empty list of property names — the only shape `ChangeEvent.omitted` crosses the bus in. */
73
+ const isNames = (value: unknown): value is readonly string[] =>
74
+ Array.isArray(value) && value.length > 0 && value.every((name) => typeof name === 'string');
75
+
76
+ /**
77
+ * The consume-side twin of the publisher's counter, and the only thing on a `sync` node that can
78
+ * say "this node missed changes". Publish-side duplicate and out-of-order guards already existed;
79
+ * there was no equivalent here, so a NATS blip during a rolling restart was eleven changes that
80
+ * simply never happened as far as every subscriber on this node could tell.
81
+ *
82
+ * Per producer, because a replicator restart legitimately rewinds the counter. A repeat or a
83
+ * reordering is *not* reported as a gap — the window's own lsn guard refuses those — and neither is
84
+ * the first message this node ever reads: a node that joined late has missed everything by
85
+ * definition, and every subscription it holds started after it did.
86
+ *
87
+ * A NEW producer after a known one IS a gap. The run before it died — that is why there is a new
88
+ * one — and nothing says its last messages reached this node: the tail of a dead stream has no
89
+ * later sequence number to be missed against. One re-read per replicator restart is the price.
90
+ */
91
+ export class SeqGapDetector {
92
+ readonly #next = new Map<string, number>();
93
+
94
+ /** `true` when at least one message between the last one and this one was never delivered. */
95
+ observe(envelope: ChangeEnvelope): boolean {
96
+ const { producer, seq } = envelope;
97
+ if (producer === null || seq === null) return false;
98
+ const expected = this.#next.get(producer);
99
+ const succeeds = expected === undefined && this.#next.size > 0;
100
+ // Never backwards: a redelivery must not lower the bar and turn the next legitimate message
101
+ // into a gap of its own.
102
+ this.#next.set(producer, Math.max(expected ?? 0, seq + 1));
103
+ return succeeds || (expected !== undefined && seq > expected);
104
+ }
105
+
106
+ /** Producers this node has read. Bounded by replicator restarts, so it is swept on a drain. */
107
+ forget(): void {
108
+ this.#next.clear();
109
+ }
110
+ }
@@ -0,0 +1,75 @@
1
+ // Single responsibility: a change's rows across the replicator bus. The bus carries text, a row
2
+ // holds a `Date` and a `Uint8Array`, and `JSON.parse` returns neither — so the publisher writes a
3
+ // form JSON can carry and the sync node reads it back through the ENTITY's own columns.
4
+
5
+ import { entityForTable } from '@ultimat3/entity';
6
+ import type { Row } from './json';
7
+
8
+ const CHUNK = 0x8000;
9
+
10
+ /** Base64 of raw bytes. Chunked: one `String.fromCharCode(...bytes)` overflows the stack at ~100k. */
11
+ function toBase64(bytes: Uint8Array): string {
12
+ let binary = '';
13
+ for (let at = 0; at < bytes.length; at += CHUNK) {
14
+ binary += String.fromCharCode(...bytes.subarray(at, at + CHUNK));
15
+ }
16
+ return btoa(binary);
17
+ }
18
+
19
+ function fromBase64(text: string): Uint8Array {
20
+ const binary = atob(text);
21
+ const bytes = new Uint8Array(binary.length);
22
+ for (let at = 0; at < binary.length; at += 1) bytes[at] = binary.charCodeAt(at);
23
+ return bytes;
24
+ }
25
+
26
+ /**
27
+ * Publish side. A `Date` already serialises to the ISO text its column reads back; a `Uint8Array`
28
+ * serialises to `{"0":1,"1":2}`, which nothing reads back, so it crosses as base64. Only a
29
+ * top-level value can be one: `arrayOf()` refuses `bytes()` at declaration.
30
+ */
31
+ export function busRow(row: Row | null): Row | null {
32
+ if (row === null) return null;
33
+ let out: Record<string, unknown> | undefined;
34
+ for (const [property, value] of Object.entries(row as Record<string, unknown>)) {
35
+ if (!(value instanceof Uint8Array)) continue;
36
+ out ??= { ...row };
37
+ out[property] = toBase64(value);
38
+ }
39
+ return out === undefined ? row : (out as unknown as Row);
40
+ }
41
+
42
+ /**
43
+ * Consume side: the row as the replicator held it before it was text. Without this every `Date`
44
+ * reached the matcher as an ISO string, `compareValues(Date, string)` fell to comparing their
45
+ * printed forms, and one edit to one column moved its row to the top of every window ordered by
46
+ * a timestamp — on every deployment with a bus, and in no in-process test, which hands the object
47
+ * over directly.
48
+ *
49
+ * Decided by the column that DECLARED the value, never by what the value looks like: a `text()`
50
+ * holding `2026-08-09T12:00:00.000Z` stays text. `table` is `ChangeEvent.entity`, which on the WAL
51
+ * path is the relation name. A table with no entity here, a property with no column, and a value
52
+ * its column refuses are all handed back as they arrived — a change that crosses unrevived is the
53
+ * old behaviour; a change dropped is a window that silently diverges.
54
+ */
55
+ export function reviveBusRow(table: string, row: Row | null): Row | null {
56
+ if (row === null) return null;
57
+ const entity = entityForTable(table);
58
+ if (entity === undefined) return row;
59
+ const out: Record<string, unknown> = {};
60
+ for (const [property, value] of Object.entries(row as Record<string, unknown>)) {
61
+ out[property] = value;
62
+ if (value === null || !Object.hasOwn(entity.$columns, property)) continue;
63
+ const column = entity.$columns[property];
64
+ if (column === undefined || column.$meta.sealed !== undefined) continue;
65
+ try {
66
+ out[property] =
67
+ column.$meta.kind === 'bytea' && typeof value === 'string'
68
+ ? fromBase64(value)
69
+ : column.$parse(value);
70
+ } catch {
71
+ // Kept as it arrived — see the function comment.
72
+ }
73
+ }
74
+ return out as unknown as Row;
75
+ }