@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/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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
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
|
|
347
|
-
constructor(args: {
|
|
292
|
+
export class LiveClientMissingError extends RealtimeError {
|
|
293
|
+
constructor(args: { hook: string }) {
|
|
348
294
|
super({
|
|
349
|
-
code: '
|
|
350
|
-
cause:
|
|
351
|
-
|
|
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
|
-
*
|
|
362
|
-
*
|
|
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
|
|
365
|
-
constructor(args: {
|
|
311
|
+
export class ServerRenderLiveError extends RealtimeError {
|
|
312
|
+
constructor(args: { operation: string }) {
|
|
366
313
|
super({
|
|
367
|
-
code: '
|
|
368
|
-
cause: `${args.
|
|
369
|
-
fix: '
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
|
55
|
-
|
|
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
|
-
|
|
203
|
+
// Advanced over the FILTERED list, never over `resumed.cursor` — which `resumeFrom` built
|
|
204
|
+
// by advancing across the retained window, and that window is PRE-POLICY. Seated as it
|
|
205
|
+
// came back, this subscriber's `cursor.ids` gained the id of every row inserted for every
|
|
206
|
+
// OTHER actor while it was away; `subscriber-gate` then reads `held.has(patch.id)` off it,
|
|
207
|
+
// takes the "the subscriber holds this row" branch, and delivers a `delete` frame carrying
|
|
208
|
+
// another tenant's row id and the instant it went. The leak that branch closes, re-opened
|
|
209
|
+
// one layer up. `live-fanout.ts` advances over `allowed` for exactly this reason.
|
|
210
|
+
const seated = advance(cursor, patches, resumed.cursor.lsn, now);
|
|
211
|
+
const subscription = this.#attachUnlessGone(entry, args.socket, sid, seated);
|
|
203
212
|
return {
|
|
204
213
|
subscription,
|
|
205
|
-
frame: { type: 'patch', v: PROTOCOL_VERSION, sid, patches, lsn:
|
|
214
|
+
frame: { type: 'patch', v: PROTOCOL_VERSION, sid, patches, lsn: seated.lsn },
|
|
206
215
|
};
|
|
207
216
|
}
|
|
208
217
|
const subscription = this.#attachUnlessGone(entry, args.socket, sid, resumed.cursor);
|
package/src/local-store.ts
CHANGED
|
@@ -222,10 +222,20 @@ export interface OpfsLocalStoreOptions {
|
|
|
222
222
|
* long write never blocks the main thread. Browser-only, so it must not be reachable from a server
|
|
223
223
|
* bundle — which is why it is a factory that throws rather than a class you can accidentally new
|
|
224
224
|
* on the server.
|
|
225
|
+
*
|
|
226
|
+
* The refusal below is the whole of what tier 3's durable half ships today, so its two lines have
|
|
227
|
+
* to be true of THIS build. Both were false until 2026-08-23: the fix told the caller to import
|
|
228
|
+
* this factory from a `/browser` subpath, which `package.json`'s `exports` has never declared —
|
|
229
|
+
* two entries ship, `.` and `./server` — so pasting it ended in a module-resolution failure. Its
|
|
230
|
+
* alternative was `persist: false` on the query, which `query()` has never accepted either (a
|
|
231
|
+
* `TS2353` excess property). An instruction that cannot run is axiom 4 failing in the package that
|
|
232
|
+
* documents it, so the fix now names an export that exists on an entry that exists:
|
|
233
|
+
* `MemoryLocalStore`, on `.`, declared beside this factory. `fix-specifier.test.ts` is the
|
|
234
|
+
* mechanical half.
|
|
225
235
|
*/
|
|
226
236
|
export function createOpfsLocalStore(options: OpfsLocalStoreOptions): LocalStore {
|
|
227
237
|
throw new NotImplementedError({
|
|
228
|
-
what: `OPFS SQLite local store
|
|
229
|
-
fix: "
|
|
238
|
+
what: `the OPFS SQLite local store for ${options.file} (schema v${options.schemaVersion}), which is realtime tier 3's durable half,`,
|
|
239
|
+
fix: "replace createOpfsLocalStore(...) with new MemoryLocalStore(), imported from '@ultimat3/realtime' beside it: the same LocalStore contract — journalled writes, ordered rollback, one row value per (entity, id) — held for the tab's lifetime rather than across a reload",
|
|
230
240
|
});
|
|
231
241
|
}
|
package/src/nats-lib-client.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// This header said "every failure leaves here as an `UltimateError`" until 2026-08, which a reader
|
|
15
15
|
// took as a guarantee it never was.
|
|
16
16
|
|
|
17
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
17
18
|
import { connect, Events, headers, Match, type Msg, type MsgHdrs, type NatsConnection } from 'nats';
|
|
18
19
|
import { TransportUnavailableError } from './errors';
|
|
19
20
|
import {
|
|
@@ -57,8 +58,13 @@ const unavailable = (target: NatsTarget, reason: string): TransportUnavailableEr
|
|
|
57
58
|
reason: `${target.host}:${target.port} — ${reason}`,
|
|
58
59
|
});
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
/**
|
|
62
|
+
* The caught value as text, for the `reason` that becomes an `X_TRANSPORT_UNAVAILABLE`. Core's
|
|
63
|
+
* total renderer and never `String(error)`: this text is built inside a `catch` block that has
|
|
64
|
+
* nothing left to answer with, and `String()` raises on a null-prototype throwable — which a
|
|
65
|
+
* library that is not this framework's may hand over.
|
|
66
|
+
*/
|
|
67
|
+
const describe = (error: unknown): string => renderThrowable(error);
|
|
62
68
|
|
|
63
69
|
class LibNatsClient implements NatsClient {
|
|
64
70
|
readonly #connection: NatsConnection;
|
package/src/nats-transport.ts
CHANGED
|
@@ -222,7 +222,9 @@ export class NatsTransport implements Transport {
|
|
|
222
222
|
transport: this.name,
|
|
223
223
|
subject,
|
|
224
224
|
code: isUltimateError(error) ? error.code : undefined,
|
|
225
|
-
|
|
225
|
+
// `renderThrowable`, never `String(error)`: this is a reporter, and a throwable that fights
|
|
226
|
+
// being read makes the report the thing that throws.
|
|
227
|
+
error: renderThrowable(error),
|
|
226
228
|
});
|
|
227
229
|
}
|
|
228
230
|
}
|
package/src/pg-array.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The Postgres array TEXT literal — `{a,b}`, `{"a,b",NULL}`, `{{1,2},{3,4}}` — and the element
|
|
2
|
+
// type behind an array oid. Split from `pg-values.ts` so that file stays the oid switch and this
|
|
3
|
+
// one stays the grammar; the element decoder arrives as a parameter, so neither imports the other.
|
|
4
|
+
//
|
|
5
|
+
// It exists because `@ultimat3/entity`'s repository hands an `arrayOf()` column back as a JS array
|
|
6
|
+
// (the driver parses the literal) while the WAL carries the literal itself. A live row and a
|
|
7
|
+
// repository row have to be the same object, so the literal is parsed here rather than shipped.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `text[]` -> `text`. The wire names only the ARRAY type, so the element type behind it is a
|
|
11
|
+
* table — and it is a closed one: `arrayOf()` refuses `jsonb`, `bytea`, `money` and a nested array
|
|
12
|
+
* at declaration, so every array a framework entity can produce has its element listed below.
|
|
13
|
+
*
|
|
14
|
+
* The two it refuses are listed anyway (`1001`, `3807`, `199`): an adopted table can hold them,
|
|
15
|
+
* and decoding an element correctly costs nothing next to leaving the whole literal as text.
|
|
16
|
+
* An oid with no row — a user-defined enum's array, whose oid is per-database — is left as text
|
|
17
|
+
* rather than guessed at, which is what `undefined` means to `pg-values.ts`.
|
|
18
|
+
*/
|
|
19
|
+
const ELEMENT_OF = Object.freeze<Record<number, number>>({
|
|
20
|
+
1000: 16, // bool[]
|
|
21
|
+
1001: 17, // bytea[]
|
|
22
|
+
1005: 21, // int2[]
|
|
23
|
+
1007: 23, // int4[]
|
|
24
|
+
1009: 25, // text[]
|
|
25
|
+
1014: 1042, // bpchar[]
|
|
26
|
+
1015: 1043, // varchar[]
|
|
27
|
+
1016: 20, // int8[]
|
|
28
|
+
1021: 700, // float4[]
|
|
29
|
+
1022: 701, // float8[]
|
|
30
|
+
1028: 26, // oid[]
|
|
31
|
+
1115: 1114, // timestamp[]
|
|
32
|
+
1182: 1082, // date[]
|
|
33
|
+
1185: 1184, // timestamptz[]
|
|
34
|
+
1231: 1700, // numeric[]
|
|
35
|
+
199: 114, // json[]
|
|
36
|
+
2951: 2950, // uuid[]
|
|
37
|
+
3807: 3802, // jsonb[]
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** The element type oid behind an array type oid, or `undefined` when this table does not name it. */
|
|
41
|
+
export function arrayElementOid(typeOid: number): number | undefined {
|
|
42
|
+
return Object.hasOwn(ELEMENT_OF, typeOid) ? ELEMENT_OF[typeOid] : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where the scan is, so every branch below advances one cursor rather than slicing the text. */
|
|
46
|
+
interface Scan {
|
|
47
|
+
readonly text: string;
|
|
48
|
+
at: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One array literal -> a nested JS array, or `null` when the text is not one this grammar
|
|
53
|
+
* describes. `null` is not an error: a dimension prefix (`[0:2]={…}`), a `DateStyle` this decoder
|
|
54
|
+
* does not read, or a corrupted literal all mean the same thing to the caller — keep the text it
|
|
55
|
+
* arrived as rather than deliver an array that is missing a member.
|
|
56
|
+
*
|
|
57
|
+
* `decode` is the ELEMENT decoder. It never sees a quoted element's quotes or its backslash
|
|
58
|
+
* escapes: an unquoted `NULL` is the null value and a quoted `"NULL"` is the four-character
|
|
59
|
+
* string, which is the one distinction the quoting exists to carry.
|
|
60
|
+
*/
|
|
61
|
+
export function parsePgArray<T>(text: string, decode: (raw: string) => T): PgArray<T> | null {
|
|
62
|
+
const scan: Scan = { text, at: 0 };
|
|
63
|
+
const parsed = readArray(scan, decode);
|
|
64
|
+
// Trailing content means the literal was not what this grammar accepted, whatever it parsed.
|
|
65
|
+
return parsed === null || scan.at !== text.length ? null : parsed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A member is a decoded value, the null member, or — for a multidimensional array — a row of them. */
|
|
69
|
+
export type PgArray<T> = (T | null | PgArray<T>)[];
|
|
70
|
+
|
|
71
|
+
function readArray<T>(scan: Scan, decode: (raw: string) => T): PgArray<T> | null {
|
|
72
|
+
if (scan.text[scan.at] !== '{') return null;
|
|
73
|
+
scan.at += 1;
|
|
74
|
+
const out: PgArray<T> = [];
|
|
75
|
+
if (scan.text[scan.at] === '}') {
|
|
76
|
+
scan.at += 1;
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
for (;;) {
|
|
80
|
+
const member = readMember(scan, decode);
|
|
81
|
+
if (member === FAILED) return null;
|
|
82
|
+
out.push(member);
|
|
83
|
+
const next = scan.text[scan.at];
|
|
84
|
+
scan.at += 1;
|
|
85
|
+
if (next === '}') return out;
|
|
86
|
+
if (next !== ',') return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A sentinel, because `null` is a legal member and `undefined` would be a second spelling of it. */
|
|
91
|
+
const FAILED = Symbol('pg-array-failed');
|
|
92
|
+
|
|
93
|
+
function readMember<T>(
|
|
94
|
+
scan: Scan,
|
|
95
|
+
decode: (raw: string) => T,
|
|
96
|
+
): T | null | PgArray<T> | typeof FAILED {
|
|
97
|
+
const head = scan.text[scan.at];
|
|
98
|
+
if (head === '{') {
|
|
99
|
+
const nested = readArray(scan, decode);
|
|
100
|
+
return nested === null ? FAILED : nested;
|
|
101
|
+
}
|
|
102
|
+
if (head === '"') {
|
|
103
|
+
const quoted = readQuoted(scan);
|
|
104
|
+
return quoted === null ? FAILED : decode(quoted);
|
|
105
|
+
}
|
|
106
|
+
const start = scan.at;
|
|
107
|
+
while (scan.at < scan.text.length) {
|
|
108
|
+
const char = scan.text[scan.at];
|
|
109
|
+
if (char === ',' || char === '}') break;
|
|
110
|
+
// A brace or a quote inside a bare element is a literal this grammar does not describe.
|
|
111
|
+
if (char === '{' || char === '"') return FAILED;
|
|
112
|
+
scan.at += 1;
|
|
113
|
+
}
|
|
114
|
+
if (scan.at === scan.text.length) return FAILED;
|
|
115
|
+
const raw = scan.text.slice(start, scan.at);
|
|
116
|
+
// Unquoted and case-insensitive is the ONLY spelling of the null member; `"NULL"` is a string.
|
|
117
|
+
return raw.toUpperCase() === 'NULL' ? null : decode(raw);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The text between one pair of quotes, with `\\` and `\"` unescaped. `null` if it never closes. */
|
|
121
|
+
function readQuoted(scan: Scan): string | null {
|
|
122
|
+
scan.at += 1;
|
|
123
|
+
let out = '';
|
|
124
|
+
while (scan.at < scan.text.length) {
|
|
125
|
+
const char = scan.text[scan.at];
|
|
126
|
+
if (char === '"') {
|
|
127
|
+
scan.at += 1;
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
if (char === '\\') {
|
|
131
|
+
const escaped = scan.text[scan.at + 1];
|
|
132
|
+
if (escaped === undefined) return null;
|
|
133
|
+
out += escaped;
|
|
134
|
+
scan.at += 2;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
out += char ?? '';
|
|
138
|
+
scan.at += 1;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
package/src/pg-connection.ts
CHANGED
|
@@ -80,8 +80,22 @@ export class PgConnection {
|
|
|
80
80
|
user: options.user,
|
|
81
81
|
database: options.database,
|
|
82
82
|
application_name: options.applicationName ?? 'ultimate-replicator',
|
|
83
|
+
// The session's output formats, decided here because the decoder cannot ask. Postgres sends
|
|
84
|
+
// every WAL value as TEXT and `pg-values.ts` reads a `timestamptz` by matching postgres'
|
|
85
|
+
// ISO spelling — keeping the raw text when it does not match, on purpose, because a wrong
|
|
86
|
+
// instant is worse than a string. So `DateStyle = SQL | German | Postgres` on the SERVER
|
|
87
|
+
// silently re-opens the defect that decode exists to close: the shared window holds `Date`s,
|
|
88
|
+
// the patch holds text, and one edit to one column jumps its row to the top of every
|
|
89
|
+
// `orderBy('createdAt','desc')` feed for every subscriber.
|
|
90
|
+
//
|
|
91
|
+
// Byte for byte what postgres' OWN logical-replication client sends (`libpqwalreceiver.c`),
|
|
92
|
+
// which is why a walsender takes it: `intervalstyle` for the values this decoder keeps as
|
|
93
|
+
// text (a text form still has to be ONE text form), `extra_float_digits` so a float8 round
|
|
94
|
+
// trips exactly on servers whose default is not already 3. A server that refuses one
|
|
95
|
+
// answers `ErrorResponse` during startup, so the replicator fails to boot with the server's
|
|
96
|
+
// own words — never a warning that a feed then goes on mis-sorting behind.
|
|
97
|
+
options: '-c datestyle=ISO -c intervalstyle=postgres -c extra_float_digits=3',
|
|
83
98
|
};
|
|
84
|
-
// A walsender rejects most GUCs, so only the two it accepts are sent.
|
|
85
99
|
if (options.replication !== undefined) parameters['replication'] = options.replication;
|
|
86
100
|
// A handshake fails on ordinary conditions — no password, an ErrorResponse, an EOF — and on
|
|
87
101
|
// every one of them the caller gets an exception instead of an object, so nothing is left
|
package/src/pg-entity-row.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { describeValue } from '@ultimat3/core';
|
|
9
9
|
import { ReplicationProtocolError } from './errors';
|
|
10
|
-
import type {
|
|
10
|
+
import type { PhysicalRow, PhysicalValue } from './pg-values';
|
|
11
11
|
|
|
12
12
|
/** `org_id` -> `orgId`, `published_at` -> `publishedAt`. The inverse of `@ultimat3/entity`'s `snake()`. */
|
|
13
13
|
export function camel(column: string): string {
|
|
@@ -24,7 +24,7 @@ interface FoldedMoney {
|
|
|
24
24
|
readonly property: string;
|
|
25
25
|
/** The physical columns the fold consumed, in declaration order — named together on a collision. */
|
|
26
26
|
readonly columns: readonly string[];
|
|
27
|
-
readonly value:
|
|
27
|
+
readonly value: PhysicalRow;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -73,7 +73,7 @@ function moneyMinor(column: string, value: number | string): number {
|
|
|
73
73
|
* `/^\d+$/` and not `Number(value)`: `Number('')` is 0, and 0 means whole units — the one value
|
|
74
74
|
* an empty column must never decode to. The same guard `parseScale` uses.
|
|
75
75
|
*/
|
|
76
|
-
function moneyScale(column: string, value:
|
|
76
|
+
function moneyScale(column: string, value: PhysicalValue): number {
|
|
77
77
|
const digits = typeof value === 'string' && /^\d+$/.test(value);
|
|
78
78
|
const scale = typeof value === 'number' ? value : digits ? Number(value) : Number.NaN;
|
|
79
79
|
if (Number.isSafeInteger(scale) && scale >= 0) return scale;
|
|
@@ -100,7 +100,7 @@ function moneyScale(column: string, value: JsonValue): number {
|
|
|
100
100
|
* string that *is* one), and everything else is reported as shape. The scale column is matched by
|
|
101
101
|
* name the same way and carries the same risk, so it renders through here too.
|
|
102
102
|
*/
|
|
103
|
-
function shownNumber(value:
|
|
103
|
+
function shownNumber(value: PhysicalValue): string {
|
|
104
104
|
if (typeof value === 'number') return `"${value}"`;
|
|
105
105
|
if (typeof value !== 'string') return describeValue(value);
|
|
106
106
|
const numeric = value.trim() !== '' && Number.isFinite(Number(value));
|
|
@@ -119,7 +119,7 @@ function shownNumber(value: JsonValue): string {
|
|
|
119
119
|
* column of its own: the fold consumes it whenever the group folds.
|
|
120
120
|
*/
|
|
121
121
|
function foldMoney(
|
|
122
|
-
physical: Readonly<Record<string,
|
|
122
|
+
physical: Readonly<Record<string, PhysicalValue>>,
|
|
123
123
|
name: string,
|
|
124
124
|
): FoldedMoney | null {
|
|
125
125
|
const prefix = moneyPrefix(name);
|
|
@@ -171,8 +171,8 @@ function claim(taken: Map<string, string>, property: string, column: string): vo
|
|
|
171
171
|
* property is camelCase, and money is one property over the columns `<p>_minor`/`<p>_currency`
|
|
172
172
|
* and the nullable `<p>_scale`.
|
|
173
173
|
*/
|
|
174
|
-
export function entityRow(physical: Readonly<Record<string,
|
|
175
|
-
const row:
|
|
174
|
+
export function entityRow(physical: Readonly<Record<string, PhysicalValue>>): PhysicalRow {
|
|
175
|
+
const row: PhysicalRow = {};
|
|
176
176
|
// Column order in is key order out; a folded money property lands wherever its earliest member
|
|
177
177
|
// (whichever of the three the source happened to emit first) would otherwise have sat.
|
|
178
178
|
const consumed = new Set<string>();
|