@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/src/errors.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // Realtime's X_* codes. Every throw in this package goes through one of these classes so
2
2
  // the same string renders in the terminal, the browser overlay, and `--json`.
3
3
 
4
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
4
+ import { registerErrorCodes } from '@ultimat3/core';
5
+ import { RealtimeError } from './realtime-error';
5
6
 
6
7
  /** Codes this package declares and owns. */
7
8
  export const REALTIME_OWNED_ERROR_CODES = [
@@ -18,6 +19,7 @@ export const REALTIME_OWNED_ERROR_CODES = [
18
19
  'X_REPLICATION_FAILED',
19
20
  'X_REPLICATOR_SLOT_HELD',
20
21
  'X_LIVE_CLIENT_MISSING',
22
+ 'X_LIVE_SERVER_RENDER',
21
23
  'X_LIVE_ROW_UNIDENTIFIED',
22
24
  'X_LIVE_QUERY_UNKNOWN',
23
25
  'X_LIVE_REPLICA_IDENTITY',
@@ -111,7 +113,8 @@ export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, stri
111
113
  X_REPLICATION_PROTOCOL: 'the WAL stream cannot be decoded',
112
114
  X_REPLICATION_FAILED: 'the replication connection was refused',
113
115
  X_REPLICATOR_SLOT_HELD: 'another replicator already owns this database',
114
- X_LIVE_CLIENT_MISSING: 'a realtime hook ran with no LiveClient registered',
116
+ X_LIVE_CLIENT_MISSING: 'a realtime hook ran in a browser with no LiveClient registered',
117
+ X_LIVE_SERVER_RENDER: 'a browser-only live operation ran during a server render',
115
118
  X_LIVE_ROW_UNIDENTIFIED: 'a live query returned a row with no id',
116
119
  X_LIVE_QUERY_UNKNOWN: 'no live query is registered under the name a subscribe frame asked for',
117
120
  X_LIVE_REPLICA_IDENTITY: 'a replicated table sends a key-only row on delete',
@@ -128,19 +131,17 @@ registerErrorCodes(
128
131
  ),
129
132
  );
130
133
 
131
- const DOCS_BASE = 'https://ultimate.dev/errors/';
132
-
133
- /** Base for every realtime error: fills `docs` from the code so no call site can forget it. */
134
- export class RealtimeError extends UltimateError {
135
- constructor(opts: { code: RealtimeErrorCode; cause: string; fix: string }) {
136
- super({
137
- code: opts.code,
138
- cause: opts.cause,
139
- fix: opts.fix,
140
- docs: `${DOCS_BASE}${opts.code}`,
141
- });
142
- }
143
- }
134
+ // Re-exported, never re-declared: `RealtimeError` lives in `realtime-error.ts` and the four
135
+ // replication errors in `replication-errors.ts`, so this file stays the CODE TABLE plus the
136
+ // client-reachable refusals. Every name is still importable from `./errors`, which is what the
137
+ // seventeen `pg-*` modules and the barrel already do.
138
+ export { RealtimeError } from './realtime-error';
139
+ export {
140
+ ReplicaIdentityError,
141
+ ReplicationFailedError,
142
+ ReplicationProtocolError,
143
+ ReplicatorSlotHeldError,
144
+ } from './replication-errors';
144
145
 
145
146
  /** Subscribe (or an actor change) denied by the topic's policy. Never leaks the topic's data. */
146
147
  export class TopicForbiddenError extends RealtimeError {
@@ -280,93 +281,39 @@ export class TransportProtocolError extends RealtimeError {
280
281
  }
281
282
 
282
283
  /**
283
- * The bytes on the replication socket are not the bytes the protocol allows: a truncated message,
284
- * an unknown pgoutput tag, an auth method we do not speak. Always a version or configuration
285
- * mismatch rather than a transient fault, so retrying the same connection cannot help.
286
- */
287
- export class ReplicationProtocolError extends RealtimeError {
288
- constructor(args: { stage: string; detail: string; fix?: string }) {
289
- super({
290
- code: 'X_REPLICATION_PROTOCOL',
291
- cause: `postgres replication ${args.stage}: ${args.detail}`,
292
- fix:
293
- args.fix ??
294
- 'x doctor db — the server must be postgres >= 14 with a pgoutput publication and wal_level=logical',
295
- });
296
- }
297
- }
298
-
299
- /**
300
- * The replication connection itself failed — refused credentials, a slot another process holds,
301
- * an `ErrorResponse` from the server. The server's own message is passed through verbatim
302
- * because it names the object that has to change.
303
- */
304
- export class ReplicationFailedError extends RealtimeError {
305
- constructor(args: { stage: string; detail: string; fix: string }) {
306
- super({
307
- code: 'X_REPLICATION_FAILED',
308
- cause: `postgres replication ${args.stage} failed: ${args.detail}`,
309
- fix: args.fix,
310
- });
311
- }
312
- }
313
-
314
- /**
315
- * A second replicator found the advisory lock held. Distinct from `X_REPLICATION_FAILED` because
316
- * nothing is wrong with this process: the database already has its one replicator, and a second
317
- * one that started anyway would publish every change twice. Terminal for a container whose whole
318
- * job is that role — the scheduler is the thing that has to change, not the connection.
319
- */
320
- export class ReplicatorSlotHeldError extends RealtimeError {
321
- constructor(args: { key: string; holder?: string | undefined }) {
322
- super({
323
- code: 'X_REPLICATOR_SLOT_HELD',
324
- cause:
325
- `advisory lock ${args.key} is held${args.holder === undefined ? '' : ` by ${args.holder}`}` +
326
- ' — one database has exactly one replicator',
327
- fix: 'scale the replicator to 1 per database: kubectl scale deploy/replicator --replicas=1',
328
- });
329
- }
330
- }
331
-
332
- /**
333
- * A table in the entity list replicates with a replica identity other than FULL, so its `delete`
334
- * (and any key-changing `update`) carries the KEY COLUMNS ONLY. `toRow` accepts that tuple —
335
- * it only requires a text `id` — so the live matcher decides "did this row leave the result set"
336
- * from a one-column row, and a row policy written against `!row.private` reads `undefined`.
284
+ * A hook was called IN A BROWSER before the app entry registered its client. Never a transient
285
+ * fault: the registration is a single call in the entry, so the fix is the call itself rather than
286
+ * a retry.
337
287
  *
338
- * **Raised at preflight and LOGGED, never thrown.** Every app running today on the default
339
- * identity would stop booting, and the replicator refusing to start is a worse outcome than the
340
- * partial rows it is warning about. The runtime half is `ReplicationStreamStats.partialBefore`,
341
- * which counts the changes this actually affects. Refusing it at `x verify` time is the follow-up.
342
- *
343
- * The tables are named because the fix is per table, and they are the entity list's own names —
344
- * every one has already passed `assertIdentifier`, so the `fix:` is SQL that can be pasted.
288
+ * A server render is deliberately not this error, and never was a missing registration: there is
289
+ * no socket to register a client for. It gets `serverRenderLiveClient()` instead the same rule
290
+ * `@ultimat3/ui`'s `solid()` follows for a missing Solid runtime, one package over.
345
291
  */
346
- export class ReplicaIdentityError extends RealtimeError {
347
- constructor(args: { tables: readonly string[] }) {
292
+ export class LiveClientMissingError extends RealtimeError {
293
+ constructor(args: { hook: string }) {
348
294
  super({
349
- code: 'X_LIVE_REPLICA_IDENTITY',
350
- cause:
351
- `${args.tables.join(', ')} replicate with a replica identity other than FULL, so a ` +
352
- 'delete carries the key columns only and a live query decides visibility from a partial row',
353
- fix:
354
- `${args.tables.map((table) => `ALTER TABLE ${table} REPLICA IDENTITY FULL;`).join(' ')}` +
355
- ' -- rows already written to the WAL keep the identity they were written with',
295
+ code: 'X_LIVE_CLIENT_MISSING',
296
+ cause: `${args.hook}() ran in a browser before any LiveClient was registered`,
297
+ fix: 'setLiveClient(new LiveClient({ signal: createSignal, connect, buildId })) in the app entry, above the first render',
356
298
  });
357
299
  }
358
300
  }
359
301
 
360
302
  /**
361
- * A hook was called before the app entry registered its client. Never a transient fault: the
362
- * registration is a single call in the entry, so the fix is the call itself rather than a retry.
303
+ * Something that can only mean "talk to the socket" ran on the server client a mutation, a
304
+ * publish, a topic subscription, a dial. There is no socket during a server render and there never
305
+ * will be one: the document is built and sent, and the browser opens the connection.
306
+ *
307
+ * A refusal rather than a silent no-op, because both alternatives are worse. Queueing it would
308
+ * hold one process-wide queue on behalf of whichever request happened to render, and dropping it
309
+ * would make a write that never happened look like one that did.
363
310
  */
364
- export class LiveClientMissingError extends RealtimeError {
365
- constructor(args: { hook: string }) {
311
+ export class ServerRenderLiveError extends RealtimeError {
312
+ constructor(args: { operation: string }) {
366
313
  super({
367
- code: 'X_LIVE_CLIENT_MISSING',
368
- cause: `${args.hook}() ran before any LiveClient was registered`,
369
- fix: 'setLiveClient(new LiveClient({ signal: createSignal, connect, buildId })) in the app entry, above the first render',
314
+ code: 'X_LIVE_SERVER_RENDER',
315
+ cause: `${args.operation} ran during a server render, where this app has no live socket`,
316
+ fix: 'call it from an island mount() instead of from the page — or guard it with hasLiveClient(), which answers false on the server',
370
317
  });
371
318
  }
372
319
  }
package/src/hooks.ts CHANGED
@@ -3,15 +3,16 @@
3
3
  // accessor is a closure over the `SignalFactory` the registered `LiveClient` was built with, and
4
4
  // every hook resolves that client through one ambient seam rather than a context per surface.
5
5
 
6
- import type { LiveClient, LiveHandle, LiveQueryRef, MutatorRef } from './client';
6
+ import type { LiveClientLike, LiveHandle, LiveQueryRef, MutatorRef } from './client';
7
7
  import { LiveClientMissingError } from './errors';
8
8
  import type { JsonValue, Row } from './json';
9
9
  import type { LocalTx } from './local-store';
10
10
  import type { ConflictStrategy } from './rebase';
11
+ import { serverRenderLiveClient } from './server-render-client';
11
12
 
12
13
  /** What `setLiveClient` holds. The version signal is the queue's only reactive handle — see below. */
13
14
  interface Registered {
14
- readonly client: LiveClient;
15
+ readonly client: LiveClientLike;
15
16
  /** Read to subscribe, bumped to invalidate: `OfflineQueue` stores plain arrays, not signals. */
16
17
  readonly version: () => number;
17
18
  readonly bump: () => void;
@@ -22,7 +23,7 @@ interface Registered {
22
23
  let registered: Registered | null = null;
23
24
 
24
25
  /** Register once, in the app entry, before the first render. One app, one socket, one client. */
25
- export function setLiveClient(client: LiveClient): void {
26
+ export function setLiveClient(client: LiveClientLike): void {
26
27
  // The previous registration's listener goes with it. The client outlives `setLiveClient` — a hot
27
28
  // reload, a test's next case, an app that re-registers after signing in — so a discarded
28
29
  // unsubscribe is a listener nothing can reach, bumping a signal nothing renders, once per
@@ -50,9 +51,41 @@ export function hasLiveClient(): boolean {
50
51
  return registered !== null;
51
52
  }
52
53
 
54
+ /**
55
+ * A DOM is the whole of the question — the same probe and the same rule `@ultimat3/ui`'s `solid()`
56
+ * follows, and deliberately the same words. With a DOM, a hook reaching for a client nobody
57
+ * registered is a real bug: the app entry forgot `setLiveClient`, and every live query on the page
58
+ * is dead. Without one there is no socket a client could have been registered FOR — that is a
59
+ * server render, and `serverRenderLiveClient()` is an honest account of it rather than a
60
+ * degradation of a working path. Never widen this to "no client, never throw": that is the silent
61
+ * feed-that-never-loads the split exists to prevent.
62
+ */
63
+ function hasDom(): boolean {
64
+ return typeof document !== 'undefined' && typeof window !== 'undefined';
65
+ }
66
+
67
+ /**
68
+ * The server render's registration, built once. Deliberately NOT written to `registered`:
69
+ * `hasLiveClient()` must keep answering `false` on the server, because that is the guard a
70
+ * component with a static fallback already uses to decide it is being server-rendered
71
+ * (`examples/dummy`'s `update-banner.tsx`).
72
+ */
73
+ let serverSide: Registered | null = null;
74
+
75
+ function serverRegistration(): Registered {
76
+ if (serverSide !== null) return serverSide;
77
+ const client = serverRenderLiveClient();
78
+ // The version signal never moves, and nothing on the server can move it: one pass, no queue,
79
+ // no ack — so `bump` and `release` are the no-ops that fact makes them.
80
+ const [version] = client.signal<number>(0);
81
+ serverSide = { client, version, bump: () => undefined, release: () => undefined };
82
+ return serverSide;
83
+ }
84
+
53
85
  function live(hook: string): Registered {
54
- if (registered === null) throw new LiveClientMissingError({ hook });
55
- return registered;
86
+ if (registered !== null) return registered;
87
+ if (hasDom()) throw new LiveClientMissingError({ hook });
88
+ return serverRegistration();
56
89
  }
57
90
 
58
91
  // ---- useLive ------------------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export { applyPatches, orderAfterPatches } from './apply-patches';
9
9
  export {
10
10
  type ClientSocket,
11
11
  LiveClient,
12
+ type LiveClientLike,
12
13
  type LiveClientOptions,
13
14
  type LiveHandle,
14
15
  type LiveQueryRef,
@@ -34,7 +35,6 @@ export {
34
35
  type ResumeSource,
35
36
  resumeFrom,
36
37
  shouldResnapshot,
37
- verifyDigest,
38
38
  } from './cursor';
39
39
  // ---- errors: one vocabulary for both halves, because every code reaches the wire ---------------
40
40
  export {
@@ -55,6 +55,7 @@ export {
55
55
  ReplicationFailedError,
56
56
  ReplicationProtocolError,
57
57
  ReplicatorSlotHeldError,
58
+ ServerRenderLiveError,
58
59
  SubscriptionLimitError,
59
60
  TopicForbiddenError,
60
61
  TransportProtocolError,
@@ -140,6 +141,8 @@ export {
140
141
  type ServerAck,
141
142
  strategyName,
142
143
  } from './rebase';
144
+ // ---- what a LiveClient IS on the server: it serves the first render and opens no socket --------
145
+ export { serverRenderLiveClient } from './server-render-client';
143
146
  // ---- the wire -------------------------------------------------------------------------------------
144
147
  export {
145
148
  type AckFrame,
package/src/live-query.ts CHANGED
@@ -10,6 +10,7 @@ import { type Actor, type Clock, systemClock, uuid } from '@ultimat3/core';
10
10
  import { queryHash } from '@ultimat3/query';
11
11
  import type { ChangeEvent } from './changefeed';
12
12
  import {
13
+ advance,
13
14
  type LiveCursor,
14
15
  makeCursor,
15
16
  type ReconnectBudget,
@@ -199,10 +200,18 @@ export class LiveQueryRegistry {
199
200
  resumed.patches,
200
201
  new Set(cursor.ids),
201
202
  );
202
- const subscription = this.#attachUnlessGone(entry, args.socket, sid, resumed.cursor);
203
+ // Advanced over the FILTERED list, never over `resumed.cursor` — which `resumeFrom` built
204
+ // by advancing across the retained window, and that window is PRE-POLICY. Seated as it
205
+ // came back, this subscriber's `cursor.ids` gained the id of every row inserted for every
206
+ // OTHER actor while it was away; `subscriber-gate` then reads `held.has(patch.id)` off it,
207
+ // takes the "the subscriber holds this row" branch, and delivers a `delete` frame carrying
208
+ // another tenant's row id and the instant it went. The leak that branch closes, re-opened
209
+ // one layer up. `live-fanout.ts` advances over `allowed` for exactly this reason.
210
+ const seated = advance(cursor, patches, resumed.cursor.lsn, now);
211
+ const subscription = this.#attachUnlessGone(entry, args.socket, sid, seated);
203
212
  return {
204
213
  subscription,
205
- frame: { type: 'patch', v: PROTOCOL_VERSION, sid, patches, lsn: resumed.cursor.lsn },
214
+ frame: { type: 'patch', v: PROTOCOL_VERSION, sid, patches, lsn: seated.lsn },
206
215
  };
207
216
  }
208
217
  const subscription = this.#attachUnlessGone(entry, args.socket, sid, resumed.cursor);
@@ -222,10 +222,20 @@ export interface OpfsLocalStoreOptions {
222
222
  * long write never blocks the main thread. Browser-only, so it must not be reachable from a server
223
223
  * bundle — which is why it is a factory that throws rather than a class you can accidentally new
224
224
  * on the server.
225
+ *
226
+ * The refusal below is the whole of what tier 3's durable half ships today, so its two lines have
227
+ * to be true of THIS build. Both were false until 2026-08-23: the fix told the caller to import
228
+ * this factory from a `/browser` subpath, which `package.json`'s `exports` has never declared —
229
+ * two entries ship, `.` and `./server` — so pasting it ended in a module-resolution failure. Its
230
+ * alternative was `persist: false` on the query, which `query()` has never accepted either (a
231
+ * `TS2353` excess property). An instruction that cannot run is axiom 4 failing in the package that
232
+ * documents it, so the fix now names an export that exists on an entry that exists:
233
+ * `MemoryLocalStore`, on `.`, declared beside this factory. `fix-specifier.test.ts` is the
234
+ * mechanical half.
225
235
  */
226
236
  export function createOpfsLocalStore(options: OpfsLocalStoreOptions): LocalStore {
227
237
  throw new NotImplementedError({
228
- what: `OPFS SQLite local store (${options.file} v${options.schemaVersion})`,
229
- fix: "import { createOpfsLocalStore } from '@ultimat3/realtime/browser' (tier 3, v2); today use MemoryLocalStore or set persist: false on the query",
238
+ what: `the OPFS SQLite local store for ${options.file} (schema v${options.schemaVersion}), which is realtime tier 3's durable half,`,
239
+ fix: "replace createOpfsLocalStore(...) with new MemoryLocalStore(), imported from '@ultimat3/realtime' beside it: the same LocalStore contract — journalled writes, ordered rollback, one row value per (entity, id) held for the tab's lifetime rather than across a reload",
230
240
  });
231
241
  }
@@ -14,6 +14,7 @@
14
14
  // This header said "every failure leaves here as an `UltimateError`" until 2026-08, which a reader
15
15
  // took as a guarantee it never was.
16
16
 
17
+ import { renderThrowable } from '@ultimat3/core';
17
18
  import { connect, Events, headers, Match, type Msg, type MsgHdrs, type NatsConnection } from 'nats';
18
19
  import { TransportUnavailableError } from './errors';
19
20
  import {
@@ -57,8 +58,13 @@ const unavailable = (target: NatsTarget, reason: string): TransportUnavailableEr
57
58
  reason: `${target.host}:${target.port} — ${reason}`,
58
59
  });
59
60
 
60
- const describe = (error: unknown): string =>
61
- error instanceof Error ? error.message : String(error);
61
+ /**
62
+ * The caught value as text, for the `reason` that becomes an `X_TRANSPORT_UNAVAILABLE`. Core's
63
+ * total renderer and never `String(error)`: this text is built inside a `catch` block that has
64
+ * nothing left to answer with, and `String()` raises on a null-prototype throwable — which a
65
+ * library that is not this framework's may hand over.
66
+ */
67
+ const describe = (error: unknown): string => renderThrowable(error);
62
68
 
63
69
  class LibNatsClient implements NatsClient {
64
70
  readonly #connection: NatsConnection;
@@ -222,7 +222,9 @@ export class NatsTransport implements Transport {
222
222
  transport: this.name,
223
223
  subject,
224
224
  code: isUltimateError(error) ? error.code : undefined,
225
- error: error instanceof Error ? error.message : String(error),
225
+ // `renderThrowable`, never `String(error)`: this is a reporter, and a throwable that fights
226
+ // being read makes the report the thing that throws.
227
+ error: renderThrowable(error),
226
228
  });
227
229
  }
228
230
  }
@@ -0,0 +1,141 @@
1
+ // The Postgres array TEXT literal — `{a,b}`, `{"a,b",NULL}`, `{{1,2},{3,4}}` — and the element
2
+ // type behind an array oid. Split from `pg-values.ts` so that file stays the oid switch and this
3
+ // one stays the grammar; the element decoder arrives as a parameter, so neither imports the other.
4
+ //
5
+ // It exists because `@ultimat3/entity`'s repository hands an `arrayOf()` column back as a JS array
6
+ // (the driver parses the literal) while the WAL carries the literal itself. A live row and a
7
+ // repository row have to be the same object, so the literal is parsed here rather than shipped.
8
+
9
+ /**
10
+ * `text[]` -> `text`. The wire names only the ARRAY type, so the element type behind it is a
11
+ * table — and it is a closed one: `arrayOf()` refuses `jsonb`, `bytea`, `money` and a nested array
12
+ * at declaration, so every array a framework entity can produce has its element listed below.
13
+ *
14
+ * The two it refuses are listed anyway (`1001`, `3807`, `199`): an adopted table can hold them,
15
+ * and decoding an element correctly costs nothing next to leaving the whole literal as text.
16
+ * An oid with no row — a user-defined enum's array, whose oid is per-database — is left as text
17
+ * rather than guessed at, which is what `undefined` means to `pg-values.ts`.
18
+ */
19
+ const ELEMENT_OF = Object.freeze<Record<number, number>>({
20
+ 1000: 16, // bool[]
21
+ 1001: 17, // bytea[]
22
+ 1005: 21, // int2[]
23
+ 1007: 23, // int4[]
24
+ 1009: 25, // text[]
25
+ 1014: 1042, // bpchar[]
26
+ 1015: 1043, // varchar[]
27
+ 1016: 20, // int8[]
28
+ 1021: 700, // float4[]
29
+ 1022: 701, // float8[]
30
+ 1028: 26, // oid[]
31
+ 1115: 1114, // timestamp[]
32
+ 1182: 1082, // date[]
33
+ 1185: 1184, // timestamptz[]
34
+ 1231: 1700, // numeric[]
35
+ 199: 114, // json[]
36
+ 2951: 2950, // uuid[]
37
+ 3807: 3802, // jsonb[]
38
+ });
39
+
40
+ /** The element type oid behind an array type oid, or `undefined` when this table does not name it. */
41
+ export function arrayElementOid(typeOid: number): number | undefined {
42
+ return Object.hasOwn(ELEMENT_OF, typeOid) ? ELEMENT_OF[typeOid] : undefined;
43
+ }
44
+
45
+ /** Where the scan is, so every branch below advances one cursor rather than slicing the text. */
46
+ interface Scan {
47
+ readonly text: string;
48
+ at: number;
49
+ }
50
+
51
+ /**
52
+ * One array literal -> a nested JS array, or `null` when the text is not one this grammar
53
+ * describes. `null` is not an error: a dimension prefix (`[0:2]={…}`), a `DateStyle` this decoder
54
+ * does not read, or a corrupted literal all mean the same thing to the caller — keep the text it
55
+ * arrived as rather than deliver an array that is missing a member.
56
+ *
57
+ * `decode` is the ELEMENT decoder. It never sees a quoted element's quotes or its backslash
58
+ * escapes: an unquoted `NULL` is the null value and a quoted `"NULL"` is the four-character
59
+ * string, which is the one distinction the quoting exists to carry.
60
+ */
61
+ export function parsePgArray<T>(text: string, decode: (raw: string) => T): PgArray<T> | null {
62
+ const scan: Scan = { text, at: 0 };
63
+ const parsed = readArray(scan, decode);
64
+ // Trailing content means the literal was not what this grammar accepted, whatever it parsed.
65
+ return parsed === null || scan.at !== text.length ? null : parsed;
66
+ }
67
+
68
+ /** A member is a decoded value, the null member, or — for a multidimensional array — a row of them. */
69
+ export type PgArray<T> = (T | null | PgArray<T>)[];
70
+
71
+ function readArray<T>(scan: Scan, decode: (raw: string) => T): PgArray<T> | null {
72
+ if (scan.text[scan.at] !== '{') return null;
73
+ scan.at += 1;
74
+ const out: PgArray<T> = [];
75
+ if (scan.text[scan.at] === '}') {
76
+ scan.at += 1;
77
+ return out;
78
+ }
79
+ for (;;) {
80
+ const member = readMember(scan, decode);
81
+ if (member === FAILED) return null;
82
+ out.push(member);
83
+ const next = scan.text[scan.at];
84
+ scan.at += 1;
85
+ if (next === '}') return out;
86
+ if (next !== ',') return null;
87
+ }
88
+ }
89
+
90
+ /** A sentinel, because `null` is a legal member and `undefined` would be a second spelling of it. */
91
+ const FAILED = Symbol('pg-array-failed');
92
+
93
+ function readMember<T>(
94
+ scan: Scan,
95
+ decode: (raw: string) => T,
96
+ ): T | null | PgArray<T> | typeof FAILED {
97
+ const head = scan.text[scan.at];
98
+ if (head === '{') {
99
+ const nested = readArray(scan, decode);
100
+ return nested === null ? FAILED : nested;
101
+ }
102
+ if (head === '"') {
103
+ const quoted = readQuoted(scan);
104
+ return quoted === null ? FAILED : decode(quoted);
105
+ }
106
+ const start = scan.at;
107
+ while (scan.at < scan.text.length) {
108
+ const char = scan.text[scan.at];
109
+ if (char === ',' || char === '}') break;
110
+ // A brace or a quote inside a bare element is a literal this grammar does not describe.
111
+ if (char === '{' || char === '"') return FAILED;
112
+ scan.at += 1;
113
+ }
114
+ if (scan.at === scan.text.length) return FAILED;
115
+ const raw = scan.text.slice(start, scan.at);
116
+ // Unquoted and case-insensitive is the ONLY spelling of the null member; `"NULL"` is a string.
117
+ return raw.toUpperCase() === 'NULL' ? null : decode(raw);
118
+ }
119
+
120
+ /** The text between one pair of quotes, with `\\` and `\"` unescaped. `null` if it never closes. */
121
+ function readQuoted(scan: Scan): string | null {
122
+ scan.at += 1;
123
+ let out = '';
124
+ while (scan.at < scan.text.length) {
125
+ const char = scan.text[scan.at];
126
+ if (char === '"') {
127
+ scan.at += 1;
128
+ return out;
129
+ }
130
+ if (char === '\\') {
131
+ const escaped = scan.text[scan.at + 1];
132
+ if (escaped === undefined) return null;
133
+ out += escaped;
134
+ scan.at += 2;
135
+ continue;
136
+ }
137
+ out += char ?? '';
138
+ scan.at += 1;
139
+ }
140
+ return null;
141
+ }
@@ -80,8 +80,22 @@ export class PgConnection {
80
80
  user: options.user,
81
81
  database: options.database,
82
82
  application_name: options.applicationName ?? 'ultimate-replicator',
83
+ // The session's output formats, decided here because the decoder cannot ask. Postgres sends
84
+ // every WAL value as TEXT and `pg-values.ts` reads a `timestamptz` by matching postgres'
85
+ // ISO spelling — keeping the raw text when it does not match, on purpose, because a wrong
86
+ // instant is worse than a string. So `DateStyle = SQL | German | Postgres` on the SERVER
87
+ // silently re-opens the defect that decode exists to close: the shared window holds `Date`s,
88
+ // the patch holds text, and one edit to one column jumps its row to the top of every
89
+ // `orderBy('createdAt','desc')` feed for every subscriber.
90
+ //
91
+ // Byte for byte what postgres' OWN logical-replication client sends (`libpqwalreceiver.c`),
92
+ // which is why a walsender takes it: `intervalstyle` for the values this decoder keeps as
93
+ // text (a text form still has to be ONE text form), `extra_float_digits` so a float8 round
94
+ // trips exactly on servers whose default is not already 3. A server that refuses one
95
+ // answers `ErrorResponse` during startup, so the replicator fails to boot with the server's
96
+ // own words — never a warning that a feed then goes on mis-sorting behind.
97
+ options: '-c datestyle=ISO -c intervalstyle=postgres -c extra_float_digits=3',
83
98
  };
84
- // A walsender rejects most GUCs, so only the two it accepts are sent.
85
99
  if (options.replication !== undefined) parameters['replication'] = options.replication;
86
100
  // A handshake fails on ordinary conditions — no password, an ErrorResponse, an EOF — and on
87
101
  // every one of them the caller gets an exception instead of an object, so nothing is left
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { describeValue } from '@ultimat3/core';
9
9
  import { ReplicationProtocolError } from './errors';
10
- import type { JsonObject, JsonValue } from './json';
10
+ import type { PhysicalRow, PhysicalValue } from './pg-values';
11
11
 
12
12
  /** `org_id` -> `orgId`, `published_at` -> `publishedAt`. The inverse of `@ultimat3/entity`'s `snake()`. */
13
13
  export function camel(column: string): string {
@@ -24,7 +24,7 @@ interface FoldedMoney {
24
24
  readonly property: string;
25
25
  /** The physical columns the fold consumed, in declaration order — named together on a collision. */
26
26
  readonly columns: readonly string[];
27
- readonly value: JsonObject;
27
+ readonly value: PhysicalRow;
28
28
  }
29
29
 
30
30
  /**
@@ -73,7 +73,7 @@ function moneyMinor(column: string, value: number | string): number {
73
73
  * `/^\d+$/` and not `Number(value)`: `Number('')` is 0, and 0 means whole units — the one value
74
74
  * an empty column must never decode to. The same guard `parseScale` uses.
75
75
  */
76
- function moneyScale(column: string, value: JsonValue): number {
76
+ function moneyScale(column: string, value: PhysicalValue): number {
77
77
  const digits = typeof value === 'string' && /^\d+$/.test(value);
78
78
  const scale = typeof value === 'number' ? value : digits ? Number(value) : Number.NaN;
79
79
  if (Number.isSafeInteger(scale) && scale >= 0) return scale;
@@ -100,7 +100,7 @@ function moneyScale(column: string, value: JsonValue): number {
100
100
  * string that *is* one), and everything else is reported as shape. The scale column is matched by
101
101
  * name the same way and carries the same risk, so it renders through here too.
102
102
  */
103
- function shownNumber(value: JsonValue): string {
103
+ function shownNumber(value: PhysicalValue): string {
104
104
  if (typeof value === 'number') return `"${value}"`;
105
105
  if (typeof value !== 'string') return describeValue(value);
106
106
  const numeric = value.trim() !== '' && Number.isFinite(Number(value));
@@ -119,7 +119,7 @@ function shownNumber(value: JsonValue): string {
119
119
  * column of its own: the fold consumes it whenever the group folds.
120
120
  */
121
121
  function foldMoney(
122
- physical: Readonly<Record<string, JsonValue>>,
122
+ physical: Readonly<Record<string, PhysicalValue>>,
123
123
  name: string,
124
124
  ): FoldedMoney | null {
125
125
  const prefix = moneyPrefix(name);
@@ -171,8 +171,8 @@ function claim(taken: Map<string, string>, property: string, column: string): vo
171
171
  * property is camelCase, and money is one property over the columns `<p>_minor`/`<p>_currency`
172
172
  * and the nullable `<p>_scale`.
173
173
  */
174
- export function entityRow(physical: Readonly<Record<string, JsonValue>>): JsonObject {
175
- const row: JsonObject = {};
174
+ export function entityRow(physical: Readonly<Record<string, PhysicalValue>>): PhysicalRow {
175
+ const row: PhysicalRow = {};
176
176
  // Column order in is key order out; a folded money property lands wherever its earliest member
177
177
  // (whichever of the three the source happened to emit first) would otherwise have sat.
178
178
  const consumed = new Set<string>();