@ultimat3/realtime 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +591 -0
- package/README.md +320 -19
- package/package.json +6 -3
- package/src/apply-patches.ts +60 -0
- package/src/change-buffer.ts +77 -11
- package/src/channel.ts +174 -19
- package/src/client-contract.ts +81 -0
- package/src/client-frames.ts +175 -0
- package/src/client-heartbeat.ts +77 -0
- package/src/client-mutations.ts +114 -0
- package/src/client-topics.ts +54 -0
- package/src/client.ts +307 -273
- package/src/cursor.ts +7 -1
- package/src/errors.ts +193 -4
- package/src/frame-lanes.ts +58 -0
- package/src/hooks.ts +19 -5
- package/src/identity-map.ts +141 -0
- package/src/index.ts +96 -28
- package/src/json.ts +38 -1
- package/src/live-contract.ts +67 -0
- package/src/live-definition.ts +16 -11
- package/src/live-fanout.ts +150 -0
- package/src/live-query.ts +215 -268
- package/src/live-rows.ts +143 -0
- package/src/local-store.ts +86 -43
- package/src/nats-client.ts +132 -0
- package/src/nats-fake.ts +389 -344
- package/src/nats-jetstream.ts +21 -20
- package/src/nats-kv.ts +7 -7
- package/src/nats-lib-client.ts +210 -0
- package/src/nats-transport.ts +109 -138
- package/src/offline-queue.ts +146 -30
- package/src/pg-entity-row.ts +99 -31
- package/src/pg-replication.ts +84 -27
- package/src/pg-socket.ts +4 -1
- package/src/policy-gate.ts +13 -5
- package/src/presence.ts +76 -6
- package/src/query-hook.ts +56 -0
- package/src/query-window.ts +151 -0
- package/src/rebase.ts +68 -8
- package/src/replicator.ts +84 -11
- package/src/socket.ts +170 -14
- package/src/subscriber-gate.ts +209 -0
- package/src/subscription-book.ts +237 -0
- package/src/sync-auth.ts +124 -0
- package/src/sync-frames.ts +185 -0
- package/src/sync-listen.ts +73 -0
- package/src/sync-node.ts +284 -243
- package/src/sync-protocol.ts +115 -24
- package/src/sync-upgrade.ts +124 -0
- package/src/thundering-herd.ts +21 -0
- package/src/transport-env.ts +3 -3
- package/src/type-pins.ts +72 -0
- package/src/window-lock.ts +21 -0
- package/src/nats-commands.ts +0 -97
- package/src/nats-connection-fixture.ts +0 -105
- package/src/nats-connection.ts +0 -464
- package/src/nats-protocol.ts +0 -222
- package/src/nats-socket.ts +0 -236
- package/src/pg-connection-fixture.ts +0 -215
- package/src/pg-replication-fixture.ts +0 -261
package/src/pg-entity-row.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// Physical Postgres row -> entity-row shaping: snake_case columns become camelCase properties,
|
|
2
|
-
// and
|
|
3
|
-
// The inverse of the camelCasing here is `@ultimat3/entity`'s `column.ts#snake
|
|
4
|
-
//
|
|
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.
|
|
5
7
|
|
|
8
|
+
import { describeValue } from '@ultimat3/core';
|
|
6
9
|
import { ReplicationProtocolError } from './errors';
|
|
7
10
|
import type { JsonObject, JsonValue } from './json';
|
|
8
11
|
|
|
@@ -17,18 +20,24 @@ function capitalize(part: string): string {
|
|
|
17
20
|
return part.charAt(0).toUpperCase() + part.slice(1);
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
interface
|
|
23
|
+
interface FoldedMoney {
|
|
21
24
|
readonly property: string;
|
|
22
|
-
|
|
23
|
-
readonly
|
|
24
|
-
readonly
|
|
25
|
-
readonly currency: string;
|
|
25
|
+
/** The physical columns the fold consumed, in declaration order — named together on a collision. */
|
|
26
|
+
readonly columns: readonly string[];
|
|
27
|
+
readonly value: JsonObject;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
/**
|
|
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
|
+
*/
|
|
29
37
|
function moneyPrefix(column: string): string | null {
|
|
30
38
|
if (column.endsWith('_minor')) return column.slice(0, -'_minor'.length);
|
|
31
39
|
if (column.endsWith('_currency')) return column.slice(0, -'_currency'.length);
|
|
40
|
+
if (column.endsWith('_scale')) return column.slice(0, -'_scale'.length);
|
|
32
41
|
return null;
|
|
33
42
|
}
|
|
34
43
|
|
|
@@ -45,20 +54,74 @@ function moneyMinor(column: string, value: number | string): number {
|
|
|
45
54
|
if (Number.isSafeInteger(minor)) return minor;
|
|
46
55
|
throw new ReplicationProtocolError({
|
|
47
56
|
stage: 'value',
|
|
48
|
-
detail: `column "${column}" carries
|
|
57
|
+
detail: `column "${column}" carries ${shownNumber(value)}, which is not a whole number of minor units`,
|
|
49
58
|
fix: `store ${column} as a bigint inside ±2^53 — Money.minor is a number, never a float or a bigint`,
|
|
50
59
|
});
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
/**
|
|
54
|
-
* `
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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: JsonValue): 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
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
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.
|
|
57
102
|
*/
|
|
58
|
-
function
|
|
103
|
+
function shownNumber(value: JsonValue): 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(
|
|
59
122
|
physical: Readonly<Record<string, JsonValue>>,
|
|
60
123
|
name: string,
|
|
61
|
-
):
|
|
124
|
+
): FoldedMoney | null {
|
|
62
125
|
const prefix = moneyPrefix(name);
|
|
63
126
|
if (prefix === null) return null;
|
|
64
127
|
|
|
@@ -68,16 +131,21 @@ function moneyPairAt(
|
|
|
68
131
|
|
|
69
132
|
const minor = physical[minorKey];
|
|
70
133
|
const currency = physical[currencyKey];
|
|
71
|
-
if ((typeof minor === 'number' || typeof minor === 'string')
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
134
|
+
if (!(typeof minor === 'number' || typeof minor === 'string') || typeof currency !== 'string') {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
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: {
|
|
76
144
|
minor: moneyMinor(minorKey, minor),
|
|
77
145
|
currency,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
146
|
+
...(scale === undefined || scale === null ? {} : { scale: moneyScale(scaleKey, scale) }),
|
|
147
|
+
},
|
|
148
|
+
};
|
|
81
149
|
}
|
|
82
150
|
|
|
83
151
|
/**
|
|
@@ -100,12 +168,13 @@ function claim(taken: Map<string, string>, property: string, column: string): vo
|
|
|
100
168
|
/**
|
|
101
169
|
* A physical postgres row -> the row shape the rest of the pipeline is written against.
|
|
102
170
|
* Two things are not one-to-one and both live here: the column is snake_case while the entity
|
|
103
|
-
* property is camelCase, and money is one property over the
|
|
171
|
+
* property is camelCase, and money is one property over the columns `<p>_minor`/`<p>_currency`
|
|
172
|
+
* and the nullable `<p>_scale`.
|
|
104
173
|
*/
|
|
105
174
|
export function entityRow(physical: Readonly<Record<string, JsonValue>>): JsonObject {
|
|
106
175
|
const row: JsonObject = {};
|
|
107
|
-
// Column order in is key order out; a folded money property lands wherever its
|
|
108
|
-
// (whichever of
|
|
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.
|
|
109
178
|
const consumed = new Set<string>();
|
|
110
179
|
// Which column produced each property, so a collision names both sides rather than losing one.
|
|
111
180
|
const taken = new Map<string, string>();
|
|
@@ -113,12 +182,11 @@ export function entityRow(physical: Readonly<Record<string, JsonValue>>): JsonOb
|
|
|
113
182
|
for (const name of Object.keys(physical)) {
|
|
114
183
|
if (consumed.has(name)) continue;
|
|
115
184
|
|
|
116
|
-
const money =
|
|
185
|
+
const money = foldMoney(physical, name);
|
|
117
186
|
if (money !== null) {
|
|
118
|
-
claim(taken, money.property,
|
|
119
|
-
row[money.property] =
|
|
120
|
-
consumed.add(
|
|
121
|
-
consumed.add(money.currencyKey);
|
|
187
|
+
claim(taken, money.property, money.columns.join('/'));
|
|
188
|
+
row[money.property] = money.value;
|
|
189
|
+
for (const column of money.columns) consumed.add(column);
|
|
122
190
|
continue;
|
|
123
191
|
}
|
|
124
192
|
|
package/src/pg-replication.ts
CHANGED
|
@@ -123,6 +123,15 @@ export class PgReplicationStream {
|
|
|
123
123
|
/** Resolves once the stream is live. Delivery continues on the pump until `stop()`. */
|
|
124
124
|
async start(handlers: ReplicationStreamHandlers): Promise<void> {
|
|
125
125
|
if (this.#running) return;
|
|
126
|
+
// `#pump` is the previous run's *terminal cleanup*, not just its read loop: `#drain` awaits
|
|
127
|
+
// `#die`, and `#die` awaits `connection.close()`. `#die` clears `#running` and nulls
|
|
128
|
+
// `#connection` before that close settles, so a restart that dialled here would replace
|
|
129
|
+
// `#pump` with its own and leave the old walsender holding the slot — the next `stop()` would
|
|
130
|
+
// await only the new pump and report a released slot to a supervisor whose next process then
|
|
131
|
+
// collides with one that is still `active`. Waiting for it is waiting for the prior teardown.
|
|
132
|
+
const previous = this.#pump;
|
|
133
|
+
this.#pump = null;
|
|
134
|
+
if (previous !== null) await previous;
|
|
126
135
|
const slot = this.#slot;
|
|
127
136
|
const publication = this.#publication;
|
|
128
137
|
const target = parsePgUrl(this.#options.url);
|
|
@@ -146,10 +155,15 @@ export class PgReplicationStream {
|
|
|
146
155
|
`(proto_version '1', publication_names '${publication}')`,
|
|
147
156
|
);
|
|
148
157
|
} catch (failure) {
|
|
149
|
-
|
|
158
|
+
// The dial failure is the one that explains the boot, so a teardown that also failed must
|
|
159
|
+
// not replace it — `stop()` has released everything either way by the time it rethrows.
|
|
160
|
+
await this.stop().catch(() => undefined);
|
|
150
161
|
throw failure;
|
|
151
162
|
}
|
|
152
163
|
this.#running = true;
|
|
164
|
+
// A restart that kept the last death in `stats()` reports a live stream as failed, and the
|
|
165
|
+
// supervisor that reads it never sees the replicator come back.
|
|
166
|
+
this.#failure = null;
|
|
153
167
|
this.#timer = setInterval(() => {
|
|
154
168
|
void this.#confirm();
|
|
155
169
|
}, this.#options.statusIntervalMs ?? DEFAULT_STATUS_INTERVAL_MS);
|
|
@@ -158,24 +172,75 @@ export class PgReplicationStream {
|
|
|
158
172
|
this.#pump = this.#drain(connection, handlers);
|
|
159
173
|
}
|
|
160
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Every step here runs whatever the step before it did. A `#confirm` or an `endCopy` that threw
|
|
177
|
+
* used to skip the close and the pump await entirely: the socket leaked, the slot stayed
|
|
178
|
+
* `active`, and `stop()` reported the failure to a supervisor that was already starting the next
|
|
179
|
+
* process — teardown announced as finished before it had begun. The first failure is the one
|
|
180
|
+
* that explains the shutdown, so it is the one rethrown, and only once everything is let go.
|
|
181
|
+
*/
|
|
161
182
|
async stop(): Promise<void> {
|
|
162
183
|
this.#running = false;
|
|
163
|
-
|
|
164
|
-
clearInterval(this.#timer);
|
|
165
|
-
this.#timer = null;
|
|
166
|
-
}
|
|
184
|
+
this.#clearTimer();
|
|
167
185
|
const connection = this.#connection;
|
|
168
186
|
this.#connection = null;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
187
|
+
const failures: unknown[] = [];
|
|
188
|
+
try {
|
|
189
|
+
// Confirming before the goodbye is what stops a restart from replaying the whole window.
|
|
190
|
+
if (connection?.inCopyBoth === true) {
|
|
191
|
+
await this.#confirm(connection);
|
|
192
|
+
await connection.endCopy();
|
|
193
|
+
}
|
|
194
|
+
} catch (failure) {
|
|
195
|
+
failures.push(failure);
|
|
174
196
|
}
|
|
175
|
-
|
|
197
|
+
try {
|
|
198
|
+
await connection?.close();
|
|
199
|
+
} catch (failure) {
|
|
200
|
+
failures.push(failure);
|
|
201
|
+
}
|
|
202
|
+
// Awaited even when the stream died on its own: `#die` nulls the connection *before* closing
|
|
203
|
+
// it, so returning here would report the socket as released while it is still going down.
|
|
176
204
|
const pump = this.#pump;
|
|
177
205
|
this.#pump = null;
|
|
178
|
-
|
|
206
|
+
try {
|
|
207
|
+
if (pump !== null) await pump;
|
|
208
|
+
} catch (failure) {
|
|
209
|
+
failures.push(failure);
|
|
210
|
+
}
|
|
211
|
+
if (failures.length > 0) throw failures[0];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The pump's only way out, however it ended — a decode error, or a walsender that said goodbye.
|
|
216
|
+
* The four things it owns go together or not at all, because each one left behind is a dead
|
|
217
|
+
* replicator claiming to be a live one: a `null` failure for a loop that stopped reading, a
|
|
218
|
+
* confirm timer still telling the walsender the stream is keeping up, a socket nobody closed,
|
|
219
|
+
* and a `#connection` the next `start()` overwrites instead of releasing. A `stop()` that got
|
|
220
|
+
* here first owns the connection, and its exit is an orderly one, not a failure.
|
|
221
|
+
*/
|
|
222
|
+
async #die(reason: string): Promise<void> {
|
|
223
|
+
if (!this.#running) return;
|
|
224
|
+
this.#running = false;
|
|
225
|
+
this.#failure = reason;
|
|
226
|
+
this.#clearTimer();
|
|
227
|
+
const connection = this.#connection;
|
|
228
|
+
this.#connection = null;
|
|
229
|
+
// The supervisor reads /readyz, so the loop records, reports and ends rather than throwing
|
|
230
|
+
// into a promise nothing awaits.
|
|
231
|
+
logger.error('replication stream ended', { slot: this.#options.slot, error: reason });
|
|
232
|
+
try {
|
|
233
|
+
await connection?.close();
|
|
234
|
+
} catch {
|
|
235
|
+
// The socket is already unusable and `failure` above is the report that matters — a
|
|
236
|
+
// goodbye that throws must not become the rejection `#drain` promised never to produce.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#clearTimer(): void {
|
|
241
|
+
if (this.#timer === null) return;
|
|
242
|
+
clearInterval(this.#timer);
|
|
243
|
+
this.#timer = null;
|
|
179
244
|
}
|
|
180
245
|
|
|
181
246
|
/** The read loop. It owns no timers and no state beyond the current transaction. */
|
|
@@ -183,7 +248,12 @@ export class PgReplicationStream {
|
|
|
183
248
|
try {
|
|
184
249
|
for (;;) {
|
|
185
250
|
const payload = await connection.nextCopyData();
|
|
186
|
-
if (payload === undefined)
|
|
251
|
+
if (payload === undefined) {
|
|
252
|
+
// The walsender ended the copy. However politely it said so, nothing reads the slot
|
|
253
|
+
// again until something restarts this — which is a failure, not a shutdown.
|
|
254
|
+
await this.#die('the walsender ended the copy stream');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
187
257
|
const reader = new ByteReader(payload, 'copy-data');
|
|
188
258
|
const tag = reader.tag();
|
|
189
259
|
if (tag === 'w') {
|
|
@@ -207,20 +277,7 @@ export class PgReplicationStream {
|
|
|
207
277
|
}
|
|
208
278
|
}
|
|
209
279
|
} catch (failure) {
|
|
210
|
-
|
|
211
|
-
this.#running = false;
|
|
212
|
-
// The supervisor reads /readyz, so the loop records, reports and ends rather than throwing
|
|
213
|
-
// into a timer callback nothing awaits — and the confirm timer stops with the loop it
|
|
214
|
-
// confirms for, or it keeps telling the walsender a dead stream is still keeping up.
|
|
215
|
-
this.#failure = failure instanceof Error ? failure.message : String(failure);
|
|
216
|
-
if (this.#timer !== null) {
|
|
217
|
-
clearInterval(this.#timer);
|
|
218
|
-
this.#timer = null;
|
|
219
|
-
}
|
|
220
|
-
logger.error('replication stream ended', {
|
|
221
|
-
slot: this.#options.slot,
|
|
222
|
-
error: this.#failure,
|
|
223
|
-
});
|
|
280
|
+
await this.#die(failure instanceof Error ? failure.message : String(failure));
|
|
224
281
|
}
|
|
225
282
|
}
|
|
226
283
|
|
package/src/pg-socket.ts
CHANGED
|
@@ -51,9 +51,12 @@ export function parsePgUrl(url: string): PgTarget {
|
|
|
51
51
|
try {
|
|
52
52
|
parsed = new URL(url);
|
|
53
53
|
} catch {
|
|
54
|
+
// The variable, never its value: a connection URL carries the database password, and an
|
|
55
|
+
// error is the one thing that reaches a log, `--json`, an agent transcript and a ticket.
|
|
56
|
+
// Same rule as `packages/mail/src/driver-smtp.ts:68`.
|
|
54
57
|
throw new ReplicationFailedError({
|
|
55
58
|
stage: 'connect',
|
|
56
|
-
detail:
|
|
59
|
+
detail: 'DATABASE_URL is not a connection URL',
|
|
57
60
|
fix: 'set DATABASE_URL to postgres://user:password@host:5432/database',
|
|
58
61
|
});
|
|
59
62
|
}
|
package/src/policy-gate.ts
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
//
|
|
5
5
|
// The row gate turns a denial into "not visible" instead of an error: a row that fails an actor's
|
|
6
6
|
// policy is dropped, never sent. That is the rule from the live-query pipeline, implemented once.
|
|
7
|
+
// A *denial* only — a gate that could not reach a decision raises, because "denied" and "the
|
|
8
|
+
// database is down" are different facts and one of them has to page someone.
|
|
7
9
|
|
|
8
10
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
9
|
-
import { guard, type QueryPolicy, type QuerySubject } from '@ultimat3/query';
|
|
11
|
+
import { guard, QueryDeniedError, type QueryPolicy, type QuerySubject } from '@ultimat3/query';
|
|
10
12
|
import type { JsonValue, Row } from './json';
|
|
11
13
|
|
|
12
14
|
export interface GateOptions {
|
|
@@ -23,7 +25,7 @@ export function authorizeWithPolicy(
|
|
|
23
25
|
return async (args) => {
|
|
24
26
|
// No row exists yet at subscribe time; `null` says so rather than leaving the predicate
|
|
25
27
|
// to infer it from an absent field.
|
|
26
|
-
|
|
28
|
+
guard(policy, subjectOf(options, args.actor, args.input, null), 'live');
|
|
27
29
|
};
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -38,10 +40,16 @@ export function visibleWithPolicy<R extends Row = Row>(
|
|
|
38
40
|
): (args: { actor: Actor | null; row: R; input: JsonValue }) => Promise<boolean> {
|
|
39
41
|
return async (args) => {
|
|
40
42
|
try {
|
|
41
|
-
|
|
43
|
+
guard(policy, subjectOf(options, args.actor, args.input, args.row), 'live');
|
|
42
44
|
return true;
|
|
43
|
-
} catch {
|
|
44
|
-
|
|
45
|
+
} catch (error) {
|
|
46
|
+
// `guard` throws `QueryDeniedError` for a decision and for nothing else, so that class is
|
|
47
|
+
// the whole of "not visible". A rule that reached for a row and timed out throws something
|
|
48
|
+
// else, and answering `false` to it would report an outage as a permission change: the rows
|
|
49
|
+
// leave the subscriber's screen, `live.rows_denied` counts the drop, and no error reaches
|
|
50
|
+
// the node. The registry is the one that decides what a failure costs — see `deliver`.
|
|
51
|
+
if (error instanceof QueryDeniedError) return false;
|
|
52
|
+
throw error;
|
|
45
53
|
}
|
|
46
54
|
};
|
|
47
55
|
}
|
package/src/presence.ts
CHANGED
|
@@ -4,13 +4,22 @@
|
|
|
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
6
|
|
|
7
|
-
import { type Clock, systemClock } from '@ultimat3/core';
|
|
7
|
+
import { type Clock, systemClock, uuid } from '@ultimat3/core';
|
|
8
8
|
import type { ChannelHub, Topic } from './channel';
|
|
9
9
|
import type { Transport } from './fanout';
|
|
10
10
|
import type { JsonObject } from './json';
|
|
11
11
|
import { type Frame, PROTOCOL_VERSION, type PresenceMember } from './sync-protocol';
|
|
12
12
|
|
|
13
13
|
export const PRESENCE_KEY_PREFIX = 'presence';
|
|
14
|
+
/** Separate namespace: the sweep lease is one member per *node*, never one per participant. */
|
|
15
|
+
export const PRESENCE_SWEEP_PREFIX = 'presence.sweep';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Members carried on one full-set frame. A 5,000-avatar row is not a UI anyone renders, and the
|
|
19
|
+
* full set is 25M member deserializations across one all-hands join storm; the count rides along
|
|
20
|
+
* on the frame so a client can say "and 4,744 others" without ever holding them.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_MAX_PRESENCE_MEMBERS = 256;
|
|
14
23
|
|
|
15
24
|
export interface PresenceOptions {
|
|
16
25
|
readonly transport: Transport;
|
|
@@ -19,6 +28,20 @@ export interface PresenceOptions {
|
|
|
19
28
|
readonly clock?: Clock;
|
|
20
29
|
/** Member TTL. Clients should heartbeat at ttl/3 so one lost beat is not a false leave. */
|
|
21
30
|
readonly ttlMs?: number;
|
|
31
|
+
/** Members on a full-set frame. The set itself is never capped — only what is shipped. */
|
|
32
|
+
readonly maxMembers?: number;
|
|
33
|
+
/**
|
|
34
|
+
* This node's identity in the per-topic sweep election. Defaults to a fresh id per registry,
|
|
35
|
+
* which is what a `sync` node wants: an election between processes, never between rooms.
|
|
36
|
+
*/
|
|
37
|
+
readonly nodeId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One full-set frame's worth of a room, and how big the room actually is. */
|
|
41
|
+
export interface PresenceRoster {
|
|
42
|
+
readonly members: readonly PresenceMember[];
|
|
43
|
+
/** Members in the set, whatever was shipped. `total > members.length` means truncated. */
|
|
44
|
+
readonly total: number;
|
|
22
45
|
}
|
|
23
46
|
|
|
24
47
|
export interface PresenceInput {
|
|
@@ -34,6 +57,8 @@ export class PresenceRegistry {
|
|
|
34
57
|
readonly #hub: ChannelHub | undefined;
|
|
35
58
|
readonly #clock: Clock;
|
|
36
59
|
readonly #ttlMs: number;
|
|
60
|
+
readonly #maxMembers: number;
|
|
61
|
+
readonly #nodeId: string;
|
|
37
62
|
/** Diffing cache only — the truth is always `transport.shared`. Safe to lose. */
|
|
38
63
|
readonly #seen = new Map<string, Set<string>>();
|
|
39
64
|
|
|
@@ -42,6 +67,8 @@ export class PresenceRegistry {
|
|
|
42
67
|
this.#hub = options.hub;
|
|
43
68
|
this.#clock = options.clock ?? systemClock;
|
|
44
69
|
this.#ttlMs = options.ttlMs ?? 30_000;
|
|
70
|
+
this.#maxMembers = Math.max(1, options.maxMembers ?? DEFAULT_MAX_PRESENCE_MEMBERS);
|
|
71
|
+
this.#nodeId = options.nodeId ?? uuid();
|
|
45
72
|
}
|
|
46
73
|
|
|
47
74
|
get ttlMs(): number {
|
|
@@ -53,7 +80,7 @@ export class PresenceRegistry {
|
|
|
53
80
|
return Math.max(1_000, Math.floor(this.#ttlMs / 3));
|
|
54
81
|
}
|
|
55
82
|
|
|
56
|
-
async join(name: Topic, input: PresenceInput): Promise<
|
|
83
|
+
async join(name: Topic, input: PresenceInput): Promise<PresenceRoster> {
|
|
57
84
|
const member: PresenceMember = {
|
|
58
85
|
id: input.id,
|
|
59
86
|
actorId: input.actorId,
|
|
@@ -63,7 +90,7 @@ export class PresenceRegistry {
|
|
|
63
90
|
await this.#write(name, member);
|
|
64
91
|
this.#track(name).add(member.id);
|
|
65
92
|
await this.#emit(name, 'join', [member]);
|
|
66
|
-
return await this.
|
|
93
|
+
return await this.roster(name);
|
|
67
94
|
}
|
|
68
95
|
|
|
69
96
|
/** `false` means the member had already expired: the caller must `join` again, not `heartbeat`. */
|
|
@@ -114,6 +141,15 @@ export class PresenceRegistry {
|
|
|
114
141
|
return members;
|
|
115
142
|
}
|
|
116
143
|
|
|
144
|
+
/**
|
|
145
|
+
* What a full-set frame carries. `list` stays the whole set because the sweep decides who left by
|
|
146
|
+
* differencing it — capping *that* would report every member past the cap as gone.
|
|
147
|
+
*/
|
|
148
|
+
async roster(name: Topic): Promise<PresenceRoster> {
|
|
149
|
+
const members = await this.list(name);
|
|
150
|
+
return { members: members.slice(0, this.#maxMembers), total: members.length };
|
|
151
|
+
}
|
|
152
|
+
|
|
117
153
|
/** Turns TTL expiry into explicit `leave` frames. Called on an interval by the `sync` node. */
|
|
118
154
|
async sweep(name: Topic): Promise<readonly PresenceMember[]> {
|
|
119
155
|
const live = await this.list(name);
|
|
@@ -144,17 +180,44 @@ export class PresenceRegistry {
|
|
|
144
180
|
async sweepAll(): Promise<readonly PresenceMember[]> {
|
|
145
181
|
const gone: PresenceMember[] = [];
|
|
146
182
|
for (const name of [...this.#seen.keys()] as Topic[]) {
|
|
147
|
-
gone.push(...(await this.sweep(name)));
|
|
148
183
|
// A room nobody is in is not a room. Without this the cache keeps one entry per topic ever
|
|
149
184
|
// subscribed to, for the life of the process, and the sweep walks all of them forever.
|
|
185
|
+
if ((this.#seen.get(name)?.size ?? 0) === 0) {
|
|
186
|
+
this.#seen.delete(name);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!(await this.#claimSweep(name))) continue;
|
|
190
|
+
gone.push(...(await this.sweep(name)));
|
|
150
191
|
if ((this.#seen.get(name)?.size ?? 0) === 0) this.#seen.delete(name);
|
|
151
192
|
}
|
|
152
193
|
return gone;
|
|
153
194
|
}
|
|
154
195
|
|
|
196
|
+
/**
|
|
197
|
+
* One node per topic per pass reads the full member set. Every node sweeping every room it has
|
|
198
|
+
* ever seen is the same full-set read multiplied by the fleet — twenty nodes reading a
|
|
199
|
+
* 5,000-member set every ten seconds, forever, to produce twenty copies of one `leave` frame.
|
|
200
|
+
*
|
|
201
|
+
* The election needs no compare-and-set the shared store does not have: the lease key is a
|
|
202
|
+
* *keyed set*, so every node's claim is its own member and the winner is simply the lowest id
|
|
203
|
+
* every claimant can see. It is eventually consistent, and the worst case of two nodes reading
|
|
204
|
+
* different views is a duplicate `leave` for a member who has already gone — which is what a
|
|
205
|
+
* `leave` frame means anyway. What it never produces is nobody sweeping: a claim is re-put every
|
|
206
|
+
* pass, and a dead leader's expires within one TTL.
|
|
207
|
+
*/
|
|
208
|
+
async #claimSweep(name: Topic): Promise<boolean> {
|
|
209
|
+
const key = `${PRESENCE_SWEEP_PREFIX}.${name}`;
|
|
210
|
+
await this.#transport.shared.put(key, this.#nodeId, '', this.#ttlMs);
|
|
211
|
+
const claimants = await this.#transport.shared.entries(key);
|
|
212
|
+
let leader = this.#nodeId;
|
|
213
|
+
for (const claimant of claimants) if (claimant.member < leader) leader = claimant.member;
|
|
214
|
+
return leader === this.#nodeId;
|
|
215
|
+
}
|
|
216
|
+
|
|
155
217
|
/** Full-set frame for a client that just (re)connected — presence has no delta protocol. */
|
|
156
218
|
async syncFrame(name: Topic): Promise<Frame> {
|
|
157
|
-
|
|
219
|
+
const roster = await this.roster(name);
|
|
220
|
+
return presenceFrame(name, 'sync', roster.members, roster.total);
|
|
158
221
|
}
|
|
159
222
|
|
|
160
223
|
#key(name: Topic): string {
|
|
@@ -194,12 +257,19 @@ export class PresenceRegistry {
|
|
|
194
257
|
}
|
|
195
258
|
}
|
|
196
259
|
|
|
260
|
+
/**
|
|
261
|
+
* `total` belongs to a **full set** and to nothing else: a `join`/`leave`/`update` frame carries the
|
|
262
|
+
* members that changed, so a count beside them would read as "and the rest were truncated". Absent
|
|
263
|
+
* is a defined answer — the client renders what it was sent.
|
|
264
|
+
*/
|
|
197
265
|
export function presenceFrame(
|
|
198
266
|
name: Topic,
|
|
199
267
|
op: 'join' | 'leave' | 'update' | 'sync',
|
|
200
268
|
members: readonly PresenceMember[],
|
|
269
|
+
total?: number,
|
|
201
270
|
): Frame {
|
|
202
|
-
|
|
271
|
+
const base = { type: 'presence', v: PROTOCOL_VERSION, topic: name, op, members } as const;
|
|
272
|
+
return total === undefined ? base : { ...base, total };
|
|
203
273
|
}
|
|
204
274
|
|
|
205
275
|
function parseMember(id: string, value: string): PresenceMember | null {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The typed projection of a query into the hook a component calls: `liveHookFor(liveFeed)` is
|
|
2
|
+
// `useLiveFeed`, and `useLiveFeed({ orgId })` carries that query's own input and row types. It
|
|
3
|
+
// binds `useLive` rather than re-implementing it — one subscribe path, given the query's name.
|
|
4
|
+
|
|
5
|
+
import { QueryNotSubscribableError } from './errors';
|
|
6
|
+
import { type LiveInput, type LiveRows, useLive } from './hooks';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the hook needs from a `@ultimat3/query` `Query`: the name it subscribes under, the declared
|
|
10
|
+
* `live:` flag, and the call signature both types are read off. Named structurally rather than
|
|
11
|
+
* imported, the way `hooks.ts` names a mutator — a hook is browser code, and a value import of
|
|
12
|
+
* `@ultimat3/query` would carry the server's read path into the bundle.
|
|
13
|
+
*
|
|
14
|
+
* `options` is `never` because this side never passes one; a `Query`, whose second parameter is
|
|
15
|
+
* optional and wider, still assigns.
|
|
16
|
+
*/
|
|
17
|
+
export interface LiveQuerySource<TInput, TRow extends object> {
|
|
18
|
+
(input: TInput, options?: never): Promise<readonly TRow[]>;
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly isLive: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The bound hook. Input is the query's own — a wrong key is a compile error in the component, not
|
|
25
|
+
* a subscription that returns nothing. A thunk is read **once**, at subscribe time, exactly as
|
|
26
|
+
* `useLive`'s is: there is no reactive runtime here to re-run it, so new input means a new
|
|
27
|
+
* subscription.
|
|
28
|
+
*/
|
|
29
|
+
export type LiveQueryHook<TInput, TRow extends object> = (
|
|
30
|
+
input: TInput | (() => TInput),
|
|
31
|
+
) => LiveRows<TRow>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Bind one live query to one hook: `export const useLiveFeed = liveHookFor(liveFeed)`, then
|
|
35
|
+
* `useLiveFeed({ orgId })` in a component. Nothing is generated and nothing is fetched by hand —
|
|
36
|
+
* the types come off the query declaration and the rows off its subscription.
|
|
37
|
+
*
|
|
38
|
+
* Binding a query that is not `live: true` throws here, at module load, rather than handing back a
|
|
39
|
+
* hook that could only ever return an empty set.
|
|
40
|
+
*/
|
|
41
|
+
export function liveHookFor<TInput, TRow extends object>(
|
|
42
|
+
query: LiveQuerySource<TInput, TRow>,
|
|
43
|
+
): LiveQueryHook<TInput, TRow> {
|
|
44
|
+
if (!query.isLive) throw new QueryNotSubscribableError({ name: query.name });
|
|
45
|
+
// The query object is handed through, never its `name` read now: `registerQueries()` stamps that
|
|
46
|
+
// at boot, and this binding runs at import — earlier. `useLive` reads it per subscription.
|
|
47
|
+
return (input) => {
|
|
48
|
+
// Both assertions are the one wire seam: the input is about to be serialised into a subscribe
|
|
49
|
+
// frame, and the rows come back off that subscription. `unknown` in between rather than a
|
|
50
|
+
// direct cast, exactly as `query.client()` hops through it at the same seam.
|
|
51
|
+
const rows: unknown = useLive(query, input as LiveInput);
|
|
52
|
+
// Erased at the wire seam, the way `query.client()` erases its own: the rows arriving on this
|
|
53
|
+
// subscription are this query's by construction, because the server built them from its `sql`.
|
|
54
|
+
return rows as LiveRows<TRow>;
|
|
55
|
+
};
|
|
56
|
+
}
|