@ultimat3/realtime 20.2.1 → 22.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +300 -952
- package/README.md +192 -131
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +14 -1
- package/src/channel-authz.ts +52 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +155 -0
- package/src/channel-describe.ts +35 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +134 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +87 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +359 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +150 -195
- package/src/cursor.ts +7 -2
- package/src/errors.ts +55 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +133 -0
- package/src/idb-types.ts +48 -0
- package/src/index.ts +80 -75
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +15 -4
- package/src/live-fanout.ts +81 -6
- package/src/live-query.ts +11 -0
- package/src/live-record-type.ts +19 -0
- package/src/live-replicator.ts +160 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +324 -0
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +85 -39
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +312 -0
- package/src/page-socket.ts +139 -0
- package/src/page-store.ts +138 -0
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-preflight.ts +24 -2
- package/src/pg-replication.ts +28 -8
- package/src/pg-wire.ts +51 -15
- package/src/pgoutput.ts +37 -2
- package/src/policy-fake.ts +14 -0
- package/src/presence.ts +17 -9
- package/src/query-window.ts +38 -21
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +20 -4
- package/src/server.ts +10 -11
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +344 -0
- package/src/socket-host.ts +225 -0
- package/src/socket-idle.ts +21 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +91 -49
- package/src/subscriber-gate.ts +92 -3
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +26 -114
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +31 -12
- package/src/transport-env.ts +55 -14
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +227 -0
- package/src/use-query.ts +260 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- package/src/server-render-client.ts +0 -96
package/src/offline-queue.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// to a socket and nothing more, so a drained mutation is `inflight` — not `acked` — until an
|
|
10
10
|
// `ack`/`fail` frame settles it, or a lost connection returns it to the queue.
|
|
11
11
|
|
|
12
|
-
import { renderThrowable, stringField } from '@ultimat3/core';
|
|
12
|
+
import { renderThrowable, stringField } from '@ultimat3/core/page';
|
|
13
13
|
import type { JsonValue } from './json';
|
|
14
|
-
import
|
|
14
|
+
import type { WireError } from './sync-protocol';
|
|
15
15
|
|
|
16
16
|
export type MutationStatus = 'pending' | 'inflight' | 'acked' | 'failed';
|
|
17
17
|
|
|
@@ -33,21 +33,38 @@ export interface QueueState {
|
|
|
33
33
|
readonly nextSeq: number;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* One change to the durable queue: these entries written BY KEY, these keys deleted, and the
|
|
38
|
+
* sequence floor. Never the whole queue — two tabs of one user share the store, and a whole-queue
|
|
39
|
+
* save let the last tab to save erase the other's queued write.
|
|
40
|
+
*/
|
|
41
|
+
export interface QueueChange {
|
|
42
|
+
readonly puts: readonly QueuedMutation[];
|
|
43
|
+
readonly deletes: readonly string[];
|
|
44
|
+
readonly nextSeq: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Durability seam: IndexedDB in the browser (`page-outbox.ts`), memory in tests. */
|
|
37
48
|
export interface QueueStore {
|
|
38
49
|
load(): Promise<QueueState>;
|
|
39
|
-
|
|
50
|
+
write(change: QueueChange): Promise<void>;
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
export class MemoryQueueStore implements QueueStore {
|
|
43
|
-
#
|
|
54
|
+
readonly #mutations = new Map<string, QueuedMutation>();
|
|
55
|
+
#nextSeq = 1;
|
|
44
56
|
|
|
45
57
|
async load(): Promise<QueueState> {
|
|
46
|
-
return
|
|
58
|
+
return {
|
|
59
|
+
mutations: [...this.#mutations.values()].map((m) => ({ ...m })),
|
|
60
|
+
nextSeq: this.#nextSeq,
|
|
61
|
+
};
|
|
47
62
|
}
|
|
48
63
|
|
|
49
|
-
async
|
|
50
|
-
|
|
64
|
+
async write(change: QueueChange): Promise<void> {
|
|
65
|
+
for (const key of change.deletes) this.#mutations.delete(key);
|
|
66
|
+
for (const mutation of change.puts) this.#mutations.set(mutation.key, { ...mutation });
|
|
67
|
+
this.#nextSeq = Math.max(this.#nextSeq, change.nextSeq);
|
|
51
68
|
}
|
|
52
69
|
}
|
|
53
70
|
|
|
@@ -81,9 +98,37 @@ export class OfflineQueue {
|
|
|
81
98
|
this.#nextSeq = state.nextSeq;
|
|
82
99
|
}
|
|
83
100
|
|
|
84
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* Rehydrates from durable storage, so a reload resumes the same queue with the same sequence.
|
|
103
|
+
*
|
|
104
|
+
* An `inflight` entry on disk belonged to a page that is gone, so it goes back to `pending`. Left
|
|
105
|
+
* 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, so a write the old
|
|
107
|
+
* page did get through is answered from the action's idempotency store, never applied twice.
|
|
108
|
+
*/
|
|
85
109
|
static async open(store: QueueStore): Promise<OfflineQueue> {
|
|
86
|
-
|
|
110
|
+
const queue = new OfflineQueue(store, await store.load());
|
|
111
|
+
await queue.#reclaimInflight();
|
|
112
|
+
return queue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Re-reads the durable queue, which another tab of the same user may have written since this one
|
|
117
|
+
* opened. Call ONLY while this queue is the one draining — `page-outbox.ts` holds a Web Lock for
|
|
118
|
+
* exactly that — because it reclaims every `inflight` entry as `pending`: with the lock held, no
|
|
119
|
+
* other pass can have one on the wire.
|
|
120
|
+
*/
|
|
121
|
+
async reload(): Promise<void> {
|
|
122
|
+
const state = await this.#store.load();
|
|
123
|
+
this.#mutations = state.mutations.map((mutation) => ({ ...mutation }));
|
|
124
|
+
this.#nextSeq = Math.max(this.#nextSeq, state.nextSeq);
|
|
125
|
+
await this.#reclaimInflight();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async #reclaimInflight(): Promise<void> {
|
|
129
|
+
const reclaimed = this.#mutations.filter((mutation) => mutation.status === 'inflight');
|
|
130
|
+
for (const mutation of reclaimed) mutation.status = 'pending';
|
|
131
|
+
if (reclaimed.length > 0) await this.#persist(reclaimed);
|
|
87
132
|
}
|
|
88
133
|
|
|
89
134
|
get size(): number {
|
|
@@ -131,8 +176,10 @@ export class OfflineQueue {
|
|
|
131
176
|
// UI — collapsing onto it makes an explicit idempotency key unusable for the rest of the
|
|
132
177
|
// session, because nothing ever retries a denial. Re-issuing one is a NEW intent, so the old
|
|
133
178
|
// entry is dropped and this one takes a new sequence at the back of the queue.
|
|
134
|
-
|
|
135
|
-
|
|
179
|
+
// By identity: `existing` IS the entry for this key, found above — no second key comparison.
|
|
180
|
+
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);
|
|
136
183
|
const mutation: QueuedMutation = {
|
|
137
184
|
key: args.key,
|
|
138
185
|
seq: this.#nextSeq,
|
|
@@ -145,7 +192,7 @@ export class OfflineQueue {
|
|
|
145
192
|
};
|
|
146
193
|
this.#nextSeq += 1;
|
|
147
194
|
this.#mutations.push(mutation);
|
|
148
|
-
await this.#persist();
|
|
195
|
+
await this.#persist([mutation]);
|
|
149
196
|
return mutation;
|
|
150
197
|
}
|
|
151
198
|
|
|
@@ -190,12 +237,14 @@ export class OfflineQueue {
|
|
|
190
237
|
async requeueInflight(): Promise<number> {
|
|
191
238
|
this.#epoch += 1;
|
|
192
239
|
let returned = 0;
|
|
240
|
+
const back: QueuedMutation[] = [];
|
|
193
241
|
for (const mutation of this.#mutations) {
|
|
194
242
|
if (mutation.status !== 'inflight') continue;
|
|
195
243
|
mutation.status = 'pending';
|
|
196
244
|
returned += 1;
|
|
245
|
+
back.push(mutation);
|
|
197
246
|
}
|
|
198
|
-
if (returned > 0) await this.#persist();
|
|
247
|
+
if (returned > 0) await this.#persist(back);
|
|
199
248
|
return returned;
|
|
200
249
|
}
|
|
201
250
|
|
|
@@ -204,8 +253,9 @@ export class OfflineQueue {
|
|
|
204
253
|
const mutation = this.find(key);
|
|
205
254
|
if (!mutation) return;
|
|
206
255
|
mutation.status = 'acked';
|
|
207
|
-
|
|
208
|
-
|
|
256
|
+
// By identity: `find` already matched it, and a second comparison of the key is the same question.
|
|
257
|
+
this.#mutations = this.#mutations.filter((entry) => entry !== mutation);
|
|
258
|
+
await this.#persist([], [key]);
|
|
209
259
|
}
|
|
210
260
|
|
|
211
261
|
/** Terminal failure (policy denial, validation): kept for the UI, never retried blindly. */
|
|
@@ -214,7 +264,7 @@ export class OfflineQueue {
|
|
|
214
264
|
if (!mutation) return;
|
|
215
265
|
mutation.status = 'failed';
|
|
216
266
|
mutation.error = error;
|
|
217
|
-
await this.#persist();
|
|
267
|
+
await this.#persist([mutation]);
|
|
218
268
|
}
|
|
219
269
|
|
|
220
270
|
/**
|
|
@@ -251,6 +301,7 @@ export class OfflineQueue {
|
|
|
251
301
|
return { sent: 0, collapsed: this.#collapsed, remaining: 0, stoppedAt: null };
|
|
252
302
|
}
|
|
253
303
|
let sent = 0;
|
|
304
|
+
const touched: QueuedMutation[] = [];
|
|
254
305
|
for (const mutation of sendable) {
|
|
255
306
|
// The connection this pass was draining into is gone, and `requeueInflight` has already
|
|
256
307
|
// handed back what was on it. Everything left stays `pending` for the pass the next
|
|
@@ -268,6 +319,7 @@ export class OfflineQueue {
|
|
|
268
319
|
if (!this.#stillSendable(mutation)) continue;
|
|
269
320
|
mutation.status = 'inflight';
|
|
270
321
|
mutation.attempts += 1;
|
|
322
|
+
touched.push(mutation);
|
|
271
323
|
try {
|
|
272
324
|
await send(mutation);
|
|
273
325
|
// Stays `inflight`. `send` resolving means the frame reached a socket — a browser
|
|
@@ -279,7 +331,7 @@ export class OfflineQueue {
|
|
|
279
331
|
} catch (error) {
|
|
280
332
|
mutation.status = 'pending';
|
|
281
333
|
mutation.error = toQueueError(error);
|
|
282
|
-
await this.#persist();
|
|
334
|
+
await this.#persist([mutation]);
|
|
283
335
|
return {
|
|
284
336
|
sent,
|
|
285
337
|
collapsed: this.#collapsed,
|
|
@@ -288,7 +340,9 @@ export class OfflineQueue {
|
|
|
288
340
|
};
|
|
289
341
|
}
|
|
290
342
|
}
|
|
291
|
-
|
|
343
|
+
// Only what this pass touched AND the queue still holds: an entry the server settled during the
|
|
344
|
+
// pass was already deleted by `ack`, and writing it back would resurrect it.
|
|
345
|
+
await this.#persist(touched.filter((mutation) => this.#mutations.includes(mutation)));
|
|
292
346
|
return {
|
|
293
347
|
sent,
|
|
294
348
|
collapsed: this.#collapsed,
|
|
@@ -298,36 +352,28 @@ export class OfflineQueue {
|
|
|
298
352
|
}
|
|
299
353
|
|
|
300
354
|
async clear(): Promise<void> {
|
|
355
|
+
const keys = this.#mutations.map((mutation) => mutation.key);
|
|
301
356
|
this.#mutations = [];
|
|
302
|
-
await this.#persist();
|
|
357
|
+
await this.#persist([], keys);
|
|
303
358
|
}
|
|
304
359
|
|
|
305
360
|
/**
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
361
|
+
* Snapshots, never the live entries. `write` is a durable write — IndexedDB — and it is allowed
|
|
362
|
+
* to await before it reads. Handed an entry itself, a store that resolves after the next pass has
|
|
363
|
+
* moved on persists a status that was never true when it was called. BY KEY, never the whole
|
|
364
|
+
* queue: a second tab's entries are not this tab's to overwrite or delete.
|
|
310
365
|
*/
|
|
311
|
-
async #persist(): Promise<void> {
|
|
312
|
-
await this.#store.
|
|
313
|
-
|
|
366
|
+
async #persist(puts: readonly QueuedMutation[], deletes: readonly string[] = []): Promise<void> {
|
|
367
|
+
await this.#store.write({
|
|
368
|
+
puts: puts.map((mutation) => ({ ...mutation })),
|
|
369
|
+
deletes,
|
|
314
370
|
nextSeq: this.#nextSeq,
|
|
315
371
|
});
|
|
316
372
|
}
|
|
317
373
|
}
|
|
318
374
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
type: 'mutate',
|
|
322
|
-
v: PROTOCOL_VERSION,
|
|
323
|
-
key: mutation.key,
|
|
324
|
-
seq: mutation.seq,
|
|
325
|
-
name: mutation.name,
|
|
326
|
-
input: mutation.input,
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function toQueueError(error: unknown): WireError {
|
|
375
|
+
/** A thrown value as the queue records it — `code`, `cause`, `fix`, never a throw of its own. */
|
|
376
|
+
export function toQueueError(error: unknown): WireError {
|
|
331
377
|
return {
|
|
332
378
|
// `stringField`, not `shape?.code`: the sender is a transport the app supplied, so the probe
|
|
333
379
|
// for "did it throw a coded error" is itself a property read on an app value. A getter that
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The page's outbox as an ISLAND reaches it: read off the page, never built. The page boot
|
|
2
|
+
// (`boot.ts`) is the one module that constructs it — IndexedDB, the queue, the drain listeners —
|
|
3
|
+
// so a writing island ships this reader and none of that (~7.5 kB it would otherwise carry).
|
|
4
|
+
|
|
5
|
+
import type { JsonValue } from './json';
|
|
6
|
+
|
|
7
|
+
export interface OutboxEntry {
|
|
8
|
+
/** The idempotency key the write was first attempted under — the SAME key on every replay. */
|
|
9
|
+
readonly key: string;
|
|
10
|
+
/** The mutator's action name; the replay POSTs to `actionPath(name)`. */
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly input: JsonValue;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What a hook asks of the outbox the boot opened: queue a write, list them, replay them. */
|
|
16
|
+
export interface OutboxHandle {
|
|
17
|
+
enqueue(entry: OutboxEntry): Promise<void>;
|
|
18
|
+
replay(): Promise<unknown>;
|
|
19
|
+
pending(): readonly OutboxEntry[];
|
|
20
|
+
/** Settles once the current principal's queue is open. */
|
|
21
|
+
readonly ready: Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Where the page keeps it: shared by every bundle on the page, as the record store is. */
|
|
25
|
+
export const OUTBOX_KEY: unique symbol = Symbol.for('ultimate.outbox');
|
|
26
|
+
export type OutboxHost = { [OUTBOX_KEY]?: OutboxHandle };
|
|
27
|
+
|
|
28
|
+
/** The outbox the page boot opened, or `undefined` on a page with no boot (nothing persisted). */
|
|
29
|
+
export function peekOutbox(): OutboxHandle | undefined {
|
|
30
|
+
return (globalThis as OutboxHost)[OUTBOX_KEY];
|
|
31
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
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 registers the whole code table at import,
|
|
3
|
+
// and an island that renders one record has no use for sixty titles. The codes stay in `errors.ts`
|
|
4
|
+
// (its `registerErrorCodes()` is what `package.json`'s `sideEffects` names, anchored by the
|
|
5
|
+
// barrel); in a browser that loaded no table a code titles itself from its name, and `code`,
|
|
6
|
+
// `cause` and `fix` — what a reader acts on — are unchanged. This module runs nothing at import.
|
|
7
|
+
|
|
8
|
+
import { renderFixShellArg } from '@ultimat3/core/page';
|
|
9
|
+
import { RealtimeError } from './realtime-error';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Client and server disagree on the wire format — a version mismatch or a malformed frame.
|
|
13
|
+
* Both are the same class of bug (a peer speaking a shape we do not have), so both get one code.
|
|
14
|
+
*/
|
|
15
|
+
export class ProtocolVersionError extends RealtimeError {
|
|
16
|
+
constructor(args: { got: unknown; expected: number; detail?: string }) {
|
|
17
|
+
super({
|
|
18
|
+
code: 'X_PROTOCOL_VERSION',
|
|
19
|
+
cause:
|
|
20
|
+
args.detail ??
|
|
21
|
+
`frame protocol version ${String(args.got)} is not the server version ${args.expected}`,
|
|
22
|
+
fix: 'x build && redeploy the client; the sync node sends `update-available` before it drains',
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A rebase could not be resolved: `custom(merge)` returned nothing, or the base row vanished. */
|
|
28
|
+
export class RebaseConflictError extends RealtimeError {
|
|
29
|
+
constructor(args: { key: string; entity: string; reason: string }) {
|
|
30
|
+
super({
|
|
31
|
+
code: 'X_REBASE_CONFLICT',
|
|
32
|
+
cause: `mutation ${args.key} on ${args.entity} could not be rebased: ${args.reason}`,
|
|
33
|
+
fix: "set conflict: 'server-wins' on the mutator, or return a row from custom(merge)",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
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
|
|
41
|
+
* hook renders through has to be that island's own — the page-wide store cannot hold one.
|
|
42
|
+
*
|
|
43
|
+
* A server render is deliberately not this error: no DOM means no reactive runtime to install,
|
|
44
|
+
* and the hooks answer the honest server-render state instead.
|
|
45
|
+
*/
|
|
46
|
+
export class RealtimeUninstalledError extends RealtimeError {
|
|
47
|
+
constructor(args: { hook: string }) {
|
|
48
|
+
super({
|
|
49
|
+
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",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A live hook needed the page's one socket, and nothing told this page where the sync node is. */
|
|
57
|
+
export class SyncUnconfiguredError extends RealtimeError {
|
|
58
|
+
constructor(args: { hook: string }) {
|
|
59
|
+
super({
|
|
60
|
+
code: 'X_SYNC_UNCONFIGURED',
|
|
61
|
+
cause: `${args.hook}() needs the page socket, and installRealtime() was given no sync target`,
|
|
62
|
+
fix: "x build # the island bootstrap passes it; a hand-built island calls installRealtime({ signal: createSignal, sync: { url, buildId } }) from '@ultimat3/realtime'",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A row reached the record store it cannot hold: not an object, or under no key. Dropped and
|
|
69
|
+
* reported, never partially merged — a keyless row would overwrite another record.
|
|
70
|
+
*/
|
|
71
|
+
export class RecordRejectedError extends RealtimeError {
|
|
72
|
+
constructor(args: { type: string; reason: string }) {
|
|
73
|
+
super({
|
|
74
|
+
code: 'X_RECORD_REJECTED',
|
|
75
|
+
cause: `a ${args.type === '' ? 'record' : `"${args.type}" record`} was rejected by the page's record store: ${args.reason}`,
|
|
76
|
+
fix: `x entities describe ${renderFixShellArg(args.type, '<entity>')} --json # the primary key every row of it must carry; return whole rows from the handler that built this one`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* IndexedDB would not open (a private window, storage disabled), so the page keeps records and its
|
|
83
|
+
* outbox in memory. A WARNING, never thrown: a page must not break because it cannot remember, so
|
|
84
|
+
* `openLocalStore` hands this to its `warn` once and carries on.
|
|
85
|
+
*/
|
|
86
|
+
export class LocalStoreUnavailableError extends RealtimeError {
|
|
87
|
+
constructor(args: { reason: string }) {
|
|
88
|
+
super({
|
|
89
|
+
code: 'X_LOCAL_STORE_UNAVAILABLE',
|
|
90
|
+
cause: `the page's durable store could not open (${args.reason}), so records and queued writes live in memory and are lost on reload`,
|
|
91
|
+
fix: 'nothing to do in the app — allow site storage in the browser (a private window blocks it) and reload',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Something that can only mean "talk to the socket" ran on the server client — a mutation, a
|
|
98
|
+
* publish, a topic subscription, a dial. There is no socket during a server render and there never
|
|
99
|
+
* will be one: the document is built and sent, and the browser opens the connection.
|
|
100
|
+
*
|
|
101
|
+
* A refusal rather than a silent no-op, because both alternatives are worse. Queueing it would
|
|
102
|
+
* hold one process-wide queue on behalf of whichever request happened to render, and dropping it
|
|
103
|
+
* would make a write that never happened look like one that did.
|
|
104
|
+
*/
|
|
105
|
+
export class ServerRenderLiveError extends RealtimeError {
|
|
106
|
+
constructor(args: { operation: string }) {
|
|
107
|
+
super({
|
|
108
|
+
code: 'X_LIVE_SERVER_RENDER',
|
|
109
|
+
cause: `${args.operation} ran during a server render, where this app has no live socket`,
|
|
110
|
+
fix: 'move the call into an island: x g island <route-dir> --at <route-dir>, import from its mount(), declare island({ src })',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A resume cursor cannot be honoured and no snapshot path was supplied. */
|
|
116
|
+
export class CursorStaleError extends RealtimeError {
|
|
117
|
+
constructor(args: { qid: string; lsn: string; reason: string }) {
|
|
118
|
+
super({
|
|
119
|
+
code: 'X_CURSOR_STALE',
|
|
120
|
+
cause: `cursor for query ${args.qid} at lsn ${args.lsn} cannot be resumed: ${args.reason}`,
|
|
121
|
+
fix: 'pass `snapshot` to resumeFrom() so the fallback path can re-snapshot instead of failing',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|