@syncular/client 0.25.2 → 0.26.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/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/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 +20 -1
- package/dist/worker-host.js +17 -2
- 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/realtime-supervisor.ts +16 -2
- package/src/worker-entry.ts +18 -4
- package/src/worker-host.ts +34 -2
- package/src/worker-protocol.ts +14 -1
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,
|
|
@@ -384,7 +384,8 @@ export class RealtimeSupervisor {
|
|
|
384
384
|
return;
|
|
385
385
|
this.#diagnosticConnectivity = snapshot.host.connectivity;
|
|
386
386
|
this.#diagnosticSecurity = snapshot.securityLifecycle;
|
|
387
|
-
if (snapshot.host.realtime === 'unsupported'
|
|
387
|
+
if (snapshot.host.realtime === 'unsupported' ||
|
|
388
|
+
snapshot.host.realtime === 'disabled') {
|
|
388
389
|
const disconnect = this.#connected || this.#transportConnected || this.#connecting;
|
|
389
390
|
this.#realtimeSupported = false;
|
|
390
391
|
this.#generation += 1;
|
|
@@ -420,7 +421,16 @@ export class RealtimeSupervisor {
|
|
|
420
421
|
void this.#adoptConnectedTransport();
|
|
421
422
|
return;
|
|
422
423
|
}
|
|
423
|
-
if (snapshot.host.realtime === '
|
|
424
|
+
if (snapshot.host.realtime === 'connecting') {
|
|
425
|
+
// An attempt is already in flight; the supervisor's own #connect (or
|
|
426
|
+
// the host's direct call) owns the outcome.
|
|
427
|
+
this.#transportConnected = false;
|
|
428
|
+
this.#connected = false;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (snapshot.host.realtime === 'disconnected' ||
|
|
432
|
+
snapshot.host.realtime === 'lost' ||
|
|
433
|
+
snapshot.host.realtime === 'refused') {
|
|
424
434
|
this.#transportConnected = false;
|
|
425
435
|
this.#connected = false;
|
|
426
436
|
if (this.#connecting)
|
package/dist/worker-entry.d.ts
CHANGED
|
@@ -8,7 +8,11 @@ export interface SyncWorkerOverrides {
|
|
|
8
8
|
readonly waitForStorageRetry?: (delayMs: number) => Promise<void>;
|
|
9
9
|
readonly createTransport?: (config: WorkerInitConfig) => SyncTransport;
|
|
10
10
|
readonly createSegments?: (config: WorkerInitConfig) => SegmentDownloader | undefined;
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Custom realtime connector, e.g. one that mints a fresh ticket per
|
|
13
|
+
* connection attempt. `headers` reads the current host auth set.
|
|
14
|
+
*/
|
|
15
|
+
readonly createRealtime?: (config: WorkerInitConfig, clientId: string, headers: () => Readonly<Record<string, string>>) => RealtimeConnector | undefined;
|
|
12
16
|
}
|
|
13
17
|
/**
|
|
14
18
|
* Register the RPC server on the current worker scope. Call this once
|
package/dist/worker-entry.js
CHANGED
|
@@ -74,6 +74,7 @@ export function startSyncWorker(overrides = {}) {
|
|
|
74
74
|
let client;
|
|
75
75
|
let database;
|
|
76
76
|
let offline = false;
|
|
77
|
+
let headers = {};
|
|
77
78
|
let closed = false;
|
|
78
79
|
// -- one sync loop: RPC-driven and auto-driven rounds serialize ----------
|
|
79
80
|
let syncChain = Promise.resolve();
|
|
@@ -183,6 +184,8 @@ export function startSyncWorker(overrides = {}) {
|
|
|
183
184
|
throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
|
|
184
185
|
}
|
|
185
186
|
autoSync = config.autoSync ?? true;
|
|
187
|
+
headers = config.headers ?? {};
|
|
188
|
+
const http = { headers: () => headers };
|
|
186
189
|
for (let attempt = 0;; attempt++) {
|
|
187
190
|
try {
|
|
188
191
|
database =
|
|
@@ -212,15 +215,15 @@ export function startSyncWorker(overrides = {}) {
|
|
|
212
215
|
}
|
|
213
216
|
const transport = gateOffline(overrides.createTransport !== undefined
|
|
214
217
|
? overrides.createTransport(config)
|
|
215
|
-
: httpSyncTransport(config.endpoints.syncUrl));
|
|
218
|
+
: httpSyncTransport(config.endpoints.syncUrl, http));
|
|
216
219
|
const segments = overrides.createSegments !== undefined
|
|
217
220
|
? overrides.createSegments(config)
|
|
218
221
|
: config.endpoints.segmentsUrl !== undefined
|
|
219
|
-
? httpSegmentDownloader(config.endpoints.segmentsUrl)
|
|
222
|
+
? httpSegmentDownloader(config.endpoints.segmentsUrl, http)
|
|
220
223
|
: undefined;
|
|
221
224
|
// §5.9 blob transport: only when a blobs URL is configured.
|
|
222
225
|
const blobs = config.endpoints.blobsUrl !== undefined
|
|
223
|
-
? httpBlobTransport(config.endpoints.blobsUrl)
|
|
226
|
+
? httpBlobTransport(config.endpoints.blobsUrl, http)
|
|
224
227
|
: undefined;
|
|
225
228
|
// Realtime needs the (possibly persisted) clientId for the
|
|
226
229
|
// `{clientId}` URL placeholder — start the client first, then attach
|
|
@@ -245,6 +248,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
245
248
|
},
|
|
246
249
|
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
247
250
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
251
|
+
...(config.realtimePolicy !== undefined
|
|
252
|
+
? { realtimePolicy: config.realtimePolicy }
|
|
253
|
+
: {}),
|
|
248
254
|
...(config.encryption !== undefined
|
|
249
255
|
? { encryption: encryptionConfigFromKeyring(config.encryption) }
|
|
250
256
|
: {}),
|
|
@@ -299,7 +305,7 @@ export function startSyncWorker(overrides = {}) {
|
|
|
299
305
|
await started.start();
|
|
300
306
|
realtimeConnector =
|
|
301
307
|
overrides.createRealtime !== undefined
|
|
302
|
-
? overrides.createRealtime(config, started.clientId)
|
|
308
|
+
? overrides.createRealtime(config, started.clientId, http.headers)
|
|
303
309
|
: config.endpoints.realtimeUrl !== undefined
|
|
304
310
|
? webSocketRealtimeConnector(config.endpoints.realtimeUrl.replace('{clientId}', encodeURIComponent(started.clientId)))
|
|
305
311
|
: undefined;
|
|
@@ -383,6 +389,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
383
389
|
presence: (scopeKey) => requireClient().presence(scopeKey),
|
|
384
390
|
uploadBlob: (bytes, options) => requireClient().uploadBlob(bytes, options),
|
|
385
391
|
fetchBlob: (blobIdOrRef) => requireClient().fetchBlob(blobIdOrRef),
|
|
392
|
+
setHeaders: (next) => {
|
|
393
|
+
headers = next;
|
|
394
|
+
},
|
|
386
395
|
setOffline: (value) => {
|
|
387
396
|
offline = value;
|
|
388
397
|
if (offline)
|