@ultimat3/realtime 21.0.0 → 22.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CLAUDE.md +293 -1009
  2. package/README.md +78 -12
  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 +21 -0
  14. package/src/idb-fake.ts +24 -4
  15. package/src/idb-types.ts +7 -0
  16. package/src/index.ts +0 -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-preflight.ts +24 -2
  31. package/src/pg-replication.ts +19 -6
  32. package/src/pg-wire.ts +51 -15
  33. package/src/policy-fake.ts +14 -0
  34. package/src/query-window.ts +35 -21
  35. package/src/replicator.ts +13 -3
  36. package/src/server.ts +8 -3
  37. package/src/socket-drops.ts +30 -0
  38. package/src/socket-engine.ts +15 -3
  39. package/src/socket-host.ts +103 -4
  40. package/src/socket-idle.ts +21 -0
  41. package/src/socket.ts +41 -38
  42. package/src/subscriber-gate.ts +92 -3
  43. package/src/sync-node.ts +2 -7
  44. package/src/thundering-herd.ts +12 -11
  45. package/src/transport-env.ts +55 -14
  46. package/src/use-mutation.ts +13 -0
  47. 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
  }
@@ -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
@@ -337,8 +337,15 @@ export class PgReplicationStream {
337
337
  case 'delete':
338
338
  await this.#deliver('delete', message.relation, message.before, null, handlers);
339
339
  return;
340
+ case 'truncate':
341
+ // One change per truncated relation, rowless. It was decoded and DROPPED here, and with the
342
+ // recommended `FOR ALL TABLES` publication every window and every client kept the rows.
343
+ for (const relation of message.relations) {
344
+ await this.#deliver('truncate', relation, null, null, handlers);
345
+ }
346
+ return;
340
347
  default:
341
- // Relation, truncate, origin, type, logical message: nothing the matcher can act on.
348
+ // Relation, origin, type, logical message: nothing the matcher can act on.
342
349
  return;
343
350
  }
344
351
  }
@@ -375,9 +382,11 @@ export class PgReplicationStream {
375
382
  // Read off the Relation message rather than off the tuple: a DEFAULT-identity table whose
376
383
  // non-key columns happen to be NULL sends the same bytes a FULL one does, so counting missing
377
384
  // keys would undercount exactly the rows a policy is most likely to misjudge.
378
- if (op !== 'insert' && relation.replicaIdentity !== 'f') this.#partialBefore += 1;
379
- const before = toRow(relation, oldTuple);
380
- 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');
381
390
  const event: ChangeEvent = {
382
391
  entity: relation.name,
383
392
  op,
@@ -462,9 +471,13 @@ export class PgReplicationStream {
462
471
  }
463
472
 
464
473
  /** A physical tuple becomes the row the matcher's predicates are written against, or nothing. */
465
- function toRow(relation: PgRelation, physical: PhysicalRow | null): Row | null {
474
+ function toRow(
475
+ relation: PgRelation,
476
+ physical: PhysicalRow | null,
477
+ image: 'before' | 'after',
478
+ ): Row | null {
466
479
  if (physical === null) return null;
467
- const row = entityRow(physical);
480
+ const row = entityRow(relation, physical, image);
468
481
  // A bigserial id decodes as a number inside `Number.isSafeInteger` range and as text outside it,
469
482
  // so the same table would otherwise identify small rows by number and large ones by string.
470
483
  // `Row.id`, `RowPatch.id` and every cursor are text: the identity is normalised once, here.
package/src/pg-wire.ts CHANGED
@@ -36,7 +36,17 @@ const MAX_MESSAGE_BYTES = 64 * 1024 * 1024;
36
36
  */
37
37
  export class MessageReader {
38
38
  readonly #stream: PgStream;
39
+ /** Bytes of messages whose length is not yet known, or complete ones not yet taken. */
39
40
  #buffer: Uint8Array = new Uint8Array(0);
41
+ /**
42
+ * One message whose length IS known and whose bytes are still arriving: allocated once at its
43
+ * full size and filled in place. Joining every chunk onto what was held re-copied the whole
44
+ * message per chunk — quadratic, measured at 5.2 s of blocked event loop for one 32 MB CopyData
45
+ * on the replication connection every live window depends on.
46
+ */
47
+ #pending: { readonly bytes: Uint8Array; filled: number } | null = null;
48
+ /** Messages completed out of `#pending`, in arrival order, ahead of anything in `#buffer`. */
49
+ readonly #ready: Uint8Array[] = [];
40
50
 
41
51
  constructor(stream: PgStream) {
42
52
  this.#stream = stream;
@@ -44,7 +54,8 @@ export class MessageReader {
44
54
 
45
55
  /** Bytes already read but not yet consumed — what a reconnect would have to replay. */
46
56
  get buffered(): number {
47
- 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. */
@@ -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
+ });
@@ -125,29 +125,43 @@ export function createEntry(
125
125
  export async function fillWindow(
126
126
  entry: QueryEntry,
127
127
  ): Promise<{ rows: readonly Row[]; lsn: string }> {
128
- // Read before `startRead` clears it: a second caller arriving during the read joins it and is
129
- // not the one that forced it, which is what keeps one forced read from becoming N.
130
- const forced = entry.stale;
131
- const pending = forced || entry.reading === null ? startRead(entry) : entry.reading;
132
- const result = await pending.result;
133
- return await entry.lock.run(async () => {
134
- // Two rules, and neither can stand in for the other. Against another READ it is identity —
135
- // the same check `startRead` makes on `entry.reading` one function down, and the one
136
- // `packages/cache/src/single-flight.ts` makes for the same reason — because an lsn cannot
137
- // order two reads at all: a definition with no lsn provider answers `''` for both, and
138
- // `'' >= ''` let the older one overwrite the gap repair the newer one had just landed, with
139
- // `stale` already cleared by its issue and therefore nothing left to re-read. Against a
140
- // CHANGE it is still the lsn, because a fanout moved `entry.lsn` forwards while this read was
141
- // in flight and rewinding to what the read saw hands that subscriber rows the fanout has
142
- // moved past — except for a forced read, which was issued *because* what is under it is
143
- // wrong.
144
- if (isNewestRead(entry, pending) && (forced || result.lsn >= entry.lsn)) {
145
- applyRead(entry, pending, result);
146
- }
147
- return { rows: entry.rows, lsn: entry.lsn };
148
- });
128
+ for (let attempt = 0; ; attempt += 1) {
129
+ // Read before `startRead` clears it: a second caller arriving during the read joins it and is
130
+ // not the one that forced it, which is what keeps one forced read from becoming N.
131
+ const forced = entry.stale;
132
+ const pending = forced || entry.reading === null ? startRead(entry) : entry.reading;
133
+ const result = await pending.result;
134
+ const again = await entry.lock.run(async () => {
135
+ // Two rules, and neither can stand in for the other. Against another READ it is identity —
136
+ // the same check `startRead` makes on `entry.reading` one function down, and the one
137
+ // `packages/cache/src/single-flight.ts` makes for the same reason — because an lsn cannot
138
+ // order two reads at all: a definition with no lsn provider answers `''` for both, and
139
+ // `'' >= ''` let the older one overwrite the gap repair the newer one had just landed, with
140
+ // `stale` already cleared by its issue and therefore nothing left to re-read. Against a
141
+ // CHANGE it is still the lsn, because a fanout moved `entry.lsn` forwards while this read was
142
+ // in flight and rewinding to what the read saw hands that subscriber rows the fanout has
143
+ // moved past — except for a forced read, which was issued *because* what is under it is
144
+ // wrong, and for the FIRST read, which has no window under it to rewind: a fanout never
145
+ // patches a window no read has landed in (`live-fanout.ts`), it marks it stale instead.
146
+ const first = entry.applied === 0;
147
+ if (isNewestRead(entry, pending) && (forced || first || result.lsn >= entry.lsn)) {
148
+ applyRead(entry, pending, result);
149
+ }
150
+ // A change reached this window while its first read was in flight and could not be folded,
151
+ // so what just landed may predate it: read once more before serving anyone a partial window.
152
+ return first && entry.stale && attempt < COLD_REREADS;
153
+ });
154
+ if (!again) return { rows: entry.rows, lsn: entry.lsn };
155
+ }
149
156
  }
150
157
 
158
+ /**
159
+ * How many times a cold window re-reads because writes kept landing during its read. Bounded: a
160
+ * table written faster than it can be read would otherwise never serve a subscriber, and after the
161
+ * bound the window stays `stale`, so the next change re-reads it anyway.
162
+ */
163
+ const COLD_REREADS = 3;
164
+
151
165
  /**
152
166
  * The same replacement, for a caller that is already holding the lane. A fanout cannot call
153
167
  * `fillWindow` — that takes the entry's own lane, and a lane is not reentrant — so the one path
package/src/replicator.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  import { isWriteDigest, logger, uuid, withSpan } from '@ultimat3/core';
15
15
  import type { ChangeEvent, ChangeFeed } from './changefeed';
16
16
  import type { Transport } from './fanout';
17
- import { type BackoffPolicy, backoffDelay, defaultBackoff, type Rng } from './thundering-herd';
17
+ import { type BackoffPolicy, defaultBackoff, policyDelay, type Rng } from './thundering-herd';
18
18
 
19
19
  export const CHANGE_SUBJECT_PREFIX = 'x.change';
20
20
 
@@ -225,13 +225,16 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
225
225
  },
226
226
 
227
227
  retryDelayMs(attempt: number): number {
228
- return backoffDelay(attempt, backoff, options.rng ?? Math.random);
228
+ // This method's contract is 0-based (`retryDelayMs(0)` is the base); core counts from 1.
229
+ return policyDelay(backoff, attempt + 1, options.rng ?? Math.random);
229
230
  },
230
231
  };
231
232
  }
232
233
 
233
234
  /** Drops events the pipeline cannot use and hoists the tenant id out of the row. */
234
235
  export function normalize(change: ChangeEvent): ChangeEvent | null {
236
+ // The one change that carries no row by design; nothing to hoist a tenant out of.
237
+ if (change.op === 'truncate') return change;
235
238
  const row = change.after ?? change.before;
236
239
  if (!row) return null;
237
240
  if (change.op === 'insert' && change.after === null) return null;
@@ -253,7 +256,14 @@ export function parseEnvelope(payload: string): ChangeEnvelope | null {
253
256
  if (typeof parsed !== 'object' || parsed === null) return null;
254
257
  const shape = parsed as Partial<ChangeEvent> & { seq?: unknown; producer?: unknown };
255
258
  if (typeof shape.entity !== 'string' || typeof shape.lsn !== 'string') return null;
256
- if (shape.op !== 'insert' && shape.op !== 'update' && shape.op !== 'delete') return null;
259
+ if (
260
+ shape.op !== 'insert' &&
261
+ shape.op !== 'update' &&
262
+ shape.op !== 'delete' &&
263
+ shape.op !== 'truncate'
264
+ ) {
265
+ return null;
266
+ }
257
267
  return {
258
268
  change: {
259
269
  entity: shape.entity,
package/src/server.ts CHANGED
@@ -41,6 +41,7 @@ export {
41
41
  DEFAULT_MAX_TOPICS_PER_NODE,
42
42
  } from './channel';
43
43
  export { type ChannelDescription, describeChannels } from './channel-describe';
44
+ export { RealtimeTopologyError } from './errors';
44
45
  export {
45
46
  InProcessTransport,
46
47
  type InProcessTransportOptions,
@@ -62,6 +63,11 @@ export {
62
63
  LiveQueryRegistry,
63
64
  type LiveQueryRegistryOptions,
64
65
  } from './live-query';
66
+ export {
67
+ type LiveReplicator,
68
+ type LiveReplicatorOptions,
69
+ startLiveReplicator,
70
+ } from './live-replicator';
65
71
  export {
66
72
  applyToWindow,
67
73
  type BridgeResult,
@@ -109,7 +115,7 @@ export { openNatsClient } from './nats-lib-client';
109
115
  export { NatsTransport, type NatsTransportOptions } from './nats-transport';
110
116
  export { PgAdvisoryLock, type PgAdvisoryLockOptions } from './pg-advisory-lock';
111
117
  // ---- the postgres replication path ------------------------------------------------------------
112
- export { camel, entityRow } from './pg-entity-row';
118
+ export { entityRow } from './pg-entity-row';
113
119
  export {
114
120
  changeLsn,
115
121
  commitPositionOf,
@@ -166,16 +172,15 @@ export {
166
172
  actorIdOf,
167
173
  CLOSE,
168
174
  DEFAULT_FRAME_BURST,
169
- DEFAULT_IDLE_TIMEOUT_MS,
170
175
  DEFAULT_MAX_BUFFERED_BYTES,
171
176
  DEFAULT_MAX_FRAMES_PER_SECOND,
172
- idleSweepPeriodMs,
173
177
  SocketRegistry,
174
178
  type SocketRegistryOptions,
175
179
  SyncSocket,
176
180
  type SyncSocketOptions,
177
181
  type WsLike,
178
182
  } from './socket';
183
+ export { DEFAULT_IDLE_TIMEOUT_MS, idleSweepPeriodMs } from './socket-idle';
179
184
  export type {
180
185
  GateFailed,
181
186
  GateStage,