@syncular/client 0.45.0 → 0.45.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/dist/client.d.ts +16 -1
- package/dist/client.js +109 -48
- package/dist/worker-entry.js +2 -2
- package/dist/worker-host.d.ts +2 -2
- package/dist/worker-host.js +2 -2
- package/dist/worker-protocol.d.ts +2 -2
- package/package.json +3 -3
- package/src/client.ts +150 -59
- package/src/worker-entry.ts +2 -2
- package/src/worker-host.ts +3 -2
- package/src/worker-protocol.ts +2 -1
package/dist/client.d.ts
CHANGED
|
@@ -95,6 +95,16 @@ export interface SyncSummary {
|
|
|
95
95
|
export interface SyncIdleResult extends SyncSummary {
|
|
96
96
|
readonly budgetExhausted: boolean;
|
|
97
97
|
}
|
|
98
|
+
export interface SyncIdleOptions {
|
|
99
|
+
/** §7.7: a strict positive round count. Default: 20. */
|
|
100
|
+
readonly maxRounds?: number;
|
|
101
|
+
/**
|
|
102
|
+
* Total wall-clock budget for one drive, across every round. When it
|
|
103
|
+
* elapses the drive rejects with `sync.idle_timeout`; a socket round in
|
|
104
|
+
* flight is aborted. Default: 60,000 ms. `Infinity` disables the bound.
|
|
105
|
+
*/
|
|
106
|
+
readonly timeoutMs?: number;
|
|
107
|
+
}
|
|
98
108
|
export interface SyncClientLimits {
|
|
99
109
|
readonly limitCommits?: number;
|
|
100
110
|
readonly limitSnapshotRows?: number;
|
|
@@ -663,8 +673,13 @@ export declare class SyncClient {
|
|
|
663
673
|
* caller may have mutated the outbox after the in-flight drive captured it.
|
|
664
674
|
* Callers that arrive during the same drive with the same budget share that
|
|
665
675
|
* single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
|
|
676
|
+
*
|
|
677
|
+
* §7.7: one drive is bounded by a total deadline (`timeoutMs`, default
|
|
678
|
+
* 60 s). A drive that reaches it rejects with `sync.idle_timeout` and
|
|
679
|
+
* aborts a socket round in flight; the round budget does not bound elapsed
|
|
680
|
+
* time, and a transport that never answers must not hang the caller.
|
|
666
681
|
*/
|
|
667
|
-
syncUntilIdle(
|
|
682
|
+
syncUntilIdle(options?: number | SyncIdleOptions): Promise<SyncIdleResult>;
|
|
668
683
|
connectRealtime(): Promise<void>;
|
|
669
684
|
disconnectRealtime(): void;
|
|
670
685
|
}
|
package/dist/client.js
CHANGED
|
@@ -86,6 +86,8 @@ function emptySummary(pushed) {
|
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
88
|
const LOG_EPOCH_META_KEY = 'logEpoch';
|
|
89
|
+
/** §7.7: one `syncUntilIdle` drive's default total deadline. */
|
|
90
|
+
const SYNC_IDLE_TIMEOUT_MS = 60_000;
|
|
89
91
|
/** RFC 0005 defaults (D2/A4/D7). */
|
|
90
92
|
const PREVIOUS_VERSION_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
91
93
|
const PREVIOUS_VERSION_DEFAULT_MAX_ROWS = 20_000;
|
|
@@ -181,8 +183,9 @@ export class SyncClient {
|
|
|
181
183
|
/**
|
|
182
184
|
* Idle drives run one after another (§8.4): `#syncIdleTail` settles when
|
|
183
185
|
* the last scheduled drive settles, and `#syncIdlePending` holds the
|
|
184
|
-
* scheduled drives that have not started, keyed by round budget
|
|
185
|
-
* caller joins a waiting drive with its budget instead of
|
|
186
|
+
* scheduled drives that have not started, keyed by round budget and
|
|
187
|
+
* deadline, so a caller joins a waiting drive with its budget instead of
|
|
188
|
+
* queueing another.
|
|
186
189
|
*/
|
|
187
190
|
#syncIdleTail;
|
|
188
191
|
#syncIdlePending = new Map();
|
|
@@ -2959,7 +2962,22 @@ export class SyncClient {
|
|
|
2959
2962
|
const explicitlyRetryable = error.retryable;
|
|
2960
2963
|
const retryable = explicitlyRetryable === true ||
|
|
2961
2964
|
(explicitlyRetryable === undefined && typeof code !== 'string');
|
|
2962
|
-
|
|
2965
|
+
// §1.8: a refused or unreachable endpoint (`connect` from the native
|
|
2966
|
+
// transport, `network` from a browser fetch) is not a round worth
|
|
2967
|
+
// backing off for. Stop without scheduling an intent or growing the
|
|
2968
|
+
// delay; the host's own connectivity signal re-arms the drive.
|
|
2969
|
+
const details = error instanceof ClientSyncError
|
|
2970
|
+
? error.details
|
|
2971
|
+
: typeof error === 'object' && error !== null && 'details' in error
|
|
2972
|
+
? error.details
|
|
2973
|
+
: undefined;
|
|
2974
|
+
const causeKind = typeof details === 'object' &&
|
|
2975
|
+
details !== null &&
|
|
2976
|
+
'causeKind' in details
|
|
2977
|
+
? details.causeKind
|
|
2978
|
+
: undefined;
|
|
2979
|
+
const unreachable = causeKind === 'connect' || causeKind === 'network';
|
|
2980
|
+
if (retryable && !unreachable) {
|
|
2963
2981
|
const realtimeUnavailable = error instanceof RealtimeUnavailableError;
|
|
2964
2982
|
const delay = realtimeUnavailable
|
|
2965
2983
|
? error.retryDelayMs
|
|
@@ -2994,27 +3012,40 @@ export class SyncClient {
|
|
|
2994
3012
|
* caller may have mutated the outbox after the in-flight drive captured it.
|
|
2995
3013
|
* Callers that arrive during the same drive with the same budget share that
|
|
2996
3014
|
* single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
|
|
3015
|
+
*
|
|
3016
|
+
* §7.7: one drive is bounded by a total deadline (`timeoutMs`, default
|
|
3017
|
+
* 60 s). A drive that reaches it rejects with `sync.idle_timeout` and
|
|
3018
|
+
* aborts a socket round in flight; the round budget does not bound elapsed
|
|
3019
|
+
* time, and a transport that never answers must not hang the caller.
|
|
2997
3020
|
*/
|
|
2998
|
-
async syncUntilIdle(
|
|
3021
|
+
async syncUntilIdle(options) {
|
|
3022
|
+
const maxRounds = typeof options === 'number' ? options : options?.maxRounds;
|
|
3023
|
+
const timeoutMs = typeof options === 'number'
|
|
3024
|
+
? SYNC_IDLE_TIMEOUT_MS
|
|
3025
|
+
: (options?.timeoutMs ?? SYNC_IDLE_TIMEOUT_MS);
|
|
2999
3026
|
// §7.7: an explicit budget is a strict positive round count. Zero,
|
|
3000
3027
|
// fractional, and out-of-range values are caller errors.
|
|
3001
3028
|
if (maxRounds !== undefined &&
|
|
3002
3029
|
(!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff))
|
|
3003
3030
|
throw invalidRequest('maxRounds must be a positive integer');
|
|
3004
|
-
|
|
3031
|
+
if (timeoutMs !== Number.POSITIVE_INFINITY &&
|
|
3032
|
+
!(Number.isFinite(timeoutMs) && timeoutMs > 0))
|
|
3033
|
+
throw invalidRequest('timeoutMs must be a positive number or Infinity');
|
|
3034
|
+
const key = `${maxRounds ?? 'default'}:${timeoutMs}`;
|
|
3035
|
+
const pending = this.#syncIdlePending.get(key);
|
|
3005
3036
|
if (pending !== undefined)
|
|
3006
3037
|
return pending;
|
|
3007
3038
|
const previous = this.#syncIdleTail;
|
|
3008
3039
|
let drive;
|
|
3009
3040
|
if (previous === undefined) {
|
|
3010
|
-
drive = this.#syncIdleRounds(maxRounds);
|
|
3041
|
+
drive = this.#syncIdleRounds(maxRounds, timeoutMs);
|
|
3011
3042
|
}
|
|
3012
3043
|
else {
|
|
3013
3044
|
drive = previous.then(() => {
|
|
3014
|
-
this.#syncIdlePending.delete(
|
|
3015
|
-
return this.#syncIdleRounds(maxRounds);
|
|
3045
|
+
this.#syncIdlePending.delete(key);
|
|
3046
|
+
return this.#syncIdleRounds(maxRounds, timeoutMs);
|
|
3016
3047
|
});
|
|
3017
|
-
this.#syncIdlePending.set(
|
|
3048
|
+
this.#syncIdlePending.set(key, drive);
|
|
3018
3049
|
}
|
|
3019
3050
|
const tail = drive.then(() => undefined, () => undefined);
|
|
3020
3051
|
this.#syncIdleTail = tail;
|
|
@@ -3025,7 +3056,7 @@ export class SyncClient {
|
|
|
3025
3056
|
return drive;
|
|
3026
3057
|
}
|
|
3027
3058
|
/** The rounds loop of one idle drive; `syncUntilIdle` owns coalescing. */
|
|
3028
|
-
async #syncIdleRounds(maxRounds) {
|
|
3059
|
+
async #syncIdleRounds(maxRounds, timeoutMs) {
|
|
3029
3060
|
const budget = maxRounds ?? 20;
|
|
3030
3061
|
const aggregate = {
|
|
3031
3062
|
pushed: 0,
|
|
@@ -3062,46 +3093,76 @@ export class SyncClient {
|
|
|
3062
3093
|
: {}),
|
|
3063
3094
|
budgetExhausted,
|
|
3064
3095
|
});
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
last
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
(last.
|
|
3100
|
-
|
|
3101
|
-
|
|
3096
|
+
let round = 0;
|
|
3097
|
+
let expired = false;
|
|
3098
|
+
let timer;
|
|
3099
|
+
const deadline = timeoutMs === Number.POSITIVE_INFINITY
|
|
3100
|
+
? undefined
|
|
3101
|
+
: new Promise((_, reject) => {
|
|
3102
|
+
timer = setTimeout(() => {
|
|
3103
|
+
expired = true;
|
|
3104
|
+
// The transport may never answer; release the round so the
|
|
3105
|
+
// client can run another one after the caller sees the error.
|
|
3106
|
+
this.#abortPendingRound('syncUntilIdle deadline exceeded');
|
|
3107
|
+
reject(new ClientSyncError('sync.idle_timeout', 'syncUntilIdle exceeded its deadline', true, { timeoutMs, rounds: round }));
|
|
3108
|
+
}, timeoutMs);
|
|
3109
|
+
});
|
|
3110
|
+
const drive = (async () => {
|
|
3111
|
+
for (; round < budget; round++) {
|
|
3112
|
+
// The race already rejected; stop spending rounds on a dead drive.
|
|
3113
|
+
if (expired)
|
|
3114
|
+
throw new ClientSyncError('sync.idle_timeout', 'syncUntilIdle exceeded its deadline', true, { timeoutMs, rounds: round });
|
|
3115
|
+
const before = JSON.stringify(loadSubscriptions(this.#db).map((sub) => [
|
|
3116
|
+
sub.id,
|
|
3117
|
+
sub.bootstrapState,
|
|
3118
|
+
]));
|
|
3119
|
+
const last = await this.sync();
|
|
3120
|
+
// Historical counters accumulate; current readiness (`bootstrapping`,
|
|
3121
|
+
// `deferredCommits`, `schemaFloor`) describes the latest round.
|
|
3122
|
+
aggregate.pushed += last.pushed;
|
|
3123
|
+
aggregate.applied.push(...last.applied);
|
|
3124
|
+
aggregate.rejected.push(...last.rejected);
|
|
3125
|
+
aggregate.retryable.push(...last.retryable);
|
|
3126
|
+
aggregate.conflicts.push(...last.conflicts);
|
|
3127
|
+
aggregate.commitsApplied += last.commitsApplied;
|
|
3128
|
+
aggregate.segmentRowsApplied += last.segmentRowsApplied;
|
|
3129
|
+
aggregate.bootstrapping = [...last.bootstrapping];
|
|
3130
|
+
aggregate.resets.push(...last.resets);
|
|
3131
|
+
aggregate.revoked.push(...last.revoked);
|
|
3132
|
+
aggregate.failed.push(...last.failed);
|
|
3133
|
+
aggregate.deferredCommits = last.deferredCommits ?? 0;
|
|
3134
|
+
aggregate.schemaFloor = last.schemaFloor;
|
|
3135
|
+
// A disabled transport or a schema floor ends the loop, not the budget.
|
|
3136
|
+
if (!this.#transportEnabled || last.schemaFloor !== undefined)
|
|
3137
|
+
return result(false);
|
|
3138
|
+
if (maxRounds === undefined &&
|
|
3139
|
+
last.segmentRowsApplied > 0 &&
|
|
3140
|
+
last.bootstrapping.length > 0 &&
|
|
3141
|
+
before !==
|
|
3142
|
+
JSON.stringify(loadSubscriptions(this.#db).map((sub) => [
|
|
3143
|
+
sub.id,
|
|
3144
|
+
sub.bootstrapState,
|
|
3145
|
+
])))
|
|
3146
|
+
round = -1;
|
|
3147
|
+
if (last.commitsApplied === 0 &&
|
|
3148
|
+
last.segmentRowsApplied === 0 &&
|
|
3149
|
+
last.bootstrapping.length === 0 &&
|
|
3150
|
+
last.resets.length === 0 &&
|
|
3151
|
+
(last.deferredCommits ?? 0) === 0 &&
|
|
3152
|
+
!this.#needsPull) {
|
|
3153
|
+
return result(false);
|
|
3154
|
+
}
|
|
3102
3155
|
}
|
|
3156
|
+
return result(true);
|
|
3157
|
+
})();
|
|
3158
|
+
try {
|
|
3159
|
+
return await (deadline === undefined
|
|
3160
|
+
? drive
|
|
3161
|
+
: Promise.race([drive, deadline]));
|
|
3162
|
+
}
|
|
3163
|
+
finally {
|
|
3164
|
+
clearTimeout(timer);
|
|
3103
3165
|
}
|
|
3104
|
-
return result(true);
|
|
3105
3166
|
}
|
|
3106
3167
|
/**
|
|
3107
3168
|
* One request/response round trip (§8.7): over the socket whenever it
|
package/dist/worker-entry.js
CHANGED
|
@@ -367,9 +367,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
367
367
|
const running = requireClient();
|
|
368
368
|
return serializedSync(() => running.sync());
|
|
369
369
|
},
|
|
370
|
-
syncUntilIdle: (
|
|
370
|
+
syncUntilIdle: (options) => {
|
|
371
371
|
const running = requireClient();
|
|
372
|
-
return serializedSync(() => running.syncUntilIdle(
|
|
372
|
+
return serializedSync(() => running.syncUntilIdle(options));
|
|
373
373
|
},
|
|
374
374
|
resetClientId: () => requireClient().resetClientId(),
|
|
375
375
|
localRevision: () => requireClient().localRevision,
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type { PromiseMethods } from './client.js';
|
|
|
25
25
|
*/
|
|
26
26
|
import type { WakeReason } from '@syncular/core';
|
|
27
27
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
28
|
-
import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SnapshotRead, SnapshotReadRequest, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleResult, WindowState } from './client.js';
|
|
28
|
+
import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SnapshotRead, SnapshotReadRequest, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleOptions, SyncIdleResult, WindowState } from './client.js';
|
|
29
29
|
import type { SqlRow, SqlValue } from './database.js';
|
|
30
30
|
import { ClientDiagnosticsEmitter, type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot, type RealtimePolicy } from './diagnostics.js';
|
|
31
31
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
@@ -245,7 +245,7 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
245
245
|
discarded: boolean;
|
|
246
246
|
}>;
|
|
247
247
|
sync(): Promise<SyncSummary>;
|
|
248
|
-
syncUntilIdle(
|
|
248
|
+
syncUntilIdle(options?: number | SyncIdleOptions): Promise<SyncIdleResult>;
|
|
249
249
|
/**
|
|
250
250
|
* Rotate the client id (§1.5) after a `sync.invalid_client_id` refusal.
|
|
251
251
|
* The worker's next round carries the new id; the replica and outbox stay
|
package/dist/worker-host.js
CHANGED
|
@@ -329,8 +329,8 @@ export class SyncClientHandle {
|
|
|
329
329
|
sync() {
|
|
330
330
|
return this.#call('sync', []);
|
|
331
331
|
}
|
|
332
|
-
syncUntilIdle(
|
|
333
|
-
return this.#call('syncUntilIdle', [
|
|
332
|
+
syncUntilIdle(options) {
|
|
333
|
+
return this.#call('syncUntilIdle', [options]);
|
|
334
334
|
}
|
|
335
335
|
/**
|
|
336
336
|
* Rotate the client id (§1.5) after a `sync.invalid_client_id` refusal.
|
|
@@ -19,7 +19,7 @@ import type { SyncProgress } from './progress.js';
|
|
|
19
19
|
*/
|
|
20
20
|
import type { WakeReason } from '@syncular/core';
|
|
21
21
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
22
|
-
import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleResult, WindowState } from './client.js';
|
|
22
|
+
import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleOptions, SyncIdleResult, WindowState } from './client.js';
|
|
23
23
|
import type { SqlRow, SqlValue } from './database.js';
|
|
24
24
|
import type { ClientDiagnosticsSnapshot, RealtimePolicy } from './diagnostics.js';
|
|
25
25
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
@@ -138,7 +138,7 @@ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'>
|
|
|
138
138
|
discarded: boolean;
|
|
139
139
|
};
|
|
140
140
|
sync(): Promise<SyncSummary>;
|
|
141
|
-
syncUntilIdle(
|
|
141
|
+
syncUntilIdle(options?: number | SyncIdleOptions): Promise<SyncIdleResult>;
|
|
142
142
|
resetClientId(): string;
|
|
143
143
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
144
144
|
authoritySnapshot(): import('./authority.js').AuthoritySnapshot;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.45.
|
|
3
|
+
"version": "0.45.1",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -97,10 +97,10 @@
|
|
|
97
97
|
},
|
|
98
98
|
"dependencies": {
|
|
99
99
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
100
|
-
"@syncular/core": "0.45.
|
|
100
|
+
"@syncular/core": "0.45.1"
|
|
101
101
|
},
|
|
102
102
|
"devDependencies": {
|
|
103
|
-
"@syncular/server": "0.45.
|
|
103
|
+
"@syncular/server": "0.45.1",
|
|
104
104
|
"playwright": "1.63.0",
|
|
105
105
|
"@electric-sql/pglite": "^0.5.4"
|
|
106
106
|
}
|
package/src/client.ts
CHANGED
|
@@ -354,6 +354,17 @@ export interface SyncIdleResult extends SyncSummary {
|
|
|
354
354
|
readonly budgetExhausted: boolean;
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
+
export interface SyncIdleOptions {
|
|
358
|
+
/** §7.7: a strict positive round count. Default: 20. */
|
|
359
|
+
readonly maxRounds?: number;
|
|
360
|
+
/**
|
|
361
|
+
* Total wall-clock budget for one drive, across every round. When it
|
|
362
|
+
* elapses the drive rejects with `sync.idle_timeout`; a socket round in
|
|
363
|
+
* flight is aborted. Default: 60,000 ms. `Infinity` disables the bound.
|
|
364
|
+
*/
|
|
365
|
+
readonly timeoutMs?: number;
|
|
366
|
+
}
|
|
367
|
+
|
|
357
368
|
export interface SyncClientLimits {
|
|
358
369
|
readonly limitCommits?: number;
|
|
359
370
|
readonly limitSnapshotRows?: number;
|
|
@@ -730,6 +741,9 @@ function emptySummary(pushed: number): MutableSummary {
|
|
|
730
741
|
|
|
731
742
|
const LOG_EPOCH_META_KEY = 'logEpoch';
|
|
732
743
|
|
|
744
|
+
/** §7.7: one `syncUntilIdle` drive's default total deadline. */
|
|
745
|
+
const SYNC_IDLE_TIMEOUT_MS = 60_000;
|
|
746
|
+
|
|
733
747
|
/** RFC 0005 defaults (D2/A4/D7). */
|
|
734
748
|
const PREVIOUS_VERSION_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
735
749
|
const PREVIOUS_VERSION_DEFAULT_MAX_ROWS = 20_000;
|
|
@@ -898,14 +912,12 @@ export class SyncClient {
|
|
|
898
912
|
/**
|
|
899
913
|
* Idle drives run one after another (§8.4): `#syncIdleTail` settles when
|
|
900
914
|
* the last scheduled drive settles, and `#syncIdlePending` holds the
|
|
901
|
-
* scheduled drives that have not started, keyed by round budget
|
|
902
|
-
* caller joins a waiting drive with its budget instead of
|
|
915
|
+
* scheduled drives that have not started, keyed by round budget and
|
|
916
|
+
* deadline, so a caller joins a waiting drive with its budget instead of
|
|
917
|
+
* queueing another.
|
|
903
918
|
*/
|
|
904
919
|
#syncIdleTail: Promise<void> | undefined;
|
|
905
|
-
readonly #syncIdlePending = new Map<
|
|
906
|
-
number | undefined,
|
|
907
|
-
Promise<SyncIdleResult>
|
|
908
|
-
>();
|
|
920
|
+
readonly #syncIdlePending = new Map<string, Promise<SyncIdleResult>>();
|
|
909
921
|
/**
|
|
910
922
|
* Bumped by every local projection reset (`rebootstrapLocalData`). A sync
|
|
911
923
|
* round captures this epoch together with its subscription state; a
|
|
@@ -4270,7 +4282,24 @@ export class SyncClient {
|
|
|
4270
4282
|
const retryable =
|
|
4271
4283
|
explicitlyRetryable === true ||
|
|
4272
4284
|
(explicitlyRetryable === undefined && typeof code !== 'string');
|
|
4273
|
-
|
|
4285
|
+
// §1.8: a refused or unreachable endpoint (`connect` from the native
|
|
4286
|
+
// transport, `network` from a browser fetch) is not a round worth
|
|
4287
|
+
// backing off for. Stop without scheduling an intent or growing the
|
|
4288
|
+
// delay; the host's own connectivity signal re-arms the drive.
|
|
4289
|
+
const details =
|
|
4290
|
+
error instanceof ClientSyncError
|
|
4291
|
+
? error.details
|
|
4292
|
+
: typeof error === 'object' && error !== null && 'details' in error
|
|
4293
|
+
? error.details
|
|
4294
|
+
: undefined;
|
|
4295
|
+
const causeKind =
|
|
4296
|
+
typeof details === 'object' &&
|
|
4297
|
+
details !== null &&
|
|
4298
|
+
'causeKind' in details
|
|
4299
|
+
? details.causeKind
|
|
4300
|
+
: undefined;
|
|
4301
|
+
const unreachable = causeKind === 'connect' || causeKind === 'network';
|
|
4302
|
+
if (retryable && !unreachable) {
|
|
4274
4303
|
const realtimeUnavailable = error instanceof RealtimeUnavailableError;
|
|
4275
4304
|
const delay = realtimeUnavailable
|
|
4276
4305
|
? error.retryDelayMs
|
|
@@ -4307,8 +4336,21 @@ export class SyncClient {
|
|
|
4307
4336
|
* caller may have mutated the outbox after the in-flight drive captured it.
|
|
4308
4337
|
* Callers that arrive during the same drive with the same budget share that
|
|
4309
4338
|
* single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
|
|
4339
|
+
*
|
|
4340
|
+
* §7.7: one drive is bounded by a total deadline (`timeoutMs`, default
|
|
4341
|
+
* 60 s). A drive that reaches it rejects with `sync.idle_timeout` and
|
|
4342
|
+
* aborts a socket round in flight; the round budget does not bound elapsed
|
|
4343
|
+
* time, and a transport that never answers must not hang the caller.
|
|
4310
4344
|
*/
|
|
4311
|
-
async syncUntilIdle(
|
|
4345
|
+
async syncUntilIdle(
|
|
4346
|
+
options?: number | SyncIdleOptions,
|
|
4347
|
+
): Promise<SyncIdleResult> {
|
|
4348
|
+
const maxRounds =
|
|
4349
|
+
typeof options === 'number' ? options : options?.maxRounds;
|
|
4350
|
+
const timeoutMs =
|
|
4351
|
+
typeof options === 'number'
|
|
4352
|
+
? SYNC_IDLE_TIMEOUT_MS
|
|
4353
|
+
: (options?.timeoutMs ?? SYNC_IDLE_TIMEOUT_MS);
|
|
4312
4354
|
// §7.7: an explicit budget is a strict positive round count. Zero,
|
|
4313
4355
|
// fractional, and out-of-range values are caller errors.
|
|
4314
4356
|
if (
|
|
@@ -4316,18 +4358,24 @@ export class SyncClient {
|
|
|
4316
4358
|
(!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff)
|
|
4317
4359
|
)
|
|
4318
4360
|
throw invalidRequest('maxRounds must be a positive integer');
|
|
4319
|
-
|
|
4361
|
+
if (
|
|
4362
|
+
timeoutMs !== Number.POSITIVE_INFINITY &&
|
|
4363
|
+
!(Number.isFinite(timeoutMs) && timeoutMs > 0)
|
|
4364
|
+
)
|
|
4365
|
+
throw invalidRequest('timeoutMs must be a positive number or Infinity');
|
|
4366
|
+
const key = `${maxRounds ?? 'default'}:${timeoutMs}`;
|
|
4367
|
+
const pending = this.#syncIdlePending.get(key);
|
|
4320
4368
|
if (pending !== undefined) return pending;
|
|
4321
4369
|
const previous = this.#syncIdleTail;
|
|
4322
4370
|
let drive: Promise<SyncIdleResult>;
|
|
4323
4371
|
if (previous === undefined) {
|
|
4324
|
-
drive = this.#syncIdleRounds(maxRounds);
|
|
4372
|
+
drive = this.#syncIdleRounds(maxRounds, timeoutMs);
|
|
4325
4373
|
} else {
|
|
4326
4374
|
drive = previous.then(() => {
|
|
4327
|
-
this.#syncIdlePending.delete(
|
|
4328
|
-
return this.#syncIdleRounds(maxRounds);
|
|
4375
|
+
this.#syncIdlePending.delete(key);
|
|
4376
|
+
return this.#syncIdleRounds(maxRounds, timeoutMs);
|
|
4329
4377
|
});
|
|
4330
|
-
this.#syncIdlePending.set(
|
|
4378
|
+
this.#syncIdlePending.set(key, drive);
|
|
4331
4379
|
}
|
|
4332
4380
|
const tail = drive.then(
|
|
4333
4381
|
() => undefined,
|
|
@@ -4343,6 +4391,7 @@ export class SyncClient {
|
|
|
4343
4391
|
/** The rounds loop of one idle drive; `syncUntilIdle` owns coalescing. */
|
|
4344
4392
|
async #syncIdleRounds(
|
|
4345
4393
|
maxRounds: number | undefined,
|
|
4394
|
+
timeoutMs: number,
|
|
4346
4395
|
): Promise<SyncIdleResult> {
|
|
4347
4396
|
const budget = maxRounds ?? 20;
|
|
4348
4397
|
const aggregate = {
|
|
@@ -4380,54 +4429,96 @@ export class SyncClient {
|
|
|
4380
4429
|
: {}),
|
|
4381
4430
|
budgetExhausted,
|
|
4382
4431
|
});
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
)
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
last
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4432
|
+
let round = 0;
|
|
4433
|
+
let expired = false;
|
|
4434
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
4435
|
+
const deadline =
|
|
4436
|
+
timeoutMs === Number.POSITIVE_INFINITY
|
|
4437
|
+
? undefined
|
|
4438
|
+
: new Promise<never>((_, reject) => {
|
|
4439
|
+
timer = setTimeout(() => {
|
|
4440
|
+
expired = true;
|
|
4441
|
+
// The transport may never answer; release the round so the
|
|
4442
|
+
// client can run another one after the caller sees the error.
|
|
4443
|
+
this.#abortPendingRound('syncUntilIdle deadline exceeded');
|
|
4444
|
+
reject(
|
|
4445
|
+
new ClientSyncError(
|
|
4446
|
+
'sync.idle_timeout',
|
|
4447
|
+
'syncUntilIdle exceeded its deadline',
|
|
4448
|
+
true,
|
|
4449
|
+
{ timeoutMs, rounds: round },
|
|
4450
|
+
),
|
|
4451
|
+
);
|
|
4452
|
+
}, timeoutMs);
|
|
4453
|
+
});
|
|
4454
|
+
const drive = (async (): Promise<SyncIdleResult> => {
|
|
4455
|
+
for (; round < budget; round++) {
|
|
4456
|
+
// The race already rejected; stop spending rounds on a dead drive.
|
|
4457
|
+
if (expired)
|
|
4458
|
+
throw new ClientSyncError(
|
|
4459
|
+
'sync.idle_timeout',
|
|
4460
|
+
'syncUntilIdle exceeded its deadline',
|
|
4461
|
+
true,
|
|
4462
|
+
{ timeoutMs, rounds: round },
|
|
4463
|
+
);
|
|
4464
|
+
const before = JSON.stringify(
|
|
4465
|
+
loadSubscriptions(this.#db).map((sub) => [
|
|
4466
|
+
sub.id,
|
|
4467
|
+
sub.bootstrapState,
|
|
4468
|
+
]),
|
|
4469
|
+
);
|
|
4470
|
+
const last = await this.sync();
|
|
4471
|
+
// Historical counters accumulate; current readiness (`bootstrapping`,
|
|
4472
|
+
// `deferredCommits`, `schemaFloor`) describes the latest round.
|
|
4473
|
+
aggregate.pushed += last.pushed;
|
|
4474
|
+
aggregate.applied.push(...last.applied);
|
|
4475
|
+
aggregate.rejected.push(...last.rejected);
|
|
4476
|
+
aggregate.retryable.push(...last.retryable);
|
|
4477
|
+
aggregate.conflicts.push(...last.conflicts);
|
|
4478
|
+
aggregate.commitsApplied += last.commitsApplied;
|
|
4479
|
+
aggregate.segmentRowsApplied += last.segmentRowsApplied;
|
|
4480
|
+
aggregate.bootstrapping = [...last.bootstrapping];
|
|
4481
|
+
aggregate.resets.push(...last.resets);
|
|
4482
|
+
aggregate.revoked.push(...last.revoked);
|
|
4483
|
+
aggregate.failed.push(...last.failed);
|
|
4484
|
+
aggregate.deferredCommits = last.deferredCommits ?? 0;
|
|
4485
|
+
aggregate.schemaFloor = last.schemaFloor;
|
|
4486
|
+
// A disabled transport or a schema floor ends the loop, not the budget.
|
|
4487
|
+
if (!this.#transportEnabled || last.schemaFloor !== undefined)
|
|
4488
|
+
return result(false);
|
|
4489
|
+
if (
|
|
4490
|
+
maxRounds === undefined &&
|
|
4491
|
+
last.segmentRowsApplied > 0 &&
|
|
4492
|
+
last.bootstrapping.length > 0 &&
|
|
4493
|
+
before !==
|
|
4494
|
+
JSON.stringify(
|
|
4495
|
+
loadSubscriptions(this.#db).map((sub) => [
|
|
4496
|
+
sub.id,
|
|
4497
|
+
sub.bootstrapState,
|
|
4498
|
+
]),
|
|
4499
|
+
)
|
|
4500
|
+
)
|
|
4501
|
+
round = -1;
|
|
4502
|
+
if (
|
|
4503
|
+
last.commitsApplied === 0 &&
|
|
4504
|
+
last.segmentRowsApplied === 0 &&
|
|
4505
|
+
last.bootstrapping.length === 0 &&
|
|
4506
|
+
last.resets.length === 0 &&
|
|
4507
|
+
(last.deferredCommits ?? 0) === 0 &&
|
|
4508
|
+
!this.#needsPull
|
|
4509
|
+
) {
|
|
4510
|
+
return result(false);
|
|
4511
|
+
}
|
|
4428
4512
|
}
|
|
4513
|
+
return result(true);
|
|
4514
|
+
})();
|
|
4515
|
+
try {
|
|
4516
|
+
return await (deadline === undefined
|
|
4517
|
+
? drive
|
|
4518
|
+
: Promise.race([drive, deadline]));
|
|
4519
|
+
} finally {
|
|
4520
|
+
clearTimeout(timer);
|
|
4429
4521
|
}
|
|
4430
|
-
return result(true);
|
|
4431
4522
|
}
|
|
4432
4523
|
|
|
4433
4524
|
/**
|
package/src/worker-entry.ts
CHANGED
|
@@ -488,9 +488,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
488
488
|
const running = requireClient();
|
|
489
489
|
return serializedSync(() => running.sync());
|
|
490
490
|
},
|
|
491
|
-
syncUntilIdle: (
|
|
491
|
+
syncUntilIdle: (options) => {
|
|
492
492
|
const running = requireClient();
|
|
493
|
-
return serializedSync(() => running.syncUntilIdle(
|
|
493
|
+
return serializedSync(() => running.syncUntilIdle(options));
|
|
494
494
|
},
|
|
495
495
|
resetClientId: () => requireClient().resetClientId(),
|
|
496
496
|
localRevision: () => requireClient().localRevision,
|
package/src/worker-host.ts
CHANGED
|
@@ -43,6 +43,7 @@ import type {
|
|
|
43
43
|
SyncClientConfig,
|
|
44
44
|
SyncClientLimits,
|
|
45
45
|
SyncSummary,
|
|
46
|
+
SyncIdleOptions,
|
|
46
47
|
SyncIdleResult,
|
|
47
48
|
WindowState,
|
|
48
49
|
} from './client';
|
|
@@ -657,8 +658,8 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
657
658
|
return this.#call('sync', []);
|
|
658
659
|
}
|
|
659
660
|
|
|
660
|
-
syncUntilIdle(
|
|
661
|
-
return this.#call('syncUntilIdle', [
|
|
661
|
+
syncUntilIdle(options?: number | SyncIdleOptions): Promise<SyncIdleResult> {
|
|
662
|
+
return this.#call('syncUntilIdle', [options]);
|
|
662
663
|
}
|
|
663
664
|
|
|
664
665
|
/**
|
package/src/worker-protocol.ts
CHANGED
|
@@ -31,6 +31,7 @@ import type {
|
|
|
31
31
|
SyncClientConfig,
|
|
32
32
|
SyncClientLimits,
|
|
33
33
|
SyncSummary,
|
|
34
|
+
SyncIdleOptions,
|
|
34
35
|
SyncIdleResult,
|
|
35
36
|
WindowState,
|
|
36
37
|
} from './client';
|
|
@@ -187,7 +188,7 @@ export interface WorkerApi extends Omit<
|
|
|
187
188
|
/** RFC 0005 D9: the executable rollback step — drop the container. */
|
|
188
189
|
previousVersionDiscard(): { present: boolean; discarded: boolean };
|
|
189
190
|
sync(): Promise<SyncSummary>;
|
|
190
|
-
syncUntilIdle(
|
|
191
|
+
syncUntilIdle(options?: number | SyncIdleOptions): Promise<SyncIdleResult>;
|
|
191
192
|
resetClientId(): string;
|
|
192
193
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
193
194
|
authoritySnapshot(): import('./authority').AuthoritySnapshot;
|