@syncular/client 0.30.0 → 0.30.2
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 +2 -1
- package/dist/multi-tab.d.ts +5 -0
- package/dist/multi-tab.js +13 -0
- package/dist/reactive-store.js +42 -13
- package/dist/worker-host.js +2 -1
- package/package.json +3 -3
- package/src/multi-tab.ts +16 -0
- package/src/reactive-store.ts +44 -12
- package/src/worker-host.ts +2 -1
package/README.md
CHANGED
|
@@ -221,7 +221,8 @@ with `client.follower_timeout` when the link goes `blocked`, with
|
|
|
221
221
|
`client.leader_handover` when another leader announces or this tab promotes
|
|
222
222
|
before the answer arrives, and with `client.worker_failed` when the handle
|
|
223
223
|
closes. Only calls queued while no leader is bound keep the
|
|
224
|
-
`followerCallTimeoutMs` deadline
|
|
224
|
+
`followerCallTimeoutMs` deadline; when this tab wins the lock itself, its own
|
|
225
|
+
core runs them.
|
|
225
226
|
|
|
226
227
|
**Tabs of different builds never serve each other.** Every `hello`, `req`,
|
|
227
228
|
and `announce` carries `MULTI_TAB_PROTOCOL_VERSION` and the schema version.
|
package/dist/multi-tab.d.ts
CHANGED
|
@@ -192,6 +192,11 @@ export declare class FollowerLink {
|
|
|
192
192
|
* flushes the queued ones.
|
|
193
193
|
*/
|
|
194
194
|
unbind(): void;
|
|
195
|
+
/**
|
|
196
|
+
* This tab won the lock and runs its own core: calls queued for the next
|
|
197
|
+
* leader run on `invoke` instead of failing, then the link closes.
|
|
198
|
+
*/
|
|
199
|
+
handOver(invoke: (method: string, args: readonly unknown[]) => Promise<unknown>): void;
|
|
195
200
|
close(): void;
|
|
196
201
|
}
|
|
197
202
|
export {};
|
package/dist/multi-tab.js
CHANGED
|
@@ -502,6 +502,19 @@ export class FollowerLink {
|
|
|
502
502
|
this.#armUnboundDeadline();
|
|
503
503
|
this.#probe();
|
|
504
504
|
}
|
|
505
|
+
/**
|
|
506
|
+
* This tab won the lock and runs its own core: calls queued for the next
|
|
507
|
+
* leader run on `invoke` instead of failing, then the link closes.
|
|
508
|
+
*/
|
|
509
|
+
handOver(invoke) {
|
|
510
|
+
const queued = this.#queue;
|
|
511
|
+
this.#queue = [];
|
|
512
|
+
for (const call of queued) {
|
|
513
|
+
call.cancelTimer?.();
|
|
514
|
+
invoke(call.method, call.args).then(call.resolve, call.reject);
|
|
515
|
+
}
|
|
516
|
+
this.close();
|
|
517
|
+
}
|
|
505
518
|
close() {
|
|
506
519
|
if (this.#closed)
|
|
507
520
|
return;
|
package/dist/reactive-store.js
CHANGED
|
@@ -233,6 +233,10 @@ class ObservationCache {
|
|
|
233
233
|
for (const entry of this.#active)
|
|
234
234
|
entry.onChange(batch);
|
|
235
235
|
}
|
|
236
|
+
reclaim() {
|
|
237
|
+
for (const entry of this.#active)
|
|
238
|
+
entry.reclaim?.();
|
|
239
|
+
}
|
|
236
240
|
clear() {
|
|
237
241
|
for (const entry of this.#entries.values())
|
|
238
242
|
entry.dispose();
|
|
@@ -364,6 +368,8 @@ class QueryEntry {
|
|
|
364
368
|
refresh = () => {
|
|
365
369
|
if (this.#delegate !== undefined)
|
|
366
370
|
this.#delegate.refresh();
|
|
371
|
+
else if (this.#claimError !== undefined)
|
|
372
|
+
this.reclaim();
|
|
367
373
|
else
|
|
368
374
|
this.#requestRead(true);
|
|
369
375
|
};
|
|
@@ -386,13 +392,29 @@ class QueryEntry {
|
|
|
386
392
|
this.store.releaseWindowClaims(this.#owner);
|
|
387
393
|
this.reset();
|
|
388
394
|
}
|
|
395
|
+
/** SPEC §7.7: a rejected claim is claimed again; its stored error would
|
|
396
|
+
* fail every read until the last subscriber leaves. */
|
|
397
|
+
reclaim() {
|
|
398
|
+
if (this.#delegate !== undefined || this.#claimError === undefined)
|
|
399
|
+
return;
|
|
400
|
+
this.#claimError = undefined;
|
|
401
|
+
this.#claimCoverage();
|
|
402
|
+
this.#requestRead();
|
|
403
|
+
}
|
|
389
404
|
onChange(batch) {
|
|
390
405
|
if (!batchMatches(batch, this.spec))
|
|
391
406
|
return;
|
|
392
407
|
if (batch.revision > this.#desiredRevision) {
|
|
393
408
|
this.#desiredRevision = batch.revision;
|
|
394
409
|
}
|
|
395
|
-
if (this.#listeners.size
|
|
410
|
+
if (this.#listeners.size === 0)
|
|
411
|
+
return;
|
|
412
|
+
// A committed row change of a dependency claims again; a window-only
|
|
413
|
+
// batch, which a failed window edit can itself produce, does not.
|
|
414
|
+
const rowChange = batch.tables.some((change) => this.spec.dependencies.some((dependency) => dependency.table === change.table));
|
|
415
|
+
if (rowChange && this.#claimError !== undefined)
|
|
416
|
+
this.reclaim();
|
|
417
|
+
else
|
|
396
418
|
this.#requestRead();
|
|
397
419
|
}
|
|
398
420
|
#publish(next) {
|
|
@@ -446,12 +468,6 @@ class QueryEntry {
|
|
|
446
468
|
return;
|
|
447
469
|
}
|
|
448
470
|
const wasBlocked = this.#state.phase === 'blocked';
|
|
449
|
-
// A claim that failed while the leader link was blocked is retried with
|
|
450
|
-
// the link: its stored error would otherwise fail every later read.
|
|
451
|
-
if (wasBlocked && this.#claimError !== undefined) {
|
|
452
|
-
this.#claimError = undefined;
|
|
453
|
-
this.#claimCoverage();
|
|
454
|
-
}
|
|
455
471
|
this.#publish({
|
|
456
472
|
...this.#state,
|
|
457
473
|
phase: wasBlocked
|
|
@@ -887,6 +903,16 @@ export class ReactiveClientStore {
|
|
|
887
903
|
this.#scheduleWindow(group);
|
|
888
904
|
}
|
|
889
905
|
}
|
|
906
|
+
/** Apply every claimed window group again and claim again for query entries
|
|
907
|
+
* whose claim was rejected. An applied group compares equal and makes no
|
|
908
|
+
* call; a group whose edit is in flight applies once more after it. */
|
|
909
|
+
#reclaimWindows() {
|
|
910
|
+
for (const group of this.#windowClaims.values()) {
|
|
911
|
+
if (group.claims.size > 0)
|
|
912
|
+
this.#scheduleWindow(group);
|
|
913
|
+
}
|
|
914
|
+
this.#queries.reclaim();
|
|
915
|
+
}
|
|
890
916
|
#scheduleWindow(group) {
|
|
891
917
|
group.requested = true;
|
|
892
918
|
if (group.scheduled || group.running)
|
|
@@ -978,7 +1004,14 @@ export class ReactiveClientStore {
|
|
|
978
1004
|
this.outcomes.refresh();
|
|
979
1005
|
});
|
|
980
1006
|
const roundFailure = this.roundFailure;
|
|
1007
|
+
let claimedAttempt;
|
|
981
1008
|
this.#offProgress = this.client.onProgress((progress) => {
|
|
1009
|
+
// A new sync attempt is the client's retry schedule (§7.6): window
|
|
1010
|
+
// claims that failed are claimed again once per attempt.
|
|
1011
|
+
if (progress.state === 'running' && progress.attempt !== claimedAttempt) {
|
|
1012
|
+
claimedAttempt = progress.attempt;
|
|
1013
|
+
this.#reclaimWindows();
|
|
1014
|
+
}
|
|
982
1015
|
const current = roundFailure.getSnapshot();
|
|
983
1016
|
if (progress.state === 'failed' && current?.attempt !== progress.attempt)
|
|
984
1017
|
roundFailure.set(new SyncRoundFailedError(progress.errorCode ?? 'client.unknown_failure', progress.attempt, progress.retryDelayMs));
|
|
@@ -997,12 +1030,8 @@ export class ReactiveClientStore {
|
|
|
997
1030
|
this.status.refresh();
|
|
998
1031
|
// Window edits that failed while no leader served this tab apply now;
|
|
999
1032
|
// an applied group compares equal and makes no call.
|
|
1000
|
-
if (leadership.state === 'leader' || leadership.state === 'follower')
|
|
1001
|
-
|
|
1002
|
-
if (group.claims.size > 0)
|
|
1003
|
-
this.#scheduleWindow(group);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1033
|
+
if (leadership.state === 'leader' || leadership.state === 'follower')
|
|
1034
|
+
this.#reclaimWindows();
|
|
1006
1035
|
});
|
|
1007
1036
|
this.status.refresh();
|
|
1008
1037
|
this.conflicts.refresh();
|
package/dist/worker-host.js
CHANGED
|
@@ -128,7 +128,8 @@ export class SyncClientHandle {
|
|
|
128
128
|
}
|
|
129
129
|
/** @internal — swap this handle from follower to leader (promotion). */
|
|
130
130
|
__becomeLeader(core) {
|
|
131
|
-
this
|
|
131
|
+
// Calls queued for the next leader run on this tab's own core.
|
|
132
|
+
this.#follower?.handOver(core.invoke);
|
|
132
133
|
this.#follower = undefined;
|
|
133
134
|
this.#core = core;
|
|
134
135
|
this.#clientId = core.clientId;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.2",
|
|
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.30.
|
|
92
|
+
"@syncular/core": "0.30.2"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.30.
|
|
95
|
+
"@syncular/server": "0.30.2",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/multi-tab.ts
CHANGED
|
@@ -724,6 +724,22 @@ export class FollowerLink {
|
|
|
724
724
|
this.#probe();
|
|
725
725
|
}
|
|
726
726
|
|
|
727
|
+
/**
|
|
728
|
+
* This tab won the lock and runs its own core: calls queued for the next
|
|
729
|
+
* leader run on `invoke` instead of failing, then the link closes.
|
|
730
|
+
*/
|
|
731
|
+
handOver(
|
|
732
|
+
invoke: (method: string, args: readonly unknown[]) => Promise<unknown>,
|
|
733
|
+
): void {
|
|
734
|
+
const queued = this.#queue;
|
|
735
|
+
this.#queue = [];
|
|
736
|
+
for (const call of queued) {
|
|
737
|
+
call.cancelTimer?.();
|
|
738
|
+
invoke(call.method, call.args).then(call.resolve, call.reject);
|
|
739
|
+
}
|
|
740
|
+
this.close();
|
|
741
|
+
}
|
|
742
|
+
|
|
727
743
|
close(): void {
|
|
728
744
|
if (this.#closed) return;
|
|
729
745
|
this.#closed = true;
|
package/src/reactive-store.ts
CHANGED
|
@@ -315,6 +315,8 @@ function reconcileRows<Row>(
|
|
|
315
315
|
|
|
316
316
|
interface CachedObservation {
|
|
317
317
|
onChange(batch: ClientChangeBatch): void;
|
|
318
|
+
/** A leader serves this tab again (it leads or follows a bound leader). */
|
|
319
|
+
reclaim?(): void;
|
|
318
320
|
reset(): void;
|
|
319
321
|
dispose(): void;
|
|
320
322
|
}
|
|
@@ -356,6 +358,9 @@ class ObservationCache {
|
|
|
356
358
|
onChange(batch: ClientChangeBatch): void {
|
|
357
359
|
for (const entry of this.#active) entry.onChange(batch);
|
|
358
360
|
}
|
|
361
|
+
reclaim(): void {
|
|
362
|
+
for (const entry of this.#active) entry.reclaim?.();
|
|
363
|
+
}
|
|
359
364
|
clear(): void {
|
|
360
365
|
for (const entry of this.#entries.values()) entry.dispose();
|
|
361
366
|
this.#entries.clear();
|
|
@@ -499,6 +504,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
499
504
|
|
|
500
505
|
refresh = (): void => {
|
|
501
506
|
if (this.#delegate !== undefined) this.#delegate.refresh();
|
|
507
|
+
else if (this.#claimError !== undefined) this.reclaim();
|
|
502
508
|
else this.#requestRead(true);
|
|
503
509
|
};
|
|
504
510
|
|
|
@@ -523,12 +529,30 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
523
529
|
this.reset();
|
|
524
530
|
}
|
|
525
531
|
|
|
532
|
+
/** SPEC §7.7: a rejected claim is claimed again; its stored error would
|
|
533
|
+
* fail every read until the last subscriber leaves. */
|
|
534
|
+
reclaim(): void {
|
|
535
|
+
if (this.#delegate !== undefined || this.#claimError === undefined) return;
|
|
536
|
+
this.#claimError = undefined;
|
|
537
|
+
this.#claimCoverage();
|
|
538
|
+
this.#requestRead();
|
|
539
|
+
}
|
|
540
|
+
|
|
526
541
|
onChange(batch: ClientChangeBatch): void {
|
|
527
542
|
if (!batchMatches(batch, this.spec)) return;
|
|
528
543
|
if (batch.revision > this.#desiredRevision) {
|
|
529
544
|
this.#desiredRevision = batch.revision;
|
|
530
545
|
}
|
|
531
|
-
if (this.#listeners.size
|
|
546
|
+
if (this.#listeners.size === 0) return;
|
|
547
|
+
// A committed row change of a dependency claims again; a window-only
|
|
548
|
+
// batch, which a failed window edit can itself produce, does not.
|
|
549
|
+
const rowChange = batch.tables.some((change) =>
|
|
550
|
+
this.spec.dependencies.some(
|
|
551
|
+
(dependency) => dependency.table === change.table,
|
|
552
|
+
),
|
|
553
|
+
);
|
|
554
|
+
if (rowChange && this.#claimError !== undefined) this.reclaim();
|
|
555
|
+
else this.#requestRead();
|
|
532
556
|
}
|
|
533
557
|
|
|
534
558
|
#publish(next: LiveQueryResult<Row>): void {
|
|
@@ -584,12 +608,6 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
584
608
|
return;
|
|
585
609
|
}
|
|
586
610
|
const wasBlocked = this.#state.phase === 'blocked';
|
|
587
|
-
// A claim that failed while the leader link was blocked is retried with
|
|
588
|
-
// the link: its stored error would otherwise fail every later read.
|
|
589
|
-
if (wasBlocked && this.#claimError !== undefined) {
|
|
590
|
-
this.#claimError = undefined;
|
|
591
|
-
this.#claimCoverage();
|
|
592
|
-
}
|
|
593
611
|
this.#publish({
|
|
594
612
|
...this.#state,
|
|
595
613
|
phase: wasBlocked
|
|
@@ -1070,6 +1088,16 @@ export class ReactiveClientStore {
|
|
|
1070
1088
|
}
|
|
1071
1089
|
}
|
|
1072
1090
|
|
|
1091
|
+
/** Apply every claimed window group again and claim again for query entries
|
|
1092
|
+
* whose claim was rejected. An applied group compares equal and makes no
|
|
1093
|
+
* call; a group whose edit is in flight applies once more after it. */
|
|
1094
|
+
#reclaimWindows(): void {
|
|
1095
|
+
for (const group of this.#windowClaims.values()) {
|
|
1096
|
+
if (group.claims.size > 0) this.#scheduleWindow(group);
|
|
1097
|
+
}
|
|
1098
|
+
this.#queries.reclaim();
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1073
1101
|
#scheduleWindow(group: WindowClaimGroup): void {
|
|
1074
1102
|
group.requested = true;
|
|
1075
1103
|
if (group.scheduled || group.running) return;
|
|
@@ -1158,7 +1186,14 @@ export class ReactiveClientStore {
|
|
|
1158
1186
|
const roundFailure = this.roundFailure as ValueEntry<
|
|
1159
1187
|
SyncRoundFailedError | undefined
|
|
1160
1188
|
>;
|
|
1189
|
+
let claimedAttempt: number | undefined;
|
|
1161
1190
|
this.#offProgress = this.client.onProgress((progress) => {
|
|
1191
|
+
// A new sync attempt is the client's retry schedule (§7.6): window
|
|
1192
|
+
// claims that failed are claimed again once per attempt.
|
|
1193
|
+
if (progress.state === 'running' && progress.attempt !== claimedAttempt) {
|
|
1194
|
+
claimedAttempt = progress.attempt;
|
|
1195
|
+
this.#reclaimWindows();
|
|
1196
|
+
}
|
|
1162
1197
|
const current = roundFailure.getSnapshot();
|
|
1163
1198
|
if (progress.state === 'failed' && current?.attempt !== progress.attempt)
|
|
1164
1199
|
roundFailure.set(
|
|
@@ -1184,11 +1219,8 @@ export class ReactiveClientStore {
|
|
|
1184
1219
|
if (previous.isLoading) this.status.refresh();
|
|
1185
1220
|
// Window edits that failed while no leader served this tab apply now;
|
|
1186
1221
|
// an applied group compares equal and makes no call.
|
|
1187
|
-
if (leadership.state === 'leader' || leadership.state === 'follower')
|
|
1188
|
-
|
|
1189
|
-
if (group.claims.size > 0) this.#scheduleWindow(group);
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1222
|
+
if (leadership.state === 'leader' || leadership.state === 'follower')
|
|
1223
|
+
this.#reclaimWindows();
|
|
1192
1224
|
});
|
|
1193
1225
|
this.status.refresh();
|
|
1194
1226
|
this.conflicts.refresh();
|
package/src/worker-host.ts
CHANGED
|
@@ -385,7 +385,8 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
385
385
|
|
|
386
386
|
/** @internal — swap this handle from follower to leader (promotion). */
|
|
387
387
|
__becomeLeader(core: LeaderCore): void {
|
|
388
|
-
this
|
|
388
|
+
// Calls queued for the next leader run on this tab's own core.
|
|
389
|
+
this.#follower?.handOver(core.invoke);
|
|
389
390
|
this.#follower = undefined;
|
|
390
391
|
this.#core = core;
|
|
391
392
|
this.#clientId = core.clientId;
|