@syncular/client 0.26.6 → 0.28.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.js +6 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +19 -0
- package/dist/progress.d.ts +2 -0
- package/dist/reactive-store.d.ts +5 -0
- package/dist/reactive-store.js +58 -5
- package/package.json +3 -3
- package/src/client.ts +6 -0
- package/src/errors.ts +25 -0
- package/src/progress.ts +2 -0
- package/src/reactive-store.ts +82 -5
package/dist/client.js
CHANGED
|
@@ -176,6 +176,7 @@ export class SyncClient {
|
|
|
176
176
|
#localResetEpoch = 0;
|
|
177
177
|
/** Retry policy belongs to the operation that classified the failure. */
|
|
178
178
|
#retryDelayMs = 250;
|
|
179
|
+
#roundRetryDelayMs;
|
|
179
180
|
#hasBlobs;
|
|
180
181
|
/** §8.6 presence: scopeKey → (peerKey `actorId clientId` → peer). */
|
|
181
182
|
#presence = new Map();
|
|
@@ -2334,6 +2335,9 @@ export class SyncClient {
|
|
|
2334
2335
|
errorCode: typeof code === 'string'
|
|
2335
2336
|
? this.#diagnosticCode(code)
|
|
2336
2337
|
: 'client.unknown_failure',
|
|
2338
|
+
...(this.#roundRetryDelayMs !== undefined
|
|
2339
|
+
? { retryDelayMs: this.#roundRetryDelayMs }
|
|
2340
|
+
: {}),
|
|
2337
2341
|
});
|
|
2338
2342
|
this.#lastRound = {
|
|
2339
2343
|
status: 'failed',
|
|
@@ -2381,6 +2385,7 @@ export class SyncClient {
|
|
|
2381
2385
|
: 'client.unknown_failure';
|
|
2382
2386
|
}
|
|
2383
2387
|
async #runSync() {
|
|
2388
|
+
this.#roundRetryDelayMs = undefined;
|
|
2384
2389
|
this.#progress.emit({
|
|
2385
2390
|
attempt: ++this.#progressAttempt,
|
|
2386
2391
|
state: 'running',
|
|
@@ -2508,6 +2513,7 @@ export class SyncClient {
|
|
|
2508
2513
|
delayMs: delay,
|
|
2509
2514
|
};
|
|
2510
2515
|
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
|
|
2516
|
+
this.#roundRetryDelayMs = delay;
|
|
2511
2517
|
this.#emitSyncIntent(intent);
|
|
2512
2518
|
}
|
|
2513
2519
|
throw error;
|
package/dist/errors.d.ts
CHANGED
|
@@ -22,6 +22,21 @@ export declare class ClientSyncError extends Error {
|
|
|
22
22
|
readonly retryable: boolean;
|
|
23
23
|
constructor(code: string, message: string, retryable?: boolean);
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* SPEC §7.5: the latest sync attempt failed while a live query still waited
|
|
27
|
+
* for its required coverage. `code` is the attempt's static progress error
|
|
28
|
+
* code; `attempt` is the progress attempt that failed. `retryable` is true when
|
|
29
|
+
* the client scheduled a background retry for the attempt, and `retryDelayMs`
|
|
30
|
+
* is that retry's delay from the failure; a non-retryable failure has no
|
|
31
|
+
* delay and no automatic next attempt. The query returns to waiting when the
|
|
32
|
+
* next attempt starts.
|
|
33
|
+
*/
|
|
34
|
+
export declare class SyncRoundFailedError extends ClientSyncError {
|
|
35
|
+
readonly name = "SyncRoundFailedError";
|
|
36
|
+
readonly attempt: number;
|
|
37
|
+
readonly retryDelayMs: number | undefined;
|
|
38
|
+
constructor(code: string, attempt: number, retryDelayMs: number | undefined);
|
|
39
|
+
}
|
|
25
40
|
/**
|
|
26
41
|
* SPEC §8.8: the `required` realtime policy refused a round because the
|
|
27
42
|
* socket is not connected. `state` is the availability state that refused
|
package/dist/errors.js
CHANGED
|
@@ -25,6 +25,25 @@ export class ClientSyncError extends Error {
|
|
|
25
25
|
this.retryable = retryable;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* SPEC §7.5: the latest sync attempt failed while a live query still waited
|
|
30
|
+
* for its required coverage. `code` is the attempt's static progress error
|
|
31
|
+
* code; `attempt` is the progress attempt that failed. `retryable` is true when
|
|
32
|
+
* the client scheduled a background retry for the attempt, and `retryDelayMs`
|
|
33
|
+
* is that retry's delay from the failure; a non-retryable failure has no
|
|
34
|
+
* delay and no automatic next attempt. The query returns to waiting when the
|
|
35
|
+
* next attempt starts.
|
|
36
|
+
*/
|
|
37
|
+
export class SyncRoundFailedError extends ClientSyncError {
|
|
38
|
+
name = 'SyncRoundFailedError';
|
|
39
|
+
attempt;
|
|
40
|
+
retryDelayMs;
|
|
41
|
+
constructor(code, attempt, retryDelayMs) {
|
|
42
|
+
super(code, 'the sync round failed before the query coverage completed', retryDelayMs !== undefined);
|
|
43
|
+
this.attempt = attempt;
|
|
44
|
+
this.retryDelayMs = retryDelayMs;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
28
47
|
/**
|
|
29
48
|
* SPEC §8.8: the `required` realtime policy refused a round because the
|
|
30
49
|
* socket is not connected. `state` is the availability state that refused
|
package/dist/progress.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface SyncProgress {
|
|
|
11
11
|
readonly rowsProcessed: number;
|
|
12
12
|
readonly rowsTotal?: number;
|
|
13
13
|
readonly errorCode?: string;
|
|
14
|
+
/** Present on `failed` when the core scheduled a background retry. */
|
|
15
|
+
readonly retryDelayMs?: number;
|
|
14
16
|
}
|
|
15
17
|
export type SyncProgressListener = (progress: SyncProgress) => void;
|
|
16
18
|
/** Shared by the direct client and event-forwarding handles. */
|
package/dist/reactive-store.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type SyncAvailability } from './availability.js';
|
|
2
2
|
import type { CommitOutcome, ClientSnapshotReader, QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
|
|
3
3
|
import type { SqlValue } from './database.js';
|
|
4
|
+
import { SyncRoundFailedError } from './errors.js';
|
|
4
5
|
import type { ClientChangeListener, SyncStatusSnapshot } from './invalidation.js';
|
|
5
6
|
import type { LeadershipState } from './multi-tab.js';
|
|
7
|
+
import type { SyncProgressListener } from './progress.js';
|
|
6
8
|
import { type WindowBase } from './window.js';
|
|
7
9
|
export interface QueryDependency {
|
|
8
10
|
readonly table: string;
|
|
@@ -33,6 +35,7 @@ export interface ReactiveQueryClient extends Pick<ClientSnapshotReader, 'statusS
|
|
|
33
35
|
querySnapshot<Row = Record<string, SqlValue>>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
34
36
|
leadershipSnapshot?(): LeadershipState | undefined;
|
|
35
37
|
onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
|
|
38
|
+
onProgress(listener: SyncProgressListener): () => void;
|
|
36
39
|
conflicts(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
37
40
|
rejections(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
38
41
|
setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
|
|
@@ -74,6 +77,8 @@ export declare class ReactiveClientStore {
|
|
|
74
77
|
readonly status: ExternalStoreEntry<StatusStoreSnapshot>;
|
|
75
78
|
readonly conflicts: ExternalStoreEntry<ConflictStoreSnapshot>;
|
|
76
79
|
readonly outcomes: ExternalStoreEntry<OutcomeStoreSnapshot>;
|
|
80
|
+
/** The latest sync attempt's failure until the next attempt starts. */
|
|
81
|
+
readonly roundFailure: ExternalStoreEntry<SyncRoundFailedError | undefined>;
|
|
77
82
|
constructor(client: ReactiveQueryClient);
|
|
78
83
|
query<Row>(spec: ReactiveQuerySpec<Row>): ExternalStoreEntry<LiveQueryResult<Row>>;
|
|
79
84
|
availabilitySnapshot(): SyncAvailability;
|
package/dist/reactive-store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { classifySyncAvailability, } from './availability.js';
|
|
2
|
-
import { ClientSyncError } from './errors.js';
|
|
2
|
+
import { ClientSyncError, SyncRoundFailedError } from './errors.js';
|
|
3
3
|
import { windowBaseKey } from './window.js';
|
|
4
4
|
function errorOf(value) {
|
|
5
5
|
return value instanceof Error ? value : new Error(String(value));
|
|
@@ -280,6 +280,7 @@ class QueryEntry {
|
|
|
280
280
|
#claimError;
|
|
281
281
|
#claimPending = false;
|
|
282
282
|
#offStatus;
|
|
283
|
+
#offRound;
|
|
283
284
|
constructor(store, spec, key, cache) {
|
|
284
285
|
this.store = store;
|
|
285
286
|
this.spec = spec;
|
|
@@ -298,6 +299,7 @@ class QueryEntry {
|
|
|
298
299
|
this.#listeners.add(notify);
|
|
299
300
|
if (this.#listeners.size === 1) {
|
|
300
301
|
this.#offStatus = this.store.status.subscribe(() => this.#onAvailabilityChange());
|
|
302
|
+
this.#offRound = this.store.roundFailure.subscribe(() => this.#onRoundFailureChange());
|
|
301
303
|
this.#onAvailabilityChange();
|
|
302
304
|
if (this.spec.claimCoverage !== false) {
|
|
303
305
|
const claims = [];
|
|
@@ -349,6 +351,8 @@ class QueryEntry {
|
|
|
349
351
|
this.cache.deactivate(this.key, this);
|
|
350
352
|
this.#offStatus?.();
|
|
351
353
|
this.#offStatus = undefined;
|
|
354
|
+
this.#offRound?.();
|
|
355
|
+
this.#offRound = undefined;
|
|
352
356
|
this.store.releaseWindowClaims(this.#owner);
|
|
353
357
|
}
|
|
354
358
|
};
|
|
@@ -373,6 +377,8 @@ class QueryEntry {
|
|
|
373
377
|
this.#listeners.clear();
|
|
374
378
|
this.#offStatus?.();
|
|
375
379
|
this.#offStatus = undefined;
|
|
380
|
+
this.#offRound?.();
|
|
381
|
+
this.#offRound = undefined;
|
|
376
382
|
this.store.releaseWindowClaims(this.#owner);
|
|
377
383
|
this.reset();
|
|
378
384
|
}
|
|
@@ -448,6 +454,30 @@ class QueryEntry {
|
|
|
448
454
|
if (wasBlocked)
|
|
449
455
|
this.#requestRead();
|
|
450
456
|
}
|
|
457
|
+
/** SPEC §7.5 failed transfer: incomplete coverage stops waiting on a failed
|
|
458
|
+
* sync attempt and resumes waiting when the next attempt starts. */
|
|
459
|
+
#onRoundFailureChange() {
|
|
460
|
+
const failure = this.store.roundFailure.getSnapshot();
|
|
461
|
+
if (failure !== undefined) {
|
|
462
|
+
if (this.#state.phase === 'loading' || this.#state.phase === 'partial')
|
|
463
|
+
this.#publish({
|
|
464
|
+
...this.#state,
|
|
465
|
+
phase: 'error',
|
|
466
|
+
error: failure,
|
|
467
|
+
isRefreshing: false,
|
|
468
|
+
});
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (this.#state.phase === 'error' &&
|
|
472
|
+
this.#state.error instanceof SyncRoundFailedError) {
|
|
473
|
+
this.#publish({
|
|
474
|
+
...this.#state,
|
|
475
|
+
phase: this.#state.rows.length > 0 ? 'partial' : 'loading',
|
|
476
|
+
error: undefined,
|
|
477
|
+
});
|
|
478
|
+
this.#requestRead();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
451
481
|
async #readLoop() {
|
|
452
482
|
if (this.#running || this.#listeners.size === 0)
|
|
453
483
|
return;
|
|
@@ -502,16 +532,21 @@ class QueryEntry {
|
|
|
502
532
|
? snapshot.rows
|
|
503
533
|
: snapshot.rows.map(this.spec.mapRow);
|
|
504
534
|
const rows = reconcileRows(this.#state.rows, mappedRows, this.spec.rowKey);
|
|
535
|
+
const roundFailure = snapshot.coverage.complete
|
|
536
|
+
? undefined
|
|
537
|
+
: this.store.roundFailure.getSnapshot();
|
|
505
538
|
const phase = snapshot.coverage.complete
|
|
506
539
|
? 'ready'
|
|
507
|
-
:
|
|
508
|
-
? '
|
|
509
|
-
:
|
|
540
|
+
: roundFailure !== undefined
|
|
541
|
+
? 'error'
|
|
542
|
+
: rows.length > 0
|
|
543
|
+
? 'partial'
|
|
544
|
+
: 'loading';
|
|
510
545
|
this.#publish({
|
|
511
546
|
rows,
|
|
512
547
|
phase,
|
|
513
548
|
revision: snapshot.revision,
|
|
514
|
-
error:
|
|
549
|
+
error: roundFailure,
|
|
515
550
|
isRefreshing: false,
|
|
516
551
|
availability,
|
|
517
552
|
});
|
|
@@ -675,6 +710,9 @@ export class ReactiveClientStore {
|
|
|
675
710
|
status;
|
|
676
711
|
conflicts;
|
|
677
712
|
outcomes;
|
|
713
|
+
/** The latest sync attempt's failure until the next attempt starts. */
|
|
714
|
+
roundFailure;
|
|
715
|
+
#offProgress;
|
|
678
716
|
constructor(client) {
|
|
679
717
|
this.client = client;
|
|
680
718
|
const status = new ValueEntry({
|
|
@@ -737,6 +775,8 @@ export class ReactiveClientStore {
|
|
|
737
775
|
this.status = status;
|
|
738
776
|
this.conflicts = conflicts;
|
|
739
777
|
this.outcomes = outcomes;
|
|
778
|
+
const roundFailure = new ValueEntry(undefined, async () => roundFailure.getSnapshot());
|
|
779
|
+
this.roundFailure = roundFailure;
|
|
740
780
|
this.start();
|
|
741
781
|
}
|
|
742
782
|
query(spec) {
|
|
@@ -932,6 +972,16 @@ export class ReactiveClientStore {
|
|
|
932
972
|
if (batch.outcomesChanged)
|
|
933
973
|
this.outcomes.refresh();
|
|
934
974
|
});
|
|
975
|
+
const roundFailure = this.roundFailure;
|
|
976
|
+
this.#offProgress = this.client.onProgress((progress) => {
|
|
977
|
+
const current = roundFailure.getSnapshot();
|
|
978
|
+
if (progress.state === 'failed' && current?.attempt !== progress.attempt)
|
|
979
|
+
roundFailure.set(new SyncRoundFailedError(progress.errorCode ?? 'client.unknown_failure', progress.attempt, progress.retryDelayMs));
|
|
980
|
+
else if (progress.state !== 'failed' &&
|
|
981
|
+
current !== undefined &&
|
|
982
|
+
progress.attempt !== current.attempt)
|
|
983
|
+
roundFailure.set(undefined);
|
|
984
|
+
});
|
|
935
985
|
this.#offLeadership = this.client.onLeadershipChange?.((leadership) => {
|
|
936
986
|
const previous = this.status.getSnapshot();
|
|
937
987
|
this.status.set({
|
|
@@ -955,6 +1005,9 @@ export class ReactiveClientStore {
|
|
|
955
1005
|
this.#offChange = undefined;
|
|
956
1006
|
this.#offLeadership?.();
|
|
957
1007
|
this.#offLeadership = undefined;
|
|
1008
|
+
this.#offProgress?.();
|
|
1009
|
+
this.#offProgress = undefined;
|
|
1010
|
+
this.roundFailure.set(undefined);
|
|
958
1011
|
for (const group of this.#windowClaims.values()) {
|
|
959
1012
|
for (const waiter of group.waiters.splice(0)) {
|
|
960
1013
|
waiter.reject(new ClientSyncError('client.reactive_store_disposed', 'reactive store disposed'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
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",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.
|
|
92
|
+
"@syncular/core": "0.28.0"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.28.0",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -779,6 +779,7 @@ export class SyncClient {
|
|
|
779
779
|
#localResetEpoch = 0;
|
|
780
780
|
/** Retry policy belongs to the operation that classified the failure. */
|
|
781
781
|
#retryDelayMs = 250;
|
|
782
|
+
#roundRetryDelayMs: number | undefined;
|
|
782
783
|
readonly #hasBlobs: boolean;
|
|
783
784
|
/** §8.6 presence: scopeKey → (peerKey `actorId clientId` → peer). */
|
|
784
785
|
readonly #presence = new Map<string, Map<string, PresencePeer>>();
|
|
@@ -3399,6 +3400,9 @@ export class SyncClient {
|
|
|
3399
3400
|
typeof code === 'string'
|
|
3400
3401
|
? this.#diagnosticCode(code)
|
|
3401
3402
|
: 'client.unknown_failure',
|
|
3403
|
+
...(this.#roundRetryDelayMs !== undefined
|
|
3404
|
+
? { retryDelayMs: this.#roundRetryDelayMs }
|
|
3405
|
+
: {}),
|
|
3402
3406
|
});
|
|
3403
3407
|
this.#lastRound = {
|
|
3404
3408
|
status: 'failed',
|
|
@@ -3454,6 +3458,7 @@ export class SyncClient {
|
|
|
3454
3458
|
}
|
|
3455
3459
|
|
|
3456
3460
|
async #runSync(): Promise<SyncSummary> {
|
|
3461
|
+
this.#roundRetryDelayMs = undefined;
|
|
3457
3462
|
this.#progress.emit({
|
|
3458
3463
|
attempt: ++this.#progressAttempt,
|
|
3459
3464
|
state: 'running',
|
|
@@ -3601,6 +3606,7 @@ export class SyncClient {
|
|
|
3601
3606
|
delayMs: delay,
|
|
3602
3607
|
};
|
|
3603
3608
|
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
|
|
3609
|
+
this.#roundRetryDelayMs = delay;
|
|
3604
3610
|
this.#emitSyncIntent(intent);
|
|
3605
3611
|
}
|
|
3606
3612
|
throw error;
|
package/src/errors.ts
CHANGED
|
@@ -35,6 +35,31 @@ export class ClientSyncError extends Error {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* SPEC §7.5: the latest sync attempt failed while a live query still waited
|
|
40
|
+
* for its required coverage. `code` is the attempt's static progress error
|
|
41
|
+
* code; `attempt` is the progress attempt that failed. `retryable` is true when
|
|
42
|
+
* the client scheduled a background retry for the attempt, and `retryDelayMs`
|
|
43
|
+
* is that retry's delay from the failure; a non-retryable failure has no
|
|
44
|
+
* delay and no automatic next attempt. The query returns to waiting when the
|
|
45
|
+
* next attempt starts.
|
|
46
|
+
*/
|
|
47
|
+
export class SyncRoundFailedError extends ClientSyncError {
|
|
48
|
+
override readonly name = 'SyncRoundFailedError';
|
|
49
|
+
readonly attempt: number;
|
|
50
|
+
readonly retryDelayMs: number | undefined;
|
|
51
|
+
|
|
52
|
+
constructor(code: string, attempt: number, retryDelayMs: number | undefined) {
|
|
53
|
+
super(
|
|
54
|
+
code,
|
|
55
|
+
'the sync round failed before the query coverage completed',
|
|
56
|
+
retryDelayMs !== undefined,
|
|
57
|
+
);
|
|
58
|
+
this.attempt = attempt;
|
|
59
|
+
this.retryDelayMs = retryDelayMs;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
38
63
|
/**
|
|
39
64
|
* SPEC §8.8: the `required` realtime policy refused a round because the
|
|
40
65
|
* socket is not connected. `state` is the availability state that refused
|
package/src/progress.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface SyncProgress {
|
|
|
11
11
|
readonly rowsProcessed: number;
|
|
12
12
|
readonly rowsTotal?: number;
|
|
13
13
|
readonly errorCode?: string;
|
|
14
|
+
/** Present on `failed` when the core scheduled a background retry. */
|
|
15
|
+
readonly retryDelayMs?: number;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export type SyncProgressListener = (progress: SyncProgress) => void;
|
package/src/reactive-store.ts
CHANGED
|
@@ -11,13 +11,14 @@ import type {
|
|
|
11
11
|
WindowState,
|
|
12
12
|
} from './client';
|
|
13
13
|
import type { SqlValue } from './database';
|
|
14
|
-
import { ClientSyncError } from './errors';
|
|
14
|
+
import { ClientSyncError, SyncRoundFailedError } from './errors';
|
|
15
15
|
import type {
|
|
16
16
|
ClientChangeBatch,
|
|
17
17
|
ClientChangeListener,
|
|
18
18
|
SyncStatusSnapshot,
|
|
19
19
|
} from './invalidation';
|
|
20
20
|
import type { LeadershipState } from './multi-tab';
|
|
21
|
+
import type { SyncProgressListener } from './progress';
|
|
21
22
|
import { type WindowBase, windowBaseKey } from './window';
|
|
22
23
|
|
|
23
24
|
export interface QueryDependency {
|
|
@@ -63,6 +64,7 @@ export interface ReactiveQueryClient extends Pick<
|
|
|
63
64
|
): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
64
65
|
leadershipSnapshot?(): LeadershipState | undefined;
|
|
65
66
|
onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
|
|
67
|
+
onProgress(listener: SyncProgressListener): () => void;
|
|
66
68
|
conflicts(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
67
69
|
rejections(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
68
70
|
setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
|
|
@@ -401,6 +403,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
401
403
|
#claimError: Error | undefined;
|
|
402
404
|
#claimPending = false;
|
|
403
405
|
#offStatus: (() => void) | undefined;
|
|
406
|
+
#offRound: (() => void) | undefined;
|
|
404
407
|
|
|
405
408
|
constructor(
|
|
406
409
|
readonly store: ReactiveClientStore,
|
|
@@ -425,6 +428,9 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
425
428
|
this.#offStatus = this.store.status.subscribe(() =>
|
|
426
429
|
this.#onAvailabilityChange(),
|
|
427
430
|
);
|
|
431
|
+
this.#offRound = this.store.roundFailure.subscribe(() =>
|
|
432
|
+
this.#onRoundFailureChange(),
|
|
433
|
+
);
|
|
428
434
|
this.#onAvailabilityChange();
|
|
429
435
|
if (this.spec.claimCoverage !== false) {
|
|
430
436
|
const claims: Promise<void>[] = [];
|
|
@@ -479,6 +485,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
479
485
|
this.cache.deactivate(this.key, this);
|
|
480
486
|
this.#offStatus?.();
|
|
481
487
|
this.#offStatus = undefined;
|
|
488
|
+
this.#offRound?.();
|
|
489
|
+
this.#offRound = undefined;
|
|
482
490
|
this.store.releaseWindowClaims(this.#owner);
|
|
483
491
|
}
|
|
484
492
|
};
|
|
@@ -504,6 +512,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
504
512
|
this.#listeners.clear();
|
|
505
513
|
this.#offStatus?.();
|
|
506
514
|
this.#offStatus = undefined;
|
|
515
|
+
this.#offRound?.();
|
|
516
|
+
this.#offRound = undefined;
|
|
507
517
|
this.store.releaseWindowClaims(this.#owner);
|
|
508
518
|
this.reset();
|
|
509
519
|
}
|
|
@@ -581,6 +591,33 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
581
591
|
if (wasBlocked) this.#requestRead();
|
|
582
592
|
}
|
|
583
593
|
|
|
594
|
+
/** SPEC §7.5 failed transfer: incomplete coverage stops waiting on a failed
|
|
595
|
+
* sync attempt and resumes waiting when the next attempt starts. */
|
|
596
|
+
#onRoundFailureChange(): void {
|
|
597
|
+
const failure = this.store.roundFailure.getSnapshot();
|
|
598
|
+
if (failure !== undefined) {
|
|
599
|
+
if (this.#state.phase === 'loading' || this.#state.phase === 'partial')
|
|
600
|
+
this.#publish({
|
|
601
|
+
...this.#state,
|
|
602
|
+
phase: 'error',
|
|
603
|
+
error: failure,
|
|
604
|
+
isRefreshing: false,
|
|
605
|
+
});
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (
|
|
609
|
+
this.#state.phase === 'error' &&
|
|
610
|
+
this.#state.error instanceof SyncRoundFailedError
|
|
611
|
+
) {
|
|
612
|
+
this.#publish({
|
|
613
|
+
...this.#state,
|
|
614
|
+
phase: this.#state.rows.length > 0 ? 'partial' : 'loading',
|
|
615
|
+
error: undefined,
|
|
616
|
+
});
|
|
617
|
+
this.#requestRead();
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
584
621
|
async #readLoop(): Promise<void> {
|
|
585
622
|
if (this.#running || this.#listeners.size === 0) return;
|
|
586
623
|
const generation = this.#generation;
|
|
@@ -643,16 +680,21 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
643
680
|
mappedRows,
|
|
644
681
|
this.spec.rowKey,
|
|
645
682
|
);
|
|
683
|
+
const roundFailure = snapshot.coverage.complete
|
|
684
|
+
? undefined
|
|
685
|
+
: this.store.roundFailure.getSnapshot();
|
|
646
686
|
const phase: LiveQueryPhase = snapshot.coverage.complete
|
|
647
687
|
? 'ready'
|
|
648
|
-
:
|
|
649
|
-
? '
|
|
650
|
-
:
|
|
688
|
+
: roundFailure !== undefined
|
|
689
|
+
? 'error'
|
|
690
|
+
: rows.length > 0
|
|
691
|
+
? 'partial'
|
|
692
|
+
: 'loading';
|
|
651
693
|
this.#publish({
|
|
652
694
|
rows,
|
|
653
695
|
phase,
|
|
654
696
|
revision: snapshot.revision,
|
|
655
|
-
error:
|
|
697
|
+
error: roundFailure,
|
|
656
698
|
isRefreshing: false,
|
|
657
699
|
availability,
|
|
658
700
|
});
|
|
@@ -819,6 +861,9 @@ export class ReactiveClientStore {
|
|
|
819
861
|
readonly status: ExternalStoreEntry<StatusStoreSnapshot>;
|
|
820
862
|
readonly conflicts: ExternalStoreEntry<ConflictStoreSnapshot>;
|
|
821
863
|
readonly outcomes: ExternalStoreEntry<OutcomeStoreSnapshot>;
|
|
864
|
+
/** The latest sync attempt's failure until the next attempt starts. */
|
|
865
|
+
readonly roundFailure: ExternalStoreEntry<SyncRoundFailedError | undefined>;
|
|
866
|
+
#offProgress: (() => void) | undefined;
|
|
822
867
|
|
|
823
868
|
constructor(readonly client: ReactiveQueryClient) {
|
|
824
869
|
const status = new ValueEntry<StatusStoreSnapshot>(
|
|
@@ -887,6 +932,13 @@ export class ReactiveClientStore {
|
|
|
887
932
|
this.status = status;
|
|
888
933
|
this.conflicts = conflicts;
|
|
889
934
|
this.outcomes = outcomes;
|
|
935
|
+
const roundFailure: ValueEntry<SyncRoundFailedError | undefined> =
|
|
936
|
+
new ValueEntry<SyncRoundFailedError | undefined>(
|
|
937
|
+
undefined,
|
|
938
|
+
async (): Promise<SyncRoundFailedError | undefined> =>
|
|
939
|
+
roundFailure.getSnapshot(),
|
|
940
|
+
);
|
|
941
|
+
this.roundFailure = roundFailure;
|
|
890
942
|
this.start();
|
|
891
943
|
}
|
|
892
944
|
|
|
@@ -1094,6 +1146,26 @@ export class ReactiveClientStore {
|
|
|
1094
1146
|
}
|
|
1095
1147
|
if (batch.outcomesChanged) this.outcomes.refresh();
|
|
1096
1148
|
});
|
|
1149
|
+
const roundFailure = this.roundFailure as ValueEntry<
|
|
1150
|
+
SyncRoundFailedError | undefined
|
|
1151
|
+
>;
|
|
1152
|
+
this.#offProgress = this.client.onProgress((progress) => {
|
|
1153
|
+
const current = roundFailure.getSnapshot();
|
|
1154
|
+
if (progress.state === 'failed' && current?.attempt !== progress.attempt)
|
|
1155
|
+
roundFailure.set(
|
|
1156
|
+
new SyncRoundFailedError(
|
|
1157
|
+
progress.errorCode ?? 'client.unknown_failure',
|
|
1158
|
+
progress.attempt,
|
|
1159
|
+
progress.retryDelayMs,
|
|
1160
|
+
),
|
|
1161
|
+
);
|
|
1162
|
+
else if (
|
|
1163
|
+
progress.state !== 'failed' &&
|
|
1164
|
+
current !== undefined &&
|
|
1165
|
+
progress.attempt !== current.attempt
|
|
1166
|
+
)
|
|
1167
|
+
roundFailure.set(undefined);
|
|
1168
|
+
});
|
|
1097
1169
|
this.#offLeadership = this.client.onLeadershipChange?.((leadership) => {
|
|
1098
1170
|
const previous = this.status.getSnapshot();
|
|
1099
1171
|
(this.status as ValueEntry<StatusStoreSnapshot>).set({
|
|
@@ -1117,6 +1189,11 @@ export class ReactiveClientStore {
|
|
|
1117
1189
|
this.#offChange = undefined;
|
|
1118
1190
|
this.#offLeadership?.();
|
|
1119
1191
|
this.#offLeadership = undefined;
|
|
1192
|
+
this.#offProgress?.();
|
|
1193
|
+
this.#offProgress = undefined;
|
|
1194
|
+
(this.roundFailure as ValueEntry<SyncRoundFailedError | undefined>).set(
|
|
1195
|
+
undefined,
|
|
1196
|
+
);
|
|
1120
1197
|
for (const group of this.#windowClaims.values()) {
|
|
1121
1198
|
for (const waiter of group.waiters.splice(0)) {
|
|
1122
1199
|
waiter.reject(
|