@syncular/client 0.15.12 → 0.15.14
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 +66 -2
- package/dist/availability.d.ts +17 -0
- package/dist/availability.js +48 -0
- package/dist/client.js +3 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/invalidation.d.ts +1 -0
- package/dist/multi-tab.d.ts +24 -1
- package/dist/multi-tab.js +113 -4
- package/dist/reactive-store.d.ts +10 -1
- package/dist/reactive-store.js +107 -5
- package/dist/state.d.ts +3 -2
- package/dist/state.js +23 -13
- package/dist/worker-host.d.ts +33 -1
- package/dist/worker-host.js +107 -7
- package/package.json +3 -3
- package/src/availability.ts +70 -0
- package/src/client.ts +7 -4
- package/src/index.ts +1 -0
- package/src/invalidation.ts +1 -0
- package/src/multi-tab.ts +158 -0
- package/src/reactive-store.ts +138 -7
- package/src/state.ts +25 -13
- package/src/worker-host.ts +148 -6
package/src/reactive-store.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
classifySyncAvailability,
|
|
3
|
+
type SyncAvailability,
|
|
4
|
+
} from './availability';
|
|
1
5
|
import type {
|
|
2
6
|
CommitOutcome,
|
|
3
7
|
QueryReadSpec,
|
|
@@ -11,6 +15,7 @@ import type {
|
|
|
11
15
|
ClientChangeListener,
|
|
12
16
|
SyncStatusSnapshot,
|
|
13
17
|
} from './invalidation';
|
|
18
|
+
import type { LeadershipState } from './multi-tab';
|
|
14
19
|
import { type WindowBase, windowBaseKey } from './window';
|
|
15
20
|
|
|
16
21
|
export interface QueryDependency {
|
|
@@ -24,11 +29,17 @@ export interface ReactiveQuerySpec<Row> {
|
|
|
24
29
|
readonly params?: readonly SqlValue[];
|
|
25
30
|
readonly dependencies: readonly QueryDependency[];
|
|
26
31
|
readonly coverage?: readonly WindowCoverage[];
|
|
32
|
+
readonly mapRow?: (row: Readonly<Record<string, SqlValue>>) => Row;
|
|
27
33
|
readonly rowKey?: (row: Row) => readonly SqlValue[];
|
|
28
34
|
readonly claimCoverage?: boolean;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
export type LiveQueryPhase =
|
|
37
|
+
export type LiveQueryPhase =
|
|
38
|
+
| 'loading'
|
|
39
|
+
| 'partial'
|
|
40
|
+
| 'ready'
|
|
41
|
+
| 'blocked'
|
|
42
|
+
| 'error';
|
|
32
43
|
|
|
33
44
|
export interface LiveQueryResult<Row> {
|
|
34
45
|
readonly rows: readonly Row[];
|
|
@@ -36,14 +47,18 @@ export interface LiveQueryResult<Row> {
|
|
|
36
47
|
readonly revision: bigint | undefined;
|
|
37
48
|
readonly error: Error | undefined;
|
|
38
49
|
readonly isRefreshing: boolean;
|
|
50
|
+
readonly availability: SyncAvailability;
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
export interface ReactiveQueryClient {
|
|
54
|
+
readonly currentSchemaVersion?: number;
|
|
42
55
|
onChange(listener: ClientChangeListener): () => void;
|
|
43
56
|
querySnapshot<Row = Record<string, SqlValue>>(
|
|
44
57
|
spec: QueryReadSpec,
|
|
45
58
|
): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
46
59
|
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
60
|
+
leadershipSnapshot?(): LeadershipState | undefined;
|
|
61
|
+
onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
|
|
47
62
|
readonly conflicts:
|
|
48
63
|
| readonly unknown[]
|
|
49
64
|
| (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
@@ -72,6 +87,7 @@ export interface WindowRetention {
|
|
|
72
87
|
|
|
73
88
|
export interface StatusStoreSnapshot {
|
|
74
89
|
readonly status: SyncStatusSnapshot | undefined;
|
|
90
|
+
readonly leadership: LeadershipState | undefined;
|
|
75
91
|
readonly error: Error | undefined;
|
|
76
92
|
readonly isLoading: boolean;
|
|
77
93
|
}
|
|
@@ -162,6 +178,25 @@ export function canonicalValue(value: unknown): string {
|
|
|
162
178
|
return encodeCanonical(value, new Set());
|
|
163
179
|
}
|
|
164
180
|
|
|
181
|
+
const reactiveFunctionIds = new WeakMap<
|
|
182
|
+
(...args: never[]) => unknown,
|
|
183
|
+
number
|
|
184
|
+
>();
|
|
185
|
+
let nextReactiveFunctionId = 1;
|
|
186
|
+
|
|
187
|
+
function reactiveFunctionId(
|
|
188
|
+
fn: ((...args: never[]) => unknown) | undefined,
|
|
189
|
+
): number | undefined {
|
|
190
|
+
if (fn === undefined) return undefined;
|
|
191
|
+
let id = reactiveFunctionIds.get(fn);
|
|
192
|
+
if (id === undefined) {
|
|
193
|
+
id = nextReactiveFunctionId;
|
|
194
|
+
nextReactiveFunctionId += 1;
|
|
195
|
+
reactiveFunctionIds.set(fn, id);
|
|
196
|
+
}
|
|
197
|
+
return id;
|
|
198
|
+
}
|
|
199
|
+
|
|
165
200
|
function scheduleMicrotask(task: () => void): void {
|
|
166
201
|
if (typeof queueMicrotask === 'function') queueMicrotask(task);
|
|
167
202
|
else void Promise.resolve().then(task);
|
|
@@ -277,6 +312,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
277
312
|
revision: undefined,
|
|
278
313
|
error: undefined,
|
|
279
314
|
isRefreshing: false,
|
|
315
|
+
availability: { state: 'ready' },
|
|
280
316
|
};
|
|
281
317
|
#subscribers = 0;
|
|
282
318
|
#scheduled = false;
|
|
@@ -284,6 +320,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
284
320
|
#requested = false;
|
|
285
321
|
#desiredRevision = 0n;
|
|
286
322
|
#claimReady: Promise<void> = Promise.resolve();
|
|
323
|
+
#offStatus: (() => void) | undefined;
|
|
287
324
|
|
|
288
325
|
constructor(
|
|
289
326
|
readonly store: ReactiveClientStore,
|
|
@@ -296,6 +333,10 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
296
333
|
this.#listeners.add(listener);
|
|
297
334
|
this.#subscribers += 1;
|
|
298
335
|
if (this.#subscribers === 1) {
|
|
336
|
+
this.#offStatus = this.store.status.subscribe(() =>
|
|
337
|
+
this.#onAvailabilityChange(),
|
|
338
|
+
);
|
|
339
|
+
this.#onAvailabilityChange();
|
|
299
340
|
if (this.spec.claimCoverage !== false) {
|
|
300
341
|
const claims: Promise<void>[] = [];
|
|
301
342
|
for (const coverage of this.spec.coverage ?? []) {
|
|
@@ -314,7 +355,11 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
314
355
|
return () => {
|
|
315
356
|
if (!this.#listeners.delete(listener)) return;
|
|
316
357
|
this.#subscribers -= 1;
|
|
317
|
-
if (this.#subscribers === 0)
|
|
358
|
+
if (this.#subscribers === 0) {
|
|
359
|
+
this.#offStatus?.();
|
|
360
|
+
this.#offStatus = undefined;
|
|
361
|
+
this.store.releaseWindowClaims(this.#owner);
|
|
362
|
+
}
|
|
318
363
|
};
|
|
319
364
|
};
|
|
320
365
|
|
|
@@ -333,7 +378,9 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
333
378
|
next.rows === this.#state.rows &&
|
|
334
379
|
next.phase === this.#state.phase &&
|
|
335
380
|
next.error === this.#state.error &&
|
|
336
|
-
next.isRefreshing === this.#state.isRefreshing
|
|
381
|
+
next.isRefreshing === this.#state.isRefreshing &&
|
|
382
|
+
canonicalValue(next.availability) ===
|
|
383
|
+
canonicalValue(this.#state.availability)
|
|
337
384
|
) {
|
|
338
385
|
return;
|
|
339
386
|
}
|
|
@@ -358,14 +405,41 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
358
405
|
});
|
|
359
406
|
}
|
|
360
407
|
|
|
408
|
+
#onAvailabilityChange(): void {
|
|
409
|
+
const availability = this.store.availabilitySnapshot();
|
|
410
|
+
if (availability.state === 'blocked') {
|
|
411
|
+
this.#publish({
|
|
412
|
+
...this.#state,
|
|
413
|
+
phase: 'blocked',
|
|
414
|
+
availability,
|
|
415
|
+
isRefreshing: false,
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const wasBlocked = this.#state.phase === 'blocked';
|
|
420
|
+
this.#publish({
|
|
421
|
+
...this.#state,
|
|
422
|
+
phase: wasBlocked
|
|
423
|
+
? this.#state.rows.length > 0
|
|
424
|
+
? 'partial'
|
|
425
|
+
: 'loading'
|
|
426
|
+
: this.#state.phase,
|
|
427
|
+
availability,
|
|
428
|
+
});
|
|
429
|
+
if (wasBlocked) this.#requestRead();
|
|
430
|
+
}
|
|
431
|
+
|
|
361
432
|
async #readLoop(): Promise<void> {
|
|
362
433
|
if (this.#running || this.#subscribers === 0) return;
|
|
363
434
|
this.#running = true;
|
|
364
435
|
try {
|
|
365
436
|
do {
|
|
366
437
|
this.#requested = false;
|
|
438
|
+
if (this.store.availabilitySnapshot().state === 'blocked') break;
|
|
367
439
|
await this.#claimReady;
|
|
368
|
-
const snapshot = await this.store.client.querySnapshot<
|
|
440
|
+
const snapshot = await this.store.client.querySnapshot<
|
|
441
|
+
Record<string, SqlValue>
|
|
442
|
+
>({
|
|
369
443
|
sql: this.spec.sql,
|
|
370
444
|
...(this.spec.params !== undefined
|
|
371
445
|
? { params: this.spec.params }
|
|
@@ -374,13 +448,27 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
374
448
|
? { coverage: this.spec.coverage }
|
|
375
449
|
: {}),
|
|
376
450
|
});
|
|
451
|
+
const availability = this.store.availabilitySnapshot();
|
|
452
|
+
if (availability.state === 'blocked') {
|
|
453
|
+
this.#publish({
|
|
454
|
+
...this.#state,
|
|
455
|
+
phase: 'blocked',
|
|
456
|
+
availability,
|
|
457
|
+
isRefreshing: false,
|
|
458
|
+
});
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
377
461
|
if (snapshot.revision < this.#desiredRevision) {
|
|
378
462
|
this.#requested = true;
|
|
379
463
|
continue;
|
|
380
464
|
}
|
|
465
|
+
const mappedRows =
|
|
466
|
+
this.spec.mapRow === undefined
|
|
467
|
+
? (snapshot.rows as readonly Row[])
|
|
468
|
+
: snapshot.rows.map(this.spec.mapRow);
|
|
381
469
|
const rows = reconcileRows(
|
|
382
470
|
this.#state.rows,
|
|
383
|
-
|
|
471
|
+
mappedRows,
|
|
384
472
|
this.spec.rowKey,
|
|
385
473
|
);
|
|
386
474
|
const phase: LiveQueryPhase = snapshot.coverage.complete
|
|
@@ -394,6 +482,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
394
482
|
revision: snapshot.revision,
|
|
395
483
|
error: undefined,
|
|
396
484
|
isRefreshing: false,
|
|
485
|
+
availability,
|
|
397
486
|
});
|
|
398
487
|
} while (this.#requested && this.#subscribers > 0);
|
|
399
488
|
} catch (error) {
|
|
@@ -403,6 +492,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
403
492
|
phase: this.#state.revision === undefined ? 'error' : this.#state.phase,
|
|
404
493
|
error: wrapped,
|
|
405
494
|
isRefreshing: false,
|
|
495
|
+
availability: this.store.availabilitySnapshot(),
|
|
406
496
|
});
|
|
407
497
|
} finally {
|
|
408
498
|
this.#running = false;
|
|
@@ -505,22 +595,34 @@ export class ReactiveClientStore {
|
|
|
505
595
|
readonly #windows = new Map<string, WindowEntry>();
|
|
506
596
|
readonly #windowClaims = new Map<string, WindowClaimGroup>();
|
|
507
597
|
#offChange: (() => void) | undefined;
|
|
598
|
+
#offLeadership: (() => void) | undefined;
|
|
508
599
|
readonly status: ExternalStoreEntry<StatusStoreSnapshot>;
|
|
509
600
|
readonly conflicts: ExternalStoreEntry<ConflictStoreSnapshot>;
|
|
510
601
|
readonly outcomes: ExternalStoreEntry<OutcomeStoreSnapshot>;
|
|
511
602
|
|
|
512
603
|
constructor(readonly client: ReactiveQueryClient) {
|
|
513
604
|
const status = new ValueEntry<StatusStoreSnapshot>(
|
|
514
|
-
{
|
|
605
|
+
{
|
|
606
|
+
status: undefined,
|
|
607
|
+
leadership: client.leadershipSnapshot?.(),
|
|
608
|
+
error: undefined,
|
|
609
|
+
isLoading: true,
|
|
610
|
+
},
|
|
515
611
|
async () => {
|
|
516
612
|
try {
|
|
517
613
|
return {
|
|
518
614
|
status: await client.statusSnapshot(),
|
|
615
|
+
leadership: client.leadershipSnapshot?.(),
|
|
519
616
|
error: undefined,
|
|
520
617
|
isLoading: false,
|
|
521
618
|
};
|
|
522
619
|
} catch (error) {
|
|
523
|
-
return {
|
|
620
|
+
return {
|
|
621
|
+
status: undefined,
|
|
622
|
+
leadership: client.leadershipSnapshot?.(),
|
|
623
|
+
error: errorOf(error),
|
|
624
|
+
isLoading: false,
|
|
625
|
+
};
|
|
524
626
|
}
|
|
525
627
|
},
|
|
526
628
|
);
|
|
@@ -584,6 +686,9 @@ export class ReactiveClientStore {
|
|
|
584
686
|
baseKey: windowBaseKey(item.base),
|
|
585
687
|
units: [...new Set(item.units)].sort(),
|
|
586
688
|
}));
|
|
689
|
+
const mapRowId = reactiveFunctionId(
|
|
690
|
+
spec.mapRow as ((...args: never[]) => unknown) | undefined,
|
|
691
|
+
);
|
|
587
692
|
const key = canonicalValue({
|
|
588
693
|
id: spec.id,
|
|
589
694
|
sql: spec.sql,
|
|
@@ -591,6 +696,7 @@ export class ReactiveClientStore {
|
|
|
591
696
|
dependencies,
|
|
592
697
|
coverage,
|
|
593
698
|
claimCoverage: spec.claimCoverage !== false,
|
|
699
|
+
...(mapRowId === undefined ? {} : { mapRow: mapRowId }),
|
|
594
700
|
});
|
|
595
701
|
let entry = this.#queries.get(key) as QueryEntry<Row> | undefined;
|
|
596
702
|
if (entry === undefined) {
|
|
@@ -600,6 +706,21 @@ export class ReactiveClientStore {
|
|
|
600
706
|
return entry;
|
|
601
707
|
}
|
|
602
708
|
|
|
709
|
+
availabilitySnapshot(): SyncAvailability {
|
|
710
|
+
const snapshot = this.status.getSnapshot();
|
|
711
|
+
if (snapshot.status === undefined) {
|
|
712
|
+
return snapshot.leadership?.state === 'blocked'
|
|
713
|
+
? {
|
|
714
|
+
state: 'blocked',
|
|
715
|
+
reason: 'leader-unreachable',
|
|
716
|
+
currentSchemaVersion: this.client.currentSchemaVersion ?? 0,
|
|
717
|
+
retryable: true,
|
|
718
|
+
}
|
|
719
|
+
: { state: 'ready' };
|
|
720
|
+
}
|
|
721
|
+
return classifySyncAvailability(snapshot.status, snapshot.leadership);
|
|
722
|
+
}
|
|
723
|
+
|
|
603
724
|
/** Retain a composable window working set outside React. The returned
|
|
604
725
|
* handle exposes registration completion and releases only this owner. */
|
|
605
726
|
retainWindow(base: WindowBase, units: readonly string[]): WindowRetention {
|
|
@@ -701,6 +822,7 @@ export class ReactiveClientStore {
|
|
|
701
822
|
if (batch.status !== undefined) {
|
|
702
823
|
(this.status as ValueEntry<StatusStoreSnapshot>).set({
|
|
703
824
|
status: batch.status,
|
|
825
|
+
leadership: this.client.leadershipSnapshot?.(),
|
|
704
826
|
error: undefined,
|
|
705
827
|
isLoading: false,
|
|
706
828
|
});
|
|
@@ -710,11 +832,20 @@ export class ReactiveClientStore {
|
|
|
710
832
|
}
|
|
711
833
|
if (batch.outcomesChanged) this.outcomes.refresh();
|
|
712
834
|
});
|
|
835
|
+
this.#offLeadership = this.client.onLeadershipChange?.((leadership) => {
|
|
836
|
+
const previous = this.status.getSnapshot();
|
|
837
|
+
(this.status as ValueEntry<StatusStoreSnapshot>).set({
|
|
838
|
+
...previous,
|
|
839
|
+
leadership,
|
|
840
|
+
});
|
|
841
|
+
});
|
|
713
842
|
}
|
|
714
843
|
|
|
715
844
|
dispose(): void {
|
|
716
845
|
this.#offChange?.();
|
|
717
846
|
this.#offChange = undefined;
|
|
847
|
+
this.#offLeadership?.();
|
|
848
|
+
this.#offLeadership = undefined;
|
|
718
849
|
for (const group of this.#windowClaims.values()) {
|
|
719
850
|
void Promise.resolve(this.client.setWindow(group.base, []));
|
|
720
851
|
}
|
package/src/state.ts
CHANGED
|
@@ -119,23 +119,35 @@ export function resetSubscriptionsForBump(db: ClientDatabase): void {
|
|
|
119
119
|
* Remove registrations whose table no longer exists in the running schema.
|
|
120
120
|
* Keeping one would make every subsequent pull fail with
|
|
121
121
|
* `sync.unknown_table`. Window bookkeeping belongs to the registration and
|
|
122
|
-
* is removed with it.
|
|
122
|
+
* is removed with it. The caller supplies its startup subscription snapshot
|
|
123
|
+
* so pruning and startup-work detection stay a single read.
|
|
123
124
|
*/
|
|
124
125
|
export function pruneUnknownSubscriptions(
|
|
125
126
|
db: ClientDatabase,
|
|
127
|
+
subscriptions: readonly SubscriptionRecord[],
|
|
126
128
|
tableNames: ReadonlySet<string>,
|
|
127
|
-
):
|
|
128
|
-
const
|
|
129
|
-
.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
129
|
+
): SubscriptionRecord[] {
|
|
130
|
+
const retained = subscriptions.filter((record) =>
|
|
131
|
+
tableNames.has(record.table),
|
|
132
|
+
);
|
|
133
|
+
const staleIds = subscriptions
|
|
134
|
+
.filter((record) => !tableNames.has(record.table))
|
|
135
|
+
.map((record) => record.id);
|
|
136
|
+
// A redundant subscription scan plus an empty write transaction cut the
|
|
137
|
+
// fresh-client bootstrap lane by more than half on bun:sqlite. The supplied
|
|
138
|
+
// snapshot and this branch are synchronous, so no application work can
|
|
139
|
+
// interleave before a real pruning transaction begins.
|
|
140
|
+
if (staleIds.length === 0) return retained;
|
|
141
|
+
db.transaction(() => {
|
|
142
|
+
for (const id of staleIds) {
|
|
143
|
+
db.exec('DELETE FROM _syncular_windows WHERE sub_id = ?', [id]);
|
|
144
|
+
db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?', [
|
|
145
|
+
id,
|
|
146
|
+
]);
|
|
147
|
+
db.exec('DELETE FROM _syncular_subscriptions WHERE id = ?', [id]);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return retained;
|
|
139
151
|
}
|
|
140
152
|
|
|
141
153
|
export function getMeta(db: ClientDatabase, key: string): string | undefined {
|