@ultimat3/realtime 21.0.0 → 22.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 +293 -1009
- package/README.md +78 -12
- package/package.json +4 -4
- package/src/changefeed.ts +7 -1
- package/src/channel-authz.ts +23 -4
- package/src/channel-decl.ts +16 -5
- package/src/channel-describe.ts +7 -5
- package/src/channel-logs.ts +19 -1
- package/src/channel-records.ts +8 -0
- package/src/client-channels.ts +75 -5
- package/src/client.ts +14 -2
- package/src/cursor.ts +5 -0
- package/src/errors.ts +21 -0
- package/src/idb-fake.ts +24 -4
- package/src/idb-types.ts +7 -0
- package/src/index.ts +0 -1
- package/src/live-definition.ts +5 -1
- package/src/live-fanout.ts +51 -2
- package/src/live-query.ts +11 -0
- package/src/live-replicator.ts +160 -0
- package/src/local-store-idb.ts +89 -15
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +76 -21
- package/src/page-outbox.ts +80 -10
- package/src/page-socket.ts +39 -8
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-preflight.ts +24 -2
- package/src/pg-replication.ts +19 -6
- package/src/pg-wire.ts +51 -15
- package/src/policy-fake.ts +14 -0
- package/src/query-window.ts +35 -21
- package/src/replicator.ts +13 -3
- package/src/server.ts +8 -3
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +15 -3
- package/src/socket-host.ts +103 -4
- package/src/socket-idle.ts +21 -0
- package/src/socket.ts +41 -38
- package/src/subscriber-gate.ts +92 -3
- package/src/sync-node.ts +2 -7
- package/src/thundering-herd.ts +12 -11
- package/src/transport-env.ts +55 -14
- package/src/use-mutation.ts +13 -0
- package/src/use-query.ts +10 -5
package/src/pg-entity-row.ts
CHANGED
|
@@ -1,198 +1,51 @@
|
|
|
1
|
-
// Physical Postgres row -> entity
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
// Physical Postgres row -> the entity row the app declared, by `@ultimat3/entity`'s own decoder.
|
|
2
|
+
// The relation names the table, `entityForTable` names the entity, and `decodeRow` shapes the row
|
|
3
|
+
// by each column's declared KIND — money included — exactly as a repository read does.
|
|
4
|
+
//
|
|
5
|
+
// It GUESSED, until 22.0.0: snake_case camelCased by string rules and any `<p>_minor`/`<p>_currency`
|
|
6
|
+
// pair folded into money by name. So a nullable money column diverged from the repository shape,
|
|
7
|
+
// two plain columns that happened to be called `x_minor`/`x_currency` were folded into a `Money`,
|
|
8
|
+
// and a `.column()` rename arrived under its physical name. One decoder, the entity's, now.
|
|
9
|
+
|
|
10
|
+
import { decodeRow, entityForTable } from '@ultimat3/entity';
|
|
9
11
|
import { ReplicationProtocolError } from './errors';
|
|
10
|
-
import type { PhysicalRow
|
|
11
|
-
|
|
12
|
-
/** `org_id` -> `orgId`, `published_at` -> `publishedAt`. The inverse of `@ultimat3/entity`'s `snake()`. */
|
|
13
|
-
export function camel(column: string): string {
|
|
14
|
-
const [head = '', ...tail] = column.split('_');
|
|
15
|
-
return head + tail.map(capitalize).join('');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** A leading, trailing, or doubled underscore produces an empty part; it contributes nothing. */
|
|
19
|
-
function capitalize(part: string): string {
|
|
20
|
-
return part.charAt(0).toUpperCase() + part.slice(1);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface FoldedMoney {
|
|
24
|
-
readonly property: string;
|
|
25
|
-
/** The physical columns the fold consumed, in declaration order — named together on a collision. */
|
|
26
|
-
readonly columns: readonly string[];
|
|
27
|
-
readonly value: PhysicalRow;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* `price_minor` / `price_currency` / `price_scale` -> `price`; any other column name has no money
|
|
32
|
-
* prefix. The scale column is matched here for the same reason the other two are: unmatched, it
|
|
33
|
-
* survived the fold as a physical `priceScale` property beside `price`, so one row read live and
|
|
34
|
-
* the same row read through a repository reported two different shapes — and the sub-cent amount
|
|
35
|
-
* the scale names was delivered to every subscriber unscaled.
|
|
36
|
-
*/
|
|
37
|
-
function moneyPrefix(column: string): string | null {
|
|
38
|
-
if (column.endsWith('_minor')) return column.slice(0, -'_minor'.length);
|
|
39
|
-
if (column.endsWith('_currency')) return column.slice(0, -'_currency'.length);
|
|
40
|
-
if (column.endsWith('_scale')) return column.slice(0, -'_scale'.length);
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* `Money` is `{ minor: number; currency: string }` everywhere in the framework, so `minor` is
|
|
46
|
-
* normalised here rather than passed through: `pgoutput` decodes an int8 as text once it leaves
|
|
47
|
-
* `Number.isSafeInteger` range and a numeric as text always, which would otherwise make one column
|
|
48
|
-
* a number on one row and a string on the next. A value no JS number holds exactly is not money
|
|
49
|
-
* this pipeline can carry, and saying so is better than shipping a `minor` the contract forbids.
|
|
50
|
-
*/
|
|
51
|
-
function moneyMinor(column: string, value: number | string): number {
|
|
52
|
-
const minor =
|
|
53
|
-
typeof value === 'number' ? value : /^-?\d+$/.test(value) ? Number(value) : Number.NaN;
|
|
54
|
-
if (Number.isSafeInteger(minor)) return minor;
|
|
55
|
-
throw new ReplicationProtocolError({
|
|
56
|
-
stage: 'value',
|
|
57
|
-
detail: `column "${column}" carries ${shownNumber(value)}, which is not a whole number of minor units`,
|
|
58
|
-
fix: `store ${column} as a bigint inside ±2^53 — Money.minor is a number, never a float or a bigint`,
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* `MoneyValue.scale` is a whole, non-negative count of decimal places, so that — and only that —
|
|
64
|
-
* is what this decoder refuses: a fractional or negative scale is not a value the shape can carry
|
|
65
|
-
* at all, exactly as an out-of-range `minor` is not.
|
|
66
|
-
*
|
|
67
|
-
* The `0…MAX_MONEY_SCALE` CEILING is `@ultimat3/schema`'s and is deliberately not restated here:
|
|
68
|
-
* it is enforced at both ends of this column already — the CHECK `@ultimat3/entity`'s
|
|
69
|
-
* `describeColumn` emits on `<p>_scale`, and `parseScale` on the repository read — and this
|
|
70
|
-
* package declares no `@ultimat3/schema` dependency, so a copy of the bound here would be a
|
|
71
|
-
* second declaration that can drift from the one that decides.
|
|
72
|
-
*
|
|
73
|
-
* `/^\d+$/` and not `Number(value)`: `Number('')` is 0, and 0 means whole units — the one value
|
|
74
|
-
* an empty column must never decode to. The same guard `parseScale` uses.
|
|
75
|
-
*/
|
|
76
|
-
function moneyScale(column: string, value: PhysicalValue): number {
|
|
77
|
-
const digits = typeof value === 'string' && /^\d+$/.test(value);
|
|
78
|
-
const scale = typeof value === 'number' ? value : digits ? Number(value) : Number.NaN;
|
|
79
|
-
if (Number.isSafeInteger(scale) && scale >= 0) return scale;
|
|
80
|
-
throw new ReplicationProtocolError({
|
|
81
|
-
stage: 'value',
|
|
82
|
-
detail: `column "${column}" carries ${shownNumber(value)}, which is not a whole number of decimal places`,
|
|
83
|
-
fix: `store ${column} as a non-negative integer, or null for the currency's own minor unit`,
|
|
84
|
-
});
|
|
85
|
-
}
|
|
12
|
+
import type { PhysicalRow } from './pg-values';
|
|
13
|
+
import type { PgRelation } from './pgoutput';
|
|
86
14
|
|
|
87
15
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* Entity reaches its echo only on a value already narrowed to a finite non-integer `number`, a
|
|
93
|
-
* `bigint`, or a `/^-?\d+$/` string. Here the value came off the WAL, and a `<p>_minor` pair is
|
|
94
|
-
* matched by column *name*: any `text` column called `note_minor` beside `note_currency` routes
|
|
95
|
-
* arbitrary user content through this throw. `"${value}"` on that path is the leak
|
|
96
|
-
* `describeValue` exists for — the message is built before any field-level redaction can see it,
|
|
97
|
-
* and it reaches the log store and the operator alike.
|
|
98
|
-
*
|
|
99
|
-
* So the amount survives when its content is a number (a float, an out-of-range integer, or a
|
|
100
|
-
* string that *is* one), and everything else is reported as shape. The scale column is matched by
|
|
101
|
-
* name the same way and carries the same risk, so it renders through here too.
|
|
16
|
+
* A key-only `before` image — any replica identity but FULL — carries NULL for every column that is
|
|
17
|
+
* not part of the key. Those NULLs are not the row's values, and handed to the decoder a not-null
|
|
18
|
+
* column would read as a table that no longer matches its entity. They are dropped, so the image
|
|
19
|
+
* decodes as what it is: the key, and nothing claimed about the rest.
|
|
102
20
|
*/
|
|
103
|
-
function
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* `name` is part of a `<p>_minor` / `<p>_currency` (/ `<p>_scale`) group, or null if it is not.
|
|
112
|
-
* The first two must be present *and* typed like money — a null currency (an unset money value) is
|
|
113
|
-
* not "half a pair", it simply is not a pair, so every column falls through as an ordinary value.
|
|
114
|
-
*
|
|
115
|
-
* The scale column is the one member that may be absent or NULL, and both mean the same thing:
|
|
116
|
-
* "the currency's own minor unit". Neither produces a `scale` key, because `undefined` and `0` are
|
|
117
|
-
* different values — `0` claims whole units, a 100x reinterpretation of an ordinary price — which
|
|
118
|
-
* is exactly the rule `moneyOf` follows on the repository side. What it may NOT do is survive as a
|
|
119
|
-
* column of its own: the fold consumes it whenever the group folds.
|
|
120
|
-
*/
|
|
121
|
-
function foldMoney(
|
|
122
|
-
physical: Readonly<Record<string, PhysicalValue>>,
|
|
123
|
-
name: string,
|
|
124
|
-
): FoldedMoney | null {
|
|
125
|
-
const prefix = moneyPrefix(name);
|
|
126
|
-
if (prefix === null) return null;
|
|
127
|
-
|
|
128
|
-
const minorKey = `${prefix}_minor`;
|
|
129
|
-
const currencyKey = `${prefix}_currency`;
|
|
130
|
-
if (!Object.hasOwn(physical, minorKey) || !Object.hasOwn(physical, currencyKey)) return null;
|
|
131
|
-
|
|
132
|
-
const minor = physical[minorKey];
|
|
133
|
-
const currency = physical[currencyKey];
|
|
134
|
-
if (!(typeof minor === 'number' || typeof minor === 'string') || typeof currency !== 'string') {
|
|
135
|
-
return null;
|
|
21
|
+
function replicatedOnly(relation: PgRelation, physical: PhysicalRow): PhysicalRow {
|
|
22
|
+
if (relation.replicaIdentity === 'f') return physical;
|
|
23
|
+
const keys = new Set(relation.columns.filter((column) => column.key).map((c) => c.name));
|
|
24
|
+
const kept: PhysicalRow = {};
|
|
25
|
+
for (const [name, value] of Object.entries(physical)) {
|
|
26
|
+
if (value !== null || keys.has(name)) kept[name] = value;
|
|
136
27
|
}
|
|
137
|
-
|
|
138
|
-
const scaleKey = `${prefix}_scale`;
|
|
139
|
-
const scale = Object.hasOwn(physical, scaleKey) ? physical[scaleKey] : undefined;
|
|
140
|
-
return {
|
|
141
|
-
property: camel(prefix),
|
|
142
|
-
columns: scale === undefined ? [minorKey, currencyKey] : [minorKey, currencyKey, scaleKey],
|
|
143
|
-
value: {
|
|
144
|
-
minor: moneyMinor(minorKey, minor),
|
|
145
|
-
currency,
|
|
146
|
-
...(scale === undefined || scale === null ? {} : { scale: moneyScale(scaleKey, scale) }),
|
|
147
|
-
},
|
|
148
|
-
};
|
|
28
|
+
return kept;
|
|
149
29
|
}
|
|
150
30
|
|
|
151
31
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
32
|
+
* The entity row for one replicated tuple. A relation with no registered entity is refused: the
|
|
33
|
+
* stream only selects tables `replicatedRelations()` named from the entity registry, so one that
|
|
34
|
+
* arrives unregistered is a process that never loaded the app's entities.
|
|
155
35
|
*/
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
|
|
36
|
+
export function entityRow(
|
|
37
|
+
relation: PgRelation,
|
|
38
|
+
physical: PhysicalRow,
|
|
39
|
+
image: 'before' | 'after',
|
|
40
|
+
): PhysicalRow {
|
|
41
|
+
const entity = entityForTable(relation.name);
|
|
42
|
+
if (entity === undefined) {
|
|
159
43
|
throw new ReplicationProtocolError({
|
|
160
44
|
stage: 'value',
|
|
161
|
-
detail: `
|
|
162
|
-
fix:
|
|
45
|
+
detail: `table "${relation.name}" has no registered entity, so its rows cannot be decoded`,
|
|
46
|
+
fix: 'load the app before starting the replicator — runRole({ root, env }) does — or drop the table from the entity list',
|
|
163
47
|
});
|
|
164
48
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* A physical postgres row -> the row shape the rest of the pipeline is written against.
|
|
170
|
-
* Two things are not one-to-one and both live here: the column is snake_case while the entity
|
|
171
|
-
* property is camelCase, and money is one property over the columns `<p>_minor`/`<p>_currency`
|
|
172
|
-
* and the nullable `<p>_scale`.
|
|
173
|
-
*/
|
|
174
|
-
export function entityRow(physical: Readonly<Record<string, PhysicalValue>>): PhysicalRow {
|
|
175
|
-
const row: PhysicalRow = {};
|
|
176
|
-
// Column order in is key order out; a folded money property lands wherever its earliest member
|
|
177
|
-
// (whichever of the three the source happened to emit first) would otherwise have sat.
|
|
178
|
-
const consumed = new Set<string>();
|
|
179
|
-
// Which column produced each property, so a collision names both sides rather than losing one.
|
|
180
|
-
const taken = new Map<string, string>();
|
|
181
|
-
|
|
182
|
-
for (const name of Object.keys(physical)) {
|
|
183
|
-
if (consumed.has(name)) continue;
|
|
184
|
-
|
|
185
|
-
const money = foldMoney(physical, name);
|
|
186
|
-
if (money !== null) {
|
|
187
|
-
claim(taken, money.property, money.columns.join('/'));
|
|
188
|
-
row[money.property] = money.value;
|
|
189
|
-
for (const column of money.columns) consumed.add(column);
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const property = camel(name);
|
|
194
|
-
claim(taken, property, name);
|
|
195
|
-
row[property] = physical[name] ?? null;
|
|
196
|
-
}
|
|
197
|
-
return row;
|
|
49
|
+
const source = image === 'before' ? replicatedOnly(relation, physical) : physical;
|
|
50
|
+
return decodeRow(entity, source) as PhysicalRow;
|
|
198
51
|
}
|
package/src/pg-preflight.ts
CHANGED
|
@@ -45,17 +45,20 @@ export async function preflight(
|
|
|
45
45
|
throw new ReplicationFailedError({
|
|
46
46
|
stage: 'preflight',
|
|
47
47
|
detail: `wal_level is "${walLevel?.[0] ?? 'unknown'}", so the server writes no logical WAL`,
|
|
48
|
-
|
|
48
|
+
// Not `ALTER SYSTEM`: managed and operator-run Postgres refuse it, and the setting lives in
|
|
49
|
+
// the provider's configuration. The setting, where it goes, and that it needs a restart.
|
|
50
|
+
fix: "set wal_level=logical in the server configuration — postgresql.conf, your managed provider's database flags, or `postgres -c wal_level=logical` on a container — then restart postgres",
|
|
49
51
|
});
|
|
50
52
|
}
|
|
51
53
|
const publications = await connection.query(
|
|
52
54
|
`SELECT 1 FROM pg_publication WHERE pubname = '${publication}'`,
|
|
53
55
|
);
|
|
54
56
|
if (publications.length === 0) {
|
|
57
|
+
const [role] = await connection.query('SELECT current_user');
|
|
55
58
|
throw new ReplicationFailedError({
|
|
56
59
|
stage: 'preflight',
|
|
57
60
|
detail: `no publication named "${publication}" exists`,
|
|
58
|
-
fix:
|
|
61
|
+
fix: publicationFix(publication, entities, role?.[0]),
|
|
59
62
|
});
|
|
60
63
|
}
|
|
61
64
|
await warnPartialIdentity(connection, entities);
|
|
@@ -77,6 +80,25 @@ export async function preflight(
|
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The publication an app needs is exactly its entities' tables, and `FOR TABLE` is what an app role
|
|
85
|
+
* that owns them may create — `FOR ALL TABLES`, which this said, needs a superuser a managed
|
|
86
|
+
* database never hands out. Streaming also needs the `REPLICATION` role attribute, which no fix
|
|
87
|
+
* line named. Every name here already passed `assertIdentifier`; the role is quoted, because a
|
|
88
|
+
* role name is whatever the operator chose.
|
|
89
|
+
*/
|
|
90
|
+
function publicationFix(
|
|
91
|
+
publication: string,
|
|
92
|
+
entities: ReadonlySet<string>,
|
|
93
|
+
role: string | null | undefined,
|
|
94
|
+
): string {
|
|
95
|
+
const tables = [...entities].sort().join(', ');
|
|
96
|
+
const create = `CREATE PUBLICATION ${publication} FOR TABLE ${tables};`;
|
|
97
|
+
const who =
|
|
98
|
+
typeof role === 'string' && role !== '' ? `"${role.replaceAll('"', '""')}"` : 'CURRENT_USER';
|
|
99
|
+
return `${create} -- and, if the role cannot stream yet: ALTER ROLE ${who} WITH REPLICATION;`;
|
|
100
|
+
}
|
|
101
|
+
|
|
80
102
|
/**
|
|
81
103
|
* The fourth preflight question, and the one that does NOT refuse. A live query decides whether a
|
|
82
104
|
* row left its result set from `change.before`, and under any replica identity but FULL that tuple
|
package/src/pg-replication.ts
CHANGED
|
@@ -337,8 +337,15 @@ export class PgReplicationStream {
|
|
|
337
337
|
case 'delete':
|
|
338
338
|
await this.#deliver('delete', message.relation, message.before, null, handlers);
|
|
339
339
|
return;
|
|
340
|
+
case 'truncate':
|
|
341
|
+
// One change per truncated relation, rowless. It was decoded and DROPPED here, and with the
|
|
342
|
+
// recommended `FOR ALL TABLES` publication every window and every client kept the rows.
|
|
343
|
+
for (const relation of message.relations) {
|
|
344
|
+
await this.#deliver('truncate', relation, null, null, handlers);
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
340
347
|
default:
|
|
341
|
-
// Relation,
|
|
348
|
+
// Relation, origin, type, logical message: nothing the matcher can act on.
|
|
342
349
|
return;
|
|
343
350
|
}
|
|
344
351
|
}
|
|
@@ -375,9 +382,11 @@ export class PgReplicationStream {
|
|
|
375
382
|
// Read off the Relation message rather than off the tuple: a DEFAULT-identity table whose
|
|
376
383
|
// non-key columns happen to be NULL sends the same bytes a FULL one does, so counting missing
|
|
377
384
|
// keys would undercount exactly the rows a policy is most likely to misjudge.
|
|
378
|
-
if (op
|
|
379
|
-
|
|
380
|
-
|
|
385
|
+
if ((op === 'update' || op === 'delete') && relation.replicaIdentity !== 'f') {
|
|
386
|
+
this.#partialBefore += 1;
|
|
387
|
+
}
|
|
388
|
+
const before = toRow(relation, oldTuple, 'before');
|
|
389
|
+
const after = toRow(relation, newTuple, 'after');
|
|
381
390
|
const event: ChangeEvent = {
|
|
382
391
|
entity: relation.name,
|
|
383
392
|
op,
|
|
@@ -462,9 +471,13 @@ export class PgReplicationStream {
|
|
|
462
471
|
}
|
|
463
472
|
|
|
464
473
|
/** A physical tuple becomes the row the matcher's predicates are written against, or nothing. */
|
|
465
|
-
function toRow(
|
|
474
|
+
function toRow(
|
|
475
|
+
relation: PgRelation,
|
|
476
|
+
physical: PhysicalRow | null,
|
|
477
|
+
image: 'before' | 'after',
|
|
478
|
+
): Row | null {
|
|
466
479
|
if (physical === null) return null;
|
|
467
|
-
const row = entityRow(physical);
|
|
480
|
+
const row = entityRow(relation, physical, image);
|
|
468
481
|
// A bigserial id decodes as a number inside `Number.isSafeInteger` range and as text outside it,
|
|
469
482
|
// so the same table would otherwise identify small rows by number and large ones by string.
|
|
470
483
|
// `Row.id`, `RowPatch.id` and every cursor are text: the identity is normalised once, here.
|
package/src/pg-wire.ts
CHANGED
|
@@ -36,7 +36,17 @@ const MAX_MESSAGE_BYTES = 64 * 1024 * 1024;
|
|
|
36
36
|
*/
|
|
37
37
|
export class MessageReader {
|
|
38
38
|
readonly #stream: PgStream;
|
|
39
|
+
/** Bytes of messages whose length is not yet known, or complete ones not yet taken. */
|
|
39
40
|
#buffer: Uint8Array = new Uint8Array(0);
|
|
41
|
+
/**
|
|
42
|
+
* One message whose length IS known and whose bytes are still arriving: allocated once at its
|
|
43
|
+
* full size and filled in place. Joining every chunk onto what was held re-copied the whole
|
|
44
|
+
* message per chunk — quadratic, measured at 5.2 s of blocked event loop for one 32 MB CopyData
|
|
45
|
+
* on the replication connection every live window depends on.
|
|
46
|
+
*/
|
|
47
|
+
#pending: { readonly bytes: Uint8Array; filled: number } | null = null;
|
|
48
|
+
/** Messages completed out of `#pending`, in arrival order, ahead of anything in `#buffer`. */
|
|
49
|
+
readonly #ready: Uint8Array[] = [];
|
|
40
50
|
|
|
41
51
|
constructor(stream: PgStream) {
|
|
42
52
|
this.#stream = stream;
|
|
@@ -44,7 +54,8 @@ export class MessageReader {
|
|
|
44
54
|
|
|
45
55
|
/** Bytes already read but not yet consumed — what a reconnect would have to replay. */
|
|
46
56
|
get buffered(): number {
|
|
47
|
-
|
|
57
|
+
const ready = this.#ready.reduce((sum, bytes) => sum + bytes.length, 0);
|
|
58
|
+
return this.#buffer.length + (this.#pending?.filled ?? 0) + ready;
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
/** The next complete message, or `undefined` at a clean EOF. */
|
|
@@ -54,10 +65,11 @@ export class MessageReader {
|
|
|
54
65
|
if (framed !== undefined) return framed;
|
|
55
66
|
const chunk = await this.#stream.read();
|
|
56
67
|
if (chunk === undefined) {
|
|
57
|
-
|
|
68
|
+
const held = this.buffered;
|
|
69
|
+
if (held === 0) return undefined;
|
|
58
70
|
throw new ReplicationProtocolError({
|
|
59
71
|
stage: 'read',
|
|
60
|
-
detail: `the connection closed with ${
|
|
72
|
+
detail: `the connection closed with ${held} bytes of a partial message`,
|
|
61
73
|
fix: 'x doctor db — the backend was terminated mid-message; the server log names the reason',
|
|
62
74
|
});
|
|
63
75
|
}
|
|
@@ -66,18 +78,33 @@ export class MessageReader {
|
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
#append(chunk: Uint8Array): void {
|
|
81
|
+
let rest = chunk;
|
|
82
|
+
const pending = this.#pending;
|
|
83
|
+
if (pending !== null) {
|
|
84
|
+
const take = Math.min(pending.bytes.length - pending.filled, rest.length);
|
|
85
|
+
pending.bytes.set(rest.subarray(0, take), pending.filled);
|
|
86
|
+
pending.filled += take;
|
|
87
|
+
rest = rest.subarray(take);
|
|
88
|
+
if (pending.filled < pending.bytes.length) return;
|
|
89
|
+
this.#ready.push(pending.bytes);
|
|
90
|
+
this.#pending = null;
|
|
91
|
+
}
|
|
92
|
+
if (rest.length === 0) return;
|
|
69
93
|
if (this.#buffer.length === 0) {
|
|
70
|
-
this.#buffer =
|
|
94
|
+
this.#buffer = rest;
|
|
71
95
|
return;
|
|
72
96
|
}
|
|
73
|
-
|
|
97
|
+
// Only ever a few bytes are held here: a message whose length is known moves to `#pending`.
|
|
98
|
+
const joined = new Uint8Array(this.#buffer.length + rest.length);
|
|
74
99
|
joined.set(this.#buffer, 0);
|
|
75
|
-
joined.set(
|
|
100
|
+
joined.set(rest, this.#buffer.length);
|
|
76
101
|
this.#buffer = joined;
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
/** A message is `tag` + Int32 length that counts itself but not the tag. */
|
|
80
105
|
#take(): PgMessage | undefined {
|
|
106
|
+
const done = this.#ready.shift();
|
|
107
|
+
if (done !== undefined) return messageOf(done);
|
|
81
108
|
const buffer = this.#buffer;
|
|
82
109
|
if (buffer.length < 5) return undefined;
|
|
83
110
|
const length = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getInt32(
|
|
@@ -92,16 +119,24 @@ export class MessageReader {
|
|
|
92
119
|
});
|
|
93
120
|
}
|
|
94
121
|
const total = length + 1;
|
|
95
|
-
if (buffer.length < total)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
122
|
+
if (buffer.length < total) {
|
|
123
|
+
// The length is known, so the message gets its one allocation now and fills in place.
|
|
124
|
+
const bytes = new Uint8Array(total);
|
|
125
|
+
bytes.set(buffer, 0);
|
|
126
|
+
this.#pending = { bytes, filled: buffer.length };
|
|
127
|
+
this.#buffer = new Uint8Array(0);
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
100
130
|
this.#buffer = buffer.subarray(total);
|
|
101
|
-
return
|
|
131
|
+
return messageOf(buffer.subarray(0, total));
|
|
102
132
|
}
|
|
103
133
|
}
|
|
104
134
|
|
|
135
|
+
const messageOf = (whole: Uint8Array): PgMessage => ({
|
|
136
|
+
tag: String.fromCharCode(whole[0] ?? 0),
|
|
137
|
+
body: whole.subarray(5),
|
|
138
|
+
});
|
|
139
|
+
|
|
105
140
|
/** `tag` + Int32 length + body — the shape of every frontend message except the startup packet. */
|
|
106
141
|
export const frame = (tag: string, body: Uint8Array): Uint8Array =>
|
|
107
142
|
new ByteWriter(body.length + 5)
|
|
@@ -189,10 +224,11 @@ export const FIXES: Readonly<Record<string, string>> = {
|
|
|
189
224
|
// the half of #97 that outlived the three log-injection holes. The publication is the operator's
|
|
190
225
|
// to create; the slot the replicator creates for itself on its next start.
|
|
191
226
|
'42704':
|
|
192
|
-
'psql "$REPLICATION_URL" -c "CREATE PUBLICATION x_changes FOR
|
|
193
|
-
" # x_changes is the default name; use REPLICATION_PUBLICATION's value where it is set. " +
|
|
227
|
+
'psql "$REPLICATION_URL" -c "CREATE PUBLICATION x_changes FOR TABLE <every entity table>"' +
|
|
228
|
+
" # x_changes is the default name; use REPLICATION_PUBLICATION's value where it is set, and FOR TABLE because FOR ALL TABLES needs a superuser. " +
|
|
194
229
|
"The slot is the replicator's own and it creates one on its next start",
|
|
195
|
-
'0A000':
|
|
230
|
+
'0A000':
|
|
231
|
+
"set wal_level=logical in the server configuration (postgresql.conf, or your managed provider's database flags) and restart the server",
|
|
196
232
|
};
|
|
197
233
|
|
|
198
234
|
/** What a SQLSTATE this table has no entry for is answered with. */
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// A policy that admits every caller, for the tests that declare a channel whose authz is not
|
|
2
|
+
// their subject. Structural, never built with `@ultimat3/policy`: realtime reaches that package
|
|
3
|
+
// only through `@ultimat3/query`'s `guard`, and a direct import would be a second authz path.
|
|
4
|
+
// An app says the same thing with `allow('public')`, which is what this stands in for.
|
|
5
|
+
|
|
6
|
+
import type { QueryPolicy } from '@ultimat3/query';
|
|
7
|
+
|
|
8
|
+
export const OPEN_POLICY: QueryPolicy = Object.freeze({
|
|
9
|
+
kind: 'allow',
|
|
10
|
+
label: 'public',
|
|
11
|
+
permissions: [],
|
|
12
|
+
children: [],
|
|
13
|
+
run: () => ({ allowed: true as const }),
|
|
14
|
+
});
|
package/src/query-window.ts
CHANGED
|
@@ -125,29 +125,43 @@ export function createEntry(
|
|
|
125
125
|
export async function fillWindow(
|
|
126
126
|
entry: QueryEntry,
|
|
127
127
|
): Promise<{ rows: readonly Row[]; lsn: string }> {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
128
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
129
|
+
// Read before `startRead` clears it: a second caller arriving during the read joins it and is
|
|
130
|
+
// not the one that forced it, which is what keeps one forced read from becoming N.
|
|
131
|
+
const forced = entry.stale;
|
|
132
|
+
const pending = forced || entry.reading === null ? startRead(entry) : entry.reading;
|
|
133
|
+
const result = await pending.result;
|
|
134
|
+
const again = await entry.lock.run(async () => {
|
|
135
|
+
// Two rules, and neither can stand in for the other. Against another READ it is identity —
|
|
136
|
+
// the same check `startRead` makes on `entry.reading` one function down, and the one
|
|
137
|
+
// `packages/cache/src/single-flight.ts` makes for the same reason — because an lsn cannot
|
|
138
|
+
// order two reads at all: a definition with no lsn provider answers `''` for both, and
|
|
139
|
+
// `'' >= ''` let the older one overwrite the gap repair the newer one had just landed, with
|
|
140
|
+
// `stale` already cleared by its issue and therefore nothing left to re-read. Against a
|
|
141
|
+
// CHANGE it is still the lsn, because a fanout moved `entry.lsn` forwards while this read was
|
|
142
|
+
// in flight and rewinding to what the read saw hands that subscriber rows the fanout has
|
|
143
|
+
// moved past — except for a forced read, which was issued *because* what is under it is
|
|
144
|
+
// wrong, and for the FIRST read, which has no window under it to rewind: a fanout never
|
|
145
|
+
// patches a window no read has landed in (`live-fanout.ts`), it marks it stale instead.
|
|
146
|
+
const first = entry.applied === 0;
|
|
147
|
+
if (isNewestRead(entry, pending) && (forced || first || result.lsn >= entry.lsn)) {
|
|
148
|
+
applyRead(entry, pending, result);
|
|
149
|
+
}
|
|
150
|
+
// A change reached this window while its first read was in flight and could not be folded,
|
|
151
|
+
// so what just landed may predate it: read once more before serving anyone a partial window.
|
|
152
|
+
return first && entry.stale && attempt < COLD_REREADS;
|
|
153
|
+
});
|
|
154
|
+
if (!again) return { rows: entry.rows, lsn: entry.lsn };
|
|
155
|
+
}
|
|
149
156
|
}
|
|
150
157
|
|
|
158
|
+
/**
|
|
159
|
+
* How many times a cold window re-reads because writes kept landing during its read. Bounded: a
|
|
160
|
+
* table written faster than it can be read would otherwise never serve a subscriber, and after the
|
|
161
|
+
* bound the window stays `stale`, so the next change re-reads it anyway.
|
|
162
|
+
*/
|
|
163
|
+
const COLD_REREADS = 3;
|
|
164
|
+
|
|
151
165
|
/**
|
|
152
166
|
* The same replacement, for a caller that is already holding the lane. A fanout cannot call
|
|
153
167
|
* `fillWindow` — that takes the entry's own lane, and a lane is not reentrant — so the one path
|
package/src/replicator.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { isWriteDigest, logger, uuid, withSpan } from '@ultimat3/core';
|
|
15
15
|
import type { ChangeEvent, ChangeFeed } from './changefeed';
|
|
16
16
|
import type { Transport } from './fanout';
|
|
17
|
-
import { type BackoffPolicy,
|
|
17
|
+
import { type BackoffPolicy, defaultBackoff, policyDelay, type Rng } from './thundering-herd';
|
|
18
18
|
|
|
19
19
|
export const CHANGE_SUBJECT_PREFIX = 'x.change';
|
|
20
20
|
|
|
@@ -225,13 +225,16 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
|
|
|
225
225
|
},
|
|
226
226
|
|
|
227
227
|
retryDelayMs(attempt: number): number {
|
|
228
|
-
|
|
228
|
+
// This method's contract is 0-based (`retryDelayMs(0)` is the base); core counts from 1.
|
|
229
|
+
return policyDelay(backoff, attempt + 1, options.rng ?? Math.random);
|
|
229
230
|
},
|
|
230
231
|
};
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
/** Drops events the pipeline cannot use and hoists the tenant id out of the row. */
|
|
234
235
|
export function normalize(change: ChangeEvent): ChangeEvent | null {
|
|
236
|
+
// The one change that carries no row by design; nothing to hoist a tenant out of.
|
|
237
|
+
if (change.op === 'truncate') return change;
|
|
235
238
|
const row = change.after ?? change.before;
|
|
236
239
|
if (!row) return null;
|
|
237
240
|
if (change.op === 'insert' && change.after === null) return null;
|
|
@@ -253,7 +256,14 @@ export function parseEnvelope(payload: string): ChangeEnvelope | null {
|
|
|
253
256
|
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
254
257
|
const shape = parsed as Partial<ChangeEvent> & { seq?: unknown; producer?: unknown };
|
|
255
258
|
if (typeof shape.entity !== 'string' || typeof shape.lsn !== 'string') return null;
|
|
256
|
-
if (
|
|
259
|
+
if (
|
|
260
|
+
shape.op !== 'insert' &&
|
|
261
|
+
shape.op !== 'update' &&
|
|
262
|
+
shape.op !== 'delete' &&
|
|
263
|
+
shape.op !== 'truncate'
|
|
264
|
+
) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
257
267
|
return {
|
|
258
268
|
change: {
|
|
259
269
|
entity: shape.entity,
|
package/src/server.ts
CHANGED
|
@@ -41,6 +41,7 @@ export {
|
|
|
41
41
|
DEFAULT_MAX_TOPICS_PER_NODE,
|
|
42
42
|
} from './channel';
|
|
43
43
|
export { type ChannelDescription, describeChannels } from './channel-describe';
|
|
44
|
+
export { RealtimeTopologyError } from './errors';
|
|
44
45
|
export {
|
|
45
46
|
InProcessTransport,
|
|
46
47
|
type InProcessTransportOptions,
|
|
@@ -62,6 +63,11 @@ export {
|
|
|
62
63
|
LiveQueryRegistry,
|
|
63
64
|
type LiveQueryRegistryOptions,
|
|
64
65
|
} from './live-query';
|
|
66
|
+
export {
|
|
67
|
+
type LiveReplicator,
|
|
68
|
+
type LiveReplicatorOptions,
|
|
69
|
+
startLiveReplicator,
|
|
70
|
+
} from './live-replicator';
|
|
65
71
|
export {
|
|
66
72
|
applyToWindow,
|
|
67
73
|
type BridgeResult,
|
|
@@ -109,7 +115,7 @@ export { openNatsClient } from './nats-lib-client';
|
|
|
109
115
|
export { NatsTransport, type NatsTransportOptions } from './nats-transport';
|
|
110
116
|
export { PgAdvisoryLock, type PgAdvisoryLockOptions } from './pg-advisory-lock';
|
|
111
117
|
// ---- the postgres replication path ------------------------------------------------------------
|
|
112
|
-
export {
|
|
118
|
+
export { entityRow } from './pg-entity-row';
|
|
113
119
|
export {
|
|
114
120
|
changeLsn,
|
|
115
121
|
commitPositionOf,
|
|
@@ -166,16 +172,15 @@ export {
|
|
|
166
172
|
actorIdOf,
|
|
167
173
|
CLOSE,
|
|
168
174
|
DEFAULT_FRAME_BURST,
|
|
169
|
-
DEFAULT_IDLE_TIMEOUT_MS,
|
|
170
175
|
DEFAULT_MAX_BUFFERED_BYTES,
|
|
171
176
|
DEFAULT_MAX_FRAMES_PER_SECOND,
|
|
172
|
-
idleSweepPeriodMs,
|
|
173
177
|
SocketRegistry,
|
|
174
178
|
type SocketRegistryOptions,
|
|
175
179
|
SyncSocket,
|
|
176
180
|
type SyncSocketOptions,
|
|
177
181
|
type WsLike,
|
|
178
182
|
} from './socket';
|
|
183
|
+
export { DEFAULT_IDLE_TIMEOUT_MS, idleSweepPeriodMs } from './socket-idle';
|
|
179
184
|
export type {
|
|
180
185
|
GateFailed,
|
|
181
186
|
GateStage,
|