@ultimat3/realtime 20.2.1 → 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 +300 -952
- package/README.md +192 -131
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +14 -1
- package/src/channel-authz.ts +52 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +155 -0
- package/src/channel-describe.ts +35 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +134 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +87 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +359 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +150 -195
- package/src/cursor.ts +7 -2
- package/src/errors.ts +55 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +133 -0
- package/src/idb-types.ts +48 -0
- package/src/index.ts +80 -75
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +15 -4
- package/src/live-fanout.ts +81 -6
- package/src/live-query.ts +11 -0
- package/src/live-record-type.ts +19 -0
- package/src/live-replicator.ts +160 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +324 -0
- 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 +85 -39
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +312 -0
- package/src/page-socket.ts +139 -0
- package/src/page-store.ts +138 -0
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-preflight.ts +24 -2
- package/src/pg-replication.ts +28 -8
- package/src/pg-wire.ts +51 -15
- package/src/pgoutput.ts +37 -2
- package/src/policy-fake.ts +14 -0
- package/src/presence.ts +17 -9
- package/src/query-window.ts +38 -21
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +20 -4
- package/src/server.ts +10 -11
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +344 -0
- package/src/socket-host.ts +225 -0
- package/src/socket-idle.ts +21 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +91 -49
- package/src/subscriber-gate.ts +92 -3
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +26 -114
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +31 -12
- package/src/transport-env.ts +55 -14
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +227 -0
- package/src/use-query.ts +260 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- package/src/server-render-client.ts +0 -96
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
|
@@ -13,7 +13,7 @@ import { entityRow } from './pg-entity-row';
|
|
|
13
13
|
import { assertIdentifier, preflight } from './pg-preflight';
|
|
14
14
|
import { bunPgStream, parsePgUrl } from './pg-socket';
|
|
15
15
|
import type { PhysicalRow } from './pg-values';
|
|
16
|
-
import { PgOutputDecoder, type PgOutputMessage, type PgRelation } from './pgoutput';
|
|
16
|
+
import { keyedWrite, PgOutputDecoder, type PgOutputMessage, type PgRelation } from './pgoutput';
|
|
17
17
|
|
|
18
18
|
const DEFAULT_STATUS_INTERVAL_MS = 10_000;
|
|
19
19
|
|
|
@@ -64,6 +64,8 @@ interface Transaction {
|
|
|
64
64
|
readonly xid: number;
|
|
65
65
|
/** Position of the next row inside this transaction. Reproducible, which is what makes it usable. */
|
|
66
66
|
sequence: number;
|
|
67
|
+
/** The keyed write this transaction is (`ChangeEvent.write`), from its opening WAL message. */
|
|
68
|
+
write?: string | undefined;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
/**
|
|
@@ -171,7 +173,7 @@ export class PgReplicationStream {
|
|
|
171
173
|
this.#confirmed = from === undefined ? 0n : commitPositionOf(from);
|
|
172
174
|
await connection.startCopyBoth(
|
|
173
175
|
`START_REPLICATION SLOT ${slot} LOGICAL ${printLsn(this.#confirmed)} ` +
|
|
174
|
-
`(proto_version '1', publication_names '${publication}')`,
|
|
176
|
+
`(proto_version '1', publication_names '${publication}', messages 'true')`,
|
|
175
177
|
);
|
|
176
178
|
} catch (failure) {
|
|
177
179
|
// The dial failure is the one that explains the boot, so a teardown that also failed must
|
|
@@ -318,6 +320,10 @@ export class PgReplicationStream {
|
|
|
318
320
|
sequence: 0,
|
|
319
321
|
};
|
|
320
322
|
return;
|
|
323
|
+
case 'message':
|
|
324
|
+
// The driver's first statement in a keyed write: every row after it is that write's.
|
|
325
|
+
if (this.#transaction !== null) this.#transaction.write ??= keyedWrite(message);
|
|
326
|
+
return;
|
|
321
327
|
case 'commit':
|
|
322
328
|
this.#transaction = null;
|
|
323
329
|
if (message.endLsn > this.#confirmed) this.#confirmed = message.endLsn;
|
|
@@ -331,8 +337,15 @@ export class PgReplicationStream {
|
|
|
331
337
|
case 'delete':
|
|
332
338
|
await this.#deliver('delete', message.relation, message.before, null, handlers);
|
|
333
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;
|
|
334
347
|
default:
|
|
335
|
-
// Relation,
|
|
348
|
+
// Relation, origin, type, logical message: nothing the matcher can act on.
|
|
336
349
|
return;
|
|
337
350
|
}
|
|
338
351
|
}
|
|
@@ -369,9 +382,11 @@ export class PgReplicationStream {
|
|
|
369
382
|
// Read off the Relation message rather than off the tuple: a DEFAULT-identity table whose
|
|
370
383
|
// non-key columns happen to be NULL sends the same bytes a FULL one does, so counting missing
|
|
371
384
|
// keys would undercount exactly the rows a policy is most likely to misjudge.
|
|
372
|
-
if (op
|
|
373
|
-
|
|
374
|
-
|
|
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');
|
|
375
390
|
const event: ChangeEvent = {
|
|
376
391
|
entity: relation.name,
|
|
377
392
|
op,
|
|
@@ -381,6 +396,7 @@ export class PgReplicationStream {
|
|
|
381
396
|
txid: transaction.xid.toString(10),
|
|
382
397
|
orgId: tenantOf(after ?? before),
|
|
383
398
|
at: transaction.commitAt,
|
|
399
|
+
...(transaction.write === undefined ? {} : { write: transaction.write }),
|
|
384
400
|
};
|
|
385
401
|
await handlers.onChange(event);
|
|
386
402
|
this.#lastLsn = lsn;
|
|
@@ -455,9 +471,13 @@ export class PgReplicationStream {
|
|
|
455
471
|
}
|
|
456
472
|
|
|
457
473
|
/** A physical tuple becomes the row the matcher's predicates are written against, or nothing. */
|
|
458
|
-
function toRow(
|
|
474
|
+
function toRow(
|
|
475
|
+
relation: PgRelation,
|
|
476
|
+
physical: PhysicalRow | null,
|
|
477
|
+
image: 'before' | 'after',
|
|
478
|
+
): Row | null {
|
|
459
479
|
if (physical === null) return null;
|
|
460
|
-
const row = entityRow(physical);
|
|
480
|
+
const row = entityRow(relation, physical, image);
|
|
461
481
|
// A bigserial id decodes as a number inside `Number.isSafeInteger` range and as text outside it,
|
|
462
482
|
// so the same table would otherwise identify small rows by number and large ones by string.
|
|
463
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. */
|
package/src/pgoutput.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// What a tuple's TEXT means is `pg-values.ts`'s: this file frames messages, that one owns the type
|
|
6
6
|
// catalogue that turns postgres' text into the value a repository row holds.
|
|
7
7
|
|
|
8
|
+
import { isWriteDigest, WRITE_ORIGIN_WAL_PREFIX } from '@ultimat3/core';
|
|
8
9
|
import { ReplicationProtocolError } from './errors';
|
|
9
10
|
import { ByteReader, pgTimestampToEpochMs } from './pg-bytes';
|
|
10
11
|
import { decodeValue, type PhysicalRow } from './pg-values';
|
|
@@ -50,7 +51,14 @@ export type PgOutputMessage =
|
|
|
50
51
|
}
|
|
51
52
|
| { readonly kind: 'delete'; readonly relation: PgRelation; readonly before: PhysicalRow }
|
|
52
53
|
| { readonly kind: 'truncate'; readonly relations: readonly PgRelation[] }
|
|
53
|
-
/**
|
|
54
|
+
/** `pg_logical_emit_message` — sent only when START_REPLICATION asks with `messages 'true'`. */
|
|
55
|
+
| {
|
|
56
|
+
readonly kind: 'message';
|
|
57
|
+
readonly transactional: boolean;
|
|
58
|
+
readonly prefix: string;
|
|
59
|
+
readonly content: string;
|
|
60
|
+
}
|
|
61
|
+
/** origin / type — decoded far enough to be skipped safely. */
|
|
54
62
|
| { readonly kind: 'other'; readonly tag: string };
|
|
55
63
|
|
|
56
64
|
/**
|
|
@@ -103,6 +111,31 @@ function decodeTupleData(reader: ByteReader, relation: PgRelation): PhysicalRow
|
|
|
103
111
|
return row;
|
|
104
112
|
}
|
|
105
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Int8 flags (bit 1: transactional) · Int64 lsn · String prefix · Int32 length · Byte[length]. No
|
|
116
|
+
* xid: that field exists only inside a streamed transaction, which this stream never asks for.
|
|
117
|
+
*/
|
|
118
|
+
function decodeMessage(reader: ByteReader): PgOutputMessage {
|
|
119
|
+
const transactional = (reader.uint8() & 1) === 1;
|
|
120
|
+
reader.int64();
|
|
121
|
+
const prefix = reader.cstring();
|
|
122
|
+
const content = reader.utf8(reader.int32());
|
|
123
|
+
return { kind: 'message', transactional, prefix, content };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The write a transactional message names — `@ultimat3/entity`'s Postgres driver opens a keyed
|
|
128
|
+
* write's transaction with one — or `undefined` for any other message an app or extension emits.
|
|
129
|
+
*/
|
|
130
|
+
export function keyedWrite(message: {
|
|
131
|
+
readonly transactional: boolean;
|
|
132
|
+
readonly prefix: string;
|
|
133
|
+
readonly content: string;
|
|
134
|
+
}): string | undefined {
|
|
135
|
+
const named = message.transactional && message.prefix === WRITE_ORIGIN_WAL_PREFIX;
|
|
136
|
+
return named && isWriteDigest(message.content) ? message.content : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
106
139
|
/**
|
|
107
140
|
* Holds the relation cache: postgres sends a `Relation` message once per table per connection and
|
|
108
141
|
* every later tuple references it by oid, so a decoder instance is per-connection and is thrown
|
|
@@ -129,7 +162,9 @@ export class PgOutputDecoder {
|
|
|
129
162
|
return this.#decodeDelete(reader);
|
|
130
163
|
case 'T':
|
|
131
164
|
return this.#decodeTruncate(reader);
|
|
132
|
-
|
|
165
|
+
case 'M':
|
|
166
|
+
return decodeMessage(reader);
|
|
167
|
+
// 'O' (origin), 'Y' (type), and any tag a newer server invents:
|
|
133
168
|
// nothing downstream needs them decoded, and guessing at an unknown tag's shape is how a
|
|
134
169
|
// truncated read turns into a silent misread instead of a clean skip.
|
|
135
170
|
default:
|
|
@@ -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/presence.ts
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
// Presence lives in `transport.shared`, never in a node's heap: when a `sync` node dies its members
|
|
4
4
|
// simply stop heartbeating and expire, and every other node already sees the same set. Ephemeral
|
|
5
5
|
// state is never modelled as rows — that rule is what keeps presence off the write path entirely.
|
|
6
|
+
// On the wire it is not a frame kind: a roster change is an `events` frame on the channel the member
|
|
7
|
+
// joined (`channel-presence.ts` is the payload), so only a channel declared `events: true` has one.
|
|
6
8
|
|
|
7
9
|
import { type Clock, finiteOption, systemClock, uuid } from '@ultimat3/core';
|
|
8
10
|
import type { ChannelHub, Topic } from './channel';
|
|
11
|
+
import { presenceEvent } from './channel-presence';
|
|
12
|
+
import type { ChannelEventsFrame } from './channel-wire';
|
|
9
13
|
import type { Transport } from './fanout';
|
|
10
14
|
import type { JsonObject } from './json';
|
|
11
|
-
import {
|
|
15
|
+
import { PROTOCOL_VERSION, type PresenceMember } from './sync-protocol';
|
|
12
16
|
|
|
13
17
|
export const PRESENCE_KEY_PREFIX = 'presence';
|
|
14
18
|
/** Separate namespace: the sweep lease is one member per *node*, never one per participant. */
|
|
@@ -218,7 +222,7 @@ export class PresenceRegistry {
|
|
|
218
222
|
}
|
|
219
223
|
|
|
220
224
|
/** Full-set frame for a client that just (re)connected — presence has no delta protocol. */
|
|
221
|
-
async syncFrame(name: Topic): Promise<
|
|
225
|
+
async syncFrame(name: Topic): Promise<ChannelEventsFrame> {
|
|
222
226
|
const roster = await this.roster(name);
|
|
223
227
|
return presenceFrame(name, 'sync', roster.members, roster.total);
|
|
224
228
|
}
|
|
@@ -256,23 +260,27 @@ export class PresenceRegistry {
|
|
|
256
260
|
members: readonly PresenceMember[],
|
|
257
261
|
): Promise<void> {
|
|
258
262
|
if (!this.#hub) return;
|
|
259
|
-
await this.#hub.
|
|
263
|
+
await this.#hub.emit(name, presenceEvent(op, members));
|
|
260
264
|
}
|
|
261
265
|
}
|
|
262
266
|
|
|
263
267
|
/**
|
|
264
|
-
* `
|
|
265
|
-
*
|
|
266
|
-
*
|
|
268
|
+
* The roster as the `events` frame ONE socket is sent directly — the join reply. Everything else
|
|
269
|
+
* reaches sockets through `ChannelHub.emit`, the channel's own events path. `total` belongs to a
|
|
270
|
+
* full `sync` set and to nothing else: a delta carries the members that changed.
|
|
267
271
|
*/
|
|
268
272
|
export function presenceFrame(
|
|
269
273
|
name: Topic,
|
|
270
274
|
op: 'join' | 'leave' | 'update' | 'sync',
|
|
271
275
|
members: readonly PresenceMember[],
|
|
272
276
|
total?: number,
|
|
273
|
-
):
|
|
274
|
-
|
|
275
|
-
|
|
277
|
+
): ChannelEventsFrame {
|
|
278
|
+
return {
|
|
279
|
+
type: 'events',
|
|
280
|
+
v: PROTOCOL_VERSION,
|
|
281
|
+
channel: name,
|
|
282
|
+
event: presenceEvent(op, members, total),
|
|
283
|
+
};
|
|
276
284
|
}
|
|
277
285
|
|
|
278
286
|
function parseMember(id: string, value: string): PresenceMember | null {
|