@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.
Files changed (103) hide show
  1. package/CLAUDE.md +300 -952
  2. package/README.md +192 -131
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +14 -1
  8. package/src/channel-authz.ts +52 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +155 -0
  11. package/src/channel-describe.ts +35 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +134 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +87 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +359 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +150 -195
  26. package/src/cursor.ts +7 -2
  27. package/src/errors.ts +55 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +133 -0
  30. package/src/idb-types.ts +48 -0
  31. package/src/index.ts +80 -75
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +15 -4
  35. package/src/live-fanout.ts +81 -6
  36. package/src/live-query.ts +11 -0
  37. package/src/live-record-type.ts +19 -0
  38. package/src/live-replicator.ts +160 -0
  39. package/src/live-rows.ts +70 -67
  40. package/src/local-store-idb.ts +324 -0
  41. package/src/matcher-bridge.ts +5 -0
  42. package/src/nats-fake.ts +10 -1
  43. package/src/nats-jetstream.ts +36 -14
  44. package/src/nats-transport.ts +2 -2
  45. package/src/offline-queue.ts +85 -39
  46. package/src/outbox-slot.ts +31 -0
  47. package/src/page-errors.ts +124 -0
  48. package/src/page-outbox.ts +312 -0
  49. package/src/page-socket.ts +139 -0
  50. package/src/page-store.ts +138 -0
  51. package/src/pg-entity-row.ts +37 -184
  52. package/src/pg-preflight.ts +24 -2
  53. package/src/pg-replication.ts +28 -8
  54. package/src/pg-wire.ts +51 -15
  55. package/src/pgoutput.ts +37 -2
  56. package/src/policy-fake.ts +14 -0
  57. package/src/presence.ts +17 -9
  58. package/src/query-window.ts +38 -21
  59. package/src/reactivity.ts +70 -0
  60. package/src/realtime-error.ts +1 -1
  61. package/src/record-await.ts +102 -0
  62. package/src/record-key.ts +34 -0
  63. package/src/record-names.ts +45 -0
  64. package/src/record-persister.ts +156 -0
  65. package/src/record-store.ts +364 -0
  66. package/src/record-synced.ts +100 -0
  67. package/src/record-tx.ts +145 -0
  68. package/src/replicator.ts +20 -4
  69. package/src/server.ts +10 -11
  70. package/src/socket-drops.ts +30 -0
  71. package/src/socket-engine.ts +344 -0
  72. package/src/socket-host.ts +225 -0
  73. package/src/socket-idle.ts +21 -0
  74. package/src/socket-port.ts +55 -0
  75. package/src/socket-routes.ts +170 -0
  76. package/src/socket.ts +91 -49
  77. package/src/subscriber-gate.ts +92 -3
  78. package/src/sync-auth.ts +2 -2
  79. package/src/sync-frames.ts +41 -114
  80. package/src/sync-meta.ts +42 -0
  81. package/src/sync-node-contract.ts +100 -0
  82. package/src/sync-node.ts +26 -114
  83. package/src/sync-protocol.ts +63 -212
  84. package/src/sync-worker.ts +12 -0
  85. package/src/thundering-herd.ts +31 -12
  86. package/src/transport-env.ts +55 -14
  87. package/src/type-pins.ts +30 -61
  88. package/src/use-channel.ts +88 -0
  89. package/src/use-connection.ts +59 -0
  90. package/src/use-mutation.ts +227 -0
  91. package/src/use-query.ts +260 -0
  92. package/src/use-record.ts +121 -0
  93. package/src/wire-channel.ts +116 -0
  94. package/src/wire-read.ts +86 -0
  95. package/src/wire-version.ts +44 -0
  96. package/src/client-mutations.ts +0 -114
  97. package/src/client-topics.ts +0 -54
  98. package/src/hooks.ts +0 -277
  99. package/src/identity-map.ts +0 -141
  100. package/src/local-store.ts +0 -241
  101. package/src/query-hook.ts +0 -56
  102. package/src/rebase.ts +0 -263
  103. package/src/server-render-client.ts +0 -96
@@ -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
  }
@@ -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
- fix: "ALTER SYSTEM SET wal_level = 'logical'; -- then restart postgres",
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: `CREATE PUBLICATION ${publication} FOR ALL TABLES;`,
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
@@ -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, truncate, origin, type, logical message: nothing the matcher can act on.
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 !== 'insert' && relation.replicaIdentity !== 'f') this.#partialBefore += 1;
373
- const before = toRow(relation, oldTuple);
374
- const after = toRow(relation, newTuple);
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(relation: PgRelation, physical: PhysicalRow | null): Row | null {
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
- return this.#buffer.length;
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
- if (this.#buffer.length === 0) return undefined;
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 ${this.#buffer.length} bytes of a partial message`,
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 = chunk;
94
+ this.#buffer = rest;
71
95
  return;
72
96
  }
73
- const joined = new Uint8Array(this.#buffer.length + chunk.length);
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(chunk, this.#buffer.length);
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) return undefined;
96
- const message: PgMessage = {
97
- tag: String.fromCharCode(buffer[0] ?? 0),
98
- body: buffer.subarray(5, total),
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 message;
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 ALL TABLES"' +
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': 'set wal_level = logical in postgresql.conf and restart the server',
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
- /** origin / type / logical message — decoded far enough to be skipped safely. */
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
- // 'O' (origin), 'Y' (type), 'M' (logical message), and any tag a newer server invents:
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 { type Frame, PROTOCOL_VERSION, type PresenceMember } from './sync-protocol';
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<Frame> {
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.publishFrame(name, presenceFrame(name, op, members));
263
+ await this.#hub.emit(name, presenceEvent(op, members));
260
264
  }
261
265
  }
262
266
 
263
267
  /**
264
- * `total` belongs to a **full set** and to nothing else: a `join`/`leave`/`update` frame carries the
265
- * members that changed, so a count beside them would read as "and the rest were truncated". Absent
266
- * is a defined answer — the client renders what it was sent.
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
- ): Frame {
274
- const base = { type: 'presence', v: PROTOCOL_VERSION, topic: name, op, members } as const;
275
- return total === undefined ? base : { ...base, total };
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 {