@ultimat3/realtime 23.0.0 → 25.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 +118 -126
- package/README.md +229 -54
- package/package.json +9 -6
- package/src/advisory-lock.ts +62 -0
- package/src/boot.ts +25 -15
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +9 -9
- package/src/changefeed.ts +81 -19
- package/src/channel-authz.ts +48 -17
- package/src/channel-describe.ts +1 -1
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +36 -15
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +35 -4
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +148 -48
- package/src/client-channels.ts +12 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +16 -3
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +30 -2
- package/src/cursor.ts +11 -0
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +113 -105
- package/src/fanout.ts +13 -0
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +25 -7
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +34 -9
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +122 -29
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +44 -0
- package/src/live-query.ts +96 -175
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +37 -6
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +149 -15
- package/src/offline-queue.ts +53 -5
- package/src/page-errors.ts +25 -9
- package/src/page-outbox.ts +125 -54
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +54 -5
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +126 -22
- package/src/pg-entity-row.ts +16 -0
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +50 -51
- package/src/pg-socket.ts +26 -7
- package/src/pg-tls.ts +18 -0
- package/src/pg-values.ts +29 -14
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +87 -10
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +17 -5
- package/src/record-persister.ts +7 -2
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +11 -7
- package/src/replicator-envelope.ts +160 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +313 -191
- package/src/server.ts +45 -23
- package/src/socket-defaults.ts +20 -0
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/socket.ts +14 -19
- package/src/subscriber-gate.ts +11 -1
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-auth.ts +47 -2
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +74 -21
- package/src/sync-node-bounds.ts +16 -1
- package/src/sync-node-contract.ts +36 -4
- package/src/sync-node.ts +163 -82
- package/src/sync-origin.ts +27 -12
- package/src/sync-protocol.ts +86 -16
- package/src/sync-upgrade.ts +115 -8
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +41 -20
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +18 -9
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/nats-transport.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Single responsibility: the production `Transport` — core NATS for fanout, JetStream KV for the
|
|
2
2
|
// shared presence sets. The client underneath owns the wire and the reconnect, including
|
|
3
|
-
// re-establishing subscriptions, which is what makes a `sync` node stateless
|
|
4
|
-
//
|
|
3
|
+
// re-establishing subscriptions, which is what makes a `sync` node stateless. The one loss it does
|
|
4
|
+
// not own is its own end: a client closed for good once its reconnect budget is spent is replaced
|
|
5
|
+
// here, on our backoff, and every kept subscription is bound again on the new one.
|
|
5
6
|
|
|
6
7
|
import {
|
|
7
8
|
type Clock,
|
|
@@ -18,6 +19,7 @@ import { parseNatsUrl } from './nats-client';
|
|
|
18
19
|
import { ensureKvBucket } from './nats-jetstream';
|
|
19
20
|
import { NatsKvSet } from './nats-kv';
|
|
20
21
|
import { openNatsClient } from './nats-open';
|
|
22
|
+
import { NatsSubscriptions } from './nats-subscriptions';
|
|
21
23
|
import { type BackoffPolicy, defaultBackoff, policyDelay, type Rng } from './thundering-herd';
|
|
22
24
|
|
|
23
25
|
const encoder = new TextEncoder();
|
|
@@ -55,6 +57,11 @@ export class NatsTransport implements Transport {
|
|
|
55
57
|
#dialing: Promise<NatsClient> | undefined;
|
|
56
58
|
#retries = 0;
|
|
57
59
|
#closed = false;
|
|
60
|
+
/** Set while a client the library gave up on is being replaced; the attempt in flight. */
|
|
61
|
+
#redialing: number | undefined;
|
|
62
|
+
#wakeRedial: (() => void) | undefined;
|
|
63
|
+
readonly #subscriptions = new NatsSubscriptions();
|
|
64
|
+
readonly #reconnectListeners = new Set<() => void>();
|
|
58
65
|
|
|
59
66
|
constructor(options: NatsTransportOptions) {
|
|
60
67
|
// Parsed here rather than at the first publish: a malformed NATS_URL is a boot-time fault, and
|
|
@@ -97,27 +104,44 @@ export class NatsTransport implements Transport {
|
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
107
|
+
* A drop is the client's to recover: the subscription comes back with the library's reconnect,
|
|
108
|
+
* untouched by this file. It is also KEPT here, for the one loss the library does not recover —
|
|
109
|
+
* a client closed for good — and bound once on its replacement, never twice on one client.
|
|
103
110
|
*/
|
|
104
111
|
async subscribe(subject: string, handler: TransportHandler): Promise<TransportSubscription> {
|
|
105
112
|
const client = await this.#ensure();
|
|
106
113
|
// Same seam as `publish`: a permissions violation on the subject is refused here, not later.
|
|
107
|
-
const
|
|
108
|
-
client
|
|
114
|
+
const release = this.#subscriptions.add(
|
|
115
|
+
client,
|
|
116
|
+
subject,
|
|
117
|
+
(message) => {
|
|
109
118
|
try {
|
|
110
119
|
handler(decoder.decode(message.payload), message.subject);
|
|
111
120
|
} catch (error) {
|
|
112
121
|
this.#report(error, message.subject);
|
|
113
122
|
}
|
|
114
|
-
}
|
|
123
|
+
},
|
|
124
|
+
(call) => this.#translating(`subscribe to ${subject}`, call),
|
|
115
125
|
);
|
|
116
|
-
return { subject, unsubscribe:
|
|
126
|
+
return { subject, unsubscribe: release };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Told every time the client comes back from a drop. Fanout is at-most-once, so the gap between
|
|
131
|
+
* the drop and this call is changes the subscriber never saw — and with no later message there
|
|
132
|
+
* is no sequence number to notice it by. A `sync` node invalidates its windows on it. Returns
|
|
133
|
+
* the unsubscribe.
|
|
134
|
+
*/
|
|
135
|
+
onReconnect(listener: () => void): () => void {
|
|
136
|
+
this.#reconnectListeners.add(listener);
|
|
137
|
+
return () => {
|
|
138
|
+
this.#reconnectListeners.delete(listener);
|
|
139
|
+
};
|
|
117
140
|
}
|
|
118
141
|
|
|
119
142
|
async close(): Promise<void> {
|
|
120
143
|
this.#closed = true;
|
|
144
|
+
this.#wakeRedial?.();
|
|
121
145
|
const client = this.#client;
|
|
122
146
|
this.#client = undefined;
|
|
123
147
|
await client?.close();
|
|
@@ -127,7 +151,9 @@ export class NatsTransport implements Transport {
|
|
|
127
151
|
* One dial, shared by every caller that races it. A client that has already been handed out is
|
|
128
152
|
* reused whatever its state: while it is reconnecting the library is re-establishing that same
|
|
129
153
|
* connection and its subscriptions, and a second dial alongside it would double every delivery.
|
|
130
|
-
* A
|
|
154
|
+
* A client closed for good is not handed out: `#lost` clears it and `#redial` replaces it, and a
|
|
155
|
+
* caller that lands meanwhile is refused at once rather than parked behind a dial that may take
|
|
156
|
+
* the library's whole connect budget.
|
|
131
157
|
*/
|
|
132
158
|
#ensure(): Promise<NatsClient> {
|
|
133
159
|
if (this.#closed) {
|
|
@@ -137,6 +163,19 @@ export class NatsTransport implements Transport {
|
|
|
137
163
|
}
|
|
138
164
|
const current = this.#client;
|
|
139
165
|
if (current !== undefined) return Promise.resolve(current);
|
|
166
|
+
if (this.#redialing !== undefined) {
|
|
167
|
+
return Promise.reject(
|
|
168
|
+
new TransportUnavailableError({
|
|
169
|
+
transport: this.name,
|
|
170
|
+
reason: `the connection closed once its reconnect budget was spent, and re-dial attempt ${this.#redialing} is in progress`,
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return this.#dialOnce();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** One dial, shared by every caller that races it. */
|
|
178
|
+
#dialOnce(): Promise<NatsClient> {
|
|
140
179
|
this.#dialing ??= this.#dial().finally(() => {
|
|
141
180
|
this.#dialing = undefined;
|
|
142
181
|
});
|
|
@@ -150,6 +189,10 @@ export class NatsTransport implements Transport {
|
|
|
150
189
|
* leaking one per retry.
|
|
151
190
|
*/
|
|
152
191
|
async #dial(): Promise<NatsClient> {
|
|
192
|
+
// The client is only known once the dial resolves, and the library may give up on it before
|
|
193
|
+
// the bucket is asserted: both cases are caught by identity, never by a flag on the transport.
|
|
194
|
+
let opened: NatsClient | undefined;
|
|
195
|
+
let closedEarly = false;
|
|
153
196
|
const client = await this.#connect({
|
|
154
197
|
url: this.#options.url,
|
|
155
198
|
name: 'ultimate',
|
|
@@ -159,9 +202,19 @@ export class NatsTransport implements Transport {
|
|
|
159
202
|
reconnectDelay: () => policyDelay(this.#backoff, ++this.#retries, this.#rng),
|
|
160
203
|
onError: (error) => this.#report(error, this.name),
|
|
161
204
|
onReconnect: () => this.#recovered(),
|
|
205
|
+
onClosed: () => {
|
|
206
|
+
if (opened === undefined) closedEarly = true;
|
|
207
|
+
else this.#lost(opened);
|
|
208
|
+
},
|
|
162
209
|
});
|
|
163
210
|
try {
|
|
164
211
|
await this.#ensureBucket(client);
|
|
212
|
+
if (closedEarly) {
|
|
213
|
+
throw new TransportUnavailableError({
|
|
214
|
+
transport: this.name,
|
|
215
|
+
reason: 'the connection closed for good while the KV bucket was being asserted',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
165
218
|
// `close()` can land while a dial is in flight, and it only closes what it can see:
|
|
166
219
|
// publishing now would leave a connection open that nothing will ever close again.
|
|
167
220
|
if (this.#closed) {
|
|
@@ -174,11 +227,76 @@ export class NatsTransport implements Transport {
|
|
|
174
227
|
await client.close();
|
|
175
228
|
throw error;
|
|
176
229
|
}
|
|
230
|
+
opened = client;
|
|
177
231
|
this.#client = client;
|
|
178
232
|
this.#retries = 0;
|
|
179
233
|
return client;
|
|
180
234
|
}
|
|
181
235
|
|
|
236
|
+
/**
|
|
237
|
+
* The library spent its reconnect budget and closed the client. Handing it out again is a node
|
|
238
|
+
* that refuses every publish until somebody restarts it — and the liveness probe never asks the
|
|
239
|
+
* bus — so it is forgotten here and replaced in the background, subscriptions included.
|
|
240
|
+
*/
|
|
241
|
+
#lost(client: NatsClient): void {
|
|
242
|
+
if (this.#closed || this.#client !== client) return;
|
|
243
|
+
this.#client = undefined;
|
|
244
|
+
this.#subscriptions.orphan();
|
|
245
|
+
this.#report(
|
|
246
|
+
new TransportUnavailableError({
|
|
247
|
+
transport: this.name,
|
|
248
|
+
reason: 'the connection closed once its reconnect budget was spent; re-dialling',
|
|
249
|
+
}),
|
|
250
|
+
this.name,
|
|
251
|
+
);
|
|
252
|
+
void this.#redial();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Re-dial until a client is up or the transport is closed. Bounded by our backoff between
|
|
257
|
+
* attempts (capped at the policy's `maxMs`), and each attempt is itself a library dial with its
|
|
258
|
+
* own budget, so a long outage costs one dial per interval rather than a spin. A dial that lands
|
|
259
|
+
* re-binds every kept subscription and is announced: changes published in the gap are gone.
|
|
260
|
+
*/
|
|
261
|
+
async #redial(): Promise<void> {
|
|
262
|
+
if (this.#redialing !== undefined) return;
|
|
263
|
+
try {
|
|
264
|
+
for (let attempt = 1; !this.#closed && this.#client === undefined; attempt += 1) {
|
|
265
|
+
this.#redialing = attempt;
|
|
266
|
+
try {
|
|
267
|
+
const client = await this.#dialOnce();
|
|
268
|
+
// Coded like a first subscribe's refusal; the subscription stays kept for the next client.
|
|
269
|
+
for (const failure of this.#subscriptions.bindAll(client)) {
|
|
270
|
+
this.#report(
|
|
271
|
+
this.#coded(`re-subscribe to ${failure.subject}`, failure.error),
|
|
272
|
+
failure.subject,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
this.#announce();
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (this.#closed) return;
|
|
278
|
+
this.#report(error, this.name);
|
|
279
|
+
await this.#pause(policyDelay(this.#backoff, attempt, this.#rng));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} finally {
|
|
283
|
+
this.#redialing = undefined;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** A wait `close()` can cut short, so a closed transport never holds a timer for `maxMs`. */
|
|
288
|
+
#pause(ms: number): Promise<void> {
|
|
289
|
+
return new Promise((resolve) => {
|
|
290
|
+
const done = (): void => {
|
|
291
|
+
clearTimeout(timer);
|
|
292
|
+
this.#wakeRedial = undefined;
|
|
293
|
+
resolve();
|
|
294
|
+
};
|
|
295
|
+
const timer = setTimeout(done, ms);
|
|
296
|
+
this.#wakeRedial = done;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
182
300
|
#ensureBucket(client: NatsClient): Promise<void> {
|
|
183
301
|
return ensureKvBucket(
|
|
184
302
|
client,
|
|
@@ -200,7 +318,19 @@ export class NatsTransport implements Transport {
|
|
|
200
318
|
this.#retries = 0;
|
|
201
319
|
const client = this.#client;
|
|
202
320
|
if (client === undefined) return;
|
|
321
|
+
// Not awaited: the library calls this from its status loop. The failure has one place to go.
|
|
203
322
|
void this.#ensureBucket(client).catch((error: unknown) => this.#report(error, this.name));
|
|
323
|
+
this.#announce();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#announce(): void {
|
|
327
|
+
for (const listener of [...this.#reconnectListeners]) {
|
|
328
|
+
try {
|
|
329
|
+
listener();
|
|
330
|
+
} catch (error) {
|
|
331
|
+
this.#report(error, this.name);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
204
334
|
}
|
|
205
335
|
|
|
206
336
|
/**
|
|
@@ -214,14 +344,18 @@ export class NatsTransport implements Transport {
|
|
|
214
344
|
try {
|
|
215
345
|
return call();
|
|
216
346
|
} catch (error) {
|
|
217
|
-
|
|
218
|
-
throw new TransportUnavailableError({
|
|
219
|
-
transport: this.name,
|
|
220
|
-
reason: `${what} was refused: ${renderThrowable(error)}`,
|
|
221
|
-
});
|
|
347
|
+
throw this.#coded(what, error);
|
|
222
348
|
}
|
|
223
349
|
}
|
|
224
350
|
|
|
351
|
+
#coded(what: string, error: unknown): unknown {
|
|
352
|
+
if (isUltimateError(error)) return error;
|
|
353
|
+
return new TransportUnavailableError({
|
|
354
|
+
transport: this.name,
|
|
355
|
+
reason: `${what} was refused: ${renderThrowable(error)}`,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
225
359
|
/**
|
|
226
360
|
* Every background failure lands here — a lost connection, a throwing subscriber, an exhausted
|
|
227
361
|
* reconnect. Dropping it when the caller passed no handler is what turns "no changes arrive"
|
package/src/offline-queue.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { renderThrowable, stringField } from '@ultimat3/core/page';
|
|
13
13
|
import type { JsonValue } from './json';
|
|
14
|
+
import { OfflineQueueAbandonedError } from './page-errors';
|
|
14
15
|
import type { WireError } from './sync-protocol';
|
|
15
16
|
|
|
16
17
|
export type MutationStatus = 'pending' | 'inflight' | 'acked' | 'failed';
|
|
@@ -68,6 +69,11 @@ export class MemoryQueueStore implements QueueStore {
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/** The one way to build the in-process queue store; the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
|
|
73
|
+
export function memoryQueueStore(): MemoryQueueStore {
|
|
74
|
+
return new MemoryQueueStore();
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
export interface DrainReport {
|
|
72
78
|
readonly sent: number;
|
|
73
79
|
readonly collapsed: number;
|
|
@@ -91,6 +97,8 @@ export class OfflineQueue {
|
|
|
91
97
|
* is parked inside `send`. Bumped by every loss so a pass that resumes afterwards claims nothing.
|
|
92
98
|
*/
|
|
93
99
|
#epoch = 0;
|
|
100
|
+
/** Set by `abandon`, never cleared: this queue's principal is gone. */
|
|
101
|
+
#abandoned = false;
|
|
94
102
|
|
|
95
103
|
private constructor(store: QueueStore, state: QueueState) {
|
|
96
104
|
this.#store = store;
|
|
@@ -103,8 +111,10 @@ export class OfflineQueue {
|
|
|
103
111
|
*
|
|
104
112
|
* An `inflight` entry on disk belonged to a page that is gone, so it goes back to `pending`. Left
|
|
105
113
|
* as it was, `#sendable` skipped it forever — no ack was coming to a page that no longer exists —
|
|
106
|
-
* and every later write overtook it. The replay carries its idempotency key,
|
|
107
|
-
*
|
|
114
|
+
* and every later write overtook it. The replay carries its idempotency key, and a `mutator()`
|
|
115
|
+
* is REQUIRED to declare `idempotent: true` (`X_MUTATOR_NOT_IDEMPOTENT` at declaration), so a
|
|
116
|
+
* write the old page did get through is answered from the action's idempotency store and is
|
|
117
|
+
* never applied twice. That requirement is what makes this resend safe, not the key alone.
|
|
108
118
|
*/
|
|
109
119
|
static async open(store: QueueStore): Promise<OfflineQueue> {
|
|
110
120
|
const queue = new OfflineQueue(store, await store.load());
|
|
@@ -119,6 +129,7 @@ export class OfflineQueue {
|
|
|
119
129
|
* other pass can have one on the wire.
|
|
120
130
|
*/
|
|
121
131
|
async reload(): Promise<void> {
|
|
132
|
+
if (this.#abandoned) return;
|
|
122
133
|
const state = await this.#store.load();
|
|
123
134
|
this.#mutations = state.mutations.map((mutation) => ({ ...mutation }));
|
|
124
135
|
this.#nextSeq = Math.max(this.#nextSeq, state.nextSeq);
|
|
@@ -167,6 +178,17 @@ export class OfflineQueue {
|
|
|
167
178
|
input: JsonValue;
|
|
168
179
|
at?: number;
|
|
169
180
|
}): Promise<QueuedMutation> {
|
|
181
|
+
// Read BEFORE the key is looked up, never between the lookup and the push: with the await in
|
|
182
|
+
// between, two enqueues of one key both found nothing and both pushed — one intent, two
|
|
183
|
+
// entries under one sequence number. Another tab of the same user may have taken sequence
|
|
184
|
+
// numbers since this one loaded, which is what the read is for.
|
|
185
|
+
//
|
|
186
|
+
// An abandoned queue REFUSES, before the read and after it (the principal can leave during
|
|
187
|
+
// it): a write accepted here would sit in memory, shown as queued, with nothing to send it.
|
|
188
|
+
if (this.#abandoned) throw new OfflineQueueAbandonedError({ name: args.name });
|
|
189
|
+
const floor = (await this.#store.load()).nextSeq;
|
|
190
|
+
if (this.#abandoned) throw new OfflineQueueAbandonedError({ name: args.name });
|
|
191
|
+
this.#nextSeq = Math.max(this.#nextSeq, floor);
|
|
170
192
|
const existing = this.find(args.key);
|
|
171
193
|
if (existing && existing.status !== 'failed') {
|
|
172
194
|
this.#collapsed += 1;
|
|
@@ -178,8 +200,6 @@ export class OfflineQueue {
|
|
|
178
200
|
// entry is dropped and this one takes a new sequence at the back of the queue.
|
|
179
201
|
// By identity: `existing` IS the entry for this key, found above — no second key comparison.
|
|
180
202
|
if (existing) this.#mutations = this.#mutations.filter((entry) => entry !== existing);
|
|
181
|
-
// Another tab of the same user may have taken sequence numbers since this one loaded.
|
|
182
|
-
this.#nextSeq = Math.max(this.#nextSeq, (await this.#store.load()).nextSeq);
|
|
183
203
|
const mutation: QueuedMutation = {
|
|
184
204
|
key: args.key,
|
|
185
205
|
seq: this.#nextSeq,
|
|
@@ -248,6 +268,22 @@ export class OfflineQueue {
|
|
|
248
268
|
return returned;
|
|
249
269
|
}
|
|
250
270
|
|
|
271
|
+
/**
|
|
272
|
+
* The principal this queue belongs to is gone (`page-outbox.ts` calls it on a `rescope`), and it
|
|
273
|
+
* is final: nothing more is SENT, WRITTEN or ACCEPTED (`enqueue` refuses). The epoch bump stops a pass parked
|
|
274
|
+
* inside `send` before its next entry — which would otherwise leave under the next principal's
|
|
275
|
+
* session — and the flag stops a pass that starts later, and every write-back: the store was
|
|
276
|
+
* wiped for this principal, and a pass that then saved what it touched put the writes back.
|
|
277
|
+
*/
|
|
278
|
+
abandon(): void {
|
|
279
|
+
this.#epoch += 1;
|
|
280
|
+
this.#abandoned = true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
get abandoned(): boolean {
|
|
284
|
+
return this.#abandoned;
|
|
285
|
+
}
|
|
286
|
+
|
|
251
287
|
/** Server acknowledged: the mutation leaves the queue and its rebase entry can be committed. */
|
|
252
288
|
async ack(key: string): Promise<void> {
|
|
253
289
|
const mutation = this.find(key);
|
|
@@ -294,7 +330,7 @@ export class OfflineQueue {
|
|
|
294
330
|
/** One drain pass. Never called concurrently with itself — `drain` owns that. */
|
|
295
331
|
async #pass(send: MutationSender): Promise<DrainReport> {
|
|
296
332
|
const epoch = this.#epoch;
|
|
297
|
-
const sendable = this.#sendable();
|
|
333
|
+
const sendable = this.#abandoned ? [] : this.#sendable();
|
|
298
334
|
// Nothing to do: a pass chained behind one that already sent everything must not rewrite the
|
|
299
335
|
// durable state for the privilege of reporting zero.
|
|
300
336
|
if (sendable.length === 0) {
|
|
@@ -326,6 +362,12 @@ export class OfflineQueue {
|
|
|
326
362
|
// `WebSocket.send` on a CLOSING socket discards it and returns normally — so calling that
|
|
327
363
|
// an ack drops the mutation on exactly the socket death this queue exists to survive.
|
|
328
364
|
// Only `ack`/`fail` (the server) or `requeueInflight` (a lost connection) moves it on.
|
|
365
|
+
//
|
|
366
|
+
// A sender may settle the write itself before it returns — the page outbox does, over
|
|
367
|
+
// HTTP. One it REFUSED keeps the error `fail` just recorded and is not counted as sent:
|
|
368
|
+
// clearing it here put `{ status: 'failed', error: null }` on disk and reported a refused
|
|
369
|
+
// write as a sent one.
|
|
370
|
+
if (statusOf(mutation) === 'failed') continue;
|
|
329
371
|
mutation.error = null;
|
|
330
372
|
sent += 1;
|
|
331
373
|
} catch (error) {
|
|
@@ -364,6 +406,7 @@ export class OfflineQueue {
|
|
|
364
406
|
* queue: a second tab's entries are not this tab's to overwrite or delete.
|
|
365
407
|
*/
|
|
366
408
|
async #persist(puts: readonly QueuedMutation[], deletes: readonly string[] = []): Promise<void> {
|
|
409
|
+
if (this.#abandoned) return;
|
|
367
410
|
await this.#store.write({
|
|
368
411
|
puts: puts.map((mutation) => ({ ...mutation })),
|
|
369
412
|
deletes,
|
|
@@ -372,6 +415,11 @@ export class OfflineQueue {
|
|
|
372
415
|
}
|
|
373
416
|
}
|
|
374
417
|
|
|
418
|
+
/** Read through a call: `send` awaited in between, and the compiler still narrows to `inflight`. */
|
|
419
|
+
function statusOf(mutation: QueuedMutation): MutationStatus {
|
|
420
|
+
return mutation.status;
|
|
421
|
+
}
|
|
422
|
+
|
|
375
423
|
/** A thrown value as the queue records it — `code`, `cause`, `fix`, never a throw of its own. */
|
|
376
424
|
export function toQueueError(error: unknown): WireError {
|
|
377
425
|
return {
|
package/src/page-errors.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// The refusals a BROWSER can reach — the page store, the hooks, the socket's wire check, the local
|
|
2
|
-
// store. Apart from `errors.ts` for bytes: that module
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// `cause` and `fix` — what a reader acts on — are unchanged. This module runs nothing at import.
|
|
2
|
+
// store. Apart from `errors.ts` for bytes: that module holds the sync node's refusal classes, which
|
|
3
|
+
// no island constructs. The codes and their titles are `error-titles.ts` (the module
|
|
4
|
+
// `package.json`'s `sideEffects` names, imported bare by the barrel), so a code constructed here is
|
|
5
|
+
// titled wherever the barrel was reached. This module runs nothing at import.
|
|
7
6
|
|
|
8
7
|
import { renderFixShellArg } from '@ultimat3/core/page';
|
|
9
8
|
import { RealtimeError } from './realtime-error';
|
|
@@ -36,8 +35,8 @@ export class RebaseConflictError extends RealtimeError {
|
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
/**
|
|
39
|
-
* A hook ran IN A BROWSER in an island bundle whose bootstrap never called `installRealtime()
|
|
40
|
-
* Per BUNDLE, not per page: every island carries its own copy of solid-js, so the signal factory a
|
|
38
|
+
* A hook ran IN A BROWSER in an island bundle whose bootstrap never called `installRealtime()`,
|
|
39
|
+
* or before the page runtime (`page-runtime.ts`, carried by the page boot) was installed. Per BUNDLE, not per page: every island carries its own copy of solid-js, so the signal factory a
|
|
41
40
|
* hook renders through has to be that island's own — the page-wide store cannot hold one.
|
|
42
41
|
*
|
|
43
42
|
* A server render is deliberately not this error: no DOM means no reactive runtime to install,
|
|
@@ -47,8 +46,8 @@ export class RealtimeUninstalledError extends RealtimeError {
|
|
|
47
46
|
constructor(args: { hook: string }) {
|
|
48
47
|
super({
|
|
49
48
|
code: 'X_REALTIME_UNINSTALLED',
|
|
50
|
-
cause: `${args.hook}() ran in a browser island whose bundle never called installRealtime()`,
|
|
51
|
-
fix: "x build # the island bootstrap installs it; a hand-built island calls installRealtime({ signal: createSignal }) from '@ultimat3/realtime' before its first render",
|
|
49
|
+
cause: `${args.hook}() ran in a browser island whose bundle never called installRealtime(), or on a page whose runtime (@ultimat3/realtime/boot) never loaded`,
|
|
50
|
+
fix: "x build # the island bootstrap installs it; a hand-built island imports '@ultimat3/realtime/boot' (the page runtime) and calls installRealtime({ signal: createSignal }) from '@ultimat3/realtime' before its first render",
|
|
52
51
|
});
|
|
53
52
|
}
|
|
54
53
|
}
|
|
@@ -93,6 +92,23 @@ export class LocalStoreUnavailableError extends RealtimeError {
|
|
|
93
92
|
}
|
|
94
93
|
}
|
|
95
94
|
|
|
95
|
+
/**
|
|
96
|
+
* A write reached a queue whose principal is gone: the page signed out, or in as someone else,
|
|
97
|
+
* while the write was being queued. It is not stored and never sent — a write issued under one
|
|
98
|
+
* session must not leave under the next — and it is RAISED, because a user's write dropped in
|
|
99
|
+
* silence is a loss nobody is told about. `name` is the mutator's registered name; the write's
|
|
100
|
+
* idempotency key is deliberately not rendered.
|
|
101
|
+
*/
|
|
102
|
+
export class OfflineQueueAbandonedError extends RealtimeError {
|
|
103
|
+
constructor(args: { name: string }) {
|
|
104
|
+
super({
|
|
105
|
+
code: 'X_OFFLINE_QUEUE_ABANDONED',
|
|
106
|
+
cause: `the write "${args.name}" was queued after its page changed principal, so it was not stored and will not be sent — call the mutation again under the current session`,
|
|
107
|
+
fix: 'x errors explain X_OFFLINE_QUEUE_ABANDONED --json',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
96
112
|
/**
|
|
97
113
|
* Something that can only mean "talk to the socket" ran on the server client — a mutation, a
|
|
98
114
|
* publish, a topic subscription, a dial. There is no socket during a server render and there never
|