@syncular/client 0.25.2 → 0.26.1
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/README.md +35 -7
- package/dist/client.d.ts +10 -1
- package/dist/client.js +153 -63
- package/dist/diagnostics.d.ts +13 -2
- package/dist/errors.d.ts +20 -1
- package/dist/errors.js +23 -0
- package/dist/http.d.ts +6 -1
- package/dist/http.js +10 -6
- package/dist/multi-tab.d.ts +11 -12
- package/dist/multi-tab.js +86 -78
- package/dist/realtime-supervisor.js +12 -2
- package/dist/worker-entry.d.ts +5 -1
- package/dist/worker-entry.js +13 -4
- package/dist/worker-host.d.ts +25 -2
- package/dist/worker-host.js +17 -4
- package/dist/worker-protocol.d.ts +14 -1
- package/package.json +3 -3
- package/src/client.ts +212 -81
- package/src/diagnostics.ts +17 -2
- package/src/errors.ts +38 -1
- package/src/http.ts +21 -7
- package/src/multi-tab.ts +99 -92
- package/src/realtime-supervisor.ts +16 -2
- package/src/worker-entry.ts +18 -4
- package/src/worker-host.ts +39 -11
- package/src/worker-protocol.ts +14 -1
package/README.md
CHANGED
|
@@ -195,14 +195,42 @@ flushed to the new leader on its announce; past the deadline they fail
|
|
|
195
195
|
loudly with `client.follower_timeout` (never a silent hang), and an
|
|
196
196
|
overflowing queue rejects rather than growing unbounded.
|
|
197
197
|
|
|
198
|
-
Leader
|
|
199
|
-
|
|
200
|
-
`followerCallTimeoutMs
|
|
198
|
+
**Leader liveness.** Followers probe the leader; the leader runs no liveness
|
|
199
|
+
timer. A bound follower that has heard no announcement for a third of
|
|
200
|
+
`followerCallTimeoutMs` (default 10 s) posts a `hello`, and the leader answers
|
|
201
|
+
every `hello` with an announcement from its message handler. Browsers throttle
|
|
202
|
+
the timers of hidden tabs (Chrome's intensive throttling wakes them about once
|
|
203
|
+
a minute) but still dispatch their `BroadcastChannel` messages, so a leader in
|
|
204
|
+
a background tab keeps its followers bound. When a probe stays unanswered for
|
|
205
|
+
the rest of `followerCallTimeoutMs`, `handle.leadership` becomes
|
|
201
206
|
`{ state: 'blocked', reason: 'leader-unreachable', code:
|
|
202
|
-
'client.follower_timeout', retryable: true }
|
|
203
|
-
A
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
'client.follower_timeout', retryable: true }` and calls reject immediately.
|
|
208
|
+
A leader tab that processes no messages (hung or frozen) therefore blocks its
|
|
209
|
+
followers within `followerCallTimeoutMs`; a closed leader releases its Web
|
|
210
|
+
Lock and hands over without waiting for that deadline. A blocked follower keeps
|
|
211
|
+
probing at the same interval: an answer rebinds the same handle, and a granted
|
|
212
|
+
Web Lock promotes it. A single call that runs past `followerCallTimeoutMs`
|
|
213
|
+
rejects with `client.follower_timeout` and leaves the handle bound, because
|
|
214
|
+
reachability is decided by the probe alone. An unreachable `BroadcastChannel`
|
|
215
|
+
is never treated as evidence that the lock owner is stale, so it never
|
|
216
|
+
authorizes a second worker or database owner.
|
|
217
|
+
|
|
218
|
+
**Upgrade note: reload every open tab.** Followers running 0.26.0 or older
|
|
219
|
+
wait for a leader heartbeat that current leaders no longer send. Such a tab
|
|
220
|
+
next to a current leader reports `leader-unreachable` with
|
|
221
|
+
`client.follower_timeout` after `followerCallTimeoutMs`, unless a current
|
|
222
|
+
follower's probe happens to make the leader announce, and stays that way
|
|
223
|
+
until it reloads. A current follower next to an older leader works, because
|
|
224
|
+
older leaders answer every `hello`. After upgrading, reload every open tab of
|
|
225
|
+
the origin.
|
|
226
|
+
|
|
227
|
+
**A hidden leader keeps leadership.** A visible tab does not take leadership
|
|
228
|
+
from a hidden leader. Web Locks `steal` revokes the lease while the leader's
|
|
229
|
+
worker still holds the OPFS database and may be mid-commit, so a transfer
|
|
230
|
+
needs a cooperative close-and-release handshake, and every focus change
|
|
231
|
+
between tabs would restart the worker, reopen the database, and reconnect the
|
|
232
|
+
realtime socket. A hidden leader answers its followers from its message
|
|
233
|
+
handler, so leadership moves only when the leader tab closes.
|
|
206
234
|
|
|
207
235
|
**Presence semantics — one device, one peer.** All tabs share the leader's
|
|
208
236
|
single connection, so a device is exactly ONE presence peer collectively:
|
package/dist/client.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { type SyncProgress, type SyncProgressListener } from './progress.js';
|
|
|
11
11
|
import { type ScopeMap, type WakeReason } from '@syncular/core';
|
|
12
12
|
import { type BlobRef, type BlobTransport, type CachedBlob } from './blob.js';
|
|
13
13
|
import type { ClientDatabase, SqlRow, SqlValue } from './database.js';
|
|
14
|
-
import { type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot } from './diagnostics.js';
|
|
14
|
+
import { type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot, type RealtimePolicy } from './diagnostics.js';
|
|
15
15
|
import type { EncryptionConfig } from './encryption.js';
|
|
16
16
|
import { type ClientChangeListener, type CommandResult, type InvalidationListener, type LocalRevision, type SyncIntent, type SyncStatusSnapshot } from './invalidation.js';
|
|
17
17
|
import { type LeaderLock } from './leader-lock.js';
|
|
@@ -119,6 +119,15 @@ export interface SyncClientConfig {
|
|
|
119
119
|
*/
|
|
120
120
|
readonly blobCacheMaxBytes?: number;
|
|
121
121
|
readonly realtime?: RealtimeConnector;
|
|
122
|
+
/**
|
|
123
|
+
* SPEC §8.8: how sync rounds treat the realtime binding. `optional`
|
|
124
|
+
* (default) rides a connected socket and otherwise uses `POST /sync`.
|
|
125
|
+
* `required` refuses a round with `RealtimeUnavailableError` while the
|
|
126
|
+
* socket is not connected and never falls back to HTTP. `off` never uses
|
|
127
|
+
* realtime; an explicit `connectRealtime` fails with `sync.invalid_request`.
|
|
128
|
+
* `required` without a `realtime` connector is refused at construction.
|
|
129
|
+
*/
|
|
130
|
+
readonly realtimePolicy?: RealtimePolicy;
|
|
122
131
|
/** Stable per-device id (§1.5); defaults to a persisted random UUID. */
|
|
123
132
|
readonly clientId?: string;
|
|
124
133
|
readonly leaderLock?: LeaderLock;
|
package/dist/client.js
CHANGED
|
@@ -14,7 +14,7 @@ import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow,
|
|
|
14
14
|
import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
|
|
15
15
|
import { registerDevtools } from './devtools.js';
|
|
16
16
|
import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, MAX_DIAGNOSTIC_QUERY_FAILURES, } from './diagnostics.js';
|
|
17
|
-
import { ClientSyncError, classifySqliteFailure } from './errors.js';
|
|
17
|
+
import { ClientSyncError, classifySqliteFailure, REALTIME_LOST_CODE, RealtimeUnavailableError, } from './errors.js';
|
|
18
18
|
import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
|
|
19
19
|
import { singleOwnerLock, } from './leader-lock.js';
|
|
20
20
|
import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatches, } from './local-purge.js';
|
|
@@ -117,6 +117,11 @@ function isFinalPushResult(frame) {
|
|
|
117
117
|
result.code === 'sync.idempotency_cache_miss' &&
|
|
118
118
|
result.retryable));
|
|
119
119
|
}
|
|
120
|
+
/** The stable `code` of a thrown realtime transport error, when it has one. */
|
|
121
|
+
function realtimeErrorCode(error) {
|
|
122
|
+
const code = error?.code;
|
|
123
|
+
return typeof code === 'string' ? code : undefined;
|
|
124
|
+
}
|
|
120
125
|
export class SyncClient {
|
|
121
126
|
#config;
|
|
122
127
|
#db;
|
|
@@ -139,6 +144,11 @@ export class SyncClient {
|
|
|
139
144
|
#conflicts = [];
|
|
140
145
|
#rejections = [];
|
|
141
146
|
#socket;
|
|
147
|
+
/** SPEC §8.8: the explicit realtime availability state and its evidence. */
|
|
148
|
+
#realtimePolicy;
|
|
149
|
+
#realtimeState;
|
|
150
|
+
#realtimeReasonCode;
|
|
151
|
+
#realtimeRetryDelayMs;
|
|
142
152
|
#realtimeConnectPromise;
|
|
143
153
|
#realtimeGeneration = 0;
|
|
144
154
|
#pendingRound;
|
|
@@ -210,9 +220,25 @@ export class SyncClient {
|
|
|
210
220
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
211
221
|
throw new ClientSyncError('sync.invalid_request', 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
|
|
212
222
|
}
|
|
213
|
-
this.#config = config;
|
|
214
223
|
this.#db = config.database;
|
|
215
224
|
this.#schema = compileClientSchema(config.schema);
|
|
225
|
+
const realtimePolicy = config.realtimePolicy ?? 'optional';
|
|
226
|
+
if (realtimePolicy !== 'required' &&
|
|
227
|
+
realtimePolicy !== 'optional' &&
|
|
228
|
+
realtimePolicy !== 'off') {
|
|
229
|
+
throw new ClientSyncError('sync.invalid_request', "realtimePolicy must be 'required', 'optional', or 'off'");
|
|
230
|
+
}
|
|
231
|
+
if (realtimePolicy === 'required' && config.realtime === undefined) {
|
|
232
|
+
throw new ClientSyncError('sync.invalid_request', "realtimePolicy 'required' needs a realtime connector");
|
|
233
|
+
}
|
|
234
|
+
this.#config = config;
|
|
235
|
+
this.#realtimePolicy = realtimePolicy;
|
|
236
|
+
this.#realtimeState =
|
|
237
|
+
realtimePolicy === 'off'
|
|
238
|
+
? 'disabled'
|
|
239
|
+
: config.realtime === undefined
|
|
240
|
+
? 'unsupported'
|
|
241
|
+
: 'disconnected';
|
|
216
242
|
this.#encryption = config.encryption;
|
|
217
243
|
this.#securityLifecycle =
|
|
218
244
|
config.securityPreflight === true ? 'preflight' : 'active';
|
|
@@ -1013,11 +1039,14 @@ export class SyncClient {
|
|
|
1013
1039
|
kind: 'direct',
|
|
1014
1040
|
role: 'single',
|
|
1015
1041
|
connectivity,
|
|
1016
|
-
realtime: this.#
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1042
|
+
realtime: this.#realtimeState,
|
|
1043
|
+
realtimePolicy: this.#realtimePolicy,
|
|
1044
|
+
...(this.#realtimeReasonCode !== undefined
|
|
1045
|
+
? { realtimeReasonCode: this.#realtimeReasonCode }
|
|
1046
|
+
: {}),
|
|
1047
|
+
...(this.#realtimeRetryDelayMs !== undefined
|
|
1048
|
+
? { realtimeRetryDelayMs: this.#realtimeRetryDelayMs }
|
|
1049
|
+
: {}),
|
|
1021
1050
|
},
|
|
1022
1051
|
securityLifecycle: this.#securityLifecycle,
|
|
1023
1052
|
schema: {
|
|
@@ -2465,9 +2494,18 @@ export class SyncClient {
|
|
|
2465
2494
|
const retryable = explicitlyRetryable === true ||
|
|
2466
2495
|
(explicitlyRetryable === undefined && typeof code !== 'string');
|
|
2467
2496
|
if (retryable) {
|
|
2497
|
+
const realtimeUnavailable = error instanceof RealtimeUnavailableError;
|
|
2498
|
+
const delay = realtimeUnavailable
|
|
2499
|
+
? error.retryDelayMs
|
|
2500
|
+
: this.#retryDelayMs;
|
|
2501
|
+
if (realtimeUnavailable ||
|
|
2502
|
+
this.#realtimeState === 'lost' ||
|
|
2503
|
+
this.#realtimeState === 'refused') {
|
|
2504
|
+
this.#realtimeRetryDelayMs = delay;
|
|
2505
|
+
}
|
|
2468
2506
|
const intent = {
|
|
2469
2507
|
kind: 'background',
|
|
2470
|
-
delayMs:
|
|
2508
|
+
delayMs: delay,
|
|
2471
2509
|
};
|
|
2472
2510
|
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
|
|
2473
2511
|
this.#emitSyncIntent(intent);
|
|
@@ -2515,38 +2553,48 @@ export class SyncClient {
|
|
|
2515
2553
|
* is connected (the socket IS the sync-round
|
|
2516
2554
|
* transport, not a fallback pair), otherwise through the configured
|
|
2517
2555
|
* `SyncTransport` seam (loopback/conformance hosts, HTTP-only
|
|
2518
|
-
* producers).
|
|
2556
|
+
* producers). Under `realtimePolicy: 'required'` an unconnected socket
|
|
2557
|
+
* refuses the round with `RealtimeUnavailableError` instead of using the
|
|
2558
|
+
* seam (SPEC §8.8).
|
|
2519
2559
|
*/
|
|
2520
2560
|
#roundTrip(request) {
|
|
2521
2561
|
const socket = this.#socket;
|
|
2522
|
-
if (socket
|
|
2523
|
-
return Promise
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2562
|
+
if (socket !== undefined && this.#realtimePolicy !== 'off') {
|
|
2563
|
+
return new Promise((resolve, reject) => {
|
|
2564
|
+
// sync() already enforces one round in flight (§8.7).
|
|
2565
|
+
this.#pendingRound = {
|
|
2566
|
+
scanner: new MessageStreamScanner(),
|
|
2567
|
+
resolve,
|
|
2568
|
+
reject,
|
|
2569
|
+
};
|
|
2570
|
+
const tagged = new Uint8Array(request.length + 1);
|
|
2571
|
+
tagged[0] = REALTIME_TAG_ROUND;
|
|
2572
|
+
tagged.set(request, 1);
|
|
2573
|
+
try {
|
|
2574
|
+
socket.sendBytes(tagged);
|
|
2575
|
+
}
|
|
2576
|
+
catch (error) {
|
|
2577
|
+
this.#pendingRound = undefined;
|
|
2578
|
+
// The socket could not carry the round: record the same loss the
|
|
2579
|
+
// Rust core records when its realtime round call fails (§8.8).
|
|
2580
|
+
this.#setRealtimeState('lost', 'sync.transport_failed');
|
|
2581
|
+
reject(new ClientSyncError('sync.transport_failed', `socket round send failed: ${error instanceof Error ? error.message : String(error)}`, true));
|
|
2529
2582
|
}
|
|
2530
|
-
throw new ClientSyncError('sync.transport_failed', `transport round failed: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
2531
2583
|
});
|
|
2532
2584
|
}
|
|
2533
|
-
|
|
2534
|
-
//
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
socket.sendBytes(tagged);
|
|
2545
|
-
}
|
|
2546
|
-
catch (error) {
|
|
2547
|
-
this.#pendingRound = undefined;
|
|
2548
|
-
reject(new ClientSyncError('sync.transport_failed', `socket round send failed: ${error instanceof Error ? error.message : String(error)}`, true));
|
|
2585
|
+
if (this.#realtimePolicy === 'required') {
|
|
2586
|
+
// §8.8: the designated realtime path is down. Refuse the round; the
|
|
2587
|
+
// HTTP binding is not an alternative for a `required` client.
|
|
2588
|
+
throw this.#realtimeUnavailableError();
|
|
2589
|
+
}
|
|
2590
|
+
return Promise.resolve()
|
|
2591
|
+
.then(() => this.#config.transport(request))
|
|
2592
|
+
.catch((error) => {
|
|
2593
|
+
if (error instanceof ClientSyncError ||
|
|
2594
|
+
typeof error?.code === 'string') {
|
|
2595
|
+
throw error;
|
|
2549
2596
|
}
|
|
2597
|
+
throw new ClientSyncError('sync.transport_failed', `transport round failed: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
2550
2598
|
});
|
|
2551
2599
|
}
|
|
2552
2600
|
/** Abort the in-flight socket round (socket closed or disconnected). */
|
|
@@ -2558,14 +2606,39 @@ export class SyncClient {
|
|
|
2558
2606
|
round.reject(new ClientSyncError('sync.transport_failed', reason, true));
|
|
2559
2607
|
}
|
|
2560
2608
|
// -- realtime (§8 client side) ----------------------------------------------
|
|
2609
|
+
/**
|
|
2610
|
+
* SPEC §8.8: record an availability transition and the evidence behind it.
|
|
2611
|
+
* The reason and retry delay belong to the state that is no longer
|
|
2612
|
+
* connected; `connected` and a policy-restricted state clear them.
|
|
2613
|
+
*/
|
|
2614
|
+
#setRealtimeState(state, reasonCode) {
|
|
2615
|
+
this.#realtimeState = state;
|
|
2616
|
+
this.#realtimeReasonCode =
|
|
2617
|
+
reasonCode === undefined ? undefined : this.#diagnosticCode(reasonCode);
|
|
2618
|
+
if (state === 'connected' ||
|
|
2619
|
+
state === 'disabled' ||
|
|
2620
|
+
state === 'unsupported') {
|
|
2621
|
+
this.#realtimeRetryDelayMs = undefined;
|
|
2622
|
+
}
|
|
2623
|
+
this.#emitDiagnostics();
|
|
2624
|
+
}
|
|
2625
|
+
#realtimeUnavailableError() {
|
|
2626
|
+
this.#realtimeRetryDelayMs = this.#retryDelayMs;
|
|
2627
|
+
return new RealtimeUnavailableError(this.#realtimeState, this.#realtimeReasonCode, this.#retryDelayMs);
|
|
2628
|
+
}
|
|
2561
2629
|
connectRealtime() {
|
|
2562
2630
|
this.#requireActive();
|
|
2631
|
+
if (this.#realtimePolicy === 'off') {
|
|
2632
|
+
return Promise.reject(new ClientSyncError('sync.invalid_request', "realtimePolicy is 'off'; realtime connect is refused"));
|
|
2633
|
+
}
|
|
2563
2634
|
if (this.#socket !== undefined)
|
|
2564
2635
|
return Promise.resolve();
|
|
2565
2636
|
if (this.#realtimeConnectPromise !== undefined) {
|
|
2566
2637
|
return this.#realtimeConnectPromise;
|
|
2567
2638
|
}
|
|
2568
2639
|
const generation = this.#realtimeGeneration;
|
|
2640
|
+
// The attempt is visible synchronously; runners of the task share it.
|
|
2641
|
+
this.#setRealtimeState('connecting');
|
|
2569
2642
|
const task = this.#runProtectedAsync(() => this.#connectRealtime(generation));
|
|
2570
2643
|
this.#realtimeConnectPromise = task;
|
|
2571
2644
|
void task.then(() => {
|
|
@@ -2585,37 +2658,50 @@ export class SyncClient {
|
|
|
2585
2658
|
throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
|
|
2586
2659
|
}
|
|
2587
2660
|
let openedSocket;
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2661
|
+
try {
|
|
2662
|
+
const socket = await connector({
|
|
2663
|
+
onText: (text) => {
|
|
2664
|
+
if (generation !== this.#realtimeGeneration || !this.#started)
|
|
2665
|
+
return;
|
|
2666
|
+
this.#handleRealtimeText(text);
|
|
2667
|
+
},
|
|
2668
|
+
onBinary: (bytes) => {
|
|
2669
|
+
if (generation !== this.#realtimeGeneration || !this.#started)
|
|
2670
|
+
return;
|
|
2671
|
+
this.#routeRealtimeBinary(bytes);
|
|
2672
|
+
},
|
|
2673
|
+
onClose: () => {
|
|
2674
|
+
if (openedSocket === undefined || this.#socket !== openedSocket)
|
|
2675
|
+
return;
|
|
2676
|
+
this.#socket = undefined;
|
|
2677
|
+
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
2678
|
+
// §8.8: an undesired close is a visible lost state, not a silent
|
|
2679
|
+
// downgrade to HTTP rounds.
|
|
2680
|
+
this.#setRealtimeState('lost', REALTIME_LOST_CODE);
|
|
2681
|
+
this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
|
|
2682
|
+
this.#emitDiagnostics();
|
|
2683
|
+
},
|
|
2684
|
+
});
|
|
2685
|
+
openedSocket = socket;
|
|
2686
|
+
if (this.#securityLifecycle === 'preflight') {
|
|
2687
|
+
socket.close();
|
|
2688
|
+
throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
|
|
2689
|
+
}
|
|
2690
|
+
if (generation !== this.#realtimeGeneration || !this.#started) {
|
|
2691
|
+
socket.close();
|
|
2692
|
+
throw new ClientSyncError('client.realtime_cancelled', 'realtime connection was cancelled before activation');
|
|
2693
|
+
}
|
|
2694
|
+
this.#socket = socket;
|
|
2695
|
+
this.#setRealtimeState('connected');
|
|
2612
2696
|
}
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2697
|
+
catch (error) {
|
|
2698
|
+
// A superseded attempt leaves the successor's (or the deliberate
|
|
2699
|
+
// disconnect's) state alone.
|
|
2700
|
+
if (generation === this.#realtimeGeneration) {
|
|
2701
|
+
this.#setRealtimeState('refused', realtimeErrorCode(error));
|
|
2702
|
+
}
|
|
2703
|
+
throw error;
|
|
2616
2704
|
}
|
|
2617
|
-
this.#socket = socket;
|
|
2618
|
-
this.#emitDiagnostics();
|
|
2619
2705
|
}
|
|
2620
2706
|
disconnectRealtime() {
|
|
2621
2707
|
this.#realtimeGeneration += 1;
|
|
@@ -2624,7 +2710,11 @@ export class SyncClient {
|
|
|
2624
2710
|
this.#socket = undefined;
|
|
2625
2711
|
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
2626
2712
|
this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
|
|
2627
|
-
this.#
|
|
2713
|
+
this.#setRealtimeState(this.#realtimePolicy === 'off'
|
|
2714
|
+
? 'disabled'
|
|
2715
|
+
: this.#config.realtime === undefined
|
|
2716
|
+
? 'unsupported'
|
|
2717
|
+
: 'disconnected');
|
|
2628
2718
|
}
|
|
2629
2719
|
/**
|
|
2630
2720
|
* §8.7 channel-tag routing (synchronous, so chunk order is preserved):
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -13,12 +13,23 @@ export declare const MAX_DIAGNOSTIC_QUERY_FAILURES = 256;
|
|
|
13
13
|
export type ClientDiagnosticsHostKind = 'direct' | 'worker' | 'tauri' | 'react-native';
|
|
14
14
|
export type ClientDiagnosticsHostRole = 'single' | 'leader' | 'follower' | 'unknown';
|
|
15
15
|
export type ClientDiagnosticsConnectivity = 'online' | 'offline' | 'unknown';
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* SPEC §8.8: how a client treats the realtime binding for sync rounds. The
|
|
18
|
+
* default is `optional`.
|
|
19
|
+
*/
|
|
20
|
+
export type RealtimePolicy = 'required' | 'optional' | 'off';
|
|
21
|
+
export type RealtimeState = 'connected' | 'connecting' | 'disconnected' | 'lost' | 'refused' | 'unsupported' | 'disabled' | 'unknown';
|
|
17
22
|
export interface ClientDiagnosticsHost {
|
|
18
23
|
readonly kind: ClientDiagnosticsHostKind;
|
|
19
24
|
readonly role: ClientDiagnosticsHostRole;
|
|
20
25
|
readonly connectivity: ClientDiagnosticsConnectivity;
|
|
21
|
-
readonly realtime:
|
|
26
|
+
readonly realtime: RealtimeState;
|
|
27
|
+
/** Present on core-emitted snapshots; host wrappers may forward only `realtime`. */
|
|
28
|
+
readonly realtimePolicy?: RealtimePolicy;
|
|
29
|
+
/** Stable code behind a `lost` or `refused` state (SPEC §8.8). */
|
|
30
|
+
readonly realtimeReasonCode?: string;
|
|
31
|
+
/** Next retry delay in ms while the socket is not connected. */
|
|
32
|
+
readonly realtimeRetryDelayMs?: number;
|
|
22
33
|
}
|
|
23
34
|
export interface ExpectedDiagnosticSubscription {
|
|
24
35
|
/** Application-owned stable identifier. It must never contain PHI. */
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RealtimeState } from './diagnostics.js';
|
|
1
2
|
/** A persistent local store is temporarily owned by another live engine. */
|
|
2
3
|
export declare const STORAGE_BUSY_CODE = "client.storage_busy";
|
|
3
4
|
/** The browser cannot provide the persistent storage APIs Syncular requires. */
|
|
@@ -6,17 +7,35 @@ export declare const STORAGE_UNAVAILABLE_CODE = "client.storage_unavailable";
|
|
|
6
7
|
export declare const STORAGE_CORRUPT_CODE = "client.storage_corrupt";
|
|
7
8
|
/** A local read hit SQLITE_IOERR (SPEC §7.5). */
|
|
8
9
|
export declare const STORAGE_IO_CODE = "client.storage_io";
|
|
10
|
+
/** SPEC §8.8: a round under `realtimePolicy: 'required'` found no socket. */
|
|
11
|
+
export declare const REALTIME_UNAVAILABLE_CODE = "sync.realtime_unavailable";
|
|
12
|
+
/** SPEC §8.8: a connected socket ended without a deliberate disconnect. */
|
|
13
|
+
export declare const REALTIME_LOST_CODE = "client.realtime_lost";
|
|
9
14
|
/**
|
|
10
15
|
* Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
|
|
11
16
|
* unchanged. Host/runtime-only conditions may use the separate `client.*`
|
|
12
17
|
* namespace and never travel on the wire.
|
|
13
18
|
*/
|
|
14
19
|
export declare class ClientSyncError extends Error {
|
|
15
|
-
readonly name
|
|
20
|
+
readonly name: string;
|
|
16
21
|
readonly code: string;
|
|
17
22
|
readonly retryable: boolean;
|
|
18
23
|
constructor(code: string, message: string, retryable?: boolean);
|
|
19
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* SPEC §8.8: the `required` realtime policy refused a round because the
|
|
27
|
+
* socket is not connected. `state` is the availability state that refused
|
|
28
|
+
* (`connecting`, `lost`, `refused`, or `disconnected`), `reasonCode` is the
|
|
29
|
+
* stable code behind a `lost` or `refused` state, and `retryDelayMs` is the
|
|
30
|
+
* delay of the background retry intent the client scheduled with this throw.
|
|
31
|
+
*/
|
|
32
|
+
export declare class RealtimeUnavailableError extends ClientSyncError {
|
|
33
|
+
readonly name = "RealtimeUnavailableError";
|
|
34
|
+
readonly state: RealtimeState;
|
|
35
|
+
readonly reasonCode: string | undefined;
|
|
36
|
+
readonly retryDelayMs: number;
|
|
37
|
+
constructor(state: RealtimeState, reasonCode: string | undefined, retryDelayMs: number);
|
|
38
|
+
}
|
|
20
39
|
/**
|
|
21
40
|
* SPEC §7.5: classify a local SQLite read failure by the numeric result code
|
|
22
41
|
* the driver exposes (bun:sqlite `errno`, node:sqlite `errcode`, sqlite-wasm
|
package/dist/errors.js
CHANGED
|
@@ -6,6 +6,10 @@ export const STORAGE_UNAVAILABLE_CODE = 'client.storage_unavailable';
|
|
|
6
6
|
export const STORAGE_CORRUPT_CODE = 'client.storage_corrupt';
|
|
7
7
|
/** A local read hit SQLITE_IOERR (SPEC §7.5). */
|
|
8
8
|
export const STORAGE_IO_CODE = 'client.storage_io';
|
|
9
|
+
/** SPEC §8.8: a round under `realtimePolicy: 'required'` found no socket. */
|
|
10
|
+
export const REALTIME_UNAVAILABLE_CODE = 'sync.realtime_unavailable';
|
|
11
|
+
/** SPEC §8.8: a connected socket ended without a deliberate disconnect. */
|
|
12
|
+
export const REALTIME_LOST_CODE = 'client.realtime_lost';
|
|
9
13
|
/**
|
|
10
14
|
* Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
|
|
11
15
|
* unchanged. Host/runtime-only conditions may use the separate `client.*`
|
|
@@ -21,6 +25,25 @@ export class ClientSyncError extends Error {
|
|
|
21
25
|
this.retryable = retryable;
|
|
22
26
|
}
|
|
23
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* SPEC §8.8: the `required` realtime policy refused a round because the
|
|
30
|
+
* socket is not connected. `state` is the availability state that refused
|
|
31
|
+
* (`connecting`, `lost`, `refused`, or `disconnected`), `reasonCode` is the
|
|
32
|
+
* stable code behind a `lost` or `refused` state, and `retryDelayMs` is the
|
|
33
|
+
* delay of the background retry intent the client scheduled with this throw.
|
|
34
|
+
*/
|
|
35
|
+
export class RealtimeUnavailableError extends ClientSyncError {
|
|
36
|
+
name = 'RealtimeUnavailableError';
|
|
37
|
+
state;
|
|
38
|
+
reasonCode;
|
|
39
|
+
retryDelayMs;
|
|
40
|
+
constructor(state, reasonCode, retryDelayMs) {
|
|
41
|
+
super(REALTIME_UNAVAILABLE_CODE, 'realtime is required and the realtime socket is not connected', true);
|
|
42
|
+
this.state = state;
|
|
43
|
+
this.reasonCode = reasonCode;
|
|
44
|
+
this.retryDelayMs = retryDelayMs;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
24
47
|
/**
|
|
25
48
|
* SPEC §7.5: classify a local SQLite read failure by the numeric result code
|
|
26
49
|
* the driver exposes (bun:sqlite `errno`, node:sqlite `errcode`, sqlite-wasm
|
package/dist/http.d.ts
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
import type { BlobTransport } from './blob.js';
|
|
8
8
|
import type { RealtimeConnector, RemoteOperationTransport, RemoteOperationRealtimeConnector, SegmentDownloader, SyncTransport } from './transport.js';
|
|
9
9
|
export interface HttpTransportOptions {
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Host auth headers. A function is read on every request, so a rotated
|
|
12
|
+
* token applies from the next request on. Signed-URL fetches never
|
|
13
|
+
* carry them (§5.4, §5.9.5).
|
|
14
|
+
*/
|
|
15
|
+
readonly headers?: Readonly<Record<string, string>> | (() => Readonly<Record<string, string>>);
|
|
11
16
|
readonly fetch?: typeof fetch;
|
|
12
17
|
}
|
|
13
18
|
/** POST `<mount>/sync` with SSP2 bodies (§1.1). */
|
package/dist/http.js
CHANGED
|
@@ -18,6 +18,10 @@ async function throwHttpError(response) {
|
|
|
18
18
|
}
|
|
19
19
|
throw new ClientSyncError(code, message, retryable);
|
|
20
20
|
}
|
|
21
|
+
function hostHeaders(options) {
|
|
22
|
+
const headers = options?.headers;
|
|
23
|
+
return typeof headers === 'function' ? headers() : headers;
|
|
24
|
+
}
|
|
21
25
|
/** POST `<mount>/sync` with SSP2 bodies (§1.1). */
|
|
22
26
|
export function httpSyncTransport(syncUrl, options) {
|
|
23
27
|
const doFetch = options?.fetch ?? fetch;
|
|
@@ -26,7 +30,7 @@ export function httpSyncTransport(syncUrl, options) {
|
|
|
26
30
|
method: 'POST',
|
|
27
31
|
headers: {
|
|
28
32
|
'Content-Type': SSP2_CONTENT_TYPE,
|
|
29
|
-
...options
|
|
33
|
+
...hostHeaders(options),
|
|
30
34
|
},
|
|
31
35
|
body: request.slice().buffer,
|
|
32
36
|
});
|
|
@@ -43,7 +47,7 @@ export function httpRemoteOperationTransport(operationsUrl, options) {
|
|
|
43
47
|
method: 'POST',
|
|
44
48
|
headers: {
|
|
45
49
|
'Content-Type': 'application/vnd.syncular.operations.v1+json',
|
|
46
|
-
...options
|
|
50
|
+
...hostHeaders(options),
|
|
47
51
|
},
|
|
48
52
|
body: request.slice().buffer,
|
|
49
53
|
});
|
|
@@ -136,7 +140,7 @@ export function httpSegmentDownloader(segmentsBaseUrl, options) {
|
|
|
136
140
|
const response = await doFetch(`${segmentsBaseUrl}/${encodeURIComponent(request.segmentId)}`, {
|
|
137
141
|
headers: {
|
|
138
142
|
'X-Syncular-Scopes': request.requestedScopesJson,
|
|
139
|
-
...options
|
|
143
|
+
...hostHeaders(options),
|
|
140
144
|
},
|
|
141
145
|
});
|
|
142
146
|
if (!response.ok)
|
|
@@ -168,7 +172,7 @@ export function httpBlobTransport(blobsBaseUrl, options) {
|
|
|
168
172
|
method: 'PUT',
|
|
169
173
|
headers: {
|
|
170
174
|
'Content-Type': mediaType ?? 'application/octet-stream',
|
|
171
|
-
...options
|
|
175
|
+
...hostHeaders(options),
|
|
172
176
|
},
|
|
173
177
|
body: bytes.slice().buffer,
|
|
174
178
|
});
|
|
@@ -177,7 +181,7 @@ export function httpBlobTransport(blobsBaseUrl, options) {
|
|
|
177
181
|
},
|
|
178
182
|
download: async (blobId) => {
|
|
179
183
|
const response = await doFetch(blobUrl(blobId), {
|
|
180
|
-
headers: { ...options
|
|
184
|
+
headers: { ...hostHeaders(options) },
|
|
181
185
|
});
|
|
182
186
|
if (!response.ok)
|
|
183
187
|
await throwHttpError(response);
|
|
@@ -215,7 +219,7 @@ export function httpBlobTransport(blobsBaseUrl, options) {
|
|
|
215
219
|
method: 'POST',
|
|
216
220
|
headers: {
|
|
217
221
|
'Content-Type': 'application/json',
|
|
218
|
-
...options
|
|
222
|
+
...hostHeaders(options),
|
|
219
223
|
},
|
|
220
224
|
body: JSON.stringify({
|
|
221
225
|
byteLength,
|
package/dist/multi-tab.d.ts
CHANGED
|
@@ -28,14 +28,6 @@ interface HelloMessage {
|
|
|
28
28
|
* advance even if it never saw the previous leader's announce). */
|
|
29
29
|
readonly epoch?: number;
|
|
30
30
|
}
|
|
31
|
-
interface ByeMessage {
|
|
32
|
-
readonly t: 'bye';
|
|
33
|
-
readonly fromId: string;
|
|
34
|
-
}
|
|
35
|
-
interface BoundMessage {
|
|
36
|
-
readonly t: 'bound';
|
|
37
|
-
readonly fromId: string;
|
|
38
|
-
}
|
|
39
31
|
interface ReqMessage {
|
|
40
32
|
readonly t: 'req';
|
|
41
33
|
readonly epoch: number;
|
|
@@ -66,7 +58,7 @@ interface EventMessage {
|
|
|
66
58
|
readonly epoch: number;
|
|
67
59
|
readonly event: SyncWorkerEvent;
|
|
68
60
|
}
|
|
69
|
-
export type MultiTabMessage = HelloMessage |
|
|
61
|
+
export type MultiTabMessage = HelloMessage | ReqMessage | AnnounceMessage | ResMessage | EventMessage;
|
|
70
62
|
/**
|
|
71
63
|
* The tiny cross-tab channel surface we depend on — the DOM `BroadcastChannel`
|
|
72
64
|
* satisfies it. Injectable so bun tests can pair two instances by name.
|
|
@@ -100,20 +92,26 @@ export declare class LeaderBridge {
|
|
|
100
92
|
epoch: number;
|
|
101
93
|
clientId: string;
|
|
102
94
|
invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
|
|
103
|
-
heartbeatMs?: number;
|
|
104
95
|
});
|
|
105
|
-
/**
|
|
96
|
+
/** Announce leadership (on promotion and in answer to every `hello`). */
|
|
106
97
|
announce(): void;
|
|
107
98
|
/** Fan a worker event out to all followers on the current epoch. */
|
|
108
99
|
broadcastEvent(event: SyncWorkerEvent): void;
|
|
109
100
|
close(): void;
|
|
110
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Timer seam for {@link FollowerLink}: run `callback` after `delayMs` and
|
|
104
|
+
* return a function that cancels it. Tests inject a manual clock.
|
|
105
|
+
*/
|
|
106
|
+
type FollowerSchedule = (callback: () => void, delayMs: number) => () => void;
|
|
111
107
|
/**
|
|
112
108
|
* Runs on a follower tab. Sends `req`s to the leader and settles them on the
|
|
113
109
|
* matching `res`; queues calls (bounded, timed) while no leader is bound (the
|
|
114
110
|
* handover gap) and flushes them once an `announce` binds a new leader.
|
|
115
111
|
* Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
|
|
116
|
-
* the resolved leader `clientId` back through `onLeaderChange`.
|
|
112
|
+
* the resolved leader `clientId` back through `onLeaderChange`. Probes the
|
|
113
|
+
* bound leader with `hello` after a quiet period and reports `blocked` only
|
|
114
|
+
* when a probe goes unanswered (see the module comment).
|
|
117
115
|
*/
|
|
118
116
|
export declare class FollowerLink {
|
|
119
117
|
#private;
|
|
@@ -125,6 +123,7 @@ export declare class FollowerLink {
|
|
|
125
123
|
onStateChange?: (state: LeadershipState) => void;
|
|
126
124
|
callTimeoutMs?: number;
|
|
127
125
|
queueLimit?: number;
|
|
126
|
+
schedule?: FollowerSchedule;
|
|
128
127
|
});
|
|
129
128
|
get epoch(): number;
|
|
130
129
|
get maxEpochSeen(): number;
|