@ultimat3/realtime 23.0.0 → 25.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 +118 -126
- package/README.md +229 -54
- package/package.json +9 -6
- package/src/advisory-lock.ts +62 -0
- package/src/boot.ts +25 -15
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +9 -9
- package/src/changefeed.ts +81 -19
- package/src/channel-authz.ts +48 -17
- package/src/channel-describe.ts +1 -1
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +36 -15
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +35 -4
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +148 -48
- package/src/client-channels.ts +12 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +16 -3
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +30 -2
- package/src/cursor.ts +11 -0
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +113 -105
- package/src/fanout.ts +13 -0
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +25 -7
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +34 -9
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +122 -29
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +44 -0
- package/src/live-query.ts +96 -175
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +37 -6
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +149 -15
- package/src/offline-queue.ts +53 -5
- package/src/page-errors.ts +25 -9
- package/src/page-outbox.ts +125 -54
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +54 -5
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +126 -22
- package/src/pg-entity-row.ts +16 -0
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +50 -51
- package/src/pg-socket.ts +26 -7
- package/src/pg-tls.ts +18 -0
- package/src/pg-values.ts +29 -14
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +87 -10
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +17 -5
- package/src/record-persister.ts +7 -2
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +11 -7
- package/src/replicator-envelope.ts +160 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +313 -191
- package/src/server.ts +45 -23
- package/src/socket-defaults.ts +20 -0
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/socket.ts +14 -19
- package/src/subscriber-gate.ts +11 -1
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-auth.ts +47 -2
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +74 -21
- package/src/sync-node-bounds.ts +16 -1
- package/src/sync-node-contract.ts +36 -4
- package/src/sync-node.ts +163 -82
- package/src/sync-origin.ts +27 -12
- package/src/sync-protocol.ts +86 -16
- package/src/sync-upgrade.ts +115 -8
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +41 -20
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +18 -9
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/pg-replication.ts
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
// the lsn is the only authority the pipeline has.
|
|
5
5
|
|
|
6
6
|
import { type Clock, finiteOption, logger, renderThrowable, systemClock } from '@ultimat3/core';
|
|
7
|
-
import type { ChangeEvent, ChangeOp,
|
|
7
|
+
import type { ChangeEvent, ChangeOp, PostgresChangeFeedOptions } from './changefeed';
|
|
8
8
|
import { ReplicationProtocolError } from './errors';
|
|
9
|
-
import { isRow, type Row } from './json';
|
|
10
9
|
import { ByteReader, ByteWriter, epochMsToPgTimestamp, printLsn } from './pg-bytes';
|
|
11
10
|
import { PgConnection } from './pg-connection';
|
|
12
|
-
import { entityRow } from './pg-entity-row';
|
|
13
11
|
import { assertIdentifier } from './pg-identifier';
|
|
12
|
+
import { paramsChannelTables } from './pg-identity-tables';
|
|
14
13
|
import { preflight } from './pg-preflight';
|
|
14
|
+
import { replicatedImages, tenantOf } from './pg-replication-row';
|
|
15
15
|
import { bunPgStream, parsePgUrl } from './pg-socket';
|
|
16
16
|
import type { PhysicalRow } from './pg-values';
|
|
17
17
|
import { keyedWrite, PgOutputDecoder, type PgOutputMessage, type PgRelation } from './pgoutput';
|
|
@@ -52,10 +52,7 @@ export interface ReplicationStreamStats {
|
|
|
52
52
|
* reporting it. Reset by the first confirm that lands.
|
|
53
53
|
*/
|
|
54
54
|
readonly confirmFailures: number;
|
|
55
|
-
/**
|
|
56
|
-
* Why the pump stopped, or `null` while it is live. The read loop cannot throw into a caller —
|
|
57
|
-
* nothing awaits it — so this is the one place `/readyz` and a test can see that it died at all.
|
|
58
|
-
*/
|
|
55
|
+
/** Why the pump stopped, or `null` while live. Recorded here and ANNOUNCED through `onEnd`. */
|
|
59
56
|
readonly failure: string | null;
|
|
60
57
|
}
|
|
61
58
|
|
|
@@ -88,14 +85,16 @@ export const commitPositionOf = (lsn: string): bigint => BigInt(`0x${lsn.slice(0
|
|
|
88
85
|
export interface ReplicationStreamHandlers {
|
|
89
86
|
readonly from?: string | undefined;
|
|
90
87
|
onChange(event: ChangeEvent): void | Promise<void>;
|
|
88
|
+
/** Called once when the pump dies on its own; never for a `stop()`. */
|
|
89
|
+
readonly onEnd?: ((reason: string) => void) | undefined;
|
|
91
90
|
}
|
|
92
91
|
|
|
93
92
|
/**
|
|
94
|
-
* One slot, one connection, one process. Started by `
|
|
93
|
+
* One slot, one connection, one process. Started by `PostgresChangeFeed`, which is itself
|
|
95
94
|
* held by the single `replicator` role — the advisory lock upstream is what keeps that "one" true.
|
|
96
95
|
*/
|
|
97
96
|
export class PgReplicationStream {
|
|
98
|
-
readonly #options:
|
|
97
|
+
readonly #options: PostgresChangeFeedOptions;
|
|
99
98
|
readonly #clock: Clock;
|
|
100
99
|
readonly #entities: ReadonlySet<string>;
|
|
101
100
|
readonly #slot: string;
|
|
@@ -115,8 +114,9 @@ export class PgReplicationStream {
|
|
|
115
114
|
#partialBefore = 0;
|
|
116
115
|
#confirmFailures = 0;
|
|
117
116
|
#failure: string | null = null;
|
|
117
|
+
#onEnd: ((reason: string) => void) | undefined;
|
|
118
118
|
|
|
119
|
-
constructor(options:
|
|
119
|
+
constructor(options: PostgresChangeFeedOptions) {
|
|
120
120
|
this.#options = options;
|
|
121
121
|
this.#clock = options.clock ?? systemClock;
|
|
122
122
|
this.#entities = new Set(options.entities.map((name) => assertIdentifier('entity', name)));
|
|
@@ -166,10 +166,12 @@ export class PgReplicationStream {
|
|
|
166
166
|
replication: 'database',
|
|
167
167
|
applicationName: `ultimate-replicator:${slot}`,
|
|
168
168
|
rng: this.#options.rng,
|
|
169
|
+
ssl: target.ssl,
|
|
169
170
|
});
|
|
170
171
|
this.#connection = connection;
|
|
171
172
|
try {
|
|
172
|
-
|
|
173
|
+
const full = this.#options.fullIdentityTables ?? paramsChannelTables();
|
|
174
|
+
await preflight(connection, slot, publication, this.#entities, new Set(full));
|
|
173
175
|
const from = handlers.from;
|
|
174
176
|
this.#confirmed = from === undefined ? 0n : commitPositionOf(from);
|
|
175
177
|
await connection.startCopyBoth(
|
|
@@ -183,10 +185,10 @@ export class PgReplicationStream {
|
|
|
183
185
|
throw failure;
|
|
184
186
|
}
|
|
185
187
|
this.#running = true;
|
|
186
|
-
// A restart that kept the last death in `stats()`
|
|
187
|
-
// supervisor that reads it never sees the replicator come back.
|
|
188
|
+
// A restart that kept the last death in `stats()` would report a live stream as failed.
|
|
188
189
|
this.#failure = null;
|
|
189
190
|
this.#confirmFailures = 0;
|
|
191
|
+
this.#onEnd = handlers.onEnd;
|
|
190
192
|
this.#timer = setInterval(
|
|
191
193
|
() => {
|
|
192
194
|
void this.#confirmOnTimer();
|
|
@@ -241,6 +243,20 @@ export class PgReplicationStream {
|
|
|
241
243
|
if (failures.length > 0) throw failures[0];
|
|
242
244
|
}
|
|
243
245
|
|
|
246
|
+
/**
|
|
247
|
+
* `stop()` without the goodbye: no confirm, no `CopyDone`, no `Terminate`, nothing awaited — on a
|
|
248
|
+
* black-holed walsender each of those is a write nothing settles. The unconfirmed tail is
|
|
249
|
+
* re-sent to the next stream and dropped by its `from`.
|
|
250
|
+
*/
|
|
251
|
+
abandon(): void {
|
|
252
|
+
this.#running = false;
|
|
253
|
+
this.#onEnd = undefined;
|
|
254
|
+
this.#clearTimer();
|
|
255
|
+
const connection = this.#connection;
|
|
256
|
+
this.#connection = null;
|
|
257
|
+
connection?.destroy();
|
|
258
|
+
}
|
|
259
|
+
|
|
244
260
|
/**
|
|
245
261
|
* The pump's only way out, however it ended — a decode error, or a walsender that said goodbye.
|
|
246
262
|
* The four things it owns go together or not at all, because each one left behind is a dead
|
|
@@ -256,8 +272,7 @@ export class PgReplicationStream {
|
|
|
256
272
|
this.#clearTimer();
|
|
257
273
|
const connection = this.#connection;
|
|
258
274
|
this.#connection = null;
|
|
259
|
-
// The
|
|
260
|
-
// into a promise nothing awaits.
|
|
275
|
+
// The loop records, reports and ends rather than throwing into a promise nothing awaits.
|
|
261
276
|
logger.error('replication stream ended', { slot: this.#options.slot, error: reason });
|
|
262
277
|
try {
|
|
263
278
|
await connection?.close();
|
|
@@ -265,6 +280,14 @@ export class PgReplicationStream {
|
|
|
265
280
|
// The socket is already unusable and `failure` above is the report that matters — a
|
|
266
281
|
// goodbye that throws must not become the rejection `#drain` promised never to produce.
|
|
267
282
|
}
|
|
283
|
+
// Last, so whoever restarts finds the socket already let go. Called, never awaited.
|
|
284
|
+
const onEnd = this.#onEnd;
|
|
285
|
+
this.#onEnd = undefined;
|
|
286
|
+
try {
|
|
287
|
+
onEnd?.(reason);
|
|
288
|
+
} catch (failure) {
|
|
289
|
+
logger.error('replication onEnd listener threw', { error: renderThrowable(failure) });
|
|
290
|
+
}
|
|
268
291
|
}
|
|
269
292
|
|
|
270
293
|
#clearTimer(): void {
|
|
@@ -330,19 +353,21 @@ export class PgReplicationStream {
|
|
|
330
353
|
if (message.endLsn > this.#confirmed) this.#confirmed = message.endLsn;
|
|
331
354
|
return;
|
|
332
355
|
case 'insert':
|
|
333
|
-
await this.#deliver('insert', message.relation, null, message.after, handlers);
|
|
356
|
+
await this.#deliver('insert', message.relation, null, message.after, [], handlers);
|
|
334
357
|
return;
|
|
335
|
-
case 'update':
|
|
336
|
-
|
|
358
|
+
case 'update': {
|
|
359
|
+
const { relation, before, after, unchanged } = message;
|
|
360
|
+
await this.#deliver('update', relation, before, after, unchanged, handlers);
|
|
337
361
|
return;
|
|
362
|
+
}
|
|
338
363
|
case 'delete':
|
|
339
|
-
await this.#deliver('delete', message.relation, message.before, null, handlers);
|
|
364
|
+
await this.#deliver('delete', message.relation, message.before, null, [], handlers);
|
|
340
365
|
return;
|
|
341
366
|
case 'truncate':
|
|
342
367
|
// One change per truncated relation, rowless. It was decoded and DROPPED here, and with the
|
|
343
368
|
// recommended `FOR ALL TABLES` publication every window and every client kept the rows.
|
|
344
369
|
for (const relation of message.relations) {
|
|
345
|
-
await this.#deliver('truncate', relation, null, null, handlers);
|
|
370
|
+
await this.#deliver('truncate', relation, null, null, [], handlers);
|
|
346
371
|
}
|
|
347
372
|
return;
|
|
348
373
|
default:
|
|
@@ -356,6 +381,7 @@ export class PgReplicationStream {
|
|
|
356
381
|
relation: PgRelation,
|
|
357
382
|
oldTuple: PhysicalRow | null,
|
|
358
383
|
newTuple: PhysicalRow | null,
|
|
384
|
+
unchanged: readonly string[],
|
|
359
385
|
handlers: ReplicationStreamHandlers,
|
|
360
386
|
): Promise<void> {
|
|
361
387
|
const transaction = this.#transaction;
|
|
@@ -386,10 +412,9 @@ export class PgReplicationStream {
|
|
|
386
412
|
if ((op === 'update' || op === 'delete') && relation.replicaIdentity !== 'f') {
|
|
387
413
|
this.#partialBefore += 1;
|
|
388
414
|
}
|
|
389
|
-
const before =
|
|
390
|
-
const after = toRow(relation, newTuple, 'after');
|
|
415
|
+
const { before, after, omitted } = replicatedImages(relation, oldTuple, newTuple, unchanged);
|
|
391
416
|
const event: ChangeEvent = {
|
|
392
|
-
|
|
417
|
+
table: relation.name,
|
|
393
418
|
op,
|
|
394
419
|
before,
|
|
395
420
|
after,
|
|
@@ -397,7 +422,8 @@ export class PgReplicationStream {
|
|
|
397
422
|
txid: transaction.xid.toString(10),
|
|
398
423
|
orgId: tenantOf(after ?? before),
|
|
399
424
|
at: transaction.commitAt,
|
|
400
|
-
|
|
425
|
+
write: transaction.write ?? null,
|
|
426
|
+
...(omitted.length === 0 ? {} : { omitted }),
|
|
401
427
|
};
|
|
402
428
|
await handlers.onChange(event);
|
|
403
429
|
this.#lastLsn = lsn;
|
|
@@ -470,30 +496,3 @@ export class PgReplicationStream {
|
|
|
470
496
|
await attempt;
|
|
471
497
|
}
|
|
472
498
|
}
|
|
473
|
-
|
|
474
|
-
/** A physical tuple becomes the row the matcher's predicates are written against, or nothing. */
|
|
475
|
-
function toRow(
|
|
476
|
-
relation: PgRelation,
|
|
477
|
-
physical: PhysicalRow | null,
|
|
478
|
-
image: 'before' | 'after',
|
|
479
|
-
): Row | null {
|
|
480
|
-
if (physical === null) return null;
|
|
481
|
-
const row = entityRow(relation, physical, image);
|
|
482
|
-
// A bigserial id decodes as a number inside `Number.isSafeInteger` range and as text outside it,
|
|
483
|
-
// so the same table would otherwise identify small rows by number and large ones by string.
|
|
484
|
-
// `Row.id`, `RowPatch.id` and every cursor are text: the identity is normalised once, here.
|
|
485
|
-
const id = row['id'];
|
|
486
|
-
if (typeof id === 'number' && Number.isSafeInteger(id)) row['id'] = String(id);
|
|
487
|
-
if (isRow(row)) return row;
|
|
488
|
-
throw new ReplicationProtocolError({
|
|
489
|
-
stage: 'stream',
|
|
490
|
-
detail: `table "${relation.name}" replicated a row with no text id column`,
|
|
491
|
-
fix: `give ${relation.name} an id column, or drop it from the publication and the entity list`,
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/** The tenant, hoisted out of the row so fanout filters without parsing it. */
|
|
496
|
-
const tenantOf = (row: Row | null): string | null => {
|
|
497
|
-
const orgId = row?.['orgId'];
|
|
498
|
-
return typeof orgId === 'string' ? orgId : null;
|
|
499
|
-
};
|
package/src/pg-socket.ts
CHANGED
|
@@ -52,6 +52,23 @@ export interface PgTarget {
|
|
|
52
52
|
readonly rootCert?: string | undefined;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* One URL part, percent-decoded. `decodeURIComponent` throws a bare `URIError` on a stray `%`
|
|
57
|
+
* (`50%off`), so a password pasted unencoded failed the replicator with no code and no fix.
|
|
58
|
+
* Refused under the part's NAME, never its value: that is the database credential.
|
|
59
|
+
*/
|
|
60
|
+
function decodeUrlPart(raw: string, part: 'user' | 'password' | 'database'): string {
|
|
61
|
+
try {
|
|
62
|
+
return decodeURIComponent(raw);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new ReplicationFailedError({
|
|
65
|
+
stage: 'connect',
|
|
66
|
+
detail: `DATABASE_URL has a malformed percent-escape in its ${part}`,
|
|
67
|
+
fix: `bun -e 'console.log(encodeURIComponent(process.argv[1]))' '<the raw value>' # then put the result in the ${part} of DATABASE_URL`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
/** `postgres://user:pass@host:5432/db?sslmode=require`. The one place a connection URL is read. */
|
|
56
73
|
export function parsePgUrl(url: string): PgTarget {
|
|
57
74
|
let parsed: URL;
|
|
@@ -75,13 +92,13 @@ export function parsePgUrl(url: string): PgTarget {
|
|
|
75
92
|
});
|
|
76
93
|
}
|
|
77
94
|
const { ssl, rootCert } = parseSsl(parsed.searchParams);
|
|
78
|
-
const database =
|
|
95
|
+
const database = decodeUrlPart(parsed.pathname.replace(/^\//, ''), 'database');
|
|
79
96
|
return {
|
|
80
97
|
host: parsed.hostname,
|
|
81
98
|
port: parsed.port === '' ? 5432 : Number.parseInt(parsed.port, 10),
|
|
82
99
|
database: database === '' ? 'postgres' : database,
|
|
83
|
-
user:
|
|
84
|
-
password: parsed.password === '' ? undefined :
|
|
100
|
+
user: decodeUrlPart(parsed.username, 'user') || 'postgres',
|
|
101
|
+
password: parsed.password === '' ? undefined : decodeUrlPart(parsed.password, 'password'),
|
|
85
102
|
ssl,
|
|
86
103
|
rootCert,
|
|
87
104
|
};
|
|
@@ -189,8 +206,8 @@ export async function pgStreamOver(runtime: BunConnect, target: PgTarget): Promi
|
|
|
189
206
|
const connectFailure = (error: Error): ReplicationFailedError =>
|
|
190
207
|
new ReplicationFailedError({
|
|
191
208
|
stage: 'connect',
|
|
192
|
-
detail: error.message
|
|
193
|
-
fix:
|
|
209
|
+
detail: `${error.message} — no route to ${target.host}:${target.port} from this process`,
|
|
210
|
+
fix: 'x doctor --json',
|
|
194
211
|
});
|
|
195
212
|
|
|
196
213
|
const handlers: SocketHandlers = {
|
|
@@ -229,8 +246,10 @@ export async function pgStreamOver(runtime: BunConnect, target: PgTarget): Promi
|
|
|
229
246
|
if (written < 0) {
|
|
230
247
|
throw new ReplicationFailedError({
|
|
231
248
|
stage: 'write',
|
|
232
|
-
detail:
|
|
233
|
-
|
|
249
|
+
detail:
|
|
250
|
+
`the socket refused a ${rest.length}-byte write — the replicator reconnects on its ` +
|
|
251
|
+
'own once the host is back',
|
|
252
|
+
fix: 'x doctor --json',
|
|
234
253
|
});
|
|
235
254
|
}
|
|
236
255
|
if (written > 0) rest = rest.subarray(written);
|
package/src/pg-tls.ts
CHANGED
|
@@ -62,6 +62,24 @@ export function parseSsl(params: URLSearchParams): SslSettings {
|
|
|
62
62
|
/** Whether a mode asks the runtime for the verification at all. */
|
|
63
63
|
export const verifies = (ssl: SslMode): boolean => ssl === 'verify-ca' || ssl === 'verify-full';
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Whether a cleartext or md5 password request is answered. Cleartext IS the password; md5 is
|
|
67
|
+
* replayable. What each mode actually protects against:
|
|
68
|
+
*
|
|
69
|
+
* - `verify-ca` / `verify-full`: the session is encrypted to a server whose certificate was
|
|
70
|
+
* checked. The only modes that stop an ON-PATH attacker.
|
|
71
|
+
* - `require`: encrypted, to whoever answered. No certificate is verified, so this stops a PASSIVE
|
|
72
|
+
* listener and nothing else — an attacker on the path can terminate the TLS session itself and
|
|
73
|
+
* ask for the password. Answered anyway: it is libpq's behaviour, and managed databases whose
|
|
74
|
+
* CA an app does not hold depend on it. An operator who needs more names a `verify-*` mode.
|
|
75
|
+
* - `disable`: the operator SAYING the session is cleartext — the opt-in.
|
|
76
|
+
* - `allow` / `prefer` (the default, and what an unstated mode is): refused. They do not even
|
|
77
|
+
* promise encryption — a stripped upgrade falls back to cleartext without a word — so they stop
|
|
78
|
+
* neither kind of attacker, and nobody chose that.
|
|
79
|
+
*/
|
|
80
|
+
export const answersWeakAuth = (ssl: SslMode | undefined): boolean =>
|
|
81
|
+
ssl === 'disable' || ssl === 'require' || (ssl !== undefined && verifies(ssl));
|
|
82
|
+
|
|
65
83
|
/** OpenSSL's code for a certificate whose chain verified but whose names do not include the host. */
|
|
66
84
|
const HOST_MISMATCH = 'ERR_TLS_CERT_ALTNAME_INVALID';
|
|
67
85
|
|
package/src/pg-values.ts
CHANGED
|
@@ -33,13 +33,20 @@ export type PhysicalRow = { [key: string]: PhysicalValue };
|
|
|
33
33
|
/**
|
|
34
34
|
* `2026-08-09 12:00:00.123456+00` -> the ISO-8601 form `new Date` is specified to accept.
|
|
35
35
|
*
|
|
36
|
-
* Postgres writes a space between the date and the clock, an offset that may be `+00`, `+0530
|
|
37
|
-
* `+05:30
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* Postgres writes a space between the date and the clock, an offset that may be `+00`, `+0530`,
|
|
37
|
+
* `+05:30` or — for an instant before its zone adopted standard time, a zone's LMT — `-04:56:02`,
|
|
38
|
+
* and as many fractional digits as the column's precision. `Date` holds milliseconds, so the
|
|
39
|
+
* fraction is TRUNCATED to three — which is what the driver does on the repository side, so both
|
|
40
|
+
* readers of one column land on the same instant.
|
|
41
|
+
*
|
|
42
|
+
* Seconds only ever follow a colon-separated minute (`EncodeTimezone` in postgres' `datetime.c`),
|
|
43
|
+
* so `-0456:02` is not a spelling this matches.
|
|
40
44
|
*/
|
|
41
45
|
const TIMESTAMP =
|
|
42
|
-
/^(\d{4,6})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}(
|
|
46
|
+
/^(\d{4,6})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}(?:\d{2}|:\d{2}(?::\d{2})?)?)?$/;
|
|
47
|
+
|
|
48
|
+
/** `±HH`, `±HHMM`, `±HH:MM`, `±HH:MM:SS` — the zone group of `TIMESTAMP`, taken apart. */
|
|
49
|
+
const OFFSET = /^([+-])(\d{2}):?(\d{2})?:?(\d{2})?$/;
|
|
43
50
|
|
|
44
51
|
/**
|
|
45
52
|
* `undefined` for a text this decoder does not describe — `infinity`, a BC date, a non-ISO
|
|
@@ -61,18 +68,26 @@ function toInstant(text: string): Date | undefined {
|
|
|
61
68
|
if (parts === null) return undefined;
|
|
62
69
|
const [, year, month, day, hour, minute, second, fraction, zone] = parts;
|
|
63
70
|
const millis = fraction === undefined ? '000' : `${fraction.slice(1)}000`.slice(0, 3);
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
const offset = offsetMillis(zone);
|
|
72
|
+
if (offset === undefined) return undefined;
|
|
73
|
+
// Read the wall clock as UTC, then move it by the offset: `Date` has no spelling for a seconds
|
|
74
|
+
// offset, so handing it the zone would leave an LMT instant as text while the driver, which
|
|
75
|
+
// reads the binary form, hands the repository its `Date`.
|
|
76
|
+
const wall = Date.parse(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}Z`);
|
|
77
|
+
if (Number.isNaN(wall)) return undefined;
|
|
78
|
+
const parsed = new Date(wall - offset);
|
|
67
79
|
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
68
80
|
}
|
|
69
81
|
|
|
70
|
-
/**
|
|
71
|
-
function
|
|
72
|
-
if (zone === undefined || zone === 'Z') return
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
|
|
82
|
+
/** East of UTC is positive. `undefined` for a minute or second past 59 — not an offset at all. */
|
|
83
|
+
function offsetMillis(zone: string | undefined): number | undefined {
|
|
84
|
+
if (zone === undefined || zone === 'Z') return 0;
|
|
85
|
+
const parts = OFFSET.exec(zone);
|
|
86
|
+
if (parts === null) return undefined;
|
|
87
|
+
const [, sign, hours, minutes = '00', seconds = '00'] = parts;
|
|
88
|
+
if (Number(minutes) > 59 || Number(seconds) > 59) return undefined;
|
|
89
|
+
const magnitude = (Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds)) * 1000;
|
|
90
|
+
return sign === '-' ? -magnitude : magnitude;
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
const HEX = /^[0-9a-fA-F]*$/;
|
package/src/pg-wire.ts
CHANGED
|
@@ -69,8 +69,10 @@ export class MessageReader {
|
|
|
69
69
|
if (held === 0) return undefined;
|
|
70
70
|
throw new ReplicationProtocolError({
|
|
71
71
|
stage: 'read',
|
|
72
|
-
detail:
|
|
73
|
-
|
|
72
|
+
detail:
|
|
73
|
+
`the connection closed with ${held} bytes of a partial message — the backend was ` +
|
|
74
|
+
'terminated mid-message, and the server log names the reason',
|
|
75
|
+
fix: 'x doctor --json',
|
|
74
76
|
});
|
|
75
77
|
}
|
|
76
78
|
this.#append(chunk);
|
|
@@ -245,7 +247,7 @@ export const FIXES: Readonly<Record<string, string>> = {
|
|
|
245
247
|
};
|
|
246
248
|
|
|
247
249
|
/** What a SQLSTATE this table has no entry for is answered with. */
|
|
248
|
-
const GENERIC_FIX = 'x doctor
|
|
250
|
+
const GENERIC_FIX = 'x doctor --json';
|
|
249
251
|
|
|
250
252
|
/**
|
|
251
253
|
* An `ErrorResponse` becomes the one error class whose `fix` names the command to run.
|
package/src/pgoutput.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
// Decodes pgoutput logical-replication messages (protocol version 1
|
|
2
|
-
//
|
|
3
|
-
// per-connection relation cache
|
|
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.
|
|
1
|
+
// Decodes pgoutput logical-replication messages (protocol version 1) into typed PgOutputMessage
|
|
2
|
+
// values; the stream asks `messages 'true'`, which needs Postgres >= 14. Pure byte decoding, no
|
|
3
|
+
// I/O: a decoder owns the per-connection relation cache later tuples reference by oid. What a
|
|
4
|
+
// tuple's TEXT means is `pg-values.ts`'s — this file frames messages, that one owns the types.
|
|
7
5
|
|
|
8
6
|
import { isWriteDigest, WRITE_ORIGIN_WAL_PREFIX } from '@ultimat3/core';
|
|
9
7
|
import { ReplicationProtocolError } from './errors';
|
|
@@ -48,6 +46,12 @@ export type PgOutputMessage =
|
|
|
48
46
|
readonly relation: PgRelation;
|
|
49
47
|
readonly before: PhysicalRow | null;
|
|
50
48
|
readonly after: PhysicalRow;
|
|
49
|
+
/**
|
|
50
|
+
* Physical columns the new tuple sent as `'u'`: an out-of-line (TOAST) value the UPDATE did
|
|
51
|
+
* not touch, for which Postgres logs no bytes. They are absent from `after`, and this list is
|
|
52
|
+
* what tells "absent" apart from "the whole row".
|
|
53
|
+
*/
|
|
54
|
+
readonly unchanged: readonly string[];
|
|
51
55
|
}
|
|
52
56
|
| { readonly kind: 'delete'; readonly relation: PgRelation; readonly before: PhysicalRow }
|
|
53
57
|
| { readonly kind: 'truncate'; readonly relations: readonly PgRelation[] }
|
|
@@ -67,7 +71,11 @@ export type PgOutputMessage =
|
|
|
67
71
|
* `'u'` standing in for the columns a key-only tuple leaves out — so the column count always
|
|
68
72
|
* matches the relation, and only the per-column byte tells us whether a value is actually there.
|
|
69
73
|
*/
|
|
70
|
-
function decodeTupleData(
|
|
74
|
+
function decodeTupleData(
|
|
75
|
+
reader: ByteReader,
|
|
76
|
+
relation: PgRelation,
|
|
77
|
+
unchanged?: string[],
|
|
78
|
+
): PhysicalRow {
|
|
71
79
|
const count = reader.int16();
|
|
72
80
|
if (count !== relation.columns.length) {
|
|
73
81
|
throw new ReplicationProtocolError({
|
|
@@ -90,6 +98,7 @@ function decodeTupleData(reader: ByteReader, relation: PgRelation): PhysicalRow
|
|
|
90
98
|
if (kind === 'u') {
|
|
91
99
|
// Omitted, not nulled: "unchanged" and "set to null" are different facts about the row,
|
|
92
100
|
// and a missing key is the only encoding that keeps those two facts distinguishable.
|
|
101
|
+
unchanged?.push(column.name);
|
|
93
102
|
continue;
|
|
94
103
|
}
|
|
95
104
|
if (kind === 't') {
|
|
@@ -245,8 +254,9 @@ export class PgOutputDecoder {
|
|
|
245
254
|
detail: `expected the new-tuple marker "N" but got "${marker}"`,
|
|
246
255
|
});
|
|
247
256
|
}
|
|
248
|
-
const
|
|
249
|
-
|
|
257
|
+
const unchanged: string[] = [];
|
|
258
|
+
const after = decodeTupleData(reader, relation, unchanged);
|
|
259
|
+
return { kind: 'update', relation, before, after, unchanged };
|
|
250
260
|
}
|
|
251
261
|
|
|
252
262
|
#decodeDelete(reader: ByteReader): PgOutputMessage {
|
package/src/policy-gate.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The single seam between realtime and authz. It goes through `@ultimat3/query`'s `
|
|
2
|
-
// is itself the only point of contact with `@ultimat3/policy` — one authz system, never two,
|
|
3
|
-
// realtime does not get its own opinion about what a decision means.
|
|
1
|
+
// The single seam between realtime and authz. It goes through `@ultimat3/query`'s `guardQuery`,
|
|
2
|
+
// which is itself the only point of contact with `@ultimat3/policy` — one authz system, never two,
|
|
3
|
+
// and realtime does not get its own opinion about what a decision means.
|
|
4
4
|
//
|
|
5
5
|
// The row gate turns a denial into "not visible" instead of an error: a row that fails an actor's
|
|
6
6
|
// policy is dropped, never sent. That is the rule from the live-query pipeline, implemented once.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// database is down" are different facts and one of them has to page someone.
|
|
9
9
|
|
|
10
10
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
11
|
-
import {
|
|
11
|
+
import { guardQuery, QueryDeniedError, type QueryPolicy, type QuerySubject } from '@ultimat3/query';
|
|
12
12
|
import type { JsonValue, Row } from './json';
|
|
13
13
|
|
|
14
14
|
export interface GateOptions {
|
|
@@ -25,7 +25,7 @@ export function authorizeWithPolicy(
|
|
|
25
25
|
return async (args) => {
|
|
26
26
|
// No row exists yet at subscribe time; `null` says so rather than leaving the predicate
|
|
27
27
|
// to infer it from an absent field.
|
|
28
|
-
|
|
28
|
+
guardQuery(policy, subjectOf(options, args.actor, args.input, null), 'live');
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -40,10 +40,10 @@ export function visibleWithPolicy<R extends Row = Row>(
|
|
|
40
40
|
): (args: { actor: Actor | null; row: R; input: JsonValue }) => Promise<boolean> {
|
|
41
41
|
return async (args) => {
|
|
42
42
|
try {
|
|
43
|
-
|
|
43
|
+
guardQuery(policy, subjectOf(options, args.actor, args.input, args.row), 'live');
|
|
44
44
|
return true;
|
|
45
45
|
} catch (error) {
|
|
46
|
-
// `
|
|
46
|
+
// `guardQuery` throws `QueryDeniedError` for a decision and for nothing else, so that class is
|
|
47
47
|
// the whole of "not visible". A rule that reached for a row and timed out throws something
|
|
48
48
|
// else, and answering `false` to it would report an outage as a permission change: the rows
|
|
49
49
|
// leave the subscriber's screen, `live.rows_denied` counts the drop, and no error reaches
|
package/src/presence.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// On the wire it is not a frame kind: a roster change is an `events` frame on the channel the member
|
|
7
7
|
// joined (`channel-presence.ts` is the payload), so only a channel declared `events: true` has one.
|
|
8
8
|
|
|
9
|
-
import { type Clock, finiteOption, systemClock,
|
|
9
|
+
import { type Clock, finiteOption, systemClock, uuidV7 } from '@ultimat3/core';
|
|
10
10
|
import type { ChannelHub, Topic } from './channel';
|
|
11
11
|
import { presenceEvent } from './channel-presence';
|
|
12
12
|
import type { ChannelEventsFrame } from './channel-wire';
|
|
@@ -25,6 +25,13 @@ export const PRESENCE_SWEEP_PREFIX = 'presence.sweep';
|
|
|
25
25
|
*/
|
|
26
26
|
export const DEFAULT_MAX_PRESENCE_MEMBERS = 256;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Member ids the sweep leader ships on its lease, for the other nodes to record. Bounded because
|
|
30
|
+
* the lease is one bus message: past it, a member is known to the leader alone, as every member
|
|
31
|
+
* was before the lease carried a roster.
|
|
32
|
+
*/
|
|
33
|
+
export const PRESENCE_LEASE_ROSTER_LIMIT = 4_096;
|
|
34
|
+
|
|
28
35
|
export interface PresenceOptions {
|
|
29
36
|
readonly transport: Transport;
|
|
30
37
|
/** Optional: without a hub, presence is queryable but silent (no join/leave frames). */
|
|
@@ -65,6 +72,10 @@ export class PresenceRegistry {
|
|
|
65
72
|
readonly #nodeId: string;
|
|
66
73
|
/** Diffing cache only — the truth is always `transport.shared`. Safe to lose. */
|
|
67
74
|
readonly #seen = new Map<string, Set<string>>();
|
|
75
|
+
/** Per topic this node LEADS: the roster on its lease, as written. */
|
|
76
|
+
readonly #shipped = new Map<string, string>();
|
|
77
|
+
/** Per topic this node FOLLOWS: the ids the leader's lease last named. */
|
|
78
|
+
readonly #heard = new Map<string, ReadonlySet<string>>();
|
|
68
79
|
|
|
69
80
|
constructor(options: PresenceOptions) {
|
|
70
81
|
this.#transport = options.transport;
|
|
@@ -75,7 +86,7 @@ export class PresenceRegistry {
|
|
|
75
86
|
1,
|
|
76
87
|
finiteOption('presence', 'maxMembers', options.maxMembers ?? DEFAULT_MAX_PRESENCE_MEMBERS),
|
|
77
88
|
);
|
|
78
|
-
this.#nodeId = options.nodeId ??
|
|
89
|
+
this.#nodeId = options.nodeId ?? uuidV7();
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
get ttlMs(): number {
|
|
@@ -100,7 +111,10 @@ export class PresenceRegistry {
|
|
|
100
111
|
return await this.roster(name);
|
|
101
112
|
}
|
|
102
113
|
|
|
103
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* A member's beat: its TTL renewed, nothing announced and nothing read. `false` means the member
|
|
116
|
+
* had already expired: the caller must `join` again, not `heartbeat`.
|
|
117
|
+
*/
|
|
104
118
|
async heartbeat(name: Topic, id: string): Promise<boolean> {
|
|
105
119
|
const alive = await this.#transport.shared.touch(this.#key(name), id, this.#ttlMs);
|
|
106
120
|
if (!alive) this.#track(name).delete(id);
|
|
@@ -190,12 +204,18 @@ export class PresenceRegistry {
|
|
|
190
204
|
// A room nobody is in is not a room. Without this the cache keeps one entry per topic ever
|
|
191
205
|
// subscribed to, for the life of the process, and the sweep walks all of them forever.
|
|
192
206
|
if ((this.#seen.get(name)?.size ?? 0) === 0) {
|
|
193
|
-
this.#
|
|
207
|
+
this.#forget(name);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const lease = await this.#claimSweep(name);
|
|
211
|
+
if (!lease.leads) {
|
|
212
|
+
this.#record(name, lease.roster);
|
|
194
213
|
continue;
|
|
195
214
|
}
|
|
196
|
-
|
|
215
|
+
this.#heard.delete(name);
|
|
197
216
|
gone.push(...(await this.sweep(name)));
|
|
198
|
-
|
|
217
|
+
await this.#ship(name);
|
|
218
|
+
if ((this.#seen.get(name)?.size ?? 0) === 0) this.#forget(name);
|
|
199
219
|
}
|
|
200
220
|
return gone;
|
|
201
221
|
}
|
|
@@ -211,14 +231,59 @@ export class PresenceRegistry {
|
|
|
211
231
|
* different views is a duplicate `leave` for a member who has already gone — which is what a
|
|
212
232
|
* `leave` frame means anyway. What it never produces is nobody sweeping: a claim is re-put every
|
|
213
233
|
* pass, and a dead leader's expires within one TTL.
|
|
234
|
+
*
|
|
235
|
+
* The leader's claim CARRIES the ids it last saw live, and every follower reads it in the read
|
|
236
|
+
* the election already makes. That is what a takeover diffs against: a leader that dies takes
|
|
237
|
+
* its own members with it, and a successor that had skipped every sweep held nothing but its own
|
|
238
|
+
* — so their leaves were never announced, to anyone, until each client reconnected.
|
|
214
239
|
*/
|
|
215
|
-
async #claimSweep(
|
|
240
|
+
async #claimSweep(
|
|
241
|
+
name: Topic,
|
|
242
|
+
): Promise<{ readonly leads: boolean; readonly roster: readonly string[] | null }> {
|
|
216
243
|
const key = `${PRESENCE_SWEEP_PREFIX}.${name}`;
|
|
217
|
-
await this.#transport.shared.put(key, this.#nodeId, '', this.#ttlMs);
|
|
244
|
+
await this.#transport.shared.put(key, this.#nodeId, this.#shipped.get(name) ?? '', this.#ttlMs);
|
|
218
245
|
const claimants = await this.#transport.shared.entries(key);
|
|
219
246
|
let leader = this.#nodeId;
|
|
220
|
-
|
|
221
|
-
|
|
247
|
+
let roster: readonly string[] | null = null;
|
|
248
|
+
for (const claimant of claimants) {
|
|
249
|
+
if (claimant.member > leader) continue;
|
|
250
|
+
leader = claimant.member;
|
|
251
|
+
roster = parseRoster(claimant.value);
|
|
252
|
+
}
|
|
253
|
+
return { leads: leader === this.#nodeId, roster };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A follower's pass: what the leader said is live is recorded, what it stopped saying is not. */
|
|
257
|
+
#record(name: Topic, roster: readonly string[] | null): void {
|
|
258
|
+
this.#shipped.delete(name);
|
|
259
|
+
// A leader that has not swept yet has said nothing: nothing is learned and nothing forgotten.
|
|
260
|
+
if (roster === null) return;
|
|
261
|
+
const tracked = this.#track(name);
|
|
262
|
+
const now = new Set(roster);
|
|
263
|
+
// Dropped by the leader while it is alive means the leader announced it.
|
|
264
|
+
for (const id of this.#heard.get(name) ?? []) if (!now.has(id)) tracked.delete(id);
|
|
265
|
+
for (const id of now) tracked.add(id);
|
|
266
|
+
this.#heard.set(name, now);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The leader's roster, written onto its lease when it changed. */
|
|
270
|
+
async #ship(name: Topic): Promise<void> {
|
|
271
|
+
const ids = [...(this.#seen.get(name) ?? [])].sort().slice(0, PRESENCE_LEASE_ROSTER_LIMIT);
|
|
272
|
+
const value = JSON.stringify(ids);
|
|
273
|
+
if (this.#shipped.get(name) === value) return;
|
|
274
|
+
this.#shipped.set(name, value);
|
|
275
|
+
await this.#transport.shared.put(
|
|
276
|
+
`${PRESENCE_SWEEP_PREFIX}.${name}`,
|
|
277
|
+
this.#nodeId,
|
|
278
|
+
value,
|
|
279
|
+
this.#ttlMs,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#forget(name: Topic): void {
|
|
284
|
+
this.#seen.delete(name);
|
|
285
|
+
this.#shipped.delete(name);
|
|
286
|
+
this.#heard.delete(name);
|
|
222
287
|
}
|
|
223
288
|
|
|
224
289
|
/** Full-set frame for a client that just (re)connected — presence has no delta protocol. */
|
|
@@ -283,6 +348,18 @@ export function presenceFrame(
|
|
|
283
348
|
};
|
|
284
349
|
}
|
|
285
350
|
|
|
351
|
+
/** A lease value as the leader wrote it: a JSON list of member ids, or nothing yet. */
|
|
352
|
+
function parseRoster(value: string): readonly string[] | null {
|
|
353
|
+
if (value === '') return null;
|
|
354
|
+
try {
|
|
355
|
+
const parsed: unknown = JSON.parse(value);
|
|
356
|
+
if (!Array.isArray(parsed)) return null;
|
|
357
|
+
return parsed.filter((id): id is string => typeof id === 'string');
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
286
363
|
function parseMember(id: string, value: string): PresenceMember | null {
|
|
287
364
|
try {
|
|
288
365
|
const parsed: unknown = JSON.parse(value);
|