@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/src/client.ts
CHANGED
|
@@ -82,9 +82,16 @@ import {
|
|
|
82
82
|
MAX_DIAGNOSTIC_DOMAINS,
|
|
83
83
|
MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
84
84
|
MAX_DIAGNOSTIC_QUERY_FAILURES,
|
|
85
|
+
type RealtimePolicy,
|
|
86
|
+
type RealtimeState,
|
|
85
87
|
} from './diagnostics';
|
|
86
88
|
import type { EncryptionConfig } from './encryption';
|
|
87
|
-
import {
|
|
89
|
+
import {
|
|
90
|
+
ClientSyncError,
|
|
91
|
+
classifySqliteFailure,
|
|
92
|
+
REALTIME_LOST_CODE,
|
|
93
|
+
RealtimeUnavailableError,
|
|
94
|
+
} from './errors';
|
|
88
95
|
import {
|
|
89
96
|
ChangeAccumulator,
|
|
90
97
|
ChangeEmitter,
|
|
@@ -344,6 +351,15 @@ export interface SyncClientConfig {
|
|
|
344
351
|
*/
|
|
345
352
|
readonly blobCacheMaxBytes?: number;
|
|
346
353
|
readonly realtime?: RealtimeConnector;
|
|
354
|
+
/**
|
|
355
|
+
* SPEC §8.8: how sync rounds treat the realtime binding. `optional`
|
|
356
|
+
* (default) rides a connected socket and otherwise uses `POST /sync`.
|
|
357
|
+
* `required` refuses a round with `RealtimeUnavailableError` while the
|
|
358
|
+
* socket is not connected and never falls back to HTTP. `off` never uses
|
|
359
|
+
* realtime; an explicit `connectRealtime` fails with `sync.invalid_request`.
|
|
360
|
+
* `required` without a `realtime` connector is refused at construction.
|
|
361
|
+
*/
|
|
362
|
+
readonly realtimePolicy?: RealtimePolicy;
|
|
347
363
|
/** Stable per-device id (§1.5); defaults to a persisted random UUID. */
|
|
348
364
|
readonly clientId?: string;
|
|
349
365
|
readonly leaderLock?: LeaderLock;
|
|
@@ -672,6 +688,12 @@ function isFinalPushResult(frame: PushResultFrame): boolean {
|
|
|
672
688
|
);
|
|
673
689
|
}
|
|
674
690
|
|
|
691
|
+
/** The stable `code` of a thrown realtime transport error, when it has one. */
|
|
692
|
+
function realtimeErrorCode(error: unknown): string | undefined {
|
|
693
|
+
const code = (error as { code?: unknown } | undefined)?.code;
|
|
694
|
+
return typeof code === 'string' ? code : undefined;
|
|
695
|
+
}
|
|
696
|
+
|
|
675
697
|
/** Canonical client reads, shared by synchronous cores and promise hosts. */
|
|
676
698
|
export type ClientSnapshotMethods = Pick<
|
|
677
699
|
SyncClient,
|
|
@@ -725,6 +747,11 @@ export class SyncClient {
|
|
|
725
747
|
#conflicts: ConflictRecord[] = [];
|
|
726
748
|
#rejections: RejectionRecord[] = [];
|
|
727
749
|
#socket: RealtimeSocket | undefined;
|
|
750
|
+
/** SPEC §8.8: the explicit realtime availability state and its evidence. */
|
|
751
|
+
readonly #realtimePolicy: RealtimePolicy;
|
|
752
|
+
#realtimeState: RealtimeState;
|
|
753
|
+
#realtimeReasonCode: string | undefined;
|
|
754
|
+
#realtimeRetryDelayMs: number | undefined;
|
|
728
755
|
#realtimeConnectPromise: Promise<void> | undefined;
|
|
729
756
|
#realtimeGeneration = 0;
|
|
730
757
|
#pendingRound: PendingRound | undefined;
|
|
@@ -802,9 +829,33 @@ export class SyncClient {
|
|
|
802
829
|
'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight',
|
|
803
830
|
);
|
|
804
831
|
}
|
|
805
|
-
this.#config = config;
|
|
806
832
|
this.#db = config.database;
|
|
807
833
|
this.#schema = compileClientSchema(config.schema);
|
|
834
|
+
const realtimePolicy = config.realtimePolicy ?? 'optional';
|
|
835
|
+
if (
|
|
836
|
+
realtimePolicy !== 'required' &&
|
|
837
|
+
realtimePolicy !== 'optional' &&
|
|
838
|
+
realtimePolicy !== 'off'
|
|
839
|
+
) {
|
|
840
|
+
throw new ClientSyncError(
|
|
841
|
+
'sync.invalid_request',
|
|
842
|
+
"realtimePolicy must be 'required', 'optional', or 'off'",
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
if (realtimePolicy === 'required' && config.realtime === undefined) {
|
|
846
|
+
throw new ClientSyncError(
|
|
847
|
+
'sync.invalid_request',
|
|
848
|
+
"realtimePolicy 'required' needs a realtime connector",
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
this.#config = config;
|
|
852
|
+
this.#realtimePolicy = realtimePolicy;
|
|
853
|
+
this.#realtimeState =
|
|
854
|
+
realtimePolicy === 'off'
|
|
855
|
+
? 'disabled'
|
|
856
|
+
: config.realtime === undefined
|
|
857
|
+
? 'unsupported'
|
|
858
|
+
: 'disconnected';
|
|
808
859
|
this.#encryption = config.encryption;
|
|
809
860
|
this.#securityLifecycle =
|
|
810
861
|
config.securityPreflight === true ? 'preflight' : 'active';
|
|
@@ -1751,12 +1802,14 @@ export class SyncClient {
|
|
|
1751
1802
|
kind: 'direct',
|
|
1752
1803
|
role: 'single',
|
|
1753
1804
|
connectivity,
|
|
1754
|
-
realtime:
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1805
|
+
realtime: this.#realtimeState,
|
|
1806
|
+
realtimePolicy: this.#realtimePolicy,
|
|
1807
|
+
...(this.#realtimeReasonCode !== undefined
|
|
1808
|
+
? { realtimeReasonCode: this.#realtimeReasonCode }
|
|
1809
|
+
: {}),
|
|
1810
|
+
...(this.#realtimeRetryDelayMs !== undefined
|
|
1811
|
+
? { realtimeRetryDelayMs: this.#realtimeRetryDelayMs }
|
|
1812
|
+
: {}),
|
|
1760
1813
|
},
|
|
1761
1814
|
securityLifecycle: this.#securityLifecycle,
|
|
1762
1815
|
schema: {
|
|
@@ -3532,9 +3585,20 @@ export class SyncClient {
|
|
|
3532
3585
|
explicitlyRetryable === true ||
|
|
3533
3586
|
(explicitlyRetryable === undefined && typeof code !== 'string');
|
|
3534
3587
|
if (retryable) {
|
|
3588
|
+
const realtimeUnavailable = error instanceof RealtimeUnavailableError;
|
|
3589
|
+
const delay = realtimeUnavailable
|
|
3590
|
+
? error.retryDelayMs
|
|
3591
|
+
: this.#retryDelayMs;
|
|
3592
|
+
if (
|
|
3593
|
+
realtimeUnavailable ||
|
|
3594
|
+
this.#realtimeState === 'lost' ||
|
|
3595
|
+
this.#realtimeState === 'refused'
|
|
3596
|
+
) {
|
|
3597
|
+
this.#realtimeRetryDelayMs = delay;
|
|
3598
|
+
}
|
|
3535
3599
|
const intent: SyncIntent = {
|
|
3536
3600
|
kind: 'background',
|
|
3537
|
-
delayMs:
|
|
3601
|
+
delayMs: delay,
|
|
3538
3602
|
};
|
|
3539
3603
|
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
|
|
3540
3604
|
this.#emitSyncIntent(intent);
|
|
@@ -3593,50 +3657,60 @@ export class SyncClient {
|
|
|
3593
3657
|
* is connected (the socket IS the sync-round
|
|
3594
3658
|
* transport, not a fallback pair), otherwise through the configured
|
|
3595
3659
|
* `SyncTransport` seam (loopback/conformance hosts, HTTP-only
|
|
3596
|
-
* producers).
|
|
3660
|
+
* producers). Under `realtimePolicy: 'required'` an unconnected socket
|
|
3661
|
+
* refuses the round with `RealtimeUnavailableError` instead of using the
|
|
3662
|
+
* seam (SPEC §8.8).
|
|
3597
3663
|
*/
|
|
3598
3664
|
#roundTrip(request: Uint8Array): Promise<Uint8Array> {
|
|
3599
3665
|
const socket = this.#socket;
|
|
3600
|
-
if (socket
|
|
3601
|
-
return Promise
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3666
|
+
if (socket !== undefined && this.#realtimePolicy !== 'off') {
|
|
3667
|
+
return new Promise<Uint8Array>((resolve, reject) => {
|
|
3668
|
+
// sync() already enforces one round in flight (§8.7).
|
|
3669
|
+
this.#pendingRound = {
|
|
3670
|
+
scanner: new MessageStreamScanner(),
|
|
3671
|
+
resolve,
|
|
3672
|
+
reject,
|
|
3673
|
+
};
|
|
3674
|
+
const tagged = new Uint8Array(request.length + 1);
|
|
3675
|
+
tagged[0] = REALTIME_TAG_ROUND;
|
|
3676
|
+
tagged.set(request, 1);
|
|
3677
|
+
try {
|
|
3678
|
+
socket.sendBytes(tagged);
|
|
3679
|
+
} catch (error) {
|
|
3680
|
+
this.#pendingRound = undefined;
|
|
3681
|
+
// The socket could not carry the round: record the same loss the
|
|
3682
|
+
// Rust core records when its realtime round call fails (§8.8).
|
|
3683
|
+
this.#setRealtimeState('lost', 'sync.transport_failed');
|
|
3684
|
+
reject(
|
|
3685
|
+
new ClientSyncError(
|
|
3686
|
+
'sync.transport_failed',
|
|
3687
|
+
`socket round send failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3688
|
+
true,
|
|
3689
|
+
),
|
|
3614
3690
|
);
|
|
3615
|
-
}
|
|
3691
|
+
}
|
|
3692
|
+
});
|
|
3616
3693
|
}
|
|
3617
|
-
|
|
3618
|
-
//
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
true,
|
|
3636
|
-
),
|
|
3694
|
+
if (this.#realtimePolicy === 'required') {
|
|
3695
|
+
// §8.8: the designated realtime path is down. Refuse the round; the
|
|
3696
|
+
// HTTP binding is not an alternative for a `required` client.
|
|
3697
|
+
throw this.#realtimeUnavailableError();
|
|
3698
|
+
}
|
|
3699
|
+
return Promise.resolve()
|
|
3700
|
+
.then(() => this.#config.transport(request))
|
|
3701
|
+
.catch((error: unknown) => {
|
|
3702
|
+
if (
|
|
3703
|
+
error instanceof ClientSyncError ||
|
|
3704
|
+
typeof (error as { code?: unknown })?.code === 'string'
|
|
3705
|
+
) {
|
|
3706
|
+
throw error;
|
|
3707
|
+
}
|
|
3708
|
+
throw new ClientSyncError(
|
|
3709
|
+
'sync.transport_failed',
|
|
3710
|
+
`transport round failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3711
|
+
true,
|
|
3637
3712
|
);
|
|
3638
|
-
}
|
|
3639
|
-
});
|
|
3713
|
+
});
|
|
3640
3714
|
}
|
|
3641
3715
|
|
|
3642
3716
|
/** Abort the in-flight socket round (socket closed or disconnected). */
|
|
@@ -3649,13 +3723,51 @@ export class SyncClient {
|
|
|
3649
3723
|
|
|
3650
3724
|
// -- realtime (§8 client side) ----------------------------------------------
|
|
3651
3725
|
|
|
3726
|
+
/**
|
|
3727
|
+
* SPEC §8.8: record an availability transition and the evidence behind it.
|
|
3728
|
+
* The reason and retry delay belong to the state that is no longer
|
|
3729
|
+
* connected; `connected` and a policy-restricted state clear them.
|
|
3730
|
+
*/
|
|
3731
|
+
#setRealtimeState(state: RealtimeState, reasonCode?: string): void {
|
|
3732
|
+
this.#realtimeState = state;
|
|
3733
|
+
this.#realtimeReasonCode =
|
|
3734
|
+
reasonCode === undefined ? undefined : this.#diagnosticCode(reasonCode);
|
|
3735
|
+
if (
|
|
3736
|
+
state === 'connected' ||
|
|
3737
|
+
state === 'disabled' ||
|
|
3738
|
+
state === 'unsupported'
|
|
3739
|
+
) {
|
|
3740
|
+
this.#realtimeRetryDelayMs = undefined;
|
|
3741
|
+
}
|
|
3742
|
+
this.#emitDiagnostics();
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
#realtimeUnavailableError(): RealtimeUnavailableError {
|
|
3746
|
+
this.#realtimeRetryDelayMs = this.#retryDelayMs;
|
|
3747
|
+
return new RealtimeUnavailableError(
|
|
3748
|
+
this.#realtimeState,
|
|
3749
|
+
this.#realtimeReasonCode,
|
|
3750
|
+
this.#retryDelayMs,
|
|
3751
|
+
);
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3652
3754
|
connectRealtime(): Promise<void> {
|
|
3653
3755
|
this.#requireActive();
|
|
3756
|
+
if (this.#realtimePolicy === 'off') {
|
|
3757
|
+
return Promise.reject(
|
|
3758
|
+
new ClientSyncError(
|
|
3759
|
+
'sync.invalid_request',
|
|
3760
|
+
"realtimePolicy is 'off'; realtime connect is refused",
|
|
3761
|
+
),
|
|
3762
|
+
);
|
|
3763
|
+
}
|
|
3654
3764
|
if (this.#socket !== undefined) return Promise.resolve();
|
|
3655
3765
|
if (this.#realtimeConnectPromise !== undefined) {
|
|
3656
3766
|
return this.#realtimeConnectPromise;
|
|
3657
3767
|
}
|
|
3658
3768
|
const generation = this.#realtimeGeneration;
|
|
3769
|
+
// The attempt is visible synchronously; runners of the task share it.
|
|
3770
|
+
this.#setRealtimeState('connecting');
|
|
3659
3771
|
const task = this.#runProtectedAsync(() =>
|
|
3660
3772
|
this.#connectRealtime(generation),
|
|
3661
3773
|
);
|
|
@@ -3684,40 +3796,53 @@ export class SyncClient {
|
|
|
3684
3796
|
);
|
|
3685
3797
|
}
|
|
3686
3798
|
let openedSocket: RealtimeSocket | undefined;
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
)
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
)
|
|
3799
|
+
try {
|
|
3800
|
+
const socket = await connector({
|
|
3801
|
+
onText: (text) => {
|
|
3802
|
+
if (generation !== this.#realtimeGeneration || !this.#started) return;
|
|
3803
|
+
this.#handleRealtimeText(text);
|
|
3804
|
+
},
|
|
3805
|
+
onBinary: (bytes) => {
|
|
3806
|
+
if (generation !== this.#realtimeGeneration || !this.#started) return;
|
|
3807
|
+
this.#routeRealtimeBinary(bytes);
|
|
3808
|
+
},
|
|
3809
|
+
onClose: () => {
|
|
3810
|
+
if (openedSocket === undefined || this.#socket !== openedSocket)
|
|
3811
|
+
return;
|
|
3812
|
+
this.#socket = undefined;
|
|
3813
|
+
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
3814
|
+
// §8.8: an undesired close is a visible lost state, not a silent
|
|
3815
|
+
// downgrade to HTTP rounds.
|
|
3816
|
+
this.#setRealtimeState('lost', REALTIME_LOST_CODE);
|
|
3817
|
+
this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
|
|
3818
|
+
this.#emitDiagnostics();
|
|
3819
|
+
},
|
|
3820
|
+
});
|
|
3821
|
+
openedSocket = socket;
|
|
3822
|
+
if (this.#securityLifecycle === 'preflight') {
|
|
3823
|
+
socket.close();
|
|
3824
|
+
throw new ClientSyncError(
|
|
3825
|
+
SECURITY_PREFLIGHT_REQUIRED_CODE,
|
|
3826
|
+
'realtime connected after the client entered security preflight',
|
|
3827
|
+
);
|
|
3828
|
+
}
|
|
3829
|
+
if (generation !== this.#realtimeGeneration || !this.#started) {
|
|
3830
|
+
socket.close();
|
|
3831
|
+
throw new ClientSyncError(
|
|
3832
|
+
'client.realtime_cancelled',
|
|
3833
|
+
'realtime connection was cancelled before activation',
|
|
3834
|
+
);
|
|
3835
|
+
}
|
|
3836
|
+
this.#socket = socket;
|
|
3837
|
+
this.#setRealtimeState('connected');
|
|
3838
|
+
} catch (error) {
|
|
3839
|
+
// A superseded attempt leaves the successor's (or the deliberate
|
|
3840
|
+
// disconnect's) state alone.
|
|
3841
|
+
if (generation === this.#realtimeGeneration) {
|
|
3842
|
+
this.#setRealtimeState('refused', realtimeErrorCode(error));
|
|
3843
|
+
}
|
|
3844
|
+
throw error;
|
|
3718
3845
|
}
|
|
3719
|
-
this.#socket = socket;
|
|
3720
|
-
this.#emitDiagnostics();
|
|
3721
3846
|
}
|
|
3722
3847
|
|
|
3723
3848
|
disconnectRealtime(): void {
|
|
@@ -3727,7 +3852,13 @@ export class SyncClient {
|
|
|
3727
3852
|
this.#socket = undefined;
|
|
3728
3853
|
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
3729
3854
|
this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
|
|
3730
|
-
this.#
|
|
3855
|
+
this.#setRealtimeState(
|
|
3856
|
+
this.#realtimePolicy === 'off'
|
|
3857
|
+
? 'disabled'
|
|
3858
|
+
: this.#config.realtime === undefined
|
|
3859
|
+
? 'unsupported'
|
|
3860
|
+
: 'disconnected',
|
|
3861
|
+
);
|
|
3731
3862
|
}
|
|
3732
3863
|
|
|
3733
3864
|
/**
|
package/src/diagnostics.ts
CHANGED
|
@@ -23,17 +23,32 @@ export type ClientDiagnosticsHostRole =
|
|
|
23
23
|
| 'follower'
|
|
24
24
|
| 'unknown';
|
|
25
25
|
export type ClientDiagnosticsConnectivity = 'online' | 'offline' | 'unknown';
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* SPEC §8.8: how a client treats the realtime binding for sync rounds. The
|
|
28
|
+
* default is `optional`.
|
|
29
|
+
*/
|
|
30
|
+
export type RealtimePolicy = 'required' | 'optional' | 'off';
|
|
31
|
+
export type RealtimeState =
|
|
27
32
|
| 'connected'
|
|
33
|
+
| 'connecting'
|
|
28
34
|
| 'disconnected'
|
|
35
|
+
| 'lost'
|
|
36
|
+
| 'refused'
|
|
29
37
|
| 'unsupported'
|
|
38
|
+
| 'disabled'
|
|
30
39
|
| 'unknown';
|
|
31
40
|
|
|
32
41
|
export interface ClientDiagnosticsHost {
|
|
33
42
|
readonly kind: ClientDiagnosticsHostKind;
|
|
34
43
|
readonly role: ClientDiagnosticsHostRole;
|
|
35
44
|
readonly connectivity: ClientDiagnosticsConnectivity;
|
|
36
|
-
readonly realtime:
|
|
45
|
+
readonly realtime: RealtimeState;
|
|
46
|
+
/** Present on core-emitted snapshots; host wrappers may forward only `realtime`. */
|
|
47
|
+
readonly realtimePolicy?: RealtimePolicy;
|
|
48
|
+
/** Stable code behind a `lost` or `refused` state (SPEC §8.8). */
|
|
49
|
+
readonly realtimeReasonCode?: string;
|
|
50
|
+
/** Next retry delay in ms while the socket is not connected. */
|
|
51
|
+
readonly realtimeRetryDelayMs?: number;
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
export interface ExpectedDiagnosticSubscription {
|
package/src/errors.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { RealtimeState } from './diagnostics';
|
|
2
|
+
|
|
1
3
|
/** A persistent local store is temporarily owned by another live engine. */
|
|
2
4
|
export const STORAGE_BUSY_CODE = 'client.storage_busy';
|
|
3
5
|
|
|
@@ -10,13 +12,19 @@ export const STORAGE_CORRUPT_CODE = 'client.storage_corrupt';
|
|
|
10
12
|
/** A local read hit SQLITE_IOERR (SPEC §7.5). */
|
|
11
13
|
export const STORAGE_IO_CODE = 'client.storage_io';
|
|
12
14
|
|
|
15
|
+
/** SPEC §8.8: a round under `realtimePolicy: 'required'` found no socket. */
|
|
16
|
+
export const REALTIME_UNAVAILABLE_CODE = 'sync.realtime_unavailable';
|
|
17
|
+
|
|
18
|
+
/** SPEC §8.8: a connected socket ended without a deliberate disconnect. */
|
|
19
|
+
export const REALTIME_LOST_CODE = 'client.realtime_lost';
|
|
20
|
+
|
|
13
21
|
/**
|
|
14
22
|
* Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
|
|
15
23
|
* unchanged. Host/runtime-only conditions may use the separate `client.*`
|
|
16
24
|
* namespace and never travel on the wire.
|
|
17
25
|
*/
|
|
18
26
|
export class ClientSyncError extends Error {
|
|
19
|
-
override readonly name = 'ClientSyncError';
|
|
27
|
+
override readonly name: string = 'ClientSyncError';
|
|
20
28
|
readonly code: string;
|
|
21
29
|
readonly retryable: boolean;
|
|
22
30
|
|
|
@@ -27,6 +35,35 @@ export class ClientSyncError extends Error {
|
|
|
27
35
|
}
|
|
28
36
|
}
|
|
29
37
|
|
|
38
|
+
/**
|
|
39
|
+
* SPEC §8.8: the `required` realtime policy refused a round because the
|
|
40
|
+
* socket is not connected. `state` is the availability state that refused
|
|
41
|
+
* (`connecting`, `lost`, `refused`, or `disconnected`), `reasonCode` is the
|
|
42
|
+
* stable code behind a `lost` or `refused` state, and `retryDelayMs` is the
|
|
43
|
+
* delay of the background retry intent the client scheduled with this throw.
|
|
44
|
+
*/
|
|
45
|
+
export class RealtimeUnavailableError extends ClientSyncError {
|
|
46
|
+
override readonly name = 'RealtimeUnavailableError';
|
|
47
|
+
readonly state: RealtimeState;
|
|
48
|
+
readonly reasonCode: string | undefined;
|
|
49
|
+
readonly retryDelayMs: number;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
state: RealtimeState,
|
|
53
|
+
reasonCode: string | undefined,
|
|
54
|
+
retryDelayMs: number,
|
|
55
|
+
) {
|
|
56
|
+
super(
|
|
57
|
+
REALTIME_UNAVAILABLE_CODE,
|
|
58
|
+
'realtime is required and the realtime socket is not connected',
|
|
59
|
+
true,
|
|
60
|
+
);
|
|
61
|
+
this.state = state;
|
|
62
|
+
this.reasonCode = reasonCode;
|
|
63
|
+
this.retryDelayMs = retryDelayMs;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
30
67
|
/**
|
|
31
68
|
* SPEC §7.5: classify a local SQLite read failure by the numeric result code
|
|
32
69
|
* the driver exposes (bun:sqlite `errno`, node:sqlite `errcode`, sqlite-wasm
|
package/src/http.ts
CHANGED
|
@@ -35,10 +35,24 @@ async function throwHttpError(response: Response): Promise<never> {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export interface HttpTransportOptions {
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Host auth headers. A function is read on every request, so a rotated
|
|
40
|
+
* token applies from the next request on. Signed-URL fetches never
|
|
41
|
+
* carry them (§5.4, §5.9.5).
|
|
42
|
+
*/
|
|
43
|
+
readonly headers?:
|
|
44
|
+
| Readonly<Record<string, string>>
|
|
45
|
+
| (() => Readonly<Record<string, string>>);
|
|
39
46
|
readonly fetch?: typeof fetch;
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
function hostHeaders(
|
|
50
|
+
options: HttpTransportOptions | undefined,
|
|
51
|
+
): Readonly<Record<string, string>> | undefined {
|
|
52
|
+
const headers = options?.headers;
|
|
53
|
+
return typeof headers === 'function' ? headers() : headers;
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
/** POST `<mount>/sync` with SSP2 bodies (§1.1). */
|
|
43
57
|
export function httpSyncTransport(
|
|
44
58
|
syncUrl: string,
|
|
@@ -50,7 +64,7 @@ export function httpSyncTransport(
|
|
|
50
64
|
method: 'POST',
|
|
51
65
|
headers: {
|
|
52
66
|
'Content-Type': SSP2_CONTENT_TYPE,
|
|
53
|
-
...options
|
|
67
|
+
...hostHeaders(options),
|
|
54
68
|
},
|
|
55
69
|
body: request.slice().buffer as ArrayBuffer,
|
|
56
70
|
});
|
|
@@ -70,7 +84,7 @@ export function httpRemoteOperationTransport(
|
|
|
70
84
|
method: 'POST',
|
|
71
85
|
headers: {
|
|
72
86
|
'Content-Type': 'application/vnd.syncular.operations.v1+json',
|
|
73
|
-
...options
|
|
87
|
+
...hostHeaders(options),
|
|
74
88
|
},
|
|
75
89
|
body: request.slice().buffer as ArrayBuffer,
|
|
76
90
|
});
|
|
@@ -188,7 +202,7 @@ export function httpSegmentDownloader(
|
|
|
188
202
|
{
|
|
189
203
|
headers: {
|
|
190
204
|
'X-Syncular-Scopes': request.requestedScopesJson,
|
|
191
|
-
...options
|
|
205
|
+
...hostHeaders(options),
|
|
192
206
|
},
|
|
193
207
|
},
|
|
194
208
|
);
|
|
@@ -232,7 +246,7 @@ export function httpBlobTransport(
|
|
|
232
246
|
method: 'PUT',
|
|
233
247
|
headers: {
|
|
234
248
|
'Content-Type': mediaType ?? 'application/octet-stream',
|
|
235
|
-
...options
|
|
249
|
+
...hostHeaders(options),
|
|
236
250
|
},
|
|
237
251
|
body: bytes.slice().buffer as ArrayBuffer,
|
|
238
252
|
});
|
|
@@ -240,7 +254,7 @@ export function httpBlobTransport(
|
|
|
240
254
|
},
|
|
241
255
|
download: async (blobId) => {
|
|
242
256
|
const response = await doFetch(blobUrl(blobId), {
|
|
243
|
-
headers: { ...options
|
|
257
|
+
headers: { ...hostHeaders(options) },
|
|
244
258
|
});
|
|
245
259
|
if (!response.ok) await throwHttpError(response);
|
|
246
260
|
// §5.9.5 always-issue: a JSON body with `url` means presigned delivery;
|
|
@@ -284,7 +298,7 @@ export function httpBlobTransport(
|
|
|
284
298
|
method: 'POST',
|
|
285
299
|
headers: {
|
|
286
300
|
'Content-Type': 'application/json',
|
|
287
|
-
...options
|
|
301
|
+
...hostHeaders(options),
|
|
288
302
|
},
|
|
289
303
|
body: JSON.stringify({
|
|
290
304
|
byteLength,
|