@ultimat3/realtime 21.0.0 → 22.1.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.
Files changed (55) hide show
  1. package/CLAUDE.md +302 -1009
  2. package/README.md +130 -26
  3. package/package.json +4 -4
  4. package/src/changefeed.ts +7 -1
  5. package/src/channel-authz.ts +23 -4
  6. package/src/channel-decl.ts +16 -5
  7. package/src/channel-describe.ts +7 -5
  8. package/src/channel-logs.ts +19 -1
  9. package/src/channel-records.ts +8 -0
  10. package/src/client-channels.ts +75 -5
  11. package/src/client.ts +14 -2
  12. package/src/cursor.ts +5 -0
  13. package/src/errors.ts +43 -1
  14. package/src/idb-fake.ts +24 -4
  15. package/src/idb-types.ts +7 -0
  16. package/src/index.ts +1 -1
  17. package/src/live-definition.ts +5 -1
  18. package/src/live-fanout.ts +51 -2
  19. package/src/live-query.ts +11 -0
  20. package/src/live-replicator.ts +160 -0
  21. package/src/local-store-idb.ts +89 -15
  22. package/src/matcher-bridge.ts +5 -0
  23. package/src/nats-fake.ts +10 -1
  24. package/src/nats-jetstream.ts +36 -14
  25. package/src/nats-transport.ts +2 -2
  26. package/src/offline-queue.ts +76 -21
  27. package/src/page-outbox.ts +80 -10
  28. package/src/page-socket.ts +39 -8
  29. package/src/pg-entity-row.ts +37 -184
  30. package/src/pg-identifier.ts +23 -0
  31. package/src/pg-preflight.ts +32 -45
  32. package/src/pg-publication.ts +95 -0
  33. package/src/pg-replication.ts +21 -7
  34. package/src/pg-socket.ts +139 -53
  35. package/src/pg-tls.ts +124 -0
  36. package/src/pg-wire.ts +65 -16
  37. package/src/policy-fake.ts +14 -0
  38. package/src/query-window.ts +35 -21
  39. package/src/replication-errors.ts +29 -16
  40. package/src/replicator.ts +13 -3
  41. package/src/server.ts +8 -3
  42. package/src/socket-drops.ts +30 -0
  43. package/src/socket-engine.ts +15 -3
  44. package/src/socket-host.ts +103 -4
  45. package/src/socket-idle.ts +21 -0
  46. package/src/socket.ts +41 -38
  47. package/src/subscriber-gate.ts +92 -3
  48. package/src/sync-node-contract.ts +6 -0
  49. package/src/sync-node.ts +3 -7
  50. package/src/sync-origin.ts +33 -0
  51. package/src/sync-upgrade.ts +33 -9
  52. package/src/thundering-herd.ts +21 -11
  53. package/src/transport-env.ts +55 -14
  54. package/src/use-mutation.ts +13 -0
  55. package/src/use-query.ts +10 -5
@@ -1,198 +1,51 @@
1
- // Physical Postgres row -> entity-row shaping: snake_case columns become camelCase properties,
2
- // and the `<p>_minor` / `<p>_currency` / `<p>_scale` columns fold into one `<p>: Money`-shaped
3
- // property. The inverse of the camelCasing here is `@ultimat3/entity`'s `column.ts#snake`, and
4
- // the fold is its `pg-row.ts#moneyOf` — neither is imported (this package declares no dependency
5
- // on tier 2), so both are pinned by a test instead: `pg-entity-row-parity.test.ts` reads one
6
- // physical row through both surfaces and asserts one object.
7
-
8
- import { describeValue } from '@ultimat3/core';
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, PhysicalValue } from './pg-values';
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
- * **Paired with `parseMinor` in `@ultimat3/entity`'s `columns.ts`, and the pairing is the rule
89
- * rather than the spelling: an amount may be echoed when it is *provably numeric*, and this file
90
- * has to prove it at run time because entity proves it from the branch.**
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 shownNumber(value: PhysicalValue): string {
104
- if (typeof value === 'number') return `"${value}"`;
105
- if (typeof value !== 'string') return describeValue(value);
106
- const numeric = value.trim() !== '' && Number.isFinite(Number(value));
107
- return numeric ? `"${value}"` : describeValue(value);
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
- * `camel()` is not injective — `a_b` and `a__b` both give `aB`, `x` and `x_` both give `x` — and
153
- * `entityRow` writes by assignment, so without this the second column would silently overwrite the
154
- * first and the row would be short one value with nothing to read about it.
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 claim(taken: Map<string, string>, property: string, column: string): void {
157
- const first = taken.get(property);
158
- if (first !== undefined) {
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: `columns "${first}" and "${column}" both map to the entity property "${property}"`,
162
- fix: `rename one of them — two columns cannot share one property once camelCased`,
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
- taken.set(property, column);
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
  }
@@ -0,0 +1,23 @@
1
+ // Single responsibility: the identifier charset every replication statement interpolates through.
2
+ // Its own module because the preflight and the publication both assert through it, and either
3
+ // importing the other for it would close a cycle.
4
+
5
+ import { ReplicationFailedError } from './errors';
6
+
7
+ /** Identifiers reach a simple query unparameterised, so the charset is the injection boundary. */
8
+ const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
9
+
10
+ /**
11
+ * The one gate between a caller-supplied name and a simple query. `PgReplicationStream` checks its
12
+ * slot, publication and entity names in its CONSTRUCTOR — a mistyped `REPLICATION_SLOT` is a
13
+ * boot-time fact, and finding it at the first WAL read means a replicator that reported itself
14
+ * started and then never delivered a change.
15
+ */
16
+ export const assertIdentifier = (kind: string, value: string): string => {
17
+ if (IDENTIFIER.test(value)) return value;
18
+ throw new ReplicationFailedError({
19
+ stage: 'preflight',
20
+ detail: `${kind} "${value}" is not a lower-case postgres identifier`,
21
+ fix: `rename the ${kind} to match [a-z_][a-z0-9_]* — it is interpolated into a replication command`,
22
+ });
23
+ };
@@ -1,35 +1,20 @@
1
- // Single responsibility: the four questions asked of a database BEFORE `START_REPLICATION`, and
2
- // the identifier charset every one of them interpolates through. Three refuse the boot with the
3
- // exact statement that fixes them; the fourth warns, because refusing it would stop every app on
4
- // the default replica identity from starting.
1
+ // Single responsibility: the four questions asked of a database BEFORE `START_REPLICATION`. Two
2
+ // refuse the boot with the exact statement that fixes them, one — the publication — is answered by
3
+ // ensuring it (`pg-publication.ts`), and the fourth warns, because refusing it would stop every app
4
+ // on the default replica identity from starting.
5
5
 
6
6
  import { logger } from '@ultimat3/core';
7
7
  import { ReplicaIdentityError, ReplicationFailedError } from './errors';
8
8
  import type { PgConnection } from './pg-connection';
9
-
10
- /** Identifiers reach a simple query unparameterised, so the charset is the injection boundary. */
11
- const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
9
+ import { assertIdentifier } from './pg-identifier';
10
+ import { ensurePublication } from './pg-publication';
12
11
 
13
12
  /**
14
- * The one gate between a caller-supplied name and a simple query. Exported because
15
- * `PgReplicationStream` checks its slot, publication and entity names in its CONSTRUCTOR — a
16
- * mistyped `REPLICATION_SLOT` is a boot-time fact, and finding it at the first WAL read means a
17
- * replicator that reported itself started and then never delivered a change.
18
- */
19
- export const assertIdentifier = (kind: string, value: string): string => {
20
- if (IDENTIFIER.test(value)) return value;
21
- throw new ReplicationFailedError({
22
- stage: 'preflight',
23
- detail: `${kind} "${value}" is not a lower-case postgres identifier`,
24
- fix: `rename the ${kind} to match [a-z_][a-z0-9_]* — it is interpolated into a replication command`,
25
- });
26
- };
27
-
28
- /**
29
- * The four things that are always misconfigured. Three produce an unreadable server message if
30
- * left to the server, so each gets its own `fix:` line; the fourth is `warnPartialIdentity` and
31
- * only warns. `slot` and `publication` are interpolated into simple queries, so the `IDENTIFIER`
32
- * charset is the injection boundary; re-asserted here rather than trusted, so the guarantee
13
+ * The four things that are always misconfigured. `wal_level` and the slot produce an unreadable
14
+ * server message if left to the server, so each gets its own `fix:` line; the publication is
15
+ * created or extended here, and refused with its statement only when this role may not; the
16
+ * fourth is `warnPartialIdentity` and only warns. `slot` and `publication` are interpolated into
17
+ * simple queries, so the identifier charset is the injection boundary; re-asserted here rather than trusted, so the guarantee
33
18
  * travels with the function instead of living only in `start()`.
34
19
  */
35
20
  export async function preflight(
@@ -45,19 +30,12 @@ export async function preflight(
45
30
  throw new ReplicationFailedError({
46
31
  stage: 'preflight',
47
32
  detail: `wal_level is "${walLevel?.[0] ?? 'unknown'}", so the server writes no logical WAL`,
48
- fix: "ALTER SYSTEM SET wal_level = 'logical'; -- then restart postgres",
49
- });
50
- }
51
- const publications = await connection.query(
52
- `SELECT 1 FROM pg_publication WHERE pubname = '${publication}'`,
53
- );
54
- if (publications.length === 0) {
55
- throw new ReplicationFailedError({
56
- stage: 'preflight',
57
- detail: `no publication named "${publication}" exists`,
58
- fix: `CREATE PUBLICATION ${publication} FOR ALL TABLES;`,
33
+ // Not `ALTER SYSTEM`: managed and operator-run Postgres refuse it, and the setting lives in
34
+ // the provider's configuration. The setting, where it goes, and that it needs a restart.
35
+ 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",
59
36
  });
60
37
  }
38
+ await ensurePublication(connection, publication, entities);
61
39
  await warnPartialIdentity(connection, entities);
62
40
  const [existing] = await connection.query(
63
41
  `SELECT plugin FROM pg_replication_slots WHERE slot_name = '${slot}'`,
@@ -78,15 +56,16 @@ export async function preflight(
78
56
  }
79
57
 
80
58
  /**
81
- * The fourth preflight question, and the one that does NOT refuse. A live query decides whether a
82
- * row left its result set from `change.before`, and under any replica identity but FULL that tuple
83
- * is the key columns alone — which `toRow` accepts, since it only requires a text `id`.
59
+ * The fourth preflight question, and the one that does NOT refuse: which entity tables have NO
60
+ * replica identity. Once such a table is in the publication, Postgres refuses its UPDATE and
61
+ * DELETE outright, and a change could not be keyed if it did not. A table with a primary key is
62
+ * not named: under DEFAULT a delete carries its key, and a live query decides against the whole
63
+ * row its shared window holds, never against `change.before` alone.
84
64
  *
85
65
  * It runs BEFORE `pg_create_logical_replication_slot`: a slot decodes with the identity the
86
66
  * catalog held when the rows were written, so asking after the slot exists answers about a stream
87
- * nobody is reading yet. It WARNS rather than throws because every app on the default identity
88
- * would otherwise stop booting, and a replicator that will not start is worse than the partial
89
- * rows it is complaining about — `ReplicationStreamStats.partialBefore` is the running half.
67
+ * nobody is reading yet. It WARNS rather than throws, as it always has — a replicator that will
68
+ * not start is worse than the tables it is naming.
90
69
  *
91
70
  * Entity names are the ones the constructor already put through `assertIdentifier`, which is what
92
71
  * makes both the interpolation and the `fix:` safe; a name postgres answers with that is not in
@@ -98,9 +77,17 @@ async function warnPartialIdentity(
98
77
  ): Promise<void> {
99
78
  if (entities.size === 0) return;
100
79
  const names = [...entities].map((name) => `'${name}'`).join(', ');
80
+ // NO identity, not "not FULL": `n` (NOTHING), `d` (DEFAULT) with no primary key, or `i` whose
81
+ // index is gone.
82
+ // A keyed table replicates correctly under DEFAULT — a delete names its key, and the shared
83
+ // window holds the whole row a live query decides on — so naming it was noise on every boot.
101
84
  const rows = await connection.query(
102
- `SELECT relname FROM pg_class WHERE relkind = 'r' AND relreplident <> 'f' ` +
103
- `AND relname IN (${names})`,
85
+ `SELECT c.relname FROM pg_class c WHERE c.relkind = 'r' AND c.relname IN (${names}) ` +
86
+ `AND (c.relreplident = 'n' OR (c.relreplident = 'd' AND NOT EXISTS ` +
87
+ `(SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary)) ` +
88
+ // USING INDEX whose index was dropped: `relreplident` stays 'i' and it behaves as NOTHING.
89
+ `OR (c.relreplident = 'i' AND NOT EXISTS ` +
90
+ `(SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisreplident)))`,
104
91
  );
105
92
  const tables = [
106
93
  ...new Set(
@@ -0,0 +1,95 @@
1
+ // Single responsibility: the replicator's publication, ensured at boot — created FOR every entity
2
+ // table when missing, extended by the entity tables it lacks when present, never shrunk. The one
3
+ // way an app gets it: a migration is `x db gen`'s, and `FOR ALL TABLES` needs a superuser.
4
+
5
+ import { logger, stringField } from '@ultimat3/core';
6
+ import { ReplicationFailedError } from './errors';
7
+ import type { PgConnection } from './pg-connection';
8
+ import { assertIdentifier } from './pg-identifier';
9
+ import { REPLICATION_GRANT_WARNING } from './pg-wire';
10
+
11
+ /** What `ensurePublication` did — `added` is sorted, and empty when the publication was complete. */
12
+ export interface PublicationOutcome {
13
+ readonly created: boolean;
14
+ readonly added: readonly string[];
15
+ }
16
+
17
+ /**
18
+ * Ensures `publication` carries every table in `entities`.
19
+ *
20
+ * `FOR TABLE`, because a table's OWNER may publish it without a superuser — and the app role that
21
+ * ran the migrations owns every entity table. Membership is read through `pg_publication_tables`,
22
+ * which also expands `FOR ALL TABLES` and `FOR TABLES IN SCHEMA`, so an operator's broader
23
+ * publication is recognised as complete rather than ALTERed into an error. Cast through `regclass`
24
+ * so a table on the search path compares by the bare name the entity list uses.
25
+ *
26
+ * Never `DROP TABLE` from it: a table the operator added is theirs. A statement the role may not
27
+ * run is refused with the exact statement to run as a role that may — the refusal preflight always
28
+ * raised, now reached only when the boot could not fix it itself.
29
+ */
30
+ export async function ensurePublication(
31
+ connection: Pick<PgConnection, 'query'>,
32
+ publication: string,
33
+ entities: ReadonlySet<string>,
34
+ ): Promise<PublicationOutcome> {
35
+ assertIdentifier('publication', publication);
36
+ const tables = [...entities].map((name) => assertIdentifier('entity', name)).sort();
37
+ const exists = await connection.query(
38
+ `SELECT 1 FROM pg_publication WHERE pubname = '${publication}'`,
39
+ );
40
+ if (exists.length === 0) {
41
+ const create = `CREATE PUBLICATION ${publication} FOR TABLE ${tables.join(', ')}`;
42
+ await attempt(connection, create, `no publication named "${publication}" exists`);
43
+ logger.info('realtime.publication.created', { publication, tables });
44
+ return { created: true, added: tables };
45
+ }
46
+ const members = await connection.query(
47
+ `SELECT (quote_ident(schemaname) || '.' || quote_ident(tablename))::regclass::text ` +
48
+ `FROM pg_publication_tables WHERE pubname = '${publication}'`,
49
+ );
50
+ const present = new Set(members.map((row) => row[0]));
51
+ const missing = tables.filter((name) => !present.has(name));
52
+ if (missing.length === 0) return { created: false, added: [] };
53
+ const alter = `ALTER PUBLICATION ${publication} ADD TABLE ${missing.join(', ')}`;
54
+ await attempt(connection, alter, `publication "${publication}" lacks ${missing.join(', ')}`);
55
+ logger.info('realtime.publication.extended', { publication, tables: missing });
56
+ return { created: false, added: missing };
57
+ }
58
+
59
+ /**
60
+ * Runs `statement`, or refuses with it. Only a server's ErrorResponse is this function's to
61
+ * explain — a dead socket is not a privilege question, so anything else propagates unchanged.
62
+ */
63
+ async function attempt(
64
+ connection: Pick<PgConnection, 'query'>,
65
+ statement: string,
66
+ why: string,
67
+ ): Promise<void> {
68
+ try {
69
+ await connection.query(statement);
70
+ } catch (error) {
71
+ if (!(error instanceof ReplicationFailedError)) throw error;
72
+ const server = stringField(error, 'cause') ?? 'the server refused it';
73
+ const [role] = await connection.query('SELECT current_user');
74
+ throw new ReplicationFailedError({
75
+ stage: 'preflight',
76
+ detail: `${why}, and this role could not ${statement.split(' ')[0]?.toLowerCase()} it: ${server}`,
77
+ fix: statementFix(statement, role?.[0]),
78
+ });
79
+ }
80
+ }
81
+
82
+ /**
83
+ * The statement to run as a role that owns the tables, plus the `REPLICATION` role attribute
84
+ * streaming needs — which no fix line named until one did. Every name in `statement` already
85
+ * passed `assertIdentifier`; the role is quoted, because a role name is whatever the operator chose.
86
+ */
87
+ function statementFix(statement: string, role: string | null | undefined): string {
88
+ const who =
89
+ typeof role === 'string' && role !== '' ? `"${role.replaceAll('"', '""')}"` : 'CURRENT_USER';
90
+ return (
91
+ `${statement}; -- as a role that owns those tables and holds CREATE on the database, then restart the replicator. ` +
92
+ `And, if the role cannot stream yet: ALTER ROLE ${who} WITH REPLICATION; -- ` +
93
+ REPLICATION_GRANT_WARNING
94
+ );
95
+ }
@@ -10,7 +10,8 @@ import { isRow, type Row } from './json';
10
10
  import { ByteReader, ByteWriter, epochMsToPgTimestamp, printLsn } from './pg-bytes';
11
11
  import { PgConnection } from './pg-connection';
12
12
  import { entityRow } from './pg-entity-row';
13
- import { assertIdentifier, preflight } from './pg-preflight';
13
+ import { assertIdentifier } from './pg-identifier';
14
+ import { preflight } from './pg-preflight';
14
15
  import { bunPgStream, parsePgUrl } from './pg-socket';
15
16
  import type { PhysicalRow } from './pg-values';
16
17
  import { keyedWrite, PgOutputDecoder, type PgOutputMessage, type PgRelation } from './pgoutput';
@@ -337,8 +338,15 @@ export class PgReplicationStream {
337
338
  case 'delete':
338
339
  await this.#deliver('delete', message.relation, message.before, null, handlers);
339
340
  return;
341
+ case 'truncate':
342
+ // One change per truncated relation, rowless. It was decoded and DROPPED here, and with the
343
+ // recommended `FOR ALL TABLES` publication every window and every client kept the rows.
344
+ for (const relation of message.relations) {
345
+ await this.#deliver('truncate', relation, null, null, handlers);
346
+ }
347
+ return;
340
348
  default:
341
- // Relation, truncate, origin, type, logical message: nothing the matcher can act on.
349
+ // Relation, origin, type, logical message: nothing the matcher can act on.
342
350
  return;
343
351
  }
344
352
  }
@@ -375,9 +383,11 @@ export class PgReplicationStream {
375
383
  // Read off the Relation message rather than off the tuple: a DEFAULT-identity table whose
376
384
  // non-key columns happen to be NULL sends the same bytes a FULL one does, so counting missing
377
385
  // keys would undercount exactly the rows a policy is most likely to misjudge.
378
- if (op !== 'insert' && relation.replicaIdentity !== 'f') this.#partialBefore += 1;
379
- const before = toRow(relation, oldTuple);
380
- const after = toRow(relation, newTuple);
386
+ if ((op === 'update' || op === 'delete') && relation.replicaIdentity !== 'f') {
387
+ this.#partialBefore += 1;
388
+ }
389
+ const before = toRow(relation, oldTuple, 'before');
390
+ const after = toRow(relation, newTuple, 'after');
381
391
  const event: ChangeEvent = {
382
392
  entity: relation.name,
383
393
  op,
@@ -462,9 +472,13 @@ export class PgReplicationStream {
462
472
  }
463
473
 
464
474
  /** A physical tuple becomes the row the matcher's predicates are written against, or nothing. */
465
- function toRow(relation: PgRelation, physical: PhysicalRow | null): Row | null {
475
+ function toRow(
476
+ relation: PgRelation,
477
+ physical: PhysicalRow | null,
478
+ image: 'before' | 'after',
479
+ ): Row | null {
466
480
  if (physical === null) return null;
467
- const row = entityRow(physical);
481
+ const row = entityRow(relation, physical, image);
468
482
  // A bigserial id decodes as a number inside `Number.isSafeInteger` range and as text outside it,
469
483
  // so the same table would otherwise identify small rows by number and large ones by string.
470
484
  // `Row.id`, `RowPatch.id` and every cursor are text: the identity is normalised once, here.